diff --git a/.changeset/cc-nc-nd-families.md b/.changeset/cc-nc-nd-families.md new file mode 100644 index 0000000..309c51e --- /dev/null +++ b/.changeset/cc-nc-nd-families.md @@ -0,0 +1,24 @@ +--- +"@refkit/core": minor +"@refkit/provider-openverse": minor +"@refkit/provider-flickr": minor +"@refkit/provider-wikimedia-commons": minor +"@refkit/provider-freesound": minor +"@refkit/provider-jamendo": minor +"@refkit/provider-europeana": minor +"@refkit/provider-internet-archive": minor +--- + +Add first-class CC NC/ND license families: `CC-BY-NC`, `CC-BY-NC-SA`, `CC-BY-NC-ND`, `CC-BY-ND`. + +NC/ND-licensed results no longer collapse to `proprietary`: they keep their real +family id (+ CC version), generate the attribution the license requires, and +verdicts name the actual license in their reasons. Gating stays strict-deny — +commercial/AI use of NC content is still denied; NC × `redistribution` intent now +returns `needs-review` (was `denied`) because the intent cannot distinguish +commercial from non-commercial redistribution. `CC-BY-ND` now correctly allows +verbatim commercial reuse (`allowed-with-attribution`) while AI/derivative use +stays denied. + +Note for TypeScript consumers: exhaustive `switch` statements over `LicenseId` +need arms for the four new ids. diff --git a/README.md b/README.md index 909e73e..87cb22e 100644 --- a/README.md +++ b/README.md @@ -195,7 +195,7 @@ Audio/video are extra factories on existing packages: `openverseAudio()`, `pexel - **Zero network in `core`** — no `fetch` call, no hard-coded endpoint. Hosts inject `ProviderContext.fetch`. - **No re-hosting** — keep `canonicalUrl` + thumbnails only; never store originals. -- **strict-deny** — when rights can't be determined, deny / needs-review (never fail-open). Unknown, NonCommercial, NoDerivatives and "no known copyright restrictions" never map to a usable license. +- **strict-deny** — when rights can't be determined, deny / needs-review (never fail-open). Unknown, NonCommercial and "no known copyright restrictions" never map to a commercially usable license; NoDerivatives allows verbatim commercial reuse (with attribution) but never derivative/AI use. ## Agent usage diff --git a/docs/superpowers/plans/2026-07-03-hardening-index.md b/docs/superpowers/plans/2026-07-03-hardening-index.md new file mode 100644 index 0000000..b48a8fc --- /dev/null +++ b/docs/superpowers/plans/2026-07-03-hardening-index.md @@ -0,0 +1,97 @@ +# Core Hardening & Capability Waves — Index + +> **For agentic workers:** This is a SEQUENCING CONTRACT, not a task plan. Each wave gets its own detailed plan doc (superpowers:writing-plans format, bite-sized TDD tasks) authored **just-in-time at wave start**, against the codebase state after prior waves merged. Execute each wave plan via superpowers:subagent-driven-development or superpowers:executing-plans. + +**Goal:** Close the architecture-review gaps of 2026-07-03: license-family fidelity, orchestrator resilience, test infrastructure, and capability extensions — without breaking core's invariants (zero-network, strict-deny, no re-hosting). + +**Architecture:** Four sequential waves, ordered by (a) file-conflict clusters — items that rewrite the same files ship together, (b) dependency — the testkit encodes Wave 1's final mapping tables, so it comes after. Tracks *within* Waves 3–4 are parallelizable via worktree-isolated subagents; anything touching `packages/core/src/client.ts` or shared wiring is serialized. + +**Tech Stack:** Existing repo stack only — TypeScript ESM, tsup, vitest, zod, pnpm workspaces, changesets. No new runtime dependencies in core. + +--- + +## File-conflict matrix (why the waves are shaped this way) + +| Cluster | Files | Items | +|---|---|---| +| license engine | `core/src/license.ts`, `rights.ts`, `rerank.ts` (LICENSE_PERMISSIVENESS), `provider-helpers.ts` (mapCcDeedUrl), 5–6 provider mappers | Wave 1 | +| orchestrator | `core/src/client.ts` + `client.test.ts` + SearchMeta types | Wave 2 (timeout, retry, latency, cache — one pass, not four) | +| new packages / workflows only | `packages/provider-testkit/`, `.github/workflows/live-smoke.yml`, per-provider `live.test.ts` | Wave 3 (parallel) | +| independent seams | `evaluate-use.ts` (4a), docs (4b, 4d), `mcp/src` (4c) | Wave 4 (parallel except 4a vs anything touching evaluate-use) | + +--- + +## Wave 1 — License-family fidelity (`CC-BY-NC*` / `CC-BY-ND`) + +**Why first:** it's the moat. Current NC/ND→`proprietary` collapse has two real defects: (1) NC assets used under `internal-moodboard` get **no attribution line** (proprietary has `attributionRequired: false`, CC-BY-NC legally requires credit); (2) verdict reasons name the wrong license ("not granted by proprietary"), corrupting explainability for agents. Precedent: the §1 CC-version-axis change (`75c557e`) shipped the same shape of core-enum + mappers atomic change. + +**Scope:** add four family ids to `LicenseId` + `LICENSE_FACTS` + `rightsRecordSchema` + `LICENSE_PERMISSIVENESS`; update `mapCcDeedUrl` (covers europeana + internet-archive centrally) and the provider-local mappers that hardcode NC/ND→proprietary: openverse, flickr, wikimedia-commons, freesound, jamendo. (gutendex's `proprietary` is for copyrighted books, not NC — verify, expect no change.) + +**Locked decisions:** + +- **H1 — Facts table rows** (Tri semantics per `license.ts`): + + | id | commercialUse | derivatives | redistribution | attributionRequired | shareAlike | + |---|---|---|---|---|---| + | `CC-BY-ND` | true | false | true | true | false | + | `CC-BY-NC` | false | true | 'unknown' | true | false | + | `CC-BY-NC-SA` | false | true | 'unknown' | true | true | + | `CC-BY-NC-ND` | false | false | 'unknown' | true | false | + +- **H2 — NC × `redistribution` intent = `'unknown'`** (→ needs-review). CC-NC grants *non-commercial* sharing; the `redistribution` intent doesn't model commercial vs non-commercial. `true` would fail open for commercial redistributors; `false` would state a falsehood ("not granted"). `'unknown'` is the honest tri-state. Behavior delta vs today: denied → needs-review (still conservative). +- **H3 — `CC-BY-ND` commercialUse = true** (verbatim commercial use is granted). `ai-generation-input` stays denied via `derivatives: false`. +- **H4 — `LICENSE_PERMISSIVENESS`** (rerank.ts, exhaustively typed — typecheck forces this update): `CC-BY-ND` 0.55, `CC-BY-NC` 0.45, `CC-BY-NC-SA` 0.4, `CC-BY-NC-ND` 0.35 (all above `unknown` 0.3, below `CC-BY-SA` 0.65). +- **H5 — `licenseVersion`** captured for all CC families incl. the new four (extend the BY/BY-SA-only guard in each mapper and in `mapCcDeedUrl`). +- **H6 — rightsstatements.org untouched.** `NoC-NC`/`InC*` are rights-STATUS statements, not CC grants; their existing mapping stands. CC deed URLs flowing through `mapRightsUrl` pick the new families up automatically via `mapCcDeedUrl`. +- **H7 — Semver:** minor for core + each touched provider. Changeset MUST note: consumers with exhaustive `switch (license)` need new arms (same class of change as the §1 rename, which also shipped as minor). + +**Verify:** `pnpm -r typecheck && pnpm test:run`; the proof tests are (a) NC ref + `internal-moodboard` → `allowed` AND `buildAttribution().required === true`, (b) verdict reason names `CC-BY-NC`, not `proprietary`. + +--- + +## Wave 2 — Orchestrator hardening (client.ts cluster) + +**Why one wave:** per-provider timeout, retry, latency metadata, and query cache all rewrite the same fan-out path in `client.ts:searchInternal` and the `SearchMeta`/`ProviderSearchStatus` types. Four separate PRs would churn the same tests four times. + +**Scope:** `RefkitOptions.resilience` (soft timeout + single backoff retry on 429/5xx), `ProviderSearchStatus.latencyMs`, per-provider result cache on the existing (currently dead) `KeyValueCache` port. + +**Locked decisions:** + +- **H8 — Resilience defaults ON:** `resilience?: { timeoutMs?: number; retries?: number } | false` — defaults `timeoutMs: 10_000`, `retries: 1` (429/5xx/network-error only, exponential backoff with jitter); `false` disables. A timed-out provider is reported `status: 'failed', error: 'timeout after Nms'`; the search never rejects because of one provider. +- **H9 — No `AbortSignal.any` dependency:** repo declares no `engines`; compose caller signal + timeout with a manual listener helper so core stays runtime-agnostic. `setTimeout` in core does not violate zero-network (the invariant test scans for `fetch(`/endpoints only — confirm it stays green). +- **H10 — Cache is per-provider, pre-merge:** key `refkit:v1::`, value = JSON of parsed refs, TTL via new `RefkitOptions.cacheTtlMs` (default 5 min when a cache is supplied). Caching per-provider (not post-merge) keeps rerank/gate/merge live on every call. Cache hits are marked in `ProviderSearchStatus` (`cached: true`). +- **H11 — latencyMs** measured around each provider promise (cache hits report ~0 + `cached`). + +**Verify:** fake-timer vitest coverage for timeout/retry/backoff; a hanging-provider test proving partial results return within budget. + +--- + +## Wave 3 — Test infrastructure (two parallel tracks; needs Wave 1 merged) + +- **3a — `packages/provider-testkit`** (`private: true`, consumed as workspace devDependency — publishing is a later decision, YAGNI). One entry `assertProviderConformance(provider, fixtures)` encoding the D1–D8 + H1–H6 rules as executable assertions: schema-valid refs at the boundary, strict-deny license mapping table, `preview.url`/`thumbnail.url` never a web page (D8 heuristics), URL-param whitelisting, `licenseVersion` only on CC families. Migrate 2–3 existing provider test files onto it as the proof; the rest migrate opportunistically. +- **3b — Live smoke against real APIs** (today ALL tests are offline fixtures — upstream drift is invisible): per-provider `src/__tests__/live.test.ts` gated by `REFKIT_LIVE=1` (+ the provider's key env), one real query asserting response *shape* via zod. New `.github/workflows/live-smoke.yml`: weekly cron, keyless providers always, BYOK providers when repo secrets exist. Never runs in the PR-blocking `ci.yml`. + +--- + +## Wave 4 — Capability extensions (parallel tracks) + +- **4a — Intent generalization:** export `evaluatePermissions(rights, required: Array)` from core; `evaluateUse` refactors into presets on top of it (behavior-identical — existing tests must pass unchanged). No custom-intent registry (YAGNI until a host asks). +- **4b — Pagination semantics:** minimum viable = document that `controls.page` is provider-local (README + jsdoc) and add a "load more without overlap" recipe using canonical-URL keys. A global cursor over RRF fusion is explicitly out of scope. +- **4c — MCP surface:** add `evaluate_use` + `build_attribution` tools taking explicit fields (`license`, `licenseVersion`, `author`, `title`, `canonicalUrl`, `intent`) so they're **stateless** — no server-side session cache needed. Unify CLI env vars to `REFKIT__KEY` with the current names kept as fallbacks (document both). +- **4d — Semantic-rerank cookbook:** `docs/examples/semantic-rerank.md` with a runnable BYO-embedding `Reranker` sample. No new published package (YAGNI; the seam exists, it needs a recipe, not a dependency). + +--- + +## Deferred (explicitly NOT scheduled — revisit on demand signals) + +- Text/video source expansion (product-positioning call first; roadmap §4 skip-list logic still holds). +- `3d` modality (core enum change; batch with real downstream demand). +- Rights snapshots / ToS-drift archiving (wait for a real audit requirement). +- More providers generally — the leverage this cycle is engine depth, not satellite count. + +## Execution mechanics + +1. One wave at a time; author `2026-MM-DD-.md` (full writing-plans format) at wave start; execute task-by-task with fresh subagents; review between tasks. +2. Waves 3/4: parallel tracks in isolated worktrees; **serialize** any shared-file wiring (mcp/cli.ts, root README, vitest.config.ts) into a final integration pass — same rule as the P1-providers S9 precedent. +3. Every PR: changeset per touched package; `pnpm -r typecheck && pnpm test:run` green before merge; core version bumps minor per wave that touches it. +4. Wave order: **1 → 2 → 3 → 4**. If wall-clock matters, 3b (live smoke) has no dependency on Wave 1/2 and may start anytime. diff --git a/docs/superpowers/plans/2026-07-03-license-families.md b/docs/superpowers/plans/2026-07-03-license-families.md new file mode 100644 index 0000000..cd71a74 --- /dev/null +++ b/docs/superpowers/plans/2026-07-03-license-families.md @@ -0,0 +1,614 @@ +# Wave 1 — CC NC/ND License Families Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `CC-BY-NC`, `CC-BY-NC-SA`, `CC-BY-NC-ND`, `CC-BY-ND` as first-class license families so NC/ND assets carry faithful rights (attribution generated, verdict reasons name the real license) instead of collapsing to `proprietary`. + +**Architecture:** Core-enum + facts change first (compiler drives the exhaustive `LICENSE_PERMISSIVENESS` update), then the shared `mapCcDeedUrl`/`ccVersionFor` helpers (fixes europeana/internet-archive/jamendo centrally), then the three provider-local mappers (openverse, flickr, wikimedia-commons, freesound). `evaluateUse` logic is UNTOUCHED — facts drive it. Decisions H1–H7 in `2026-07-03-hardening-index.md` are locked; do not re-litigate them. + +**Tech Stack:** TypeScript ESM, vitest, zod, pnpm workspaces, changesets. + +**Working directory:** repo root (the worktree). All paths below are repo-relative. Run tests with `pnpm vitest run ` from the root. + +--- + +### Task 1: Core license ids + facts + +**Files:** +- Modify: `packages/core/src/license.ts` +- Test: `packages/core/src/__tests__/license.test.ts` + +- [ ] **Step 1: Write the failing tests** — append inside the existing `describe('LICENSE_FACTS', …)` block: + +```ts + it('CC-BY-ND allows verbatim commercial use but no derivatives', () => { + expect(LICENSE_FACTS['CC-BY-ND']).toEqual({ + commercialUse: true, + derivatives: false, + redistribution: true, + attributionRequired: true, + shareAlike: false, + }) + }) + + it('CC-BY-NC family: commercial false, redistribution unknown (intent cannot model NC-only sharing)', () => { + for (const id of ['CC-BY-NC', 'CC-BY-NC-SA', 'CC-BY-NC-ND'] as const) { + expect(LICENSE_FACTS[id].commercialUse).toBe(false) + expect(LICENSE_FACTS[id].redistribution).toBe('unknown') + expect(LICENSE_FACTS[id].attributionRequired).toBe(true) + } + expect(LICENSE_FACTS['CC-BY-NC'].derivatives).toBe(true) + expect(LICENSE_FACTS['CC-BY-NC-SA'].derivatives).toBe(true) + expect(LICENSE_FACTS['CC-BY-NC-SA'].shareAlike).toBe(true) + expect(LICENSE_FACTS['CC-BY-NC-ND'].derivatives).toBe(false) + }) +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `pnpm vitest run packages/core/src/__tests__/license.test.ts` +Expected: FAIL — TS error / undefined entries for the new ids. + +- [ ] **Step 3: Implement.** In `packages/core/src/license.ts`: + +Extend the union (after `'CC-BY-SA'`): + +```ts +export type LicenseId = + | 'CC0-1.0' + | 'CC-BY' + | 'CC-BY-SA' + | 'CC-BY-NC' + | 'CC-BY-NC-SA' + | 'CC-BY-NC-ND' + | 'CC-BY-ND' + | 'PD' + | 'unsplash' + | 'pexels' + | 'pixabay' + | 'proprietary' + | 'unknown' +``` + +Add to `LICENSE_FACTS` (after the `'CC-BY-SA'` row): + +```ts + // NC family: sharing/derivatives are granted only NON-commercially. The + // 'redistribution' intent doesn't model commercial vs non-commercial, so the + // honest tri-state is 'unknown' (→ needs-review) — never true (fail-open) nor + // false (falsely claims "not granted"). + 'CC-BY-NC': { commercialUse: false, derivatives: true, redistribution: 'unknown', attributionRequired: true, shareAlike: false }, + 'CC-BY-NC-SA': { commercialUse: false, derivatives: true, redistribution: 'unknown', attributionRequired: true, shareAlike: true }, + 'CC-BY-NC-ND': { commercialUse: false, derivatives: false, redistribution: 'unknown', attributionRequired: true, shareAlike: false }, + // ND: verbatim reuse (incl. commercial) is granted; derivatives are not. + 'CC-BY-ND': { commercialUse: true, derivatives: false, redistribution: true, attributionRequired: true, shareAlike: false }, +``` + +- [ ] **Step 4: This breaks the exhaustive `LICENSE_PERMISSIVENESS` in `packages/core/src/rerank.ts` (typed `Record`) and the zod enum in `packages/core/src/rights.ts` — fix both now** (they are part of this atomic step; the build is red until done): + +`packages/core/src/rights.ts` — extend `licenseIdSchema`: + +```ts +const licenseIdSchema: z.ZodType = z.enum([ + 'CC0-1.0', 'CC-BY', 'CC-BY-SA', 'CC-BY-NC', 'CC-BY-NC-SA', 'CC-BY-NC-ND', 'CC-BY-ND', 'PD', + 'unsplash', 'pexels', 'pixabay', 'proprietary', 'unknown', +]) +``` + +`packages/core/src/rerank.ts` — extend the permissiveness table (H4 scores: below `CC-BY-SA` 0.65, above `unknown` 0.3): + +```ts +const LICENSE_PERMISSIVENESS: Record = { + 'CC0-1.0': 1, PD: 1, + unsplash: 0.85, pexels: 0.85, pixabay: 0.85, + 'CC-BY': 0.75, 'CC-BY-SA': 0.65, + 'CC-BY-ND': 0.55, 'CC-BY-NC': 0.45, 'CC-BY-NC-SA': 0.4, 'CC-BY-NC-ND': 0.35, + unknown: 0.3, proprietary: 0.2, +} +``` + +- [ ] **Step 5: Run core typecheck + tests** + +Run: `pnpm --filter @refkit/core typecheck && pnpm vitest run packages/core` +Expected: PASS (all core suites — nothing else in core reads the enum exhaustively). + +- [ ] **Step 6: Commit** + +```bash +git add packages/core/src/license.ts packages/core/src/rights.ts packages/core/src/rerank.ts packages/core/src/__tests__/license.test.ts +git commit -m "feat(core): add CC-BY-NC/NC-SA/NC-ND/ND license families" +``` + +--- + +### Task 2: Strict-deny + attribution coverage for the new families (test-only) + +**Files:** +- Test: `packages/core/src/__tests__/evaluate-use.test.ts` +- Test: `packages/core/src/__tests__/attribution.test.ts` +- Test: `packages/core/src/__tests__/rights.test.ts` + +No production code changes — `evaluateUse`/`buildAttribution` are facts-driven. These tests prove the two review defects are fixed. + +- [ ] **Step 1: evaluate-use tests.** In `evaluate-use.test.ts`, extend the exhaustive list in the strict-deny invariant test (currently `['CC0-1.0', 'CC-BY', 'CC-BY-SA', 'PD', 'unsplash', 'pexels', 'pixabay', 'proprietary', 'unknown']`, ~line 75): + +```ts + const licenses: LicenseId[] = [ + 'CC0-1.0', 'CC-BY', 'CC-BY-SA', 'CC-BY-NC', 'CC-BY-NC-SA', 'CC-BY-NC-ND', 'CC-BY-ND', + 'PD', 'unsplash', 'pexels', 'pixabay', 'proprietary', 'unknown', + ] +``` + +Then add new cases (using the file's existing `rec()` helper): + +```ts + it('CC-BY-NC: commercial denied with the REAL license named, moodboard allowed, redistribution needs-review', () => { + const commercial = evaluateUse(rec('CC-BY-NC'), 'commercial-product') + expect(commercial.decision).toBe('denied') + expect(commercial.reasons.join(' ')).toContain('CC-BY-NC') // not "proprietary" + expect(evaluateUse(rec('CC-BY-NC'), 'ai-generation-input').decision).toBe('denied') + expect(evaluateUse(rec('CC-BY-NC'), 'internal-moodboard').decision).toBe('allowed') + expect(evaluateUse(rec('CC-BY-NC'), 'redistribution').decision).toBe('needs-review') + }) + + it('CC-BY-ND: verbatim commercial allowed-with-attribution, AI-generation denied (derivatives)', () => { + expect(evaluateUse(rec('CC-BY-ND'), 'commercial-product').decision).toBe('allowed-with-attribution') + expect(evaluateUse(rec('CC-BY-ND'), 'ai-generation-input').decision).toBe('denied') + expect(evaluateUse(rec('CC-BY-ND'), 'redistribution').decision).toBe('allowed-with-attribution') + }) +``` + +- [ ] **Step 2: attribution test.** In `attribution.test.ts` add (match the file's existing import style): + +```ts + it('CC-BY-NC requires attribution (the old proprietary collapse silently dropped it)', () => { + const a = buildAttribution({ + license: 'CC-BY-NC', + licenseVersion: '2.0', + author: 'Bob', + title: 'mountain', + canonicalUrl: 'https://example.org/x', + }) + expect(a.required).toBe(true) + expect(a.text).toContain('CC-BY-NC 2.0') + expect(a.text).toContain('Bob') + }) +``` + +- [ ] **Step 3: rights schema test.** In `rights.test.ts` add: + +```ts + it('accepts the NC/ND family ids with a licenseVersion', () => { + const r = rightsRecordSchema.parse({ + license: 'CC-BY-NC-ND', + licenseVersion: '3.0', + rehostPolicy: 'cache-allowed', + raw: { sourceTerms: 't', sourceUrl: 'u' }, + }) + expect(r.license).toBe('CC-BY-NC-ND') + }) +``` + +(Ensure `rightsRecordSchema` is imported; add it to the existing import if absent.) + +- [ ] **Step 4: Run** + +Run: `pnpm vitest run packages/core` +Expected: PASS — all new tests green with zero production changes. + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/__tests__/evaluate-use.test.ts packages/core/src/__tests__/attribution.test.ts packages/core/src/__tests__/rights.test.ts +git commit -m "test(core): strict-deny + attribution coverage for NC/ND families" +``` + +--- + +### Task 3: Shared helpers — `mapCcDeedUrl` families + `ccVersionFor` + +**Files:** +- Modify: `packages/core/src/provider-helpers.ts` (mapCcDeedUrl ~lines 71–91; add helpers) +- Modify: `packages/core/src/index.ts` (export `ccVersionFor`, `CC_FAMILY_BY_TOKEN`) +- Test: `packages/core/src/__tests__/provider-helpers.test.ts` + +- [ ] **Step 1: Update the failing tests.** In `provider-helpers.test.ts` the current test (~line 75) asserts `by-nc-nd`/`by-nd` → proprietary. Replace those two assertions and retitle: + +```ts + it('maps PD/CC0, all six CC families (+version), else unknown', () => { + // …keep the existing PD/CC0/BY/BY-SA assertions unchanged… + expect(mapCcDeedUrl('http://creativecommons.org/licenses/by-nc-nd/3.0/')).toEqual({ license: 'CC-BY-NC-ND', version: '3.0' }) + expect(mapCcDeedUrl('http://creativecommons.org/licenses/by-nd/4.0/')).toEqual({ license: 'CC-BY-ND', version: '4.0' }) + expect(mapCcDeedUrl('https://creativecommons.org/licenses/by-nc/2.0/')).toEqual({ license: 'CC-BY-NC', version: '2.0' }) + expect(mapCcDeedUrl('https://creativecommons.org/licenses/by-nc-sa/4.0/deed.en')).toEqual({ license: 'CC-BY-NC-SA', version: '4.0' }) + expect(mapCcDeedUrl('https://creativecommons.org/licenses/by-nc/')).toEqual({ license: 'CC-BY-NC' }) // no version + expect(mapCcDeedUrl('https://creativecommons.org/licenses/sampling+/1.0/')).toEqual({ license: 'unknown' }) // bespoke, not a family + }) + + it('ccVersionFor: version rides only on versioned CC families', () => { + expect(ccVersionFor('CC-BY-NC', '2.0')).toBe('2.0') + expect(ccVersionFor('CC-BY-ND', '4.0')).toBe('4.0') + expect(ccVersionFor('CC-BY', '4.0')).toBe('4.0') + expect(ccVersionFor('CC0-1.0', '1.0')).toBeUndefined() + expect(ccVersionFor('proprietary', '2.0')).toBeUndefined() + expect(ccVersionFor('CC-BY-NC', undefined)).toBeUndefined() + }) +``` + +Add `ccVersionFor` to the test file's imports from `../provider-helpers`. The `mapRightsUrl` rightsstatements tests (InC/NoC-*) are UNCHANGED — verify they still pass untouched (H6). + +- [ ] **Step 2: Run to verify failure** + +Run: `pnpm vitest run packages/core/src/__tests__/provider-helpers.test.ts` +Expected: FAIL — `ccVersionFor` not exported; NC deeds still map to proprietary. + +- [ ] **Step 3: Implement.** In `provider-helpers.ts`, replace the body of `mapCcDeedUrl` (keep `mapRightsUrl` and `RIGHTS_STATEMENT` untouched): + +```ts +/** CC deed path token → family LicenseId. Shared by the URL mapper here and by + * code-based mappers (e.g. wikimedia-commons extmetadata codes). */ +export const CC_FAMILY_BY_TOKEN: Record = { + 'by': 'CC-BY', 'by-sa': 'CC-BY-SA', 'by-nd': 'CC-BY-ND', + 'by-nc': 'CC-BY-NC', 'by-nc-sa': 'CC-BY-NC-SA', 'by-nc-nd': 'CC-BY-NC-ND', +} + +/** Map a Creative Commons deed URL to a core LicenseId (+ CC version). All six CC + * families map faithfully; PD mark / CC0 → PD / CC0-1.0; bespoke deeds (sampling…) + * and absent/unrecognized → unknown. **CC deeds only** — rightsstatements.org is + * handled by `mapRightsUrl`. Match is on the path so http/https both work. */ +export function mapCcDeedUrl(url: string | undefined | null): { license: LicenseId; version?: string } { + if (typeof url !== 'string' || !url) return { license: 'unknown' } + const u = url.toLowerCase() + if (u.includes('creativecommons.org/publicdomain/zero')) return { license: 'CC0-1.0' } + if (u.includes('creativecommons.org/publicdomain/mark')) return { license: 'PD' } + const m = u.match(/creativecommons\.org\/licenses\/(by(?:-nc)?(?:-sa|-nd)?)(?:\/(\d(?:\.\d)?))?/) + if (m) { + const license = CC_FAMILY_BY_TOKEN[m[1]] + if (license) return m[2] ? { license, version: m[2] } : { license } + } + return { license: 'unknown' } +} + +const CC_VERSIONED_FAMILIES: ReadonlySet = new Set([ + 'CC-BY', 'CC-BY-SA', 'CC-BY-NC', 'CC-BY-NC-SA', 'CC-BY-NC-ND', 'CC-BY-ND', +]) + +/** `version` when `license` is a versioned CC family, else undefined — the shared + * licenseVersion guard for provider mappers (replaces the hand-rolled + * `license === 'CC-BY' || license === 'CC-BY-SA'` checks). */ +export function ccVersionFor(license: LicenseId, version: string | undefined): string | undefined { + return version !== undefined && CC_VERSIONED_FAMILIES.has(license) ? version : undefined +} +``` + +In `packages/core/src/index.ts`, extend the provider-helpers export line: + +```ts +export { + setIfString, setIfBoolean, setIfStringList, + setIfInt, setIfPositiveInt, setIfNonNegativeInt, setIfNumber, + first, mapCcDeedUrl, mapRightsUrl, ccVersionFor, CC_FAMILY_BY_TOKEN, + isLikelyImageUrl, imageMediaType, IMAGE_EXT, +} from './provider-helpers' +``` + +- [ ] **Step 4: Run** + +Run: `pnpm vitest run packages/core && pnpm --filter @refkit/core typecheck` +Expected: PASS (regex behavior identical for the old cases: by/by-sa with and without version, sampling → unknown). + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/provider-helpers.ts packages/core/src/index.ts packages/core/src/__tests__/provider-helpers.test.ts +git commit -m "feat(core): mapCcDeedUrl maps NC/ND families; add ccVersionFor guard helper" +``` + +--- + +### Task 4: Openverse mapper + +**Files:** +- Modify: `packages/provider-openverse/src/index.ts` (mapOpenverseLicense ~lines 65–81; two licenseVersion guards ~lines 130, 199) +- Test: `packages/provider-openverse/src/__tests__/openverse.test.ts`, `packages/provider-openverse/src/__tests__/integration.test.ts` + +- [ ] **Step 1: Invert the tests.** + +`openverse.test.ts` line ~36: `expect(mapOpenverseLicense('by-nc-nd')).toBe('CC-BY-NC-ND')`. Add alongside: + +```ts + expect(mapOpenverseLicense('by-nc')).toBe('CC-BY-NC') + expect(mapOpenverseLicense('by-nc-sa')).toBe('CC-BY-NC-SA') + expect(mapOpenverseLicense('by-nd')).toBe('CC-BY-ND') + expect(mapOpenverseLicense('sampling+')).toBe('proprietary') +``` + +E2E test ~lines 180–183 (fixture is `by-nc-nd` 2.0): expect `rights.license` `'CC-BY-NC-ND'`, `rights.licenseVersion` `'2.0'`, decision for `commercial-product` STILL `'denied'`, and the reason to contain `'CC-BY-NC-ND'`. Retitle: `'END-TO-END moat: a real by-nc-nd item keeps its family, version, and is denied for commercial use'`. + +Audio test ~lines 263–266 (fixture `by-nc` 3.0): expect `'CC-BY-NC'` + `licenseVersion === '3.0'` + still denied. + +`integration.test.ts` gate test: assertions stand as-is (NC still gates out of commercial-product); update only the trailing comment to `// CC-BY-NC-ND → commercial denied → filtered`. + +- [ ] **Step 2: Run to verify failure** + +Run: `pnpm vitest run packages/provider-openverse` +Expected: FAIL — mapper still returns proprietary. + +- [ ] **Step 3: Implement.** Replace the switch and both guards; import `ccVersionFor` from `@refkit/core`: + +```ts +export function mapOpenverseLicense(code: string): LicenseId { + switch (code) { + case 'cc0': return 'CC0-1.0' + case 'pdm': return 'PD' + case 'by': return 'CC-BY' + case 'by-sa': return 'CC-BY-SA' + case 'by-nc': return 'CC-BY-NC' + case 'by-nc-sa': return 'CC-BY-NC-SA' + case 'by-nc-nd': return 'CC-BY-NC-ND' + case 'by-nd': return 'CC-BY-ND' + case 'sampling': + case 'sampling+': + case 'nc-sampling+': + return 'proprietary' // bespoke sampling licences — not clean family grants + default: return 'unknown' + } +} +``` + +Both `toReference` and `toAudioReference`: `licenseVersion: ccVersionFor(license, r.license_version),` and update the doc comment above the mapper (NC/ND now map faithfully; permissions still gate via LICENSE_FACTS). + +- [ ] **Step 4: Run** — `pnpm vitest run packages/provider-openverse` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/provider-openverse/src +git commit -m "feat(openverse): map NC/ND codes to their CC families" +``` + +--- + +### Task 5: Flickr license table + +**Files:** +- Modify: `packages/provider-flickr/src/index.ts` (FLICKR_LICENSE ~lines 50–68; guard ~line 151) +- Test: `packages/provider-flickr/src/__tests__/flickr.test.ts` + +- [ ] **Step 1: Invert tests** (~lines 37–39). Keep `'0'` → proprietary (All Rights Reserved). Change/add: + +```ts + expect(mapFlickrLicense('3')).toEqual({ license: 'CC-BY-NC-ND', version: '2.0' }) + expect(mapFlickrLicense('16')).toEqual({ license: 'CC-BY-NC-ND', version: '4.0' }) + expect(mapFlickrLicense('6')).toEqual({ license: 'CC-BY-ND', version: '2.0' }) + expect(mapFlickrLicense('14')).toEqual({ license: 'CC-BY-NC', version: '4.0' }) +``` + +- [ ] **Step 2: Run to verify failure** — `pnpm vitest run packages/provider-flickr` → FAIL. + +- [ ] **Step 3: Implement.** Replace the table rows for 1, 2, 3, 6, 13, 14, 15, 16 (0/4/5/7–12 unchanged): + +```ts + 1: { license: 'CC-BY-NC-SA', version: '2.0' }, // CC BY-NC-SA 2.0 + 2: { license: 'CC-BY-NC', version: '2.0' }, // CC BY-NC 2.0 + 3: { license: 'CC-BY-NC-ND', version: '2.0' }, // CC BY-NC-ND 2.0 + 6: { license: 'CC-BY-ND', version: '2.0' }, // CC BY-ND 2.0 + 13: { license: 'CC-BY-ND', version: '4.0' }, // CC BY-ND 4.0 + 14: { license: 'CC-BY-NC', version: '4.0' }, // CC BY-NC 4.0 + 15: { license: 'CC-BY-NC-SA', version: '4.0' }, // CC BY-NC-SA 4.0 + 16: { license: 'CC-BY-NC-ND', version: '4.0' }, // CC BY-NC-ND 4.0 +``` + +Guard → `licenseVersion: ccVersionFor(license, version),` (import `ccVersionFor` from `@refkit/core`). Update the table's lead comment: only All Rights Reserved (0) stays proprietary; NC/ND map to their families. `DEFAULT_LICENSE_FILTER` is UNCHANGED (it is a relevance hint listing usable-by-default licenses, not the rights gate). + +- [ ] **Step 4: Run** — `pnpm vitest run packages/provider-flickr` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/provider-flickr/src +git commit -m "feat(flickr): map NC/ND numeric ids to their CC families" +``` + +--- + +### Task 6: Wikimedia Commons mapper + +**Files:** +- Modify: `packages/provider-wikimedia-commons/src/index.ts` (mapCommonsLicense ~lines 33–47; guard ~line 96) +- Test: `packages/provider-wikimedia-commons/src/__tests__/wikimedia-commons.test.ts` + +- [ ] **Step 1: Invert tests** (~lines 60–61): + +```ts + expect(mapCommonsLicense('cc-by-nc-2.0')).toEqual({ license: 'CC-BY-NC', version: '2.0' }) + expect(mapCommonsLicense('cc-by-nd-4.0')).toEqual({ license: 'CC-BY-ND', version: '4.0' }) + expect(mapCommonsLicense('cc-by-nc-sa-3.0-de')).toEqual({ license: 'CC-BY-NC-SA', version: '3.0' }) // jurisdiction port +``` + +- [ ] **Step 2: Run to verify failure** — `pnpm vitest run packages/provider-wikimedia-commons` → FAIL. + +- [ ] **Step 3: Implement** — replace `mapCommonsLicense` with the unified family matcher (import `CC_FAMILY_BY_TOKEN` from `@refkit/core`; keep CC0/PD branches identical, keep unversioned codes → unknown): + +```ts +export function mapCommonsLicense(code: string | undefined): { license: LicenseId; version?: string } { + const c = (code ?? '').trim().toLowerCase() + if (!c) return { license: 'unknown' } + if (c === 'cc0' || c.startsWith('cc0-')) return { license: 'CC0-1.0' } + // tolerate jurisdiction ports (e.g. cc-by-sa-2.5-in, cc-by-3.0-us) — same permission family + const m = c.match(/^cc-(by(?:-nc)?(?:-sa|-nd)?)-(\d+\.\d+)(?:-[a-z]{2,})?$/) + if (m) { + const license = CC_FAMILY_BY_TOKEN[m[1]] + if (license) return { license, version: m[2] } + } + if (c === 'pd' || c.startsWith('pd-') || c.startsWith('public') || c.includes('publicdomain')) { + return { license: 'PD' } + } + return { license: 'unknown' } +} +``` + +Guard → `licenseVersion: ccVersionFor(license, version),` (add to the `@refkit/core` import). Update the function's lead comment (NC/ND → faithful families). + +- [ ] **Step 4: Run** — `pnpm vitest run packages/provider-wikimedia-commons` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/provider-wikimedia-commons/src +git commit -m "feat(wikimedia-commons): map NC/ND codes to their CC families" +``` + +--- + +### Task 7: Freesound name map + +**Files:** +- Modify: `packages/provider-freesound/src/index.ts` (FREESOUND_NAME_LICENSE ~lines 12–18; guard ~line 73) +- Test: `packages/provider-freesound/src/__tests__/freesound.test.ts` + +- [ ] **Step 1: Invert tests.** Lines ~94–95 (`'Attribution NonCommercial'` / `'Attribution Noncommercial'`) → `{ license: 'CC-BY-NC' }` (NO version — D4, name strings carry none). Line ~105 (deed URL form) → `{ license: 'CC-BY-NC', version: '3.0' }`. Line ~45 (fixture-driven rights) → `'CC-BY-NC'`. Sampling+ assertions (~97–98) UNCHANGED (stay proprietary). + +- [ ] **Step 2: Run to verify failure** — `pnpm vitest run packages/provider-freesound` → FAIL. + +- [ ] **Step 3: Implement.** In `FREESOUND_NAME_LICENSE`: `'attribution noncommercial': { license: 'CC-BY-NC' },` (comment: `// NC family — commercial use still gates to denied`). Sampling entries unchanged. Guard → `licenseVersion: ccVersionFor(license, version),` (import from `@refkit/core`); update the comment on that line (version present only for the deed-URL form). + +- [ ] **Step 4: Run** — `pnpm vitest run packages/provider-freesound` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/provider-freesound/src +git commit -m "feat(freesound): map NonCommercial name to CC-BY-NC" +``` + +--- + +### Task 8: URL-mapper providers — jamendo, europeana, internet-archive, rijksmuseum + +**Files:** +- Modify: `packages/provider-jamendo/src/index.ts:65`, `packages/provider-europeana/src/index.ts:69`, `packages/provider-internet-archive/src/index.ts:59`, `packages/provider-rijksmuseum/src/index.ts:142` (licenseVersion guards only — their mappers delegate to core `mapCcDeedUrl`/`mapRightsUrl`, already fixed by Task 3) +- Test: `packages/provider-jamendo/src/__tests__/jamendo.test.ts`, `packages/provider-europeana/src/__tests__/europeana.test.ts`, `packages/provider-internet-archive/src/__tests__/internet-archive.test.ts` + +- [ ] **Step 1: Invert the tests.** + +`jamendo.test.ts` ~39–41: + +```ts + expect(mapJamendoLicense('http://creativecommons.org/licenses/by-nc-nd/3.0/')).toEqual({ license: 'CC-BY-NC-ND', version: '3.0' }) + expect(mapJamendoLicense('http://creativecommons.org/licenses/by-nc/2.0/')).toEqual({ license: 'CC-BY-NC', version: '2.0' }) + expect(mapJamendoLicense('http://creativecommons.org/licenses/by-nd/4.0/')).toEqual({ license: 'CC-BY-ND', version: '4.0' }) +``` + +`jamendo.test.ts` E2E ~73–77 (fixture `by-nc-nd/3.0`): expect `rights.license === 'CC-BY-NC-ND'`, `rights.licenseVersion === '3.0'`, commercial decision STILL `'denied'`; retitle to `'maps a CC-BY-NC-ND track faithfully → denied for commercial use'`. + +`europeana.test.ts` ~16–18: + +```ts + expect(mapEuropeanaRights('http://creativecommons.org/licenses/by-nc/4.0/')).toEqual({ license: 'CC-BY-NC', version: '4.0' }) + expect(mapEuropeanaRights('http://creativecommons.org/licenses/by-nc-sa/4.0/')).toEqual({ license: 'CC-BY-NC-SA', version: '4.0' }) + expect(mapEuropeanaRights('http://creativecommons.org/licenses/by-nd/4.0/')).toEqual({ license: 'CC-BY-ND', version: '4.0' }) +``` + +rightsstatements assertions (InC/NoC-NC/NoC-US, ~22–24 and the InC E2E) UNCHANGED (H6). + +`internet-archive.test.ts` ~16–18: + +```ts + expect(mapIaLicense('https://creativecommons.org/licenses/by-nc/4.0/')).toEqual({ license: 'CC-BY-NC', version: '4.0' }) + expect(mapIaLicense('https://creativecommons.org/licenses/by-nd/4.0/')).toEqual({ license: 'CC-BY-ND', version: '4.0' }) + expect(mapIaLicense('https://creativecommons.org/licenses/by-nc-sa/4.0/')).toEqual({ license: 'CC-BY-NC-SA', version: '4.0' }) +``` + +(If those assertions use `.license` property access instead of `toEqual`, keep their structure and assert both `license` and `version`.) rightsstatements assertions (~28–30) UNCHANGED. + +- [ ] **Step 2: Run to verify failure** + +Run: `pnpm vitest run packages/provider-jamendo packages/provider-europeana packages/provider-internet-archive` +Expected: mapper assertions PASS already via Task 3 (they delegate to core) — but the E2E `licenseVersion` expectations FAIL against the stale `CC-BY || CC-BY-SA` guards. + +- [ ] **Step 3: Implement** — in each of the four files replace + +```ts + licenseVersion: license === 'CC-BY' || license === 'CC-BY-SA' ? version : undefined, +``` + +with + +```ts + licenseVersion: ccVersionFor(license, version), +``` + +adding `ccVersionFor` to each file's `@refkit/core` import. (rijksmuseum emits only CC0/PD so this is behavior-neutral there — done for consistency so no stale guard survives.) + +- [ ] **Step 4: Run** + +Run: `pnpm vitest run packages/provider-jamendo packages/provider-europeana packages/provider-internet-archive packages/provider-rijksmuseum` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/provider-jamendo/src packages/provider-europeana/src packages/provider-internet-archive/src packages/provider-rijksmuseum/src +git commit -m "feat(providers): NC/ND families flow through URL-mapped providers via ccVersionFor" +``` + +--- + +### Task 9: Full verify, changesets, README + +**Files:** +- Create: `.changeset/cc-nc-nd-families.md` +- Modify: `README.md` (only if the providers table's license column mentions NC handling — inspect first; likely no change) + +- [ ] **Step 1: Full repo verify** + +Run: `pnpm -r typecheck && pnpm test:run` +Expected: ALL green. If any suite not touched above fails (e.g. mcp snapshot of license strings), fix the stale expectation — the license VALUES changed for NC/ND items by design. + +- [ ] **Step 2: Grep for stragglers** + +Run: `grep -rn "=== 'CC-BY' || license === 'CC-BY-SA'" packages/*/src/index.ts` +Expected: no matches (all guards migrated). Also run `grep -rn "'proprietary'" packages/*/src/index.ts` and confirm every remaining hit is genuinely non-family (ARR, sampling, copyrighted books, InC/NoC-NC rightsstatements, unsplash/pexels/pixabay platform terms — those are correct). + +- [ ] **Step 3: Changeset** — create `.changeset/cc-nc-nd-families.md`: + +```md +--- +"@refkit/core": minor +"@refkit/provider-openverse": minor +"@refkit/provider-flickr": minor +"@refkit/provider-wikimedia-commons": minor +"@refkit/provider-freesound": minor +"@refkit/provider-jamendo": minor +"@refkit/provider-europeana": minor +"@refkit/provider-internet-archive": minor +--- + +Add first-class CC NC/ND license families: `CC-BY-NC`, `CC-BY-NC-SA`, `CC-BY-NC-ND`, `CC-BY-ND`. + +NC/ND-licensed results no longer collapse to `proprietary`: they keep their real +family id (+ CC version), generate the attribution the license requires, and +verdicts name the actual license in their reasons. Gating stays strict-deny — +commercial/AI use of NC content is still denied; NC × `redistribution` intent now +returns `needs-review` (was `denied`) because the intent cannot distinguish +commercial from non-commercial redistribution. + +Note for TypeScript consumers: exhaustive `switch` statements over `LicenseId` +need arms for the four new ids. +``` + +- [ ] **Step 4: Commit** + +```bash +git add .changeset/cc-nc-nd-families.md +git commit -m "chore: changeset for CC NC/ND license families" +``` + +--- + +## Self-review notes + +- Spec coverage: H1 (Task 1), H2/H3 (Tasks 1–2), H4 (Task 1 step 4), H5 (Tasks 3–8 via `ccVersionFor`), H6 (Tasks 3/8 — rightsstatements untouched), H7 (Task 9 changeset). +- `evaluateUse`, `buildAttribution`, MCP, client: zero code changes — facts-driven by design; Task 9's full run proves it. +- Line numbers are anchors, not gospel — locate by the quoted code if drifted. diff --git a/packages/core/src/__tests__/attribution.test.ts b/packages/core/src/__tests__/attribution.test.ts index c16626b..5dab4d3 100644 --- a/packages/core/src/__tests__/attribution.test.ts +++ b/packages/core/src/__tests__/attribution.test.ts @@ -28,4 +28,17 @@ describe('buildAttribution', () => { // no licenseVersion → bare family name, no trailing version expect(a.text).toContain('licensed under CC-BY-SA.') }) + + it('CC-BY-NC requires attribution (the old proprietary collapse silently dropped it)', () => { + const a = buildAttribution({ + license: 'CC-BY-NC', + licenseVersion: '2.0', + author: 'Bob', + title: 'mountain', + canonicalUrl: 'https://example.org/x', + }) + expect(a.required).toBe(true) + expect(a.text).toContain('CC-BY-NC 2.0') + expect(a.text).toContain('Bob') + }) }) diff --git a/packages/core/src/__tests__/evaluate-use.test.ts b/packages/core/src/__tests__/evaluate-use.test.ts index af7a329..4da6995 100644 --- a/packages/core/src/__tests__/evaluate-use.test.ts +++ b/packages/core/src/__tests__/evaluate-use.test.ts @@ -72,7 +72,10 @@ describe('evaluateUse — strict-deny', () => { .filter(([, f]) => f.commercialUse === true) .map(([id]) => id) ) - const licenses: LicenseId[] = ['CC0-1.0', 'CC-BY', 'CC-BY-SA', 'PD', 'unsplash', 'pexels', 'pixabay', 'proprietary', 'unknown'] + const licenses: LicenseId[] = [ + 'CC0-1.0', 'CC-BY', 'CC-BY-SA', 'CC-BY-NC', 'CC-BY-NC-SA', 'CC-BY-NC-ND', 'CC-BY-ND', + 'PD', 'unsplash', 'pexels', 'pixabay', 'proprietary', 'unknown', + ] for (const license of licenses) { for (const intent of ['commercial-product', 'ai-generation-input'] as const) { const v = evaluateUse(rec(license), intent) @@ -83,4 +86,19 @@ describe('evaluateUse — strict-deny', () => { } } }) + + it('CC-BY-NC: commercial denied with the REAL license named, moodboard allowed, redistribution needs-review', () => { + const commercial = evaluateUse(rec('CC-BY-NC'), 'commercial-product') + expect(commercial.decision).toBe('denied') + expect(commercial.reasons.join(' ')).toContain('CC-BY-NC') // not "proprietary" + expect(evaluateUse(rec('CC-BY-NC'), 'ai-generation-input').decision).toBe('denied') + expect(evaluateUse(rec('CC-BY-NC'), 'internal-moodboard').decision).toBe('allowed') + expect(evaluateUse(rec('CC-BY-NC'), 'redistribution').decision).toBe('needs-review') + }) + + it('CC-BY-ND: verbatim commercial allowed-with-attribution, AI-generation denied (derivatives)', () => { + expect(evaluateUse(rec('CC-BY-ND'), 'commercial-product').decision).toBe('allowed-with-attribution') + expect(evaluateUse(rec('CC-BY-ND'), 'ai-generation-input').decision).toBe('denied') + expect(evaluateUse(rec('CC-BY-ND'), 'redistribution').decision).toBe('allowed-with-attribution') + }) }) diff --git a/packages/core/src/__tests__/license.test.ts b/packages/core/src/__tests__/license.test.ts index a2fd4ea..1698db1 100644 --- a/packages/core/src/__tests__/license.test.ts +++ b/packages/core/src/__tests__/license.test.ts @@ -36,4 +36,28 @@ describe('LICENSE_FACTS', () => { it('factsFor falls back to unknown for an unrecognized id', () => { expect(factsFor('not-a-real-license' as LicenseId)).toBe(LICENSE_FACTS.unknown) }) + + it('CC-BY-ND allows verbatim commercial use but no derivatives', () => { + expect(LICENSE_FACTS['CC-BY-ND']).toEqual({ + commercialUse: true, + derivatives: false, + redistribution: true, + attributionRequired: true, + shareAlike: false, + }) + }) + + it('CC-BY-NC family: commercial false, redistribution unknown (intent cannot model NC-only sharing)', () => { + for (const id of ['CC-BY-NC', 'CC-BY-NC-SA', 'CC-BY-NC-ND'] as const) { + expect(LICENSE_FACTS[id].commercialUse).toBe(false) + expect(LICENSE_FACTS[id].redistribution).toBe('unknown') + expect(LICENSE_FACTS[id].attributionRequired).toBe(true) + } + expect(LICENSE_FACTS['CC-BY-NC'].derivatives).toBe(true) + expect(LICENSE_FACTS['CC-BY-NC-SA'].derivatives).toBe(true) + expect(LICENSE_FACTS['CC-BY-NC-SA'].shareAlike).toBe(true) + expect(LICENSE_FACTS['CC-BY-NC-ND'].derivatives).toBe(false) + expect(LICENSE_FACTS['CC-BY-NC'].shareAlike).toBe(false) + expect(LICENSE_FACTS['CC-BY-NC-ND'].shareAlike).toBe(false) + }) }) diff --git a/packages/core/src/__tests__/provider-helpers.test.ts b/packages/core/src/__tests__/provider-helpers.test.ts index 211b93a..5fa094d 100644 --- a/packages/core/src/__tests__/provider-helpers.test.ts +++ b/packages/core/src/__tests__/provider-helpers.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { setIfString, setIfBoolean, setIfStringList, setIfInt, setIfPositiveInt, setIfNonNegativeInt, setIfNumber, - first, mapCcDeedUrl, mapRightsUrl, isLikelyImageUrl, imageMediaType, + first, mapCcDeedUrl, mapRightsUrl, ccVersionFor, isLikelyImageUrl, imageMediaType, } from '../provider-helpers' const params = (fn: (u: URL) => void) => { const u = new URL('https://x.test/'); fn(u); return u.searchParams } @@ -72,13 +72,19 @@ describe('first', () => { }) describe('mapCcDeedUrl', () => { - it('maps PD/CC0, BY/BY-SA (+version), NC/ND → proprietary, else unknown', () => { + it('maps PD/CC0, all six CC families (+version), else unknown', () => { expect(mapCcDeedUrl('http://creativecommons.org/publicdomain/zero/1.0/')).toEqual({ license: 'CC0-1.0' }) expect(mapCcDeedUrl('https://creativecommons.org/publicdomain/mark/1.0/')).toEqual({ license: 'PD' }) expect(mapCcDeedUrl('http://creativecommons.org/licenses/by/4.0/')).toEqual({ license: 'CC-BY', version: '4.0' }) expect(mapCcDeedUrl('http://creativecommons.org/licenses/by-sa/3.0/')).toEqual({ license: 'CC-BY-SA', version: '3.0' }) - expect(mapCcDeedUrl('http://creativecommons.org/licenses/by-nc-nd/3.0/')).toEqual({ license: 'proprietary' }) - expect(mapCcDeedUrl('http://creativecommons.org/licenses/by-nd/4.0/')).toEqual({ license: 'proprietary' }) + expect(mapCcDeedUrl('http://creativecommons.org/licenses/by-nc-nd/3.0/')).toEqual({ license: 'CC-BY-NC-ND', version: '3.0' }) + expect(mapCcDeedUrl('http://creativecommons.org/licenses/by-nd/4.0/')).toEqual({ license: 'CC-BY-ND', version: '4.0' }) + expect(mapCcDeedUrl('https://creativecommons.org/licenses/by-nc/2.0/')).toEqual({ license: 'CC-BY-NC', version: '2.0' }) + expect(mapCcDeedUrl('https://creativecommons.org/licenses/by-nc-sa/4.0/deed.en')).toEqual({ license: 'CC-BY-NC-SA', version: '4.0' }) + expect(mapCcDeedUrl('https://creativecommons.org/licenses/by-nc/')).toEqual({ license: 'CC-BY-NC' }) // no version + expect(mapCcDeedUrl('https://creativecommons.org/licenses/sampling+/1.0/')).toEqual({ license: 'unknown' }) // bespoke, not a family + expect(mapCcDeedUrl('https://creativecommons.org/licenses/by4.0/')).toEqual({ license: 'unknown' }) // malformed: no boundary after token + expect(mapCcDeedUrl('https://creativecommons.org/licenses/by-ncgarbage/4.0/')).toEqual({ license: 'unknown' }) // mapCcDeedUrl is CC-only — a rightsstatements URL has no CC pattern → unknown here // (the faithful rightsstatements mapping lives in mapRightsUrl, tested below). expect(mapCcDeedUrl('http://rightsstatements.org/vocab/InC/1.0/')).toEqual({ license: 'unknown' }) @@ -88,6 +94,14 @@ describe('mapCcDeedUrl', () => { it('never throws on a non-string input (array/number) → unknown', () => { expect(mapCcDeedUrl(['x'] as any)).toEqual({ license: 'unknown' }) }) + it('ccVersionFor: version rides only on versioned CC families', () => { + expect(ccVersionFor('CC-BY-NC', '2.0')).toBe('2.0') + expect(ccVersionFor('CC-BY-ND', '4.0')).toBe('4.0') + expect(ccVersionFor('CC-BY', '4.0')).toBe('4.0') + expect(ccVersionFor('CC0-1.0', '1.0')).toBeUndefined() + expect(ccVersionFor('proprietary', '2.0')).toBeUndefined() + expect(ccVersionFor('CC-BY-NC', undefined)).toBeUndefined() + }) }) describe('mapRightsUrl (CC deeds + faithful rightsstatements.org)', () => { diff --git a/packages/core/src/__tests__/rights.test.ts b/packages/core/src/__tests__/rights.test.ts index c9b707a..f4e6891 100644 --- a/packages/core/src/__tests__/rights.test.ts +++ b/packages/core/src/__tests__/rights.test.ts @@ -22,4 +22,14 @@ describe('rightsRecordSchema', () => { const { raw, ...withoutRaw } = valid expect(() => rightsRecordSchema.parse(withoutRaw)).toThrow() }) + + it('accepts the NC/ND family ids with a licenseVersion', () => { + const r = rightsRecordSchema.parse({ + license: 'CC-BY-NC-ND', + licenseVersion: '3.0', + rehostPolicy: 'cache-allowed', + raw: { sourceTerms: 't', sourceUrl: 'u' }, + }) + expect(r.license).toBe('CC-BY-NC-ND') + }) }) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6db58a4..5966c33 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,6 @@ // Public API — @refkit/core P0. export type { Modality } from './modality' -export { LICENSE_FACTS, factsFor } from './license' +export { LICENSE_FACTS, factsFor, LICENSE_IDS } from './license' export type { LicenseId, LicenseFacts, Tri } from './license' export type { RehostPolicy, RightsRecord } from './rights' export { rightsRecordSchema } from './rights' @@ -46,7 +46,8 @@ export type { export { setIfString, setIfBoolean, setIfStringList, setIfInt, setIfPositiveInt, setIfNonNegativeInt, setIfNumber, - first, mapCcDeedUrl, mapRightsUrl, isLikelyImageUrl, imageMediaType, IMAGE_EXT, + first, mapCcDeedUrl, mapRightsUrl, ccVersionFor, CC_FAMILY_BY_TOKEN, + isLikelyImageUrl, imageMediaType, IMAGE_EXT, } from './provider-helpers' export { normalizeQuery } from './query' export { createRefkit } from './client' diff --git a/packages/core/src/license.ts b/packages/core/src/license.ts index 955cb91..947a591 100644 --- a/packages/core/src/license.ts +++ b/packages/core/src/license.ts @@ -1,13 +1,9 @@ -export type LicenseId = - | 'CC0-1.0' - | 'CC-BY' - | 'CC-BY-SA' - | 'PD' - | 'unsplash' - | 'pexels' - | 'pixabay' - | 'proprietary' - | 'unknown' +export const LICENSE_IDS = [ + 'CC0-1.0', 'CC-BY', 'CC-BY-SA', 'CC-BY-NC', 'CC-BY-NC-SA', 'CC-BY-NC-ND', 'CC-BY-ND', 'PD', + 'unsplash', 'pexels', 'pixabay', 'proprietary', 'unknown', +] as const + +export type LicenseId = (typeof LICENSE_IDS)[number] /** Three-state: known-true / known-false / not-determinable. Drives strict-deny. */ export type Tri = true | false | 'unknown' @@ -28,6 +24,15 @@ export const LICENSE_FACTS: Record = { 'PD': { commercialUse: true, derivatives: true, redistribution: true, attributionRequired: false, shareAlike: false }, 'CC-BY': { commercialUse: true, derivatives: true, redistribution: true, attributionRequired: true, shareAlike: false }, 'CC-BY-SA': { commercialUse: true, derivatives: true, redistribution: true, attributionRequired: true, shareAlike: true }, + // NC family: sharing/derivatives are granted only NON-commercially. The + // 'redistribution' intent doesn't model commercial vs non-commercial, so the + // honest tri-state is 'unknown' (→ needs-review) — never true (fail-open) nor + // false (falsely claims "not granted"). + 'CC-BY-NC': { commercialUse: false, derivatives: true, redistribution: 'unknown', attributionRequired: true, shareAlike: false }, + 'CC-BY-NC-SA': { commercialUse: false, derivatives: true, redistribution: 'unknown', attributionRequired: true, shareAlike: true }, + 'CC-BY-NC-ND': { commercialUse: false, derivatives: false, redistribution: 'unknown', attributionRequired: true, shareAlike: false }, + // ND: verbatim reuse (incl. commercial) is granted; derivatives are not. + 'CC-BY-ND': { commercialUse: true, derivatives: false, redistribution: true, attributionRequired: true, shareAlike: false }, // Stock-platform licenses: free to use incl. commercial, no attribution legally // required, but NOT redistributable as-is (can't resell/redistribute the asset itself). 'unsplash': { commercialUse: true, derivatives: true, redistribution: false, attributionRequired: false, shareAlike: false }, diff --git a/packages/core/src/provider-helpers.ts b/packages/core/src/provider-helpers.ts index 3fe76b4..d85f41c 100644 --- a/packages/core/src/provider-helpers.ts +++ b/packages/core/src/provider-helpers.ts @@ -70,26 +70,41 @@ export function first(arr: T[] | undefined | null): T | undefined { // — license: CC deed URL → LicenseId (the moat; shared by URL-based sources) — -/** Map a Creative Commons deed URL to a core LicenseId (+ CC version for the BY/BY-SA - * families). Conservative: NC/ND variants → proprietary; PD mark / CC0 → PD / CC0-1.0; - * absent/unrecognized → unknown. **CC deeds only** — rightsstatements.org is handled by - * `mapRightsUrl`. Match is on the path so http/https both work. */ +/** CC deed path token → family LicenseId. Shared by the URL mapper here and by + * code-based mappers (e.g. wikimedia-commons extmetadata codes). */ +export const CC_FAMILY_BY_TOKEN: Record = { + 'by': 'CC-BY', 'by-sa': 'CC-BY-SA', 'by-nd': 'CC-BY-ND', + 'by-nc': 'CC-BY-NC', 'by-nc-sa': 'CC-BY-NC-SA', 'by-nc-nd': 'CC-BY-NC-ND', +} + +/** Map a Creative Commons deed URL to a core LicenseId (+ CC version). All six CC + * families map faithfully; PD mark / CC0 → PD / CC0-1.0; bespoke deeds (sampling…) + * and absent/unrecognized → unknown. **CC deeds only** — rightsstatements.org is + * handled by `mapRightsUrl`. Match is on the path so http/https both work. */ export function mapCcDeedUrl(url: string | undefined | null): { license: LicenseId; version?: string } { if (typeof url !== 'string' || !url) return { license: 'unknown' } const u = url.toLowerCase() if (u.includes('creativecommons.org/publicdomain/zero')) return { license: 'CC0-1.0' } if (u.includes('creativecommons.org/publicdomain/mark')) return { license: 'PD' } - // NC / ND are NOT open grants — check before plain by/by-sa ("by-nc-sa" contains "by-sa"). - if (/creativecommons\.org\/licenses\/by-(?:nc|nd)/.test(u)) return { license: 'proprietary' } - const sa = u.match(/creativecommons\.org\/licenses\/by-sa\/(\d(?:\.\d)?)/) - if (sa) return { license: 'CC-BY-SA', version: sa[1] } - const by = u.match(/creativecommons\.org\/licenses\/by\/(\d(?:\.\d)?)/) - if (by) return { license: 'CC-BY', version: by[1] } - if (/creativecommons\.org\/licenses\/by-sa\b/.test(u)) return { license: 'CC-BY-SA' } - if (/creativecommons\.org\/licenses\/by\b/.test(u)) return { license: 'CC-BY' } + const m = u.match(/creativecommons\.org\/licenses\/(by(?:-nc)?(?:-sa|-nd)?)(?=\/|$)(?:\/(\d(?:\.\d)?))?/) + if (m) { + const license = CC_FAMILY_BY_TOKEN[m[1]] + if (license) return m[2] ? { license, version: m[2] } : { license } + } return { license: 'unknown' } } +const CC_VERSIONED_FAMILIES: ReadonlySet = new Set([ + 'CC-BY', 'CC-BY-SA', 'CC-BY-NC', 'CC-BY-NC-SA', 'CC-BY-NC-ND', 'CC-BY-ND', +]) + +/** `version` when `license` is a versioned CC family, else undefined — the shared + * licenseVersion guard for provider mappers (replaces the hand-rolled + * `license === 'CC-BY' || license === 'CC-BY-SA'` checks). */ +export function ccVersionFor(license: LicenseId, version: string | undefined): string | undefined { + return version !== undefined && CC_VERSIONED_FAMILIES.has(license) ? version : undefined +} + // rightsstatements.org is a controlled vocabulary of rights STATUS statements (not license // grants). Map each token FAITHFULLY to the closest true refkit representation rather than // collapsing all to unknown — discarding a signal the source did give us is not "honest": diff --git a/packages/core/src/rerank.ts b/packages/core/src/rerank.ts index 1a795c2..b9a6a03 100644 --- a/packages/core/src/rerank.ts +++ b/packages/core/src/rerank.ts @@ -61,6 +61,7 @@ const LICENSE_PERMISSIVENESS: Record = { 'CC0-1.0': 1, PD: 1, unsplash: 0.85, pexels: 0.85, pixabay: 0.85, 'CC-BY': 0.75, 'CC-BY-SA': 0.65, + 'CC-BY-ND': 0.55, 'CC-BY-NC': 0.45, 'CC-BY-NC-SA': 0.4, 'CC-BY-NC-ND': 0.35, unknown: 0.3, proprietary: 0.2, } diff --git a/packages/core/src/rights.ts b/packages/core/src/rights.ts index 1dbdc2f..1a107af 100644 --- a/packages/core/src/rights.ts +++ b/packages/core/src/rights.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import type { LicenseId } from './license' +import { LICENSE_IDS, type LicenseId } from './license' export type RehostPolicy = 'hotlink-required' | 'cache-allowed' | 'thumbnail-only' | 'no-store' @@ -21,10 +21,7 @@ export interface RightsRecord { raw: { sourceTerms: string; sourceUrl: string } } -const licenseIdSchema: z.ZodType = z.enum([ - 'CC0-1.0', 'CC-BY', 'CC-BY-SA', 'PD', - 'unsplash', 'pexels', 'pixabay', 'proprietary', 'unknown', -]) +const licenseIdSchema: z.ZodType = z.enum(LICENSE_IDS) export const rightsRecordSchema: z.ZodType = z.object({ license: licenseIdSchema, diff --git a/packages/provider-europeana/src/__tests__/europeana.test.ts b/packages/provider-europeana/src/__tests__/europeana.test.ts index 98ba586..1ecec6b 100644 --- a/packages/provider-europeana/src/__tests__/europeana.test.ts +++ b/packages/provider-europeana/src/__tests__/europeana.test.ts @@ -12,10 +12,10 @@ describe('mapEuropeanaRights', () => { expect(mapEuropeanaRights('https://creativecommons.org/licenses/by-sa/3.0/')).toEqual({ license: 'CC-BY-SA', version: '3.0' }) }) - it('maps NC / ND variants to proprietary (not an open grant)', () => { - expect(mapEuropeanaRights('http://creativecommons.org/licenses/by-nc/4.0/')).toEqual({ license: 'proprietary' }) - expect(mapEuropeanaRights('http://creativecommons.org/licenses/by-nc-sa/4.0/')).toEqual({ license: 'proprietary' }) - expect(mapEuropeanaRights('http://creativecommons.org/licenses/by-nd/4.0/')).toEqual({ license: 'proprietary' }) + it('maps NC / ND variants to their own CC families with version (not proprietary)', () => { + expect(mapEuropeanaRights('http://creativecommons.org/licenses/by-nc/4.0/')).toEqual({ license: 'CC-BY-NC', version: '4.0' }) + expect(mapEuropeanaRights('http://creativecommons.org/licenses/by-nc-sa/4.0/')).toEqual({ license: 'CC-BY-NC-SA', version: '4.0' }) + expect(mapEuropeanaRights('http://creativecommons.org/licenses/by-nd/4.0/')).toEqual({ license: 'CC-BY-ND', version: '4.0' }) }) it('maps rightsstatements.org faithfully: InC→proprietary, NoC-US→PD+US, NoC-NC→proprietary', () => { @@ -101,6 +101,15 @@ describe('europeana toReference', () => { expect(evaluateUse(r.rights, 'commercial-product').decision).toBe('allowed-with-attribution') }) + it('maps a CC-BY-NC item faithfully → denied for commercial use', async () => { + const ncItem = { ...ITEM_CC0, id: '/x/nc', rights: ['http://creativecommons.org/licenses/by-nc/4.0/'] } + const refs = await europeana({ apiKey: 'k' }).search({ text: 'x', modalities: ['image'] }, okCtx([ncItem])) + const r = refs[0] + expect(r.rights.license).toBe('CC-BY-NC') + expect(r.rights.licenseVersion).toBe('4.0') + expect(evaluateUse(r.rights, 'commercial-product').decision).toBe('denied') + }) + it('maps an in-copyright (InC) rights statement to proprietary → denied', async () => { const refs = await europeana({ apiKey: 'k' }).search({ text: 'photo', modalities: ['image'] }, okCtx([ITEM_INC])) const r = refs[0] diff --git a/packages/provider-europeana/src/index.ts b/packages/provider-europeana/src/index.ts index f5b1963..b9fe0b5 100644 --- a/packages/provider-europeana/src/index.ts +++ b/packages/provider-europeana/src/index.ts @@ -1,6 +1,6 @@ import { defineProvider, referenceId, - first, isLikelyImageUrl, imageMediaType, mapRightsUrl, + first, isLikelyImageUrl, imageMediaType, mapRightsUrl, ccVersionFor, type Reference, type RightsRecord, type NormalizedQuery, type ProviderContext, } from '@refkit/core' @@ -66,7 +66,10 @@ function toReference(it: EuropeanaItem): Reference | null { const rights: RightsRecord = { license, - licenseVersion: license === 'CC-BY' || license === 'CC-BY-SA' ? version : undefined, + // CC version is metadata only (attribution/audit), kept for every versioned CC family — + // NC stays denied for commercial/AI use; ND allows verbatim commercial reuse + // (allowed-with-attribution) but stays denied for AI/derivative use. + licenseVersion: ccVersionFor(license, version), // jurisdiction-scoped PD (e.g. NoC-US → PD in the US); metadata for evaluateUse. ...(jurisdiction ? { jurisdiction } : {}), author: first(it.dataProvider) ?? first(it.provider) ?? undefined, diff --git a/packages/provider-flickr/src/__tests__/flickr.test.ts b/packages/provider-flickr/src/__tests__/flickr.test.ts index f7e67e5..ff5efb5 100644 --- a/packages/provider-flickr/src/__tests__/flickr.test.ts +++ b/packages/provider-flickr/src/__tests__/flickr.test.ts @@ -25,7 +25,7 @@ const FIXTURE = { } describe('mapFlickrLicense', () => { - it('maps CC/PD/CC0 ids with versions; ARR + every NC/ND → proprietary; unknown id → unknown', () => { + it('maps CC/PD/CC0 ids with versions; ARR → proprietary; NC/ND → their CC families; unknown id → unknown', () => { expect(mapFlickrLicense('4')).toEqual({ license: 'CC-BY', version: '2.0' }) expect(mapFlickrLicense(11)).toEqual({ license: 'CC-BY', version: '4.0' }) expect(mapFlickrLicense('5')).toEqual({ license: 'CC-BY-SA', version: '2.0' }) @@ -35,8 +35,11 @@ describe('mapFlickrLicense', () => { expect(mapFlickrLicense('8')).toEqual({ license: 'PD' }) expect(mapFlickrLicense('10')).toEqual({ license: 'PD' }) expect(mapFlickrLicense('0')).toEqual({ license: 'proprietary' }) // All Rights Reserved - expect(mapFlickrLicense('3')).toEqual({ license: 'proprietary' }) // CC BY-NC-ND 2.0 - expect(mapFlickrLicense('16')).toEqual({ license: 'proprietary' }) // CC BY-NC-ND 4.0 + expect(mapFlickrLicense('3')).toEqual({ license: 'CC-BY-NC-ND', version: '2.0' }) + expect(mapFlickrLicense('16')).toEqual({ license: 'CC-BY-NC-ND', version: '4.0' }) + expect(mapFlickrLicense('6')).toEqual({ license: 'CC-BY-ND', version: '2.0' }) + expect(mapFlickrLicense('13')).toEqual({ license: 'CC-BY-ND', version: '4.0' }) + expect(mapFlickrLicense('14')).toEqual({ license: 'CC-BY-NC', version: '4.0' }) expect(mapFlickrLicense('99')).toEqual({ license: 'unknown' }) }) }) @@ -170,6 +173,25 @@ describe('flickr provider', () => { expect(url.searchParams.get('user_id')).toBe('99@N00') }) + it('commercial-only controls (no modification) include the ND ids (6, 13) since ND permits verbatim commercial reuse', async () => { + let calledUrl = '' + const ctx: ProviderContext = { + fetch: (async (input: Parameters[0]) => { + calledUrl = String(input) + return new Response(JSON.stringify(FIXTURE), { status: 200 }) + }) as typeof fetch, + } + await flickr({ apiKey: 'k' }).search({ + text: 'sunset', + modalities: ['image'], + controls: { + license: { commercial: true }, + }, + }, ctx) + const url = new URL(calledUrl) + expect(url.searchParams.get('license')).toBe('4,5,6,8,9,10,11,12,13') + }) + it('lets explicit Flickr providerOptions override equivalent unified controls', async () => { let calledUrl = '' const ctx: ProviderContext = { diff --git a/packages/provider-flickr/src/index.ts b/packages/provider-flickr/src/index.ts index 9688adc..d1ffd89 100644 --- a/packages/provider-flickr/src/index.ts +++ b/packages/provider-flickr/src/index.ts @@ -1,5 +1,5 @@ import { - defineProvider, referenceId, + defineProvider, referenceId, ccVersionFor, setIfString, setIfInt, setIfStringList, type Reference, type RightsRecord, type LicenseId, type SearchLicenseControls, type NormalizedQuery, type ProviderContext, @@ -45,26 +45,28 @@ export interface FlickrSearchOptions { } // Flickr numeric license id → our LicenseId (+ CC version). See -// flickr.photos.licenses.getInfo. All Rights Reserved (0) and every NC/ND -// variant map to 'proprietary' (→ denied for commercial/AI use). +// flickr.photos.licenses.getInfo. All Rights Reserved (0) → proprietary; NC/ND +// variants map to their CC families and gate through core's LICENSE_FACTS — +// NC stays denied for commercial/AI use; ND permits verbatim commercial reuse +// (allowed-with-attribution) but stays denied for AI/derivative use. const FLICKR_LICENSE: Record = { 0: { license: 'proprietary' }, // All Rights Reserved - 1: { license: 'proprietary' }, // CC BY-NC-SA 2.0 - 2: { license: 'proprietary' }, // CC BY-NC 2.0 - 3: { license: 'proprietary' }, // CC BY-NC-ND 2.0 + 1: { license: 'CC-BY-NC-SA', version: '2.0' }, // CC BY-NC-SA 2.0 + 2: { license: 'CC-BY-NC', version: '2.0' }, // CC BY-NC 2.0 + 3: { license: 'CC-BY-NC-ND', version: '2.0' }, // CC BY-NC-ND 2.0 4: { license: 'CC-BY', version: '2.0' }, // CC BY 2.0 5: { license: 'CC-BY-SA', version: '2.0' }, // CC BY-SA 2.0 - 6: { license: 'proprietary' }, // CC BY-ND 2.0 + 6: { license: 'CC-BY-ND', version: '2.0' }, // CC BY-ND 2.0 7: { license: 'unknown' }, // "No known copyright restrictions" — NOT a guaranteed-free grant → needs-review 8: { license: 'PD' }, // United States Government Work 9: { license: 'CC0-1.0' }, // Public Domain Dedication (CC0) 10: { license: 'PD' }, // Public Domain Mark 11: { license: 'CC-BY', version: '4.0' }, // CC BY 4.0 12: { license: 'CC-BY-SA', version: '4.0' }, // CC BY-SA 4.0 - 13: { license: 'proprietary' }, // CC BY-ND 4.0 - 14: { license: 'proprietary' }, // CC BY-NC 4.0 - 15: { license: 'proprietary' }, // CC BY-NC-SA 4.0 - 16: { license: 'proprietary' }, // CC BY-NC-ND 4.0 + 13: { license: 'CC-BY-ND', version: '4.0' }, // CC BY-ND 4.0 + 14: { license: 'CC-BY-NC', version: '4.0' }, // CC BY-NC 4.0 + 15: { license: 'CC-BY-NC-SA', version: '4.0' }, // CC BY-NC-SA 4.0 + 16: { license: 'CC-BY-NC-ND', version: '4.0' }, // CC BY-NC-ND 4.0 } /** Map a Flickr numeric license id (string or number) to our license + CC version. */ @@ -125,7 +127,7 @@ function flickrLicenseForControls(license: SearchLicenseControls | undefined): s if (!license) return undefined if (license.allowUnknown) return DEFAULT_LICENSE_FILTER if (license.commercial && license.modification) return '4,5,9,10,11,12' - if (license.commercial) return '4,5,8,9,10,11,12' + if (license.commercial) return '4,5,6,8,9,10,11,12,13' return undefined } @@ -148,7 +150,7 @@ function toReference(p: FlickrPhoto): Reference { const canonicalUrl = `https://www.flickr.com/photos/${p.owner}/${p.id}` const rights: RightsRecord = { license, - licenseVersion: license === 'CC-BY' || license === 'CC-BY-SA' ? version : undefined, + licenseVersion: ccVersionFor(license, version), author: p.ownername || undefined, rehostPolicy: 'cache-allowed', raw: { sourceTerms: 'https://www.flickr.com/help/terms', sourceUrl: canonicalUrl }, diff --git a/packages/provider-freesound/src/__tests__/freesound.test.ts b/packages/provider-freesound/src/__tests__/freesound.test.ts index 1939f48..dcc5d7e 100644 --- a/packages/provider-freesound/src/__tests__/freesound.test.ts +++ b/packages/provider-freesound/src/__tests__/freesound.test.ts @@ -42,7 +42,7 @@ describe('freesound provider', () => { expect(cc.preview?.mediaType).toBe('audio/mpeg') const nc = byId['https://freesound.org/people/bob/sounds/2/'] - expect(nc.rights.license).toBe('proprietary') + expect(nc.rights.license).toBe('CC-BY-NC') expect(evaluateUse(nc.rights, 'commercial-product').decision).toBe('denied') const cc0 = byId['https://freesound.org/people/carol/sounds/3/'] @@ -91,18 +91,18 @@ describe('freesound provider', () => { describe('mapFreesoundLicense', () => { it('maps CC name strings (D4 — no version)', () => { expect(mapFreesoundLicense('Attribution')).toEqual({ license: 'CC-BY' }) - expect(mapFreesoundLicense('Attribution NonCommercial')).toEqual({ license: 'proprietary' }) - expect(mapFreesoundLicense('Attribution Noncommercial')).toEqual({ license: 'proprietary' }) + expect(mapFreesoundLicense('Attribution NonCommercial')).toEqual({ license: 'CC-BY-NC' }) + expect(mapFreesoundLicense('Attribution Noncommercial')).toEqual({ license: 'CC-BY-NC' }) expect(mapFreesoundLicense('Creative Commons 0')).toEqual({ license: 'CC0-1.0' }) expect(mapFreesoundLicense('Sampling+')).toEqual({ license: 'proprietary' }) expect(mapFreesoundLicense('Attribution Sampling+')).toEqual({ license: 'proprietary' }) }) - it('maps CC deed URLs and extracts version for BY/BY-SA (D7)', () => { + it('maps CC deed URLs and extracts version for BY/BY-SA/BY-NC (D7)', () => { expect(mapFreesoundLicense('http://creativecommons.org/licenses/by/4.0/')).toEqual({ license: 'CC-BY', version: '4.0' }) expect(mapFreesoundLicense('http://creativecommons.org/licenses/by-sa/3.0/')).toEqual({ license: 'CC-BY-SA', version: '3.0' }) expect(mapFreesoundLicense('http://creativecommons.org/publicdomain/zero/1.0/')).toEqual({ license: 'CC0-1.0' }) - expect(mapFreesoundLicense('http://creativecommons.org/licenses/by-nc/3.0/')).toEqual({ license: 'proprietary' }) + expect(mapFreesoundLicense('http://creativecommons.org/licenses/by-nc/3.0/')).toEqual({ license: 'CC-BY-NC', version: '3.0' }) }) it('returns unknown for anything unrecognized', () => { diff --git a/packages/provider-freesound/src/index.ts b/packages/provider-freesound/src/index.ts index 32602c2..5228587 100644 --- a/packages/provider-freesound/src/index.ts +++ b/packages/provider-freesound/src/index.ts @@ -1,17 +1,17 @@ import { defineProvider, referenceId, - setIfString, setIfPositiveInt, mapCcDeedUrl, + setIfString, setIfPositiveInt, mapCcDeedUrl, ccVersionFor, type Reference, type RightsRecord, type LicenseId, type NormalizedQuery, type ProviderContext, } from '@refkit/core' // Freesound's `license` is usually a CC NAME string ("Attribution", "Creative // Commons 0") but has historically also been a CC DEED URL. Handle both. -// D4: name → family LicenseId, no version. D7: URL → family (+ version for BY/BY-SA). -// Conservative: noncommercial / sampling / unrecognized → proprietary or unknown. +// D4: name → family LicenseId, no version. D7: URL → family + version (ccVersionFor). +// Sampling+ licences are bespoke (not a clean CC free grant) → stay proprietary. const FREESOUND_NAME_LICENSE: Record = { 'attribution': { license: 'CC-BY' }, - 'attribution noncommercial': { license: 'proprietary' }, // NC → not commercially usable + 'attribution noncommercial': { license: 'CC-BY-NC' }, // NC family — commercial/AI use still gates to denied 'creative commons 0': { license: 'CC0-1.0' }, 'sampling+': { license: 'proprietary' }, // bespoke CC sampling licence, not a clean free grant 'attribution sampling+': { license: 'proprietary' }, @@ -69,8 +69,8 @@ function toAudioReference(r: FreesoundResult): Reference | null { const canonicalUrl = r.url const rights: RightsRecord = { license, - // version only ever set when the license arrived as a CC deed URL (D7); D4 omits it. - licenseVersion: license === 'CC-BY' || license === 'CC-BY-SA' ? version : undefined, + // D4 name-strings carry no version; D7 deed-URLs may — ccVersionFor keeps it only for versioned CC families. + licenseVersion: ccVersionFor(license, version), author: r.username || undefined, rehostPolicy: 'cache-allowed', raw: { sourceTerms: 'https://freesound.org/help/tos_api/', sourceUrl: canonicalUrl }, diff --git a/packages/provider-internet-archive/src/__tests__/internet-archive.test.ts b/packages/provider-internet-archive/src/__tests__/internet-archive.test.ts index 02f88ad..1d20923 100644 --- a/packages/provider-internet-archive/src/__tests__/internet-archive.test.ts +++ b/packages/provider-internet-archive/src/__tests__/internet-archive.test.ts @@ -12,10 +12,10 @@ describe('mapIaLicense', () => { expect(mapIaLicense('http://creativecommons.org/licenses/by-sa/3.0/')).toEqual({ license: 'CC-BY-SA', version: '3.0' }) }) - it('maps NC/ND variants to proprietary (D5)', () => { - expect(mapIaLicense('https://creativecommons.org/licenses/by-nc/4.0/').license).toBe('proprietary') - expect(mapIaLicense('https://creativecommons.org/licenses/by-nd/4.0/').license).toBe('proprietary') - expect(mapIaLicense('https://creativecommons.org/licenses/by-nc-sa/4.0/').license).toBe('proprietary') + it('maps NC/ND variants to their own CC families with version (D5)', () => { + expect(mapIaLicense('https://creativecommons.org/licenses/by-nc/4.0/')).toEqual({ license: 'CC-BY-NC', version: '4.0' }) + expect(mapIaLicense('https://creativecommons.org/licenses/by-nd/4.0/')).toEqual({ license: 'CC-BY-ND', version: '4.0' }) + expect(mapIaLicense('https://creativecommons.org/licenses/by-nc-sa/4.0/')).toEqual({ license: 'CC-BY-NC-SA', version: '4.0' }) }) it('falls back to unknown for absent / unrecognized URLs (D3)', () => { @@ -80,6 +80,13 @@ const DOCS = [ licenseurl: ['https://creativecommons.org/licenses/by/4.0/'], mediatype: 'movies', }, + { // CC-BY-NC movie — must keep its own family, not collapse to proprietary + identifier: 'nc_doc', + title: 'NC Clip', + creator: 'Someone', + licenseurl: 'https://creativecommons.org/licenses/by-nc/4.0/', + mediatype: 'movies', + }, ] const ctxResponding = (body: unknown, onUrl?: (u: string) => void): ProviderContext => ({ @@ -135,7 +142,19 @@ describe('internetArchive search', () => { ctxResponding({ response: { numFound: 4, docs: DOCS } }), ) expect(refs.map(r => r.canonicalUrl)).not.toContain('https://archive.org/details/some_collection') - expect(refs).toHaveLength(4) // bunny + clip + alice + arr_doc + expect(refs).toHaveLength(5) // bunny + clip + alice + arr_doc + nc_doc + }) + + it('maps a CC-BY-NC movie faithfully → denied for commercial use', async () => { + const refs = await internetArchive().search( + { text: 'nc', modalities: ['video', 'text'] }, + ctxResponding({ response: { numFound: 5, docs: DOCS } }), + ) + const nc = refs.find(r => r.canonicalUrl === 'https://archive.org/details/nc_doc')! + expect(nc).toBeDefined() + expect(nc.rights.license).toBe('CC-BY-NC') + expect(nc.rights.licenseVersion).toBe('4.0') + expect(evaluateUse(nc.rights, 'commercial-product').decision).toBe('denied') }) it('coerces array-valued title/licenseurl Solr fields to scalars (no crash)', async () => { diff --git a/packages/provider-internet-archive/src/index.ts b/packages/provider-internet-archive/src/index.ts index d7827dd..3b42e5e 100644 --- a/packages/provider-internet-archive/src/index.ts +++ b/packages/provider-internet-archive/src/index.ts @@ -1,5 +1,5 @@ import { - defineProvider, referenceId, mapRightsUrl, + defineProvider, referenceId, mapRightsUrl, ccVersionFor, type Reference, type RightsRecord, type Modality, type NormalizedQuery, type ProviderContext, } from '@refkit/core' @@ -56,7 +56,10 @@ export function toReference(doc: IaDoc): Reference | null { const { license, version, jurisdiction } = mapIaLicense(licenseurl) const rights: RightsRecord = { license, - licenseVersion: license === 'CC-BY' || license === 'CC-BY-SA' ? version : undefined, + // CC version is metadata only (attribution/audit), kept for every versioned CC family — + // NC stays denied for commercial/AI use; ND allows verbatim commercial reuse + // (allowed-with-attribution) but stays denied for AI/derivative use. + licenseVersion: ccVersionFor(license, version), // jurisdiction-scoped PD (e.g. rightsstatements NoC-US → PD in the US) ...(jurisdiction ? { jurisdiction } : {}), author: authorOf(doc.creator), diff --git a/packages/provider-jamendo/src/__tests__/jamendo.test.ts b/packages/provider-jamendo/src/__tests__/jamendo.test.ts index 0cb0b0c..336ece1 100644 --- a/packages/provider-jamendo/src/__tests__/jamendo.test.ts +++ b/packages/provider-jamendo/src/__tests__/jamendo.test.ts @@ -33,12 +33,12 @@ const TRACK_BY = { } describe('mapJamendoLicense', () => { - it('maps CC-BY and CC-BY-SA with version, NC/ND → proprietary, missing → unknown', () => { + it('maps CC-BY and CC-BY-SA with version, NC/ND → their own CC families with version, missing → unknown', () => { expect(mapJamendoLicense('http://creativecommons.org/licenses/by/4.0/')).toEqual({ license: 'CC-BY', version: '4.0' }) expect(mapJamendoLicense('https://creativecommons.org/licenses/by-sa/3.0/')).toEqual({ license: 'CC-BY-SA', version: '3.0' }) - expect(mapJamendoLicense('http://creativecommons.org/licenses/by-nc-nd/3.0/')).toEqual({ license: 'proprietary' }) - expect(mapJamendoLicense('http://creativecommons.org/licenses/by-nc/2.0/')).toEqual({ license: 'proprietary' }) - expect(mapJamendoLicense('http://creativecommons.org/licenses/by-nd/4.0/')).toEqual({ license: 'proprietary' }) + expect(mapJamendoLicense('http://creativecommons.org/licenses/by-nc-nd/3.0/')).toEqual({ license: 'CC-BY-NC-ND', version: '3.0' }) + expect(mapJamendoLicense('http://creativecommons.org/licenses/by-nc/2.0/')).toEqual({ license: 'CC-BY-NC', version: '2.0' }) + expect(mapJamendoLicense('http://creativecommons.org/licenses/by-nd/4.0/')).toEqual({ license: 'CC-BY-ND', version: '4.0' }) expect(mapJamendoLicense('')).toEqual({ license: 'unknown' }) expect(mapJamendoLicense('https://example.com/whatever')).toEqual({ license: 'unknown' }) }) @@ -70,12 +70,12 @@ describe('jamendo provider', () => { shareurl: 'https://www.jamendo.com/track/2000001', } - it('maps a CC-BY-NC-ND track to proprietary → denied for commercial use', async () => { + it('maps a CC-BY-NC-ND track faithfully → denied for commercial use', async () => { const { ctx } = ctxCapturing(envelope([TRACK_NC])) const refs = await jamendo({ clientId: 'cid' }).search({ text: 'listen', modalities: ['audio'] }, ctx) expect(refs).toHaveLength(1) - expect(refs[0].rights.license).toBe('proprietary') - expect(refs[0].rights.licenseVersion).toBeUndefined() + expect(refs[0].rights.license).toBe('CC-BY-NC-ND') + expect(refs[0].rights.licenseVersion).toBe('3.0') expect(evaluateUse(refs[0].rights, 'commercial-product').decision).toBe('denied') }) diff --git a/packages/provider-jamendo/src/index.ts b/packages/provider-jamendo/src/index.ts index fb8cb8b..4febf75 100644 --- a/packages/provider-jamendo/src/index.ts +++ b/packages/provider-jamendo/src/index.ts @@ -1,6 +1,6 @@ import { defineProvider, referenceId, - setIfString, setIfStringList, setIfBoolean, setIfNonNegativeInt, mapCcDeedUrl, + setIfString, setIfStringList, setIfBoolean, setIfNonNegativeInt, mapCcDeedUrl, ccVersionFor, type Reference, type RightsRecord, type NormalizedQuery, type ProviderContext, } from '@refkit/core' @@ -49,8 +49,9 @@ interface JamendoResponse { } // Jamendo deed URLs look like http(s)://creativecommons.org/licenses///. -// Only by/by-sa fit our enum (D5); capture the version (D7). Any nc/nd variant is -// non-commercial or no-derivatives → 'proprietary'. Missing/unrecognized → 'unknown'. +// All six CC families map faithfully with version captured (D7), including nc/nd +// variants (their own CC-BY-NC*/CC-BY-ND families — gating gets stricter, not +// 'proprietary'). Missing/unrecognized → 'unknown'. // This is exactly the core CC-deed mapper, re-exported under the jamendo-specific name // the provider's tests import. export const mapJamendoLicense = mapCcDeedUrl @@ -61,8 +62,10 @@ function toAudioReference(t: JamendoTrack, mediaType: string): Reference | null const canonicalUrl = t.shareurl const rights: RightsRecord = { license, - // CC version is metadata only (attribution/audit), kept for the BY/BY-SA family. - licenseVersion: license === 'CC-BY' || license === 'CC-BY-SA' ? version : undefined, + // CC version is metadata only (attribution/audit), kept for every versioned CC family — + // NC stays denied for commercial/AI use; ND allows verbatim commercial reuse + // (allowed-with-attribution) but stays denied for AI/derivative use. + licenseVersion: ccVersionFor(license, version), author: t.artist_name || undefined, // governed by the per-item CC license; the mp3 stream is served directly by Jamendo rehostPolicy: 'cache-allowed', diff --git a/packages/provider-openverse/src/__tests__/integration.test.ts b/packages/provider-openverse/src/__tests__/integration.test.ts index d662383..4e7ac60 100644 --- a/packages/provider-openverse/src/__tests__/integration.test.ts +++ b/packages/provider-openverse/src/__tests__/integration.test.ts @@ -35,6 +35,6 @@ describe('P1 integration: createRefkit + openverse + pexels (offline)', () => { const titles = refs.map(r => r.title) expect(titles).toContain('cc0 sky') expect(titles).toContain('pexels sky') - expect(titles).not.toContain('nc mountain') // proprietary → denied → filtered + expect(titles).not.toContain('nc mountain') // CC-BY-NC-ND → commercial denied → filtered }) }) diff --git a/packages/provider-openverse/src/__tests__/openverse.test.ts b/packages/provider-openverse/src/__tests__/openverse.test.ts index ef57715..64c3dd3 100644 --- a/packages/provider-openverse/src/__tests__/openverse.test.ts +++ b/packages/provider-openverse/src/__tests__/openverse.test.ts @@ -28,12 +28,17 @@ const ctxWith = (body: unknown): ProviderContext => ({ }) describe('mapOpenverseLicense', () => { - it('maps cc0/pdm and the BY/BY-SA family version-agnostically, NC/ND → proprietary', () => { + it('maps cc0/pdm and all six CC families faithfully; bespoke sampling deeds stay proprietary', () => { expect(mapOpenverseLicense('cc0')).toBe('CC0-1.0') expect(mapOpenverseLicense('pdm')).toBe('PD') expect(mapOpenverseLicense('by')).toBe('CC-BY') expect(mapOpenverseLicense('by-sa')).toBe('CC-BY-SA') - expect(mapOpenverseLicense('by-nc-nd')).toBe('proprietary') + expect(mapOpenverseLicense('by-nc-nd')).toBe('CC-BY-NC-ND') + expect(mapOpenverseLicense('by-nc')).toBe('CC-BY-NC') + expect(mapOpenverseLicense('by-nc-sa')).toBe('CC-BY-NC-SA') + expect(mapOpenverseLicense('by-nd')).toBe('CC-BY-ND') + expect(mapOpenverseLicense('sampling+')).toBe('proprietary') + expect(mapOpenverseLicense('nc-sampling+')).toBe('proprietary') expect(mapOpenverseLicense('whatever')).toBe('unknown') }) }) @@ -177,11 +182,14 @@ describe('openverse provider', () => { expect(evaluateUse(result.rights, 'commercial-product').decision).toBe('allowed-with-attribution') }) - it('END-TO-END moat: a real by-nc-nd item maps to proprietary and is denied for commercial use', async () => { + it('END-TO-END moat: a real by-nc-nd item keeps its family, version, and is denied for commercial use', async () => { const refs = await openverse().search({ text: 'sky', modalities: ['image'] }, ctxWith(FIXTURE)) const ncnd = refs[1] - expect(ncnd.rights.license).toBe('proprietary') - expect(evaluateUse(ncnd.rights, 'commercial-product').decision).toBe('denied') + expect(ncnd.rights.license).toBe('CC-BY-NC-ND') + expect(ncnd.rights.licenseVersion).toBe('2.0') + const verdict = evaluateUse(ncnd.rights, 'commercial-product') + expect(verdict.decision).toBe('denied') + expect(verdict.reasons.join(' ')).toContain('CC-BY-NC-ND') // ...while the cc0 item is allowed: expect(evaluateUse(refs[0].rights, 'commercial-product').decision).toBe('allowed') }) @@ -260,10 +268,11 @@ describe('openverseAudio provider', () => { expect(url.searchParams.get('size')).toBeNull() }) - it('a by-nc audio item maps to proprietary and is denied for commercial use (moat)', async () => { + it('a by-nc audio item keeps its family and version, still denied for commercial use (moat)', async () => { const NC = { results: [{ ...AUDIO.results[0], license: 'by-nc', license_version: '3.0' }] } const refs = await openverseAudio().search({ text: 'x', modalities: ['audio'] }, ctxWith(NC)) - expect(refs[0].rights.license).toBe('proprietary') + expect(refs[0].rights.license).toBe('CC-BY-NC') + expect(refs[0].rights.licenseVersion).toBe('3.0') expect(evaluateUse(refs[0].rights, 'commercial-product').decision).toBe('denied') }) }) diff --git a/packages/provider-openverse/src/index.ts b/packages/provider-openverse/src/index.ts index 300b91c..a5e06e6 100644 --- a/packages/provider-openverse/src/index.ts +++ b/packages/provider-openverse/src/index.ts @@ -1,6 +1,7 @@ import { - defineProvider, referenceId, + defineProvider, referenceId, ccVersionFor, setIfString, setIfStringList, setIfBoolean, setIfPositiveInt, setIfNumber, + CC_FAMILY_BY_TOKEN, type Reference, type RightsRecord, type LicenseId, type NormalizedQuery, type ProviderContext, } from '@refkit/core' @@ -61,22 +62,21 @@ interface OpenverseResponse { results: OpenverseResult[] } // Map Openverse's per-item `license` code to our LicenseId. The CC version is // captured separately (rights.licenseVersion) and doesn't change the permission -// family, so BY/BY-SA map regardless of version; NC/ND variants → 'proprietary'. +// family, so all six CC families (BY/BY-SA/BY-NC/BY-NC-SA/BY-NC-ND/BY-ND) map +// regardless of version; commercial/modification permissions still gate through +// core's LICENSE_FACTS (NC stays denied for commercial, ND for modification). +// The six CC-family codes are identical to core's CC_FAMILY_BY_TOKEN keys, so +// they're delegated there instead of duplicated. Bespoke sampling deeds aren't +// clean family grants, so they stay 'proprietary'. export function mapOpenverseLicense(code: string): LicenseId { switch (code) { case 'cc0': return 'CC0-1.0' case 'pdm': return 'PD' - case 'by': return 'CC-BY' - case 'by-sa': return 'CC-BY-SA' - case 'by-nc': - case 'by-nc-sa': - case 'by-nc-nd': - case 'by-nd': case 'sampling': case 'sampling+': case 'nc-sampling+': - return 'proprietary' - default: return 'unknown' + return 'proprietary' // bespoke sampling licences — not clean family grants + default: return CC_FAMILY_BY_TOKEN[code] ?? 'unknown' } } @@ -126,8 +126,8 @@ function toReference(r: OpenverseResult): Reference { const license = mapOpenverseLicense(r.license) const rights: RightsRecord = { license, - // CC version is metadata only (attribution/audit), captured for the BY/BY-SA family. - licenseVersion: license === 'CC-BY' || license === 'CC-BY-SA' ? r.license_version : undefined, + // CC version is metadata only (attribution/audit), captured for all CC families. + licenseVersion: ccVersionFor(license, r.license_version), author: r.creator ?? undefined, // governed by the per-item CC/PD license (Openverse imposes no hotlink/download-trigger requirement) rehostPolicy: 'cache-allowed', @@ -196,7 +196,8 @@ function toAudioReference(r: OpenverseAudioResult): Reference { const license = mapOpenverseLicense(r.license) const rights: RightsRecord = { license, - licenseVersion: license === 'CC-BY' || license === 'CC-BY-SA' ? r.license_version : undefined, + // CC version is metadata only (attribution/audit), captured for all CC families. + licenseVersion: ccVersionFor(license, r.license_version), author: r.creator ?? undefined, rehostPolicy: 'cache-allowed', raw: { sourceTerms: r.license_url, sourceUrl: r.foreign_landing_url }, diff --git a/packages/provider-rijksmuseum/src/index.ts b/packages/provider-rijksmuseum/src/index.ts index 7bd2792..25c5df8 100644 --- a/packages/provider-rijksmuseum/src/index.ts +++ b/packages/provider-rijksmuseum/src/index.ts @@ -1,6 +1,6 @@ import { defineProvider, referenceId, - setIfString, setIfBoolean, mapRightsUrl, isLikelyImageUrl, + setIfString, setIfBoolean, mapRightsUrl, isLikelyImageUrl, ccVersionFor, type Reference, type RightsRecord, type NormalizedQuery, type ProviderContext, } from '@refkit/core' @@ -139,7 +139,11 @@ function toReference(rec: Record): Reference | null { const { license, version, jurisdiction } = mapRightsUrl(findRightsUrl(rec)) const rights: RightsRecord = { license, - licenseVersion: license === 'CC-BY' || license === 'CC-BY-SA' ? version : undefined, + // CC version is metadata only (attribution/audit), kept for every versioned CC family + // (BY/BY-SA/NC/ND variants). Rijksmuseum open-access items are typically CC0/PD, so this + // is behavior-neutral here in practice — kept for consistency with the other URL-mapped + // providers so no stale guard survives. + licenseVersion: ccVersionFor(license, version), // jurisdiction-scoped status (e.g. rightsstatements NoC-US → PD in the US) ...(jurisdiction ? { jurisdiction } : {}), author: findCreator(rec) || undefined, diff --git a/packages/provider-wikimedia-commons/src/__tests__/wikimedia-commons.test.ts b/packages/provider-wikimedia-commons/src/__tests__/wikimedia-commons.test.ts index 65744e4..499e41a 100644 --- a/packages/provider-wikimedia-commons/src/__tests__/wikimedia-commons.test.ts +++ b/packages/provider-wikimedia-commons/src/__tests__/wikimedia-commons.test.ts @@ -50,20 +50,22 @@ const FIXTURE = { } describe('mapCommonsLicense', () => { - it('maps cc0 / cc-by / cc-by-sa (any version) / pd; NC-ND → proprietary; blank/unknown → unknown', () => { + it('maps cc0 / all six CC families (any version, jurisdiction ports) / pd; blank/unknown → unknown', () => { expect(mapCommonsLicense('cc0')).toEqual({ license: 'CC0-1.0' }) expect(mapCommonsLicense('cc-by-4.0')).toEqual({ license: 'CC-BY', version: '4.0' }) expect(mapCommonsLicense('cc-by-2.0')).toEqual({ license: 'CC-BY', version: '2.0' }) expect(mapCommonsLicense('cc-by-sa-3.0')).toEqual({ license: 'CC-BY-SA', version: '3.0' }) expect(mapCommonsLicense('cc-by-sa-2.5-in')).toEqual({ license: 'CC-BY-SA', version: '2.5' }) // jurisdiction port expect(mapCommonsLicense('cc-by-3.0-us')).toEqual({ license: 'CC-BY', version: '3.0' }) - expect(mapCommonsLicense('cc-by-nc-2.0')).toEqual({ license: 'proprietary' }) - expect(mapCommonsLicense('cc-by-nd-4.0')).toEqual({ license: 'proprietary' }) + expect(mapCommonsLicense('cc-by-nc-2.0')).toEqual({ license: 'CC-BY-NC', version: '2.0' }) + expect(mapCommonsLicense('cc-by-nd-4.0')).toEqual({ license: 'CC-BY-ND', version: '4.0' }) + expect(mapCommonsLicense('cc-by-nc-sa-3.0-de')).toEqual({ license: 'CC-BY-NC-SA', version: '3.0' }) // jurisdiction port expect(mapCommonsLicense('pd')).toEqual({ license: 'PD' }) expect(mapCommonsLicense('pd-old-100')).toEqual({ license: 'PD' }) expect(mapCommonsLicense('')).toEqual({ license: 'unknown' }) expect(mapCommonsLicense(undefined)).toEqual({ license: 'unknown' }) expect(mapCommonsLicense('fair use')).toEqual({ license: 'unknown' }) + expect(mapCommonsLicense('cc-by-nc')).toEqual({ license: 'unknown' }) // versionless NC stays out of the family branch }) }) diff --git a/packages/provider-wikimedia-commons/src/index.ts b/packages/provider-wikimedia-commons/src/index.ts index 91f218a..c86807b 100644 --- a/packages/provider-wikimedia-commons/src/index.ts +++ b/packages/provider-wikimedia-commons/src/index.ts @@ -1,6 +1,7 @@ import { defineProvider, referenceId, setIfString, setIfNonNegativeInt, setIfPositiveInt, setIfBoolean, + CC_FAMILY_BY_TOKEN, ccVersionFor, type Reference, type RightsRecord, type LicenseId, type NormalizedQuery, type ProviderContext, } from '@refkit/core' @@ -26,20 +27,24 @@ export interface WikimediaCommonsSearchOptions { iiextmetadatafilter?: string | readonly string[] } -// Map a Wikimedia Commons extmetadata `License` code (e.g. "cc-by-sa-4.0", "cc0", -// "pd-old") to our LicenseId + CC version. NC/ND variants → 'proprietary'; anything -// unrecognized — including non-free / fair-use files — → 'unknown' (strict-deny → -// needs-review), so we never present unclear-rights media as reusable. +// Map a Wikimedia Commons extmetadata `License` code (e.g. "cc-by-sa-4.0", "cc-by-nc-2.0", +// "cc0", "pd-old") to our LicenseId + CC version. All six CC families (by/by-sa/by-nd/ +// by-nc/by-nc-sa/by-nc-nd) map faithfully, version included; anything unrecognized — +// including non-free / fair-use files — → 'unknown' (strict-deny → needs-review), so we +// never present unclear-rights media as reusable. Faithful mapping is not the same as +// permissive gating: NC families still deny commercial/AI use downstream, and ND now +// yields allowed-with-attribution for verbatim commercial reuse while AI/derivatives +// remain denied — see evaluateUse. export function mapCommonsLicense(code: string | undefined): { license: LicenseId; version?: string } { const c = (code ?? '').trim().toLowerCase() if (!c) return { license: 'unknown' } if (c === 'cc0' || c.startsWith('cc0-')) return { license: 'CC0-1.0' } - if (c.includes('-nc') || c.includes('-nd')) return { license: 'proprietary' } // tolerate jurisdiction ports (e.g. cc-by-sa-2.5-in, cc-by-3.0-us) — same permission family - const sa = c.match(/^cc-by-sa-(\d+\.\d+)(?:-[a-z]{2,})?$/) - if (sa) return { license: 'CC-BY-SA', version: sa[1] } - const by = c.match(/^cc-by-(\d+\.\d+)(?:-[a-z]{2,})?$/) - if (by) return { license: 'CC-BY', version: by[1] } + const m = c.match(/^cc-(by(?:-nc)?(?:-sa|-nd)?)-(\d+\.\d+)(?:-[a-z]{2,})?$/) + if (m) { + const license = CC_FAMILY_BY_TOKEN[m[1]] + if (license) return { license, version: m[2] } + } if (c === 'pd' || c.startsWith('pd-') || c.startsWith('public') || c.includes('publicdomain')) { return { license: 'PD' } } @@ -93,7 +98,7 @@ function toReference(page: CommonsPage): Reference | null { const title = pickTitle(emVal(info.extmetadata, 'ObjectName'), page.title) const rights: RightsRecord = { license, - licenseVersion: license === 'CC-BY' || license === 'CC-BY-SA' ? version : undefined, + licenseVersion: ccVersionFor(license, version), author: author || undefined, rehostPolicy: 'cache-allowed', raw: {