From 06398c7e9ba9c1571ab1559441c2241a2efd2d9f Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:53:00 +0000 Subject: [PATCH 01/20] feat(pod-migrator): scaffold app manifest and design record Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KLrUernPrjVRT4K7UiaGgK --- apps/pod-migrator/docs/DESIGN.md | 195 +++++++++++++++++++++++++++++ apps/pod-migrator/package.json | 51 ++++++++ pnpm-lock.yaml | 205 +++++++++++++++++++------------ suite.json | 5 + 4 files changed, 376 insertions(+), 80 deletions(-) create mode 100644 apps/pod-migrator/docs/DESIGN.md create mode 100644 apps/pod-migrator/package.json diff --git a/apps/pod-migrator/docs/DESIGN.md b/apps/pod-migrator/docs/DESIGN.md new file mode 100644 index 000000000..66eb83cbf --- /dev/null +++ b/apps/pod-migrator/docs/DESIGN.md @@ -0,0 +1,195 @@ + + +# Pod Migrator design + +Pod Migrator copies everything a user holds in one Solid pod into another pod, in either +direction, and carries the access controls across. The first-class pair is Inrupt PodSpaces +(ESS, Access Control Policies) and solidcommunity.net (Web Access Control). Same-model +migrations (WAC to WAC, ACP to ACP) use the same pipeline. + +The one rule that overrides every other consideration: **a migration never makes any resource +more accessible than it was in the source, at any moment, including partway through.** When a +rule cannot be expressed faithfully, the migrator either drops it, substitutes something +strictly narrower, or stops. It never substitutes something broader. + +## 1. Two sessions, two fences + +The app holds two independent `createSolidAuth()` instances from `@jeswr/solid-auth-core`, +`source` and `target`. Each has its own `dbName`, `rememberedAccountsKey`, `recentAccountsKey`, +`redirectFlowKey`, `redirectSentinelKey` and `redirectUri` (`/callback/source`, +`/callback/target`), and `patchGlobalFetch: false`, so neither touches the global fetch and the +two credentials can never be confused. + +Each instance is armed with `includeIssuerOrigin: false`. After login the app reads the +authenticated WebID profile, takes `pim:storage` (asking the user to choose when there are +several), and calls `reArmAllowedOrigins([storageOrigin])`. + +The migration engine never sees `authenticatedFetch` directly. It gets a `PodHttp` per side +(`src/engine/pod-http.ts`), which: + +- accepts only a string URL (never a `Request`, never a `URL` subclass), parses it, and requires + it to be inside that side's storage root by path, not just by origin (`assertWithinPodScope` + from `@jeswr/guarded-fetch`). PodSpaces serves every user's pod from the same origin, so + origin alone is not a fence; +- builds the request from that checked string only (this sidesteps the shadowed-`Request` + credential leak tracked as sst-m040, which needs the caller to hand a crafted `Request` to the + token provider); +- sends `redirect: "manual"` and treats any 3xx as an error; +- refuses caller-supplied `Authorization` and `DPoP` headers; +- validates any `Location` it acts on against the same root. + +Data crosses from source to target only as bytes plus a content type. No header, URL or +`Request` object from one side is ever passed to the other side's `PodHttp`. + +## 2. Pipeline + +1. **Crawl** (read only). Walk the source root breadth first through `ldp:contains`, read with + `@jeswr/fetch-rdf` and `@solid/object`'s `Container`. Skip auxiliary resources (`.acl`, + `.acr`, `.meta`, anything advertised as `rel="acl"` or `rel="describedby"`). For every + resource record URL, kind, content type, size, and its ACL/ACR URL from the `Link` header. +2. **Read access** (read only). Detect the access-control system per side (see section 4) and + read every ACL or ACR into the neutral model (section 3). Unreadable rules (403 / 404 on an + advertised ACL) become report items. +3. **Plan** (pure). Compute each source resource's effective permissions, translate them to the + target's system, and collect every place the translation is not faithful as a `ReportItem`. + Also check the target: existing resources at the same path, the target's own root ACL and + WebID profile document. +4. **Review** (UI). Show the plan: resource counts, bytes, and every report item with its + options. Nothing has been written yet. The user resolves each item or aborts. Resolutions can + be applied in bulk per item kind. +5. **Execute** (writes, target only). Three phases, in order: + - *Lock.* Create each target container top down. Immediately after creating one, write an + owner-only ACL/ACR on it (owner gets Read, Write, Control on the container and its members; + nothing else). Before writing any resource directly into a target container that already + existed before the migration, first create it empty, lock it owner-only, then write its + content. + - *Copy.* PUT each resource's bytes with its content type and `If-None-Match: *` (never + overwrite unless the user chose "overwrite" for that path). RDF documents may optionally have + source-pod IRIs rewritten to target-pod IRIs (parse with `@jeswr/fetch-rdf`, rewrite terms, + serialise with `n3.Writer`); off by default the bytes are copied verbatim. + - *Grant.* Write the final ACLs/ACRs **bottom up** (deepest first, containers after their + members). Every intermediate state is a subset of the final state, which is a subset of the + source. +6. **Verify.** Re-read each written ACL/ACR, re-parse it, and compare it with the planned rules. + Read back each copied resource's size or hash. Produce a final report (downloadable JSON). + +Cancel is available at every step. A cancel during execute stops after the in-flight request; +everything already copied stays owner-only or at its final, never-wider state, and the report +lists what was and wasn't done. The run log is kept in `sessionStorage` so a reload can show it. + +## 3. Neutral model + +```ts +type Mode = "read" | "append" | "write" | "control"; +type Subject = + | { kind: "agent"; webId: string } + | { kind: "public" } // foaf:Agent / acp:PublicAgent + | { kind: "authenticated" } // acl:AuthenticatedAgent / acp:AuthenticatedAgent + | { kind: "group"; group: string } // WAC acl:agentGroup (vcard:Group IRI) + | { kind: "creator" } | { kind: "owner" } // ACP only + | { kind: "client"; clientId: string } // ACP acp:client + | { kind: "issuer"; issuer: string } // ACP acp:issuer + | { kind: "vc"; vcType: string } // ACP acp:vc + | { kind: "origin"; origin: string }; // WAC acl:origin +interface Grant { + modes: Set; + /** Disjunction of conjunctions: the grant applies when every subject in ANY clause matches. */ + who: Subject[][]; + /** Subjects that must NOT match (ACP noneOf). */ + except: Subject[]; + appliesTo: "resource" | "members" | "both"; + source: { url: string; node: string }; // provenance for the report +} +interface Denial { modes: Set; who: Subject[][]; except: Subject[]; appliesTo: ...; source: ... } +interface EffectivePolicy { grants: Grant[]; denials: Denial[] } // per resource, after inheritance +``` + +`write` implies `append` in both systems; normalise on read. + +### Reading WAC + +Per the WAC spec: an authorization with `acl:accessTo` applies to the resource, `acl:default` +to the container's members. Subjects are the union of `acl:agent`, `acl:agentClass` +(`foaf:Agent` = public, `acl:AuthenticatedAgent`), and `acl:agentGroup`. `acl:origin` is an +extra condition ANDed with the subject (one clause per subject, each with the origin added). +Effective policy of a resource: its own ACL's `accessTo` authorizations when it has an ACL; +otherwise the `default` authorizations of the nearest ancestor ACL. A child ACL replaces, never +merges with, the inherited defaults. + +### Reading ACP + +Per the ACP spec: an ACR's `acp:accessControl` applies to the resource, `acp:memberAccessControl` +to all descendants and is **additive**: a descendant cannot remove an ancestor's member +controls. Each access control applies policies; a policy has `acp:allow` / `acp:deny` modes and +`acp:allOf` (all matchers must match), `acp:anyOf` (at least one), `acp:noneOf` (none). A +matcher matches when any of its `acp:agent` / `acp:client` / `acp:issuer` / `acp:vc` values +match; `acp:agent` may be `acp:PublicAgent`, `acp:AuthenticatedAgent`, `acp:CreatorAgent`, +`acp:OwnerAgent`. A mode is granted when an applicable policy allows it and no applicable +policy denies it. Control is access to the ACR itself (`acp:access` / control mode as the +server implements it; verify against both the spec copy and CSS's ACP implementation). +Translate `allOf`/`anyOf`/`noneOf` into the `who`/`except` normal form; a policy whose +expansion exceeds 64 clauses is a report item (`too-complex`) rather than silently truncated. + +## 4. Detecting the system + +Follow `Link: <…>; rel="acl"` from the root. If that resource is advertised as +`acp:AccessControlResource` (Link `type`) or parses to triples using the ACP namespace, the side +is ACP; if it uses `acl:Authorization`, WAC. If the root has no ACL link, or the two signals +conflict, the side is `unknown` and the plan refuses to translate permissions (the user can +still copy data with everything owner-only). + +## 5. Translation rules + +Translation works on effective policies, never on raw documents. The target owner (the WebID +signed in on the target) always gets `read`, `write`, `control` on everything written, so the +migration can never lock the user out; this is not a widening because the owner already +controls the target pod. + +| Source construct | To WAC | To ACP | If not faithful | +|---|---|---|---| +| agent, public, authenticated | same | same | — | +| WAC `agentGroup` | same | no equivalent | `group-unsupported`: **skip** (drop the grant) / **expand** to the group's current members, read at plan time, shown as a list, with a warning that later membership changes won't follow / **abort** | +| WAC `acl:origin` | same | no equivalent (`acp:client` is a client id, not an origin) | `origin-unsupported`: **skip** / **abort**. Dropping the origin condition would widen, so it is never offered | +| ACP `client`, `issuer`, `vc` in a clause | no equivalent | same | `condition-unsupported`: **skip the clause** / **abort**. Never drop just the condition | +| ACP `noneOf` exclusions | no equivalent | same | `exclusion-unsupported`: **skip the grant** / **abort** | +| ACP `deny` | subtract it when the denial's subjects exactly cover a grant's clause; otherwise | same | `deny-unsupported`: **drop every grant the denial overlaps** (narrowest) / **abort** | +| ACP `creator` / `owner` | target owner WebID | same | info item `owner-mapped` | +| Grant to the source owner's WebID | kept verbatim (it is the same person, and they may still use that WebID) | same | info item | +| WAC child ACL narrower than an inherited default | write the child ACL | ACP inheritance is additive, so the parent's member rules would reach the child | `inheritance-narrowed`: **per-resource** (no member controls on that parent; every existing descendant gets its own explicit controls; new files added later inherit nothing) / **abort** | +| ACP member controls + own controls | child ACL = union of own and inherited | same | — | +| Unreadable ACL/ACR | — | — | `acl-unreadable`: **owner-only** / **skip the resource** / **abort** | +| Unknown access-control system on either side | — | — | `acl-system-unknown`: **owner-only for everything** / **abort** | +| Target path already exists | — | — | `target-exists`: **skip** / **overwrite content, keep target permissions** / **abort** | +| Target WebID profile document, target root ACL/ACR | never overwritten | never overwritten | `protected-target`: **skip** / **merge grants** (root only: add the translated root grants to the existing root ACL, keeping its owner Control) / **abort** | +| Source `.meta` / description resources | not copied | not copied | info item | + +Each report item records the source URL, the source rule text (the triples involved, as Turtle), +what each option would write, and a short plain-English explanation. Every "nearest safe" option +is checked mechanically: `assertNotWider(sourceEffective, plannedTargetEffective)` runs over +every resource before execute, and execute refuses to start if it fails. + +## 6. Writing ACLs and ACRs + +Serialise with `n3.Writer` only, never by string concatenation. WAC: one `acl:Authorization` +per (subject set, modes, scope). ACP: one policy + matcher per grant clause, attached with +`acp:accessControl` and, where planned, `acp:memberAccessControl`. ACR writes go to the ACR URL +from the resource's `Link rel="acl"`; ESS and CSS both accept PUT of Turtle there. Never write an +ACL/ACR that lacks the owner's Control. + +## 7. Testing + +- Unit (Vitest, pure): WAC and ACP readers, effective-policy computation, every translation row + above, `assertNotWider` (property tests over random policies: translated output is never wider), + plan ordering (lock top down, grant bottom up), `PodHttp` fences (path scope on a shared origin, + redirects, header stripping, `Request` objects refused, `@` / backslash / percent-encoding + tricks). +- Integration (`pnpm test:css`, not in the default gate): two local Community Solid Server + instances, one WAC (`@css:config/file.json`-style) and one ACP + (`ldp/authorization/acp.json`), seeded with resources and permissions, then migrate WAC→ACP and + ACP→WAC with client-credential DPoP tokens, and assert data and effective permissions through + anonymous and second-account requests. +- UI: Playwright screenshots at 375 / 768 / 1280 px, light and dark. + +PodSpaces itself can only be tested with a real account; ESS specifics (ACR URL form, any extra +policies it requires) are isolated in `src/engine/systems/acp.ts` so they can be adjusted after +a live run. diff --git a/apps/pod-migrator/package.json b/apps/pod-migrator/package.json new file mode 100644 index 000000000..00b9e93f3 --- /dev/null +++ b/apps/pod-migrator/package.json @@ -0,0 +1,51 @@ +{ + "name": "@jeswr/app-pod-migrator", + "version": "0.0.1", + "private": true, + "type": "module", + "description": "Pod Migrator: copy everything from one Solid pod to another and translate access controls between ACP and WAC without ever widening access. Experimental, AI-agent-generated.", + "securityCritical": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "lint": "biome check src test vite.config.ts vitest.config.ts", + "lint:fix": "biome check --write src test vite.config.ts vitest.config.ts", + "typecheck": "tsc --noEmit", + "test": "vitest run --coverage", + "test:css": "node test/css/run.mjs", + "test:e2e": "playwright test" + }, + "dependencies": { + "@jeswr/app-shell": "workspace:*", + "@jeswr/fetch-rdf": "workspace:*", + "@jeswr/guarded-fetch": "workspace:*", + "@jeswr/solid-auth-core": "workspace:*", + "@jeswr/storage-capabilities": "workspace:*", + "@rdfjs/wrapper": "0.34.0", + "@solid/object": "0.6.0", + "n3": "2.0.3", + "react": "19.2.7", + "react-dom": "19.2.7" + }, + "devDependencies": { + "@biomejs/biome": "2.5.0", + "@rdfjs/types": "2.0.1", + "@tailwindcss/vite": "4.3.1", + "@testing-library/jest-dom": "6.9.1", + "@testing-library/react": "16.3.2", + "@types/n3": "1.26.1", + "@types/node": "22.19.3", + "@types/react": "19.2.17", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "5.2.0", + "jsdom": "26.1.0", + "tailwindcss": "4.3.1", + "typescript": "5.7.3", + "vite": "7.3.5", + "vitest": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@playwright/test": "1.60.0", + "@solid/community-server": "7.2.0" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b94494a59..9d1732175 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1625,6 +1625,94 @@ importers: specifier: ^4.1.8 version: 4.1.10(@opentelemetry/api@1.10.0)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1)(vite@8.1.4(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.51.2)) + apps/pod-migrator: + dependencies: + '@jeswr/app-shell': + specifier: workspace:* + version: link:../../packages/app-shell + '@jeswr/fetch-rdf': + specifier: workspace:* + version: link:../../packages/fetch-rdf + '@jeswr/guarded-fetch': + specifier: workspace:* + version: link:../../packages/guarded-fetch + '@jeswr/solid-auth-core': + specifier: workspace:* + version: link:../../packages/solid-auth-core + '@jeswr/storage-capabilities': + specifier: workspace:* + version: link:../../packages/storage-capabilities + '@rdfjs/wrapper': + specifier: 0.34.0 + version: 0.34.0 + '@solid/object': + specifier: 0.6.0 + version: 0.6.0 + n3: + specifier: 2.0.3 + version: 2.0.3 + react: + specifier: 19.2.7 + version: 19.2.7 + react-dom: + specifier: 19.2.7 + version: 19.2.7(react@19.2.7) + devDependencies: + '@biomejs/biome': + specifier: 2.5.0 + version: 2.5.0 + '@playwright/test': + specifier: 1.60.0 + version: 1.60.0 + '@rdfjs/types': + specifier: 2.0.1 + version: 2.0.1 + '@solid/community-server': + specifier: 7.2.0 + version: 7.2.0(jiti@2.7.0) + '@tailwindcss/vite': + specifier: 4.3.1 + version: 4.3.1(vite@7.3.5(@types/node@22.19.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)) + '@testing-library/jest-dom': + specifier: 6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: 16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@types/n3': + specifier: 1.26.1 + version: 1.26.1 + '@types/node': + specifier: 22.19.3 + version: 22.19.3 + '@types/react': + specifier: 19.2.17 + version: 19.2.17 + '@types/react-dom': + specifier: 19.2.3 + version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react': + specifier: 5.2.0 + version: 5.2.0(vite@7.3.5(@types/node@22.19.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)) + '@vitest/coverage-v8': + specifier: 4.1.10 + version: 4.1.10(vitest@4.1.10) + jsdom: + specifier: 26.1.0 + version: 26.1.0 + tailwindcss: + specifier: 4.3.1 + version: 4.3.1 + typescript: + specifier: 5.7.3 + version: 5.7.3 + vite: + specifier: 7.3.5 + version: 7.3.5(@types/node@22.19.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2) + vitest: + specifier: 4.1.10 + version: 4.1.10(@opentelemetry/api@1.10.0)(@types/node@22.19.3)(@vitest/coverage-v8@4.1.10)(jsdom@26.1.0)(vite@7.3.5(@types/node@22.19.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)) + apps/pod-money: dependencies: '@jeswr/fetch-rdf': @@ -8448,6 +8536,11 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/test@1.60.0': + resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} + engines: {node: '>=18'} + hasBin: true + '@playwright/test@1.61.1': resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} engines: {node: '>=18'} @@ -14452,11 +14545,21 @@ packages: resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} engines: {node: '>=8'} + playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} + engines: {node: '>=18'} + hasBin: true + playwright-core@1.61.1: resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} engines: {node: '>=18'} hasBin: true + playwright@1.60.0: + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} + engines: {node: '>=18'} + hasBin: true + playwright@1.61.1: resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} engines: {node: '>=18'} @@ -16138,46 +16241,6 @@ packages: yaml: optional: true - vite@7.3.6: - resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 - jiti: '>=1.21.0' - less: ^4.0.0 - lightningcss: ^1.21.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - vite@8.1.4: resolution: {integrity: sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -23711,6 +23774,10 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/test@1.60.0': + dependencies: + playwright: 1.60.0 + '@playwright/test@1.61.1': dependencies: playwright: 1.61.1 @@ -26474,21 +26541,21 @@ snapshots: optionalDependencies: vite: 5.4.21(@types/node@24.13.3)(lightningcss@1.32.0)(terser@5.51.2) - '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2))': + '@vitest/mocker@3.2.7(vite@7.3.5(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2))': dependencies: '@vitest/spy': 3.2.7 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.6(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2) + vite: 7.3.5(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2) - '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2))': + '@vitest/mocker@3.2.7(vite@7.3.5(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2))': dependencies: '@vitest/spy': 3.2.7 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2) + vite: 7.3.5(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2) '@vitest/mocker@4.1.10(vite@7.3.5(@types/node@22.19.21)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2))': dependencies: @@ -30760,8 +30827,16 @@ snapshots: dependencies: find-up: 3.0.0 + playwright-core@1.60.0: {} + playwright-core@1.61.1: {} + playwright@1.60.0: + dependencies: + playwright-core: 1.60.0 + optionalDependencies: + fsevents: 2.3.2 + playwright@1.61.1: dependencies: playwright-core: 1.61.1 @@ -33127,36 +33202,6 @@ snapshots: lightningcss: 1.32.0 terser: 5.51.2 - vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2): - dependencies: - esbuild: 0.28.1 - fdir: 6.5.0(picomatch@4.0.5) - picomatch: 4.0.5 - postcss: 8.5.17 - rollup: 4.62.2 - tinyglobby: 0.2.17 - optionalDependencies: - '@types/node': 20.19.43 - fsevents: 2.3.3 - jiti: 2.7.0 - lightningcss: 1.32.0 - terser: 5.51.2 - - vite@7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2): - dependencies: - esbuild: 0.28.1 - fdir: 6.5.0(picomatch@4.0.5) - picomatch: 4.0.5 - postcss: 8.5.17 - rollup: 4.62.2 - tinyglobby: 0.2.17 - optionalDependencies: - '@types/node': 22.20.1 - fsevents: 2.3.3 - jiti: 2.7.0 - lightningcss: 1.32.0 - terser: 5.51.2 - vite@8.1.4(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.51.2): dependencies: lightningcss: 1.32.0 @@ -33323,7 +33368,7 @@ snapshots: dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.7 - '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)) + '@vitest/mocker': 3.2.7(vite@7.3.5(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)) '@vitest/pretty-format': 3.2.7 '@vitest/runner': 3.2.7 '@vitest/snapshot': 3.2.7 @@ -33341,7 +33386,7 @@ snapshots: tinyglobby: 0.2.17 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.6(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2) + vite: 7.3.5(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2) vite-node: 3.2.4(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2) why-is-node-running: 2.3.0 optionalDependencies: @@ -33365,7 +33410,7 @@ snapshots: dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.7 - '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)) + '@vitest/mocker': 3.2.7(vite@7.3.5(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)) '@vitest/pretty-format': 3.2.7 '@vitest/runner': 3.2.7 '@vitest/snapshot': 3.2.7 @@ -33383,7 +33428,7 @@ snapshots: tinyglobby: 0.2.17 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2) + vite: 7.3.5(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2) vite-node: 3.2.4(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2) why-is-node-running: 2.3.0 optionalDependencies: @@ -33407,7 +33452,7 @@ snapshots: dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.7 - '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)) + '@vitest/mocker': 3.2.7(vite@7.3.5(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)) '@vitest/pretty-format': 3.2.7 '@vitest/runner': 3.2.7 '@vitest/snapshot': 3.2.7 @@ -33425,7 +33470,7 @@ snapshots: tinyglobby: 0.2.17 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2) + vite: 7.3.5(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2) vite-node: 3.2.4(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2) why-is-node-running: 2.3.0 optionalDependencies: diff --git a/suite.json b/suite.json index b840128a4..d38da1fe7 100644 --- a/suite.json +++ b/suite.json @@ -173,6 +173,11 @@ "slug": "extension", "name": "@jeswr/app-extension", "kind": "application" + }, + { + "slug": "pod-migrator", + "name": "@jeswr/app-pod-migrator", + "kind": "application" } ], "dependsOn": [], From 06e85274012b3e8c819257b9299225ef2ef35954 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:21:24 +0000 Subject: [PATCH 02/20] feat(pod-migrator): migration engine with never-widen ACP/WAC translation Crawl, neutral access model, WAC and ACP readers and writers, effective-policy computation, translation with report items, assertNotWider proof, and an executor that locks top down, copies, and grants bottom up. Implemented by Codex GPT-6 (xhigh) from the design record; reviewed by Claude. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KLrUernPrjVRT4K7UiaGgK --- apps/pod-migrator/src/engine/README.md | 62 ++ apps/pod-migrator/src/engine/access.ts | 116 ++++ apps/pod-migrator/src/engine/executor.ts | 371 ++++++++++++ apps/pod-migrator/src/engine/index.ts | 8 + apps/pod-migrator/src/engine/planner.ts | 572 ++++++++++++++++++ apps/pod-migrator/src/engine/pod-http.ts | 57 ++ apps/pod-migrator/src/engine/policy.ts | 204 +++++++ apps/pod-migrator/src/engine/rdf.ts | 91 +++ apps/pod-migrator/src/engine/systems/acp.ts | 208 +++++++ apps/pod-migrator/src/engine/systems/wac.ts | 85 +++ apps/pod-migrator/src/engine/translation.ts | 280 +++++++++ apps/pod-migrator/src/engine/types.ts | 126 ++++ .../test/engine/decisions.test.ts | 247 ++++++++ apps/pod-migrator/test/engine/edges.test.ts | 470 ++++++++++++++ apps/pod-migrator/test/engine/fake-pod.ts | 262 ++++++++ apps/pod-migrator/test/engine/helpers.ts | 60 ++ apps/pod-migrator/test/engine/http.test.ts | 97 +++ .../pod-migrator/test/engine/pipeline.test.ts | 196 ++++++ apps/pod-migrator/test/engine/policy.test.ts | 293 +++++++++ apps/pod-migrator/test/engine/systems.test.ts | 317 ++++++++++ apps/pod-migrator/tsconfig.json | 13 + apps/pod-migrator/vitest.config.ts | 13 + 22 files changed, 4148 insertions(+) create mode 100644 apps/pod-migrator/src/engine/README.md create mode 100644 apps/pod-migrator/src/engine/access.ts create mode 100644 apps/pod-migrator/src/engine/executor.ts create mode 100644 apps/pod-migrator/src/engine/index.ts create mode 100644 apps/pod-migrator/src/engine/planner.ts create mode 100644 apps/pod-migrator/src/engine/pod-http.ts create mode 100644 apps/pod-migrator/src/engine/policy.ts create mode 100644 apps/pod-migrator/src/engine/rdf.ts create mode 100644 apps/pod-migrator/src/engine/systems/acp.ts create mode 100644 apps/pod-migrator/src/engine/systems/wac.ts create mode 100644 apps/pod-migrator/src/engine/translation.ts create mode 100644 apps/pod-migrator/src/engine/types.ts create mode 100644 apps/pod-migrator/test/engine/decisions.test.ts create mode 100644 apps/pod-migrator/test/engine/edges.test.ts create mode 100644 apps/pod-migrator/test/engine/fake-pod.ts create mode 100644 apps/pod-migrator/test/engine/helpers.ts create mode 100644 apps/pod-migrator/test/engine/http.test.ts create mode 100644 apps/pod-migrator/test/engine/pipeline.test.ts create mode 100644 apps/pod-migrator/test/engine/policy.test.ts create mode 100644 apps/pod-migrator/test/engine/systems.test.ts create mode 100644 apps/pod-migrator/tsconfig.json create mode 100644 apps/pod-migrator/vitest.config.ts diff --git a/apps/pod-migrator/src/engine/README.md b/apps/pod-migrator/src/engine/README.md new file mode 100644 index 000000000..cb370a8de --- /dev/null +++ b/apps/pod-migrator/src/engine/README.md @@ -0,0 +1,62 @@ + + +# Migration engine safety notes + +The public contract is `index.ts`. `planMigration` performs reads only. `finalizePlan` requires +an explicit valid choice for every decision item and proves the resulting access policies do +not widen access. Executable plans carry a private snapshot, are bound to the original two +sessions, and are single-use. Downloaded JSON is a report, not a resumable executable plan. + +The offline CSS 7.2 and `@solid/access-control-policy` implementations resolve ambiguities in +DESIGN.md as follows. These notes are colocated with the engine because this lane does not own +the design document. + +- ACP matcher values are ORed within one attribute; different attributes are ANDed. The design's + statement that all matcher attributes are alternatives would widen access. The reader preserves + the intersections. Clause expansion is bounded at 64. Conjunctive `noneOf` expressions that the + neutral model cannot represent produce a `too-complex` decision, with owner-only/skip/abort. +- CSS uses ACL mode IRIs in ACP policies and evaluates policies protecting the ACR URL itself. + ESS-style ACP mode IRIs and `acp:access` policies are supported separately in `systems/acp.ts`. + The root document determines the dialect. Live PodSpaces behavior still needs account testing. +- A WAC 404 means that an own ACL is absent, so the nearest ancestor's defaults apply. ACP 404s + contribute no own controls. A 403, parsing failure, or failed request makes the policy unknown; + it never causes permissive inheritance. Uncertainty propagates conservatively to descendants. +- Creating an empty resource before locking it can already expose access through its parent or + a pre-existing orphan ACL. The engine proves the initial target permissions safe before creation + and requires that the target owner retains control. Unsafe target inheritance is refused; + owner-only output cannot remove additive ACP ancestor grants. Unknown target systems must abort. +- Placeholder resources are created with `If-None-Match: *`, then locked, then filled using the + placeholder's ETag. Existing content is overwritten only by explicit choice with its reviewed + ETag and unchanged safe target permissions. Missing validators cause a safe failure. +- Grants are installed deepest first. WAC-to-ACP uses explicit controls on existing resources and + owner-only member controls, with a `per-resource` decision; future children receive no shared + grants. Other model pairs retain safely translated member controls. Finalization also proves + the effect of all generated ACP ancestor controls together. +- Root merging preserves original RDF, including unrelated triples and existing member controls; + it adds only resource grants under distinct rule identifiers and requires the reviewed ACL ETag. + Target WebID documents and advertised auxiliary resources are protected. Encoded auxiliary + suffixes and encoded profile path aliases receive the same protection as their decoded forms. +- Owner/creator grants map to the target owner. Source-owner grants remain verbatim. Owner roles + in exclusions and denials retain the source owner's identity. The original creator identity + cannot be established from an ACR alone: creator exclusions drop the grant, and creator denials + remove every possibly denied mode. Denials always exempt the target owner on the target side. +- Group expansion uses only an in-fence, plan-time membership snapshot and warns that future + membership changes do not follow. Foreign groups cannot be expanded through a credentialed + transport. The snapshot is rechecked before execution. +- RDF rewriting is opt-in. Parsed term positions are mapped; literal text stays untouched. + Rewritten data uses TriG so named graphs survive. Default copying preserves the source bytes. + Partial representations and redirects are refused. No remote JSON-LD contexts are fetched. + +Before the first write the executor rechecks relevant source and target controls, group snapshots, +content validators, and root-merge validators. It checks source permissions again before each grant. +These checks detect stale review decisions; they cannot provide a transaction across independent +pods. Concurrent external permission edits after a check are outside the migration's control. +Cancellation stops subsequent requests after the current safe unit, finishing an owner-only lock +when necessary. No rollback broadens or restores old permissions. Logs and the final result are +saved to sessionStorage when available, without depending on browser storage succeeding. + +The in-memory LDP tests implement WAC nearest-ACL replacement and ACP additive inheritance with +deny precedence independently of the engine's readers and proof code. They check anonymous and +stranger access after each write, ownership, stale plans, path/redirect fences, conditional +writes, cancellation, every translation construct, RDF rewriting, and read-back verification. +The engine coverage gate requires 100% lines, branches, functions, and statements. diff --git a/apps/pod-migrator/src/engine/access.ts b/apps/pod-migrator/src/engine/access.ts new file mode 100644 index 000000000..42017b3c4 --- /dev/null +++ b/apps/pod-migrator/src/engine/access.ts @@ -0,0 +1,116 @@ +// AUTHORED-BY Codex GPT-6 +import { DataFactory } from "n3"; +import { assertNotWider, ownerPolicy, policiesEqual, scopePolicy } from "./policy.js"; +import { + ACL, + ACP, + aclLink, + links, + parse, + RDF, + requireOk, + requireRepresentation, + serialize, +} from "./rdf.js"; +import { type AcpDialect, readAcp, writeAcp } from "./systems/acp.js"; +import { readWac, writeWac } from "./systems/wac.js"; +import type { AccessDocument, AccessSystem, EffectivePolicy, PodSide } from "./types.js"; +export async function detectAccessSystem( + side: PodSide, + signal?: AbortSignal, +): Promise { + const head = requireOk(await side.http.head(side.root, { signal })); + const url = aclLink(head, side.root, side.root); + if (!url) return "unknown"; + const response = await side.http.get(url, { accept: "text/turtle", signal }); + const advertised = + links(head, side.root, "type").includes(`${ACP}AccessControlResource`) || + links(response, url, "type").includes(`${ACP}AccessControlResource`); + if (!response.ok) return advertised ? "acp" : "unknown"; + try { + const dataset = await parse(response, url); + const acp = [...dataset].some( + (q) => + q.predicate.value.startsWith(ACP) || + (q.object.termType === "NamedNode" && q.object.value.startsWith(ACP)), + ); + const wac = + dataset.match( + null, + DataFactory.namedNode(`${RDF}type`), + DataFactory.namedNode(`${ACL}Authorization`), + ).size > 0; + return (acp || advertised) && wac + ? "unknown" + : acp || advertised + ? "acp" + : wac + ? "wac" + : "unknown"; + } catch { + return "unknown"; + } +} +export async function readAccess( + side: PodSide, + resource: string, + system: AccessSystem, + signal?: AbortSignal, + advertised?: string, +): Promise { + const url = + advertised ?? aclLink(await side.http.head(resource, { signal }), resource, side.root); + if (!url) return undefined; + const response = await side.http.get(url, { accept: "text/turtle", signal }); + if (response.status === 404) return undefined; + requireOk(response); + const turtle = await requireRepresentation(response).text(); + const dataset = await parse(new Response(turtle, { headers: response.headers }), url); + if (system === "unknown") throw new TypeError("Cannot read unknown access system"); + return { + policy: system === "wac" ? readWac(dataset, resource, url) : readAcp(dataset, resource, url), + url, + turtle, + etag: response.headers.get("etag"), + }; +} +export async function writeAccess( + side: PodSide, + resource: string, + url: string, + policy: EffectivePolicy, + system: AccessSystem, + dialect: AcpDialect, + conditional: { ifMatch?: string; ifNoneMatch?: "*" } = {}, + mergeDocument?: AccessDocument, +): Promise { + assertNotWider(scopePolicy(policy, "resource"), ownerPolicy(side.ownerWebId)); + if (resource.endsWith("/")) + assertNotWider(scopePolicy(policy, "members"), ownerPolicy(side.ownerWebId)); + if (system === "unknown") throw new TypeError("Cannot lock an unknown access system"); + const prefix = mergeDocument ? `${url}?migration=${crypto.randomUUID()}` : url; + let turtle = + system === "wac" + ? await writeWac(policy, resource, url, prefix) + : await writeAcp(policy, resource, url, dialect, prefix); + const dataset = await parse( + new Response(turtle, { headers: { "content-type": "text/turtle" } }), + url, + ); + if (mergeDocument) { + const original = await parse( + new Response(mergeDocument.turtle, { headers: { "content-type": "text/turtle" } }), + url, + ); + for (const quad of original) dataset.add(quad); + turtle = await serialize(dataset); + } + const emitted = + system === "wac" ? readWac(dataset, resource, url) : readAcp(dataset, resource, url); + for (const scope of ["resource", "members"] as const) + if (!policiesEqual(scopePolicy(policy, scope), scopePolicy(emitted, scope))) + throw new TypeError("Serialized access policy differs from the plan"); + return requireOk( + await side.http.put(url, turtle, { contentType: "text/turtle", ...conditional }), + ); +} diff --git a/apps/pod-migrator/src/engine/executor.ts b/apps/pod-migrator/src/engine/executor.ts new file mode 100644 index 000000000..40e0735e0 --- /dev/null +++ b/apps/pod-migrator/src/engine/executor.ts @@ -0,0 +1,371 @@ +// AUTHORED-BY Codex GPT-6 +import { extractMediaType, parseRdf, SUPPORTED_RDF_MEDIA_TYPES } from "@jeswr/fetch-rdf"; +import { Group } from "@solid/object"; +import { DataFactory, Store } from "n3"; +import { readAccess, writeAccess } from "./access.js"; +import { executions } from "./planner.js"; +import { + assertNotWider, + effectivePolicy, + ownerPolicy, + policiesEqual, + scopePolicy, + unionPolicies, +} from "./policy.js"; +import { aclLink, parent, parse, requireOk, requireRepresentation, serialize } from "./rdf.js"; +import type { + AccessDocument, + EffectivePolicy, + ExecutablePlan, + MigrationResult, + PodSide, + Progress, +} from "./types.js"; + +/** Rewrite RDF term positions through the parsed dataset; literal strings remain verbatim. */ +export async function rewriteRdf( + bytes: Uint8Array, + contentType: string, + sourceRoot: string, + targetRoot: string, + sourceUrl: string, +): Promise { + const dataset = await parseRdf( + new TextDecoder("utf-8", { fatal: true }).decode(bytes), + contentType, + { baseIRI: sourceUrl }, + ); + const output = new Store(); + const rewrite = (term: import("@rdfjs/types").Term) => + term.termType === "NamedNode" && term.value.startsWith(sourceRoot) + ? DataFactory.namedNode(targetRoot + term.value.slice(sourceRoot.length)) + : term; + // Existing RDF quads are mapped, not manually authored graphs. + for (const quad of dataset) + output.addQuad( + DataFactory.quad( + rewrite(quad.subject) as typeof quad.subject, + rewrite(quad.predicate) as typeof quad.predicate, + rewrite(quad.object) as typeof quad.object, + rewrite(quad.graph) as typeof quad.graph, + ), + ); + return new TextEncoder().encode(await serialize(output)); +} +export async function executeMigration( + executable: ExecutablePlan, + opts: { + source: PodSide; + target: PodSide; + signal?: AbortSignal; + onProgress?: (p: Progress) => void; + }, +): Promise { + const execution = executions.get(executable); + if (!execution) throw new TypeError("Execute requires a finalized engine plan"); + const { state, resources } = execution; + for (const name of ["source", "target"] as const) + if ( + opts[name].http !== state[name].http || + opts[name].root !== state[name].root || + opts[name].ownerWebId !== state[name].ownerWebId + ) + throw new TypeError("Execution sessions differ from planning sessions"); + executions.delete(executable); // A plan is single-use, including cancelled/failed attempts. + const result: MigrationResult = { + copied: [], + skipped: [], + failed: [], + aclWritten: [], + verified: false, + cancelled: false, + log: [], + }; + let current = state.target.root; + function persist(): void { + try { + globalThis.sessionStorage?.setItem("pod-migrator:last-run", JSON.stringify(result)); + } catch { + /* Storage quota/privacy settings must not interrupt safe locking. */ + } + } + function log(level: "info" | "warn" | "error", message: string): void { + result.log.push({ at: new Date().toISOString(), level, message }); + persist(); + } + function progress(phase: Progress["phase"], done: number, total: number, url: string): void { + opts.onProgress?.({ phase, done, total, current: url }); + } + const active = resources.filter((entry) => !entry.skipped); + for (const entry of resources.filter((entry) => entry.skipped)) + result.skipped.push({ + url: entry.state.resource.sourceUrl, + reason: "Skipped by review decision", + }); + const context = { + sourceOwner: state.source.ownerWebId, + targetOwner: state.target.ownerWebId, + groups: state.groups, + }; + const aclUrls = new Map(); + const aclEtags = new Map(); + const bytesCopied = new Map(); + const written = new Map(); + const checkCancel = () => opts.signal?.throwIfAborted(); + async function currentDocuments( + side: PodSide, + urls: string[], + system: typeof state.sourceSystem, + ): Promise> { + const docs = new Map(); + if (system === "unknown") return docs; + for (const resource of urls) { + let url: string | undefined = resource; + while (url !== undefined) { + if (!docs.has(url)) { + docs.set(url, await readAccess(side, url, system, opts.signal)); + } + url = parent(url, side.root); + } + } + return docs; + } + async function writeAcl( + url: string, + policy: EffectivePolicy, + mergeDocument?: AccessDocument, + ): Promise { + const acl = aclUrls.get(url); + if (!acl) throw new TypeError("Target has no discoverable ACL link"); + const etag = aclEtags.get(url); + const response = await writeAccess( + state.target, + url, + acl, + policy, + state.targetSystem, + state.dialect, + etag ? { ifMatch: etag } : { ifNoneMatch: "*" }, + mergeDocument, + ); + const nextEtag = response.headers.get("etag"); + if (nextEtag) aclEtags.set(url, nextEtag); + else aclEtags.delete(url); + result.aclWritten.push(acl); + written.set(url, policy); + log("info", `Wrote access controls for ${url}`); + } + try { + checkCancel(); + // Re-read all relevant access documents before the first write; stale reviews fail closed. + const sourceDocs = await currentDocuments( + state.source, + active.filter((e) => !e.state.unreadable).map((e) => e.state.resource.sourceUrl), + state.sourceSystem, + ); + const targetDocs = await currentDocuments( + state.target, + active.map((e) => e.state.resource.targetUrl), + state.targetSystem, + ); + for (const [group, expected] of state.groups) { + const url = group.split("#")[0] as string; + const dataset = await parse( + requireOk(await state.source.http.get(url, { accept: "text/turtle", signal: opts.signal })), + url, + ); + const actual = [...new Group(group, dataset, DataFactory).hasMember]; + if (JSON.stringify(actual.sort()) !== JSON.stringify([...expected].sort())) + throw new TypeError("Group membership changed; create a new plan"); + } + for (const entry of active) { + current = entry.state.resource.targetUrl; + const head = await state.target.http.head(current, { signal: opts.signal }); + if ( + entry.state.exists + ? !head.ok || head.headers.get("etag") !== entry.state.targetEtag + : head.status !== 404 + ) + throw new TypeError("Target changed after review"); + const targetEffective = effectivePolicy( + state.targetSystem, + current, + state.target.root, + targetDocs, + ); + if (!policiesEqual(entry.state.targetEffective, targetEffective)) + throw new TypeError("Target permissions changed after review"); + const sourceEffective = effectivePolicy( + state.sourceSystem, + entry.state.resource.sourceUrl, + state.source.root, + sourceDocs, + ); + const actualPolicy = + entry.decision === "overwrite" + ? targetEffective + : entry.decision === "merge" + ? unionPolicies(targetEffective, scopePolicy(entry.policy, "resource")) + : entry.policy; + assertNotWider(sourceEffective, scopePolicy(actualPolicy, "resource"), context); + const acl = aclLink(head, current, state.target.root); + if (acl) aclUrls.set(current, acl); + const doc = targetDocs.get(current); + if (entry.decision === "merge" && doc?.etag !== entry.state.targetDocument?.etag) + throw new TypeError("Root access document changed after review"); + if (doc?.etag) aclEtags.set(current, doc.etag); + } + const containers = active + .filter((e) => e.state.resource.kind === "container") + .sort((a, b) => a.state.resource.targetUrl.length - b.state.resource.targetUrl.length); + for (const [index, entry] of containers.entries()) { + checkCancel(); + current = entry.state.resource.targetUrl; + if (!entry.state.exists) { + const response = requireOk( + await state.target.http.put(current, "", { + contentType: "text/turtle", + ifNoneMatch: "*", + }), + ); + const acl = + aclLink(response, current, state.target.root) ?? + aclLink(await state.target.http.head(current), current, state.target.root); + if (acl) aclUrls.set(current, acl); + await writeAcl(current, ownerPolicy(state.target.ownerWebId, true)); + } + progress("lock", index + 1, containers.length, current); + } + const files = active.filter((e) => e.state.resource.kind === "resource"); + for (const [index, entry] of files.entries()) { + checkCancel(); + current = entry.state.resource.targetUrl; + const source = requireRepresentation( + await state.source.http.get(entry.state.resource.sourceUrl, { signal: opts.signal }), + ); + let bytes = new Uint8Array(await source.arrayBuffer()); + let contentType = source.headers.get("content-type") ?? "application/octet-stream"; + if ( + state.rewriteIris && + SUPPORTED_RDF_MEDIA_TYPES.some((type) => type === extractMediaType(contentType)) + ) { + bytes = await rewriteRdf( + bytes, + contentType, + state.source.root, + new URL( + current.slice( + 0, + current.length - + entry.state.resource.sourceUrl.slice(state.source.root.length).length, + ), + ).href, + entry.state.resource.sourceUrl, + ); + contentType = "application/trig"; + } + checkCancel(); + let etag = entry.state.targetEtag; + if (!entry.state.exists) { + const placeholder = requireOk( + await state.target.http.put(current, new Uint8Array(), { contentType, ifNoneMatch: "*" }), + ); + etag = placeholder.headers.get("etag") ?? undefined; + const acl = + aclLink(placeholder, current, state.target.root) ?? + aclLink(await state.target.http.head(current), current, state.target.root); + if (acl) aclUrls.set(current, acl); + await writeAcl(current, ownerPolicy(state.target.ownerWebId)); + } + if (!etag) throw new TypeError("Safe content writes require an ETag"); + checkCancel(); + requireOk(await state.target.http.put(current, bytes, { contentType, ifMatch: etag })); + bytesCopied.set(current, bytes); + result.copied.push(current); + log("info", `Copied ${current}`); + progress("copy", index + 1, files.length, current); + } + const grants = active + .filter((e) => e.decision !== "overwrite") + .sort((a, b) => b.state.resource.targetUrl.length - a.state.resource.targetUrl.length); + for (const [index, entry] of grants.entries()) { + checkCancel(); + current = entry.state.resource.targetUrl; + let policy = entry.policy; + if (entry.decision === "merge") { + const original = entry.state.targetDocument as AccessDocument; + policy = unionPolicies(original.policy, scopePolicy(entry.policy, "resource")); + } + if (!entry.state.unreadable) { + const documents = await currentDocuments( + state.source, + [entry.state.resource.sourceUrl], + state.sourceSystem, + ); + const currentSource = effectivePolicy( + state.sourceSystem, + entry.state.resource.sourceUrl, + state.source.root, + documents, + ); + assertNotWider(currentSource, scopePolicy(policy, "resource"), context); + } + await writeAcl( + current, + policy, + entry.decision === "merge" ? entry.state.targetDocument : undefined, + ); + progress("grant", index + 1, grants.length, current); + } + for (const [index, entry] of active.entries()) { + checkCancel(); + current = entry.state.resource.targetUrl; + const expected = written.get(current); + if (expected) { + const actual = await readAccess(state.target, current, state.targetSystem, opts.signal); + if ( + !actual || + !policiesEqual( + scopePolicy(expected, "resource"), + scopePolicy(actual.policy, "resource"), + ) || + !policiesEqual(scopePolicy(expected, "members"), scopePolicy(actual.policy, "members")) + ) + throw new TypeError("Access verification failed"); + } + const bytes = bytesCopied.get(current); + if (bytes) { + const actual = new Uint8Array( + await requireRepresentation( + await state.target.http.get(current, { signal: opts.signal }), + ).arrayBuffer(), + ); + if (actual.length !== bytes.length || actual.some((value, i) => value !== bytes[i])) + throw new TypeError("Content verification failed"); + } + progress("verify", index + 1, active.length, current); + } + result.verified = true; + log("info", "Migration verified"); + } catch (error) { + if (opts.signal?.aborted) { + result.cancelled = true; + log("warn", "Migration cancelled; completed writes retain their safe permissions"); + } else { + const message = error instanceof Error ? error.message : String(error); + result.failed.push({ url: current, error: message }); + log("error", message); + } + } + for (const entry of active) + if ( + entry.state.resource.kind === "resource" && + !result.copied.includes(entry.state.resource.targetUrl) + ) + result.skipped.push({ + url: entry.state.resource.sourceUrl, + reason: result.cancelled ? "Cancelled before copy completed" : "Copy did not complete", + }); + persist(); + return result; +} diff --git a/apps/pod-migrator/src/engine/index.ts b/apps/pod-migrator/src/engine/index.ts new file mode 100644 index 000000000..0e9bb5c2d --- /dev/null +++ b/apps/pod-migrator/src/engine/index.ts @@ -0,0 +1,8 @@ +// AUTHORED-BY Codex GPT-6 + +export { detectAccessSystem } from "./access.js"; +export { executeMigration } from "./executor.js"; +export { finalizePlan, planMigration } from "./planner.js"; +export { createPodHttp } from "./pod-http.js"; +export { assertNotWider, effectivePolicy } from "./policy.js"; +export * from "./types.js"; diff --git a/apps/pod-migrator/src/engine/planner.ts b/apps/pod-migrator/src/engine/planner.ts new file mode 100644 index 000000000..ff8f0c194 --- /dev/null +++ b/apps/pod-migrator/src/engine/planner.ts @@ -0,0 +1,572 @@ +// AUTHORED-BY Codex GPT-6 +import { assertWithinPodScope, isWithinPodScope, normalizePodBase } from "@jeswr/guarded-fetch"; +import { Container, Group } from "@solid/object"; +import { DataFactory } from "n3"; +import { detectAccessSystem, readAccess } from "./access.js"; +import { + assertNotWider, + effectiveMembers, + effectivePolicy, + emptyPolicy, + ownerPolicy, + scopePolicy, + unionPolicies, +} from "./policy.js"; +import { aclLink, links, parent, parse, requireOk } from "./rdf.js"; +import { type AcpDialect, acpDialect, PolicyTooComplex } from "./systems/acp.js"; +import { reportItem, type TranslationContext, translatePolicy } from "./translation.js"; +import { + type AccessDocument, + type EffectivePolicy, + type ExecutablePlan, + MigrationAborted, + type MigrationPlan, + type OptionId, + type PlannedResource, + type PodSide, + type Progress, + type Resolutions, +} from "./types.js"; +export interface ResourceState { + resource: PlannedResource; + sourceEffective: EffectivePolicy; + sourceMembers: EffectivePolicy; + sourceDocument?: AccessDocument; + unreadable: boolean; + exists: boolean; + targetDocument?: AccessDocument; + targetEffective: EffectivePolicy; + targetInherited: EffectivePolicy; + targetEtag?: string; + targetUnreadable: boolean; +} +export interface PlanState { + sourceSystem: MigrationPlan["sourceSystem"]; + targetSystem: MigrationPlan["targetSystem"]; + source: PodSide; + target: PodSide; + resources: ResourceState[]; + groups: Map; + dialect: AcpDialect; + rewriteIris: boolean; + snapshot: string; +} +export interface ExecutionState { + state: PlanState; + resources: { + state: ResourceState; + policy: EffectivePolicy; + decision?: OptionId; + skipped: boolean; + }[]; +} +const plans = new WeakMap(); +export const executions = new WeakMap(); +function bound(side: PodSide): PodSide { + const root = normalizePodBase(side.root); + if (side.http.root !== root) throw new TypeError("Pod side and transport roots differ"); + return { ...side, root }; +} +function isProfileDocument(webId: string, resource: string): boolean { + const profile = new URL(webId); + const target = new URL(resource); + return ( + profile.origin === target.origin && + decodeURIComponent(profile.pathname) === decodeURIComponent(target.pathname) + ); +} +function decisionFor( + plan: MigrationPlan, + resolutions: Resolutions, + url: string, + kind: string, +): OptionId | undefined { + const item = plan.report.find((item) => item.resourceUrl === url && item.kind === kind); + return item && resolutions[item.id]; +} +export async function planMigration(opts: { + source: PodSide; + target: PodSide; + targetContainer?: string; + rewriteIris?: boolean; + signal?: AbortSignal; + onProgress?: (p: Progress) => void; +}): Promise { + const source = bound(opts.source); + const target = bound(opts.target); + const destination = assertWithinPodScope(target.root, opts.targetContainer ?? target.root); + if (!destination.endsWith("/")) throw new TypeError("Target container must end with a slash"); + if (isWithinPodScope(source.root, destination) || isWithinPodScope(target.root, source.root)) + throw new TypeError("Source and target pods must be disjoint"); + const report: MigrationPlan["report"] = []; + const discovered: { resource: PlannedResource; acl?: string }[] = []; + const auxiliaries = new Set(); + const queue = [source.root]; + const seen = new Set(); + while (queue.length) { + opts.signal?.throwIfAborted(); + const url = queue.shift() as string; + seen.add(url); + if (seen.size > 10000) throw new PolicyTooComplex("Pod has more than 10000 resources"); + const head = requireOk(await source.http.head(url, { signal: opts.signal })); + const acl = aclLink(head, url, source.root); + for (const auxiliary of [...links(head, url, "acl"), ...links(head, url, "describedby")]) + auxiliaries.add(auxiliary); + const resource: PlannedResource = { + sourceUrl: url, + targetUrl: assertWithinPodScope(target.root, destination + url.slice(source.root.length)), + kind: url.endsWith("/") ? "container" : "resource", + }; + const contentType = head.headers.get("content-type"); + if (contentType) resource.contentType = contentType; + const length = head.headers.get("content-length"); + if (length !== null && Number.isSafeInteger(Number(length)) && Number(length) >= 0) + resource.size = Number(length); + discovered.push({ resource, acl }); + if (resource.kind === "container") { + const dataset = await parse( + requireOk(await source.http.get(url, { accept: "text/turtle", signal: opts.signal })), + url, + ); + for (const member of new Container(url, dataset, DataFactory).contains) { + const child = assertWithinPodScope(source.root, member.id); + if (parent(child, source.root) !== url) + throw new TypeError("Container lists a non-child resource"); + if ( + /\.(acl|acr|meta)\/?$/i.test(decodeURIComponent(new URL(child).pathname)) || + auxiliaries.has(child) + ) { + report.push( + reportItem( + "metadata-skipped", + child, + "Auxiliary access or description metadata is translated separately and never copied as content.", + ["acknowledge"], + ), + ); + } else queue.push(child); + } + } + opts.onProgress?.({ + phase: "crawl", + done: seen.size, + total: seen.size + queue.length, + current: url, + }); + } + const isAuxiliary = (url: string) => + [...auxiliaries].some( + (auxiliary) => url === auxiliary || (auxiliary.endsWith("/") && url.startsWith(auxiliary)), + ); + for (const item of discovered.filter((item) => isAuxiliary(item.resource.sourceUrl))) + report.push( + reportItem( + "metadata-skipped", + item.resource.sourceUrl, + "Advertised auxiliary metadata is translated separately and never copied as content.", + ["acknowledge"], + ), + ); + const resources = discovered.filter((item) => !isAuxiliary(item.resource.sourceUrl)); + const sourceSystem = await detectAccessSystem(source, opts.signal); + const targetSystem = await detectAccessSystem(target, opts.signal); + if (sourceSystem === "unknown" || targetSystem === "unknown") + report.push( + reportItem( + "acl-system-unknown", + source.root, + targetSystem === "unknown" + ? "The target access system cannot be locked safely; execution must stop until it is identifiable." + : "The source access system is unknown. All copied resources will remain owner-only.", + targetSystem === "unknown" ? ["abort"] : ["owner-only", "abort"], + ), + ); + const sourceDocs = new Map(); + const sourceErrors = new Set(); + for (const [index, item] of resources.entries()) { + if (sourceSystem !== "unknown") + try { + const doc = await readAccess( + source, + item.resource.sourceUrl, + sourceSystem, + opts.signal, + item.acl, + ); + sourceDocs.set(item.resource.sourceUrl, doc); + } catch (error) { + opts.signal?.throwIfAborted(); + sourceErrors.add(item.resource.sourceUrl); + report.push( + reportItem( + error instanceof PolicyTooComplex ? "too-complex" : "acl-unreadable", + item.resource.sourceUrl, + error instanceof Error ? error.message : "The access document could not be read.", + ["owner-only", "skip", "abort"], + ), + ); + } + opts.onProgress?.({ + phase: "read-access", + done: index + 1, + total: resources.length, + current: item.resource.sourceUrl, + }); + } + const targetHeads = new Map(); + const targetDocs = new Map(); + const targetErrors = new Set(); + for (const item of resources) { + let url: string | undefined = item.resource.targetUrl; + while (url !== undefined) { + if (!targetHeads.has(url)) { + const head = await target.http.head(url, { signal: opts.signal }); + if (head.status !== 404) requireOk(head); + targetHeads.set(url, head); + if (targetSystem !== "unknown") + try { + targetDocs.set(url, await readAccess(target, url, targetSystem, opts.signal)); + } catch { + opts.signal?.throwIfAborted(); + targetErrors.add(url); + } + } + url = parent(url, target.root); + } + } + if ( + destination !== target.root && + !(targetHeads.get(parent(destination, target.root) as string) as Response).ok + ) + throw new TypeError("Target container parent must already exist"); + const groups = new Map(); + for (const doc of sourceDocs.values()) + for (const subject of doc?.policy.grants.flatMap((rule) => rule.who.flat()) ?? []) + if (subject.kind === "group" && !groups.has(subject.group)) { + const documentUrl = subject.group.split("#")[0] as string; + if (!isWithinPodScope(source.root, documentUrl)) continue; + try { + const dataset = await parse( + requireOk( + await source.http.get(documentUrl, { accept: "text/turtle", signal: opts.signal }), + ), + documentUrl, + ); + groups.set(subject.group, [...new Group(subject.group, dataset, DataFactory).hasMember]); + } catch { + opts.signal?.throwIfAborted(); + } + } + const protectedAuxiliaries = new Set( + [...targetHeads].flatMap(([url, response]) => [ + ...links(response, url, "acl"), + ...links(response, url, "describedby"), + ]), + ); + const states: ResourceState[] = []; + const hasError = (url: string, root: string, errors: Set) => { + let current: string | undefined = url; + while (current !== undefined) { + if (errors.has(current)) return true; + current = parent(current, root); + } + return false; + }; + for (const { resource } of resources) { + const unreadable = hasError(resource.sourceUrl, source.root, sourceErrors); + if (unreadable && !sourceErrors.has(resource.sourceUrl)) + report.push( + reportItem( + "acl-unreadable", + resource.sourceUrl, + "An ancestor's access controls could not be read; inheritance cannot be proven safe.", + ["owner-only", "skip", "abort"], + ), + ); + const sourceEffective = unreadable + ? emptyPolicy() + : effectivePolicy(sourceSystem, resource.sourceUrl, source.root, sourceDocs); + const sourceMembers = + unreadable || resource.kind === "resource" + ? emptyPolicy() + : effectiveMembers(sourceSystem, resource.sourceUrl, source.root, sourceDocs); + const targetEffective = effectivePolicy( + targetSystem, + resource.targetUrl, + target.root, + targetDocs, + ); + const ownless = new Map(targetDocs); + ownless.delete(resource.targetUrl); + const targetInherited = effectivePolicy(targetSystem, resource.targetUrl, target.root, ownless); + const exists = (targetHeads.get(resource.targetUrl) as Response).ok; + const targetUnreadable = hasError(resource.targetUrl, target.root, targetErrors); + if ( + resource.targetUrl === target.root || + protectedAuxiliaries.has(resource.targetUrl) || + isProfileDocument(target.ownerWebId, resource.targetUrl) + ) + report.push( + reportItem( + "protected-target", + resource.sourceUrl, + "The target root and target WebID document are protected. Root merging preserves existing member controls and requires a safety proof.", + resource.targetUrl === target.root && !targetUnreadable + ? ["skip", "merge", "abort"] + : ["skip", "abort"], + ), + ); + else if (exists) + report.push( + reportItem( + "target-exists", + resource.sourceUrl, + "This target path already exists. Overwrite retains its current permissions and requires a safety proof and ETag.", + resource.kind === "resource" && !targetUnreadable + ? ["skip", "overwrite", "abort"] + : ["skip", "abort"], + ), + ); + if (targetUnreadable) + report.push( + reportItem( + "acl-unreadable", + resource.sourceUrl, + "Target ancestor permissions are unreadable. This path cannot be written safely.", + ["skip", "abort"], + "target", + ), + ); + const sourceDocument = sourceDocs.get(resource.sourceUrl); + const context: TranslationContext = { + resourceUrl: resource.sourceUrl, + sourceOwner: source.ownerWebId, + targetOwner: target.ownerWebId, + groups, + sourceRuleTurtle: sourceDocument?.turtle, + sourceRules: new Map( + [...sourceDocs.values()] + .filter((doc): doc is AccessDocument => !!doc) + .map((doc) => [doc.url, doc.turtle]), + ), + }; + if (!unreadable && sourceSystem !== "unknown" && targetSystem !== "unknown") { + report.push(...translatePolicy(sourceEffective, targetSystem, context).report); + if (resource.kind === "container" && !(sourceSystem === "wac" && targetSystem === "acp")) + report.push( + ...translatePolicy(sourceMembers, targetSystem, { ...context, scopeId: "members:" }) + .report, + ); + } + if (sourceSystem === "wac" && targetSystem === "acp" && resource.kind === "container") { + const memberPolicy = sourceDocument && scopePolicy(sourceDocument.policy, "members"); + if (memberPolicy?.grants.length) + report.push( + reportItem( + "inheritance-narrowed", + resource.sourceUrl, + "ACP inheritance is additive. Use explicit per-resource controls for all existing descendants so narrower child ACLs remain narrow; new descendants receive owner access only.", + ["per-resource", "abort"], + "", + sourceDocument?.turtle, + ), + ); + } + states.push({ + resource, + sourceEffective, + sourceMembers, + sourceDocument, + unreadable, + exists, + targetDocument: targetDocs.get(resource.targetUrl), + targetEffective, + targetInherited, + targetEtag: + (targetHeads.get(resource.targetUrl) as Response).headers.get("etag") ?? undefined, + targetUnreadable, + }); + } + const rootDoc = targetDocs.get(target.root); + const dialect = rootDoc + ? acpDialect( + await parse( + new Response(rootDoc.turtle, { headers: { "content-type": "text/turtle" } }), + rootDoc.url, + ), + ) + : "css"; + const plan: MigrationPlan = { + sourceSystem, + targetSystem, + resources: resources.map((item) => item.resource), + report: [...new Map(report.map((item) => [item.id, item])).values()], + stats: { + containers: resources.filter((item) => item.resource.kind === "container").length, + resources: resources.filter((item) => item.resource.kind === "resource").length, + bytes: resources + .filter((item) => item.resource.kind === "resource") + .reduce((sum, item) => sum + (item.resource.size ?? 0), 0), + aclDocuments: [...sourceDocs.values()].filter(Boolean).length, + }, + }; + plans.set(plan, { + sourceSystem, + targetSystem, + source, + target, + resources: states, + groups, + dialect, + rewriteIris: opts.rewriteIris ?? false, + snapshot: JSON.stringify(plan), + }); + opts.onProgress?.({ phase: "plan", done: resources.length, total: resources.length }); + return plan; +} +export function finalizePlan(plan: MigrationPlan, resolutions: Resolutions): ExecutablePlan { + if (Object.values(resolutions).includes("abort")) + throw new MigrationAborted("Migration aborted during review"); + const state = plans.get(plan); + if (!state || JSON.stringify(plan) !== state.snapshot) + throw new TypeError("Plan is not an unmodified engine plan"); + for (const item of plan.report) { + const resolution = resolutions[item.id]; + if ( + (item.severity === "needs-decision" && !resolution) || + (resolution && !item.options.some((option) => option.id === resolution)) + ) + throw new TypeError(`Resolve report item: ${item.id}`); + } + const unknown = plan.sourceSystem === "unknown"; + const selected = state.resources.map((resourceState) => { + const { resource } = resourceState; + const related = plan.report.filter((item) => item.resourceUrl === resource.sourceUrl); + const protectedDecision = decisionFor( + plan, + resolutions, + resource.sourceUrl, + "protected-target", + ); + const existingDecision = decisionFor(plan, resolutions, resource.sourceUrl, "target-exists"); + const skip = + state.resources.some( + (ancestor) => + ancestor.resource.kind === "container" && + ancestor.resource.targetUrl !== state.target.root && + ancestor !== resourceState && + resource.targetUrl.startsWith(ancestor.resource.targetUrl) && + plan.report.some( + (item) => + item.resourceUrl === ancestor.resource.sourceUrl && + ["acl-unreadable", "too-complex", "protected-target", "target-exists"].includes( + item.kind, + ) && + resolutions[item.id] === "skip", + ), + ) || + related.some( + (item) => + ["acl-unreadable", "too-complex", "protected-target", "target-exists"].includes( + item.kind, + ) && resolutions[item.id] === "skip", + ); + const translationContext: TranslationContext = { + resourceUrl: resource.sourceUrl, + sourceOwner: state.source.ownerWebId, + targetOwner: state.target.ownerWebId, + groups: state.groups, + }; + const translated = translatePolicy( + resourceState.sourceEffective, + plan.targetSystem, + translationContext, + resolutions, + ).policy; + const members = + resource.kind === "container" && !(plan.sourceSystem === "wac" && plan.targetSystem === "acp") + ? translatePolicy( + resourceState.sourceMembers, + plan.targetSystem, + { ...translationContext, scopeId: "members:" }, + resolutions, + ).policy + : emptyPolicy(); + const asMembers: EffectivePolicy = { + grants: members.grants.map((rule) => ({ ...rule, appliesTo: "members" })), + denials: members.denials.map((rule) => ({ ...rule, appliesTo: "members" })), + }; + const policy = + unknown || resourceState.unreadable + ? ownerPolicy(state.target.ownerWebId, resource.kind === "container") + : unionPolicies( + translated, + asMembers, + ownerPolicy(state.target.ownerWebId, resource.kind === "container"), + ); + if (!skip) { + const context = { + sourceOwner: state.source.ownerWebId, + targetOwner: state.target.ownerWebId, + groups: state.groups, + }; + const actual = + existingDecision === "overwrite" + ? resourceState.targetEffective + : protectedDecision === "merge" + ? unionPolicies(resourceState.targetEffective, scopePolicy(policy, "resource")) + : plan.targetSystem === "acp" + ? unionPolicies(policy, resourceState.targetInherited) + : policy; + assertNotWider(resourceState.sourceEffective, scopePolicy(actual, "resource"), context); + assertNotWider(scopePolicy(actual, "resource"), ownerPolicy(state.target.ownerWebId)); + // A write under an existing ancestor must be safe even before its own ACL exists. + if (!resourceState.exists) { + assertNotWider(resourceState.sourceEffective, resourceState.targetEffective, context); + assertNotWider(resourceState.targetEffective, ownerPolicy(state.target.ownerWebId)); + } + if (protectedDecision === "merge" && !resourceState.targetDocument?.etag) + throw new TypeError("Root merge requires an access-document ETag"); + if (existingDecision === "overwrite" && !resourceState.targetEtag) + throw new TypeError("Overwrite requires a content ETag"); + } + return { + state: resourceState, + policy, + skipped: skip, + decision: protectedDecision ?? existingDecision, + }; + }); + if (state.targetSystem === "acp") + for (const entry of selected.filter((entry) => !entry.skipped)) { + const inherited = selected + .filter( + (other) => + !other.skipped && + other !== entry && + other.decision !== "merge" && + other.state.resource.kind === "container" && + entry.state.resource.targetUrl.startsWith(other.state.resource.targetUrl), + ) + .map((other) => scopePolicy(other.policy, "members")); + assertNotWider( + entry.state.sourceEffective, + unionPolicies( + scopePolicy(entry.policy, "resource"), + entry.state.targetInherited, + ...inherited, + ), + { + sourceOwner: state.source.ownerWebId, + targetOwner: state.target.ownerWebId, + groups: state.groups, + }, + ); + } + const executable: ExecutablePlan = Object.freeze({ + plan: structuredClone(plan), + resolutions: Object.freeze({ ...resolutions }), + }); + executions.set(executable, { state, resources: structuredClone(selected) }); + return executable; +} diff --git a/apps/pod-migrator/src/engine/pod-http.ts b/apps/pod-migrator/src/engine/pod-http.ts new file mode 100644 index 000000000..2e11eb5f7 --- /dev/null +++ b/apps/pod-migrator/src/engine/pod-http.ts @@ -0,0 +1,57 @@ +// AUTHORED-BY Codex GPT-6 +import { assertWithinPodScope, normalizePodBase } from "@jeswr/guarded-fetch"; +import type { PodHttp } from "./types.js"; + +/** Only checked primitive strings ever reach the credential-bearing transport. */ +export function createPodHttp(opts: { + root: string; + fetch: typeof fetch; + allowInsecureLoopback?: boolean; +}): PodHttp { + if (typeof opts.root !== "string" || /[?#\\]/.test(opts.root)) + throw new TypeError("Invalid pod root"); + const root = normalizePodBase(opts.root); + const parsed = new URL(root); + if ( + parsed.protocol !== "https:" && + !(opts.allowInsecureLoopback && ["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname)) + ) + throw new TypeError("HTTPS pod root required"); + function checked(url: string): string { + if (typeof url !== "string" || /[?#\\]/.test(url) || /%25/i.test(url)) + throw new TypeError("A plain pod resource URL is required"); + return assertWithinPodScope(root, url); + } + async function send(url: string, method: string, init: RequestInit): Promise { + const target = checked(url); + const response = await opts.fetch(target, { ...init, method, redirect: "manual" }); + if ( + response.type === "opaqueredirect" || + response.redirected || + (response.status >= 300 && response.status < 400) + ) + throw new TypeError("Pod redirects are refused"); + if (response.url && checked(response.url) !== target) + throw new TypeError("Unexpected response URL"); + const location = response.headers.get("location"); + if (location) checked(new URL(location, target).href); + return response; + } + return Object.freeze({ + root, + get: (url, init = {}) => + send(url, "GET", { headers: { Accept: init.accept ?? "*/*" }, signal: init.signal }), + head: (url, init = {}) => send(url, "HEAD", { signal: init.signal }), + put: (url, body, init) => { + const headers: Record = { "Content-Type": init.contentType }; + if (init.ifNoneMatch) headers["If-None-Match"] = init.ifNoneMatch; + if (init.ifMatch) headers["If-Match"] = init.ifMatch; + return send(url, "PUT", { + headers, + body: typeof body === "string" ? body : new Uint8Array(body), + signal: init.signal, + }); + }, + delete: (url, init = {}) => send(url, "DELETE", { signal: init.signal }), + } satisfies PodHttp); +} diff --git a/apps/pod-migrator/src/engine/policy.ts b/apps/pod-migrator/src/engine/policy.ts new file mode 100644 index 000000000..7ede6969d --- /dev/null +++ b/apps/pod-migrator/src/engine/policy.ts @@ -0,0 +1,204 @@ +// AUTHORED-BY Codex GPT-6 +import { modes, parent } from "./rdf.js"; +import type { + AccessDocument, + AccessSystem, + EffectivePolicy, + Grant, + Mode, + Subject, +} from "./types.js"; +export function emptyPolicy(): EffectivePolicy { + return { grants: [], denials: [] }; +} +export function scopePolicy( + policy: EffectivePolicy, + scope: "resource" | "members", +): EffectivePolicy { + const select = (rules: Grant[]) => + rules + .filter((rule) => rule.appliesTo === scope || rule.appliesTo === "both") + .map((rule) => ({ ...rule, appliesTo: "resource" as const })); + return { grants: select(policy.grants), denials: select(policy.denials) }; +} +export function unionPolicies(...policies: EffectivePolicy[]): EffectivePolicy { + return { grants: policies.flatMap((p) => p.grants), denials: policies.flatMap((p) => p.denials) }; +} +export function effectivePolicy( + system: AccessSystem, + url: string, + root: string, + documents: ReadonlyMap, +): EffectivePolicy { + const found: EffectivePolicy[] = []; + let current: string | undefined = url; + while (current !== undefined) { + const doc = documents.get(current); + if (doc) { + found.push(scopePolicy(doc.policy, current === url ? "resource" : "members")); + if (system === "wac") break; + } + current = parent(current, root); + } + return unionPolicies(...found); +} +export function ownerPolicy(webId: string, members = false): EffectivePolicy { + return { + grants: [ + { + modes: new Set(modes), + who: [[{ kind: "agent", webId }]], + except: [], + appliesTo: members ? "both" : "resource", + source: { url: webId, node: webId }, + }, + ], + denials: [], + }; +} +export function effectiveMembers( + system: AccessSystem, + url: string, + root: string, + documents: ReadonlyMap, +): EffectivePolicy { + const found: EffectivePolicy[] = []; + let current: string | undefined = url; + while (current !== undefined) { + const doc = documents.get(current); + if (doc) { + found.push(scopePolicy(doc.policy, "members")); + if (system === "wac") break; + } + current = parent(current, root); + } + return unionPolicies(...found); +} +export function subjectKey(subject: Subject): string { + switch (subject.kind) { + case "agent": + return JSON.stringify([subject.kind, subject.webId]); + case "group": + return JSON.stringify([subject.kind, subject.group]); + case "client": + return JSON.stringify([subject.kind, subject.clientId]); + case "issuer": + return JSON.stringify([subject.kind, subject.issuer]); + case "vc": + return JSON.stringify([subject.kind, subject.vcType]); + case "origin": + return JSON.stringify([subject.kind, subject.origin]); + default: + return subject.kind; + } +} +export interface ProofContext { + sourceOwner?: string; + targetOwner?: string; + groups?: ReadonlyMap; +} +type Tri = boolean | undefined; +function and(values: Tri[]): Tri { + return values.includes(false) ? false : values.every((v) => v === true) ? true : undefined; +} +function or(values: Tri[]): Tri { + return values.includes(true) ? true : values.every((v) => v === false) ? false : undefined; +} +function not(value: Tri): Tri { + return value === undefined ? undefined : !value; +} +/** A bounded exhaustive symbolic implication check. Unknown/group conditions stay symbolic. */ +export function assertNotWider( + source: EffectivePolicy, + target: EffectivePolicy, + context: ProofContext = {}, +): void { + const variables = new Map(); + const assignment = new Map(); + function subjectValue(subject: Subject, side: "source" | "target"): Tri { + if (subject.kind === "public") return true; + if (subject.kind === "owner" || (subject.kind === "creator" && side === "target")) { + const webId = side === "target" ? context.targetOwner : context.sourceOwner; + if (webId) return subjectValue({ kind: "agent", webId }, side); + } + if (subject.kind === "group" && context.groups?.has(subject.group)) + return or( + (context.groups.get(subject.group) as readonly string[]).map((webId) => + subjectValue({ kind: "agent", webId }, side), + ), + ); + const key = subjectKey(subject); + variables.set(key, subject); + return assignment.get(key); + } + let budget = 65536; + function ruleValue(rule: Grant, side: "source" | "target"): Tri { + if (--budget === 0) throw new TypeError("Access proof is too complex"); + return and([ + or(rule.who.map((clause) => and(clause.map((subject) => subjectValue(subject, side))))), + ...rule.except.map((subject) => not(subjectValue(subject, side))), + ]); + } + function access(policy: EffectivePolicy, mode: Mode, side: "source" | "target"): Tri { + return and([ + or(policy.grants.filter((rule) => rule.modes.has(mode)).map((rule) => ruleValue(rule, side))), + not( + or( + policy.denials + .filter((rule) => rule.modes.has(mode)) + .map((rule) => ruleValue(rule, side)), + ), + ), + ]); + } + function compatible(): boolean { + const singletons = new Set(); + const authenticated = assignment.get(subjectKey({ kind: "authenticated" })); + for (const [key, subject] of variables) + if (assignment.get(key) === true) { + if (subject.kind === "agent" && authenticated === false) return false; + if (["agent", "client", "issuer", "origin"].includes(subject.kind)) { + if (singletons.has(subject.kind)) return false; + singletons.add(subject.kind); + } + } + return true; + } + for (const mode of modes) { + variables.clear(); + assignment.clear(); + budget = 65536; + function counterexample(): boolean { + if (!compatible()) return false; + const value = and([ + access(target, mode, "target"), + not(access(source, mode, "source")), + context.targetOwner + ? not(subjectValue({ kind: "agent", webId: context.targetOwner }, "target")) + : true, + ]); + if (value !== undefined) return value; + const key = [...variables.keys()].find((key) => !assignment.has(key)) as string; + assignment.set(key, false); + if (counterexample()) return true; + assignment.set(key, true); + const found = counterexample(); + assignment.delete(key); + return found; + } + if (counterexample()) throw new TypeError(`Planned policy widens ${mode} access`); + } +} +export function policiesEqual( + a: EffectivePolicy, + b: EffectivePolicy, + context: ProofContext = {}, +): boolean { + try { + assertNotWider(a, b, context); + assertNotWider(b, a, context); + return true; + } catch { + return false; + } +} diff --git a/apps/pod-migrator/src/engine/rdf.ts b/apps/pod-migrator/src/engine/rdf.ts new file mode 100644 index 000000000..54be2784e --- /dev/null +++ b/apps/pod-migrator/src/engine/rdf.ts @@ -0,0 +1,91 @@ +// AUTHORED-BY Codex GPT-6 +import { parseRdf } from "@jeswr/fetch-rdf"; +import { assertWithinPodScope } from "@jeswr/guarded-fetch"; +import { parseLinkHeaders } from "@jeswr/storage-capabilities"; +import type { DatasetCore, Term } from "@rdfjs/types"; +import { NamedNodeAs, NamedNodeFrom, SetFrom, TermWrapper } from "@rdfjs/wrapper"; +import { DataFactory, Writer } from "n3"; +import type { Mode } from "./types.js"; +export const ACL = "http://www.w3.org/ns/auth/acl#"; +export const ACP = "http://www.w3.org/ns/solid/acp#"; +export const RDF = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"; +export const FOAF = "http://xmlns.com/foaf/0.1/"; +export const modes: Mode[] = ["read", "append", "write", "control"]; +export function modeIri(mode: Mode): string { + return ACL + mode.charAt(0).toUpperCase() + mode.slice(1); +} +export function readModes(iris: Iterable): Set { + const result = new Set(); + for (const mode of modes) if ([...iris].includes(modeIri(mode))) result.add(mode); + if (result.has("write")) result.add("append"); + return result; +} +/** Reject malformed IRIREFs instead of letting N3 Writer emit injected Turtle. */ +export function safeIri(value: string): string { + if ( + !/^[a-z][a-z0-9+.-]*:/i.test(value) || + // biome-ignore lint/suspicious/noControlCharactersInRegex: RDF IRIREF must exclude control characters. + /[\s<>"{}|^`\\\u0000-\u001f\u007f-\u009f]/u.test(value) + ) + throw new TypeError("Unsafe RDF IRI"); + return value; +} +export class RdfNode extends TermWrapper { + iris(predicate: string): string[] { + return [...SetFrom.subjectPredicate(this, predicate, NamedNodeAs.string, NamedNodeFrom.string)]; + } + add(predicate: string, value: string): void { + SetFrom.subjectPredicate(this, predicate, NamedNodeAs.string, NamedNodeFrom.string).add( + safeIri(value), + ); + } + nodes(predicate: string): RdfNode[] { + return [...this.dataset.match(this as unknown as Term, DataFactory.namedNode(predicate))].map( + (q) => { + if (q.object.termType !== "NamedNode" && q.object.termType !== "BlankNode") + throw new TypeError("RDF node required"); + return new RdfNode(q.object, this.dataset, DataFactory); + }, + ); + } +} +export function serialize(dataset: DatasetCore): Promise { + for (const quad of dataset) + for (const term of [quad.subject, quad.predicate, quad.object, quad.graph]) + if (term.termType === "NamedNode") safeIri(term.value); + const writer = new Writer(); + writer.addQuads([...dataset]); + return new Promise((resolve, reject) => + writer.end((error, value) => (error ? reject(error) : resolve(value))), + ); +} +export async function parse( + response: Response, + url: string, +): Promise>> { + requireRepresentation(response); + return parseRdf(await response.text(), response.headers.get("content-type"), { baseIRI: url }); +} +export function requireRepresentation(response: Response): Response { + if (response.status !== 200) + throw new Error(`Expected a complete representation (${response.status})`); + return response; +} +export function links(response: Response, url: string, rel: string): string[] { + return parseLinkHeaders(response.headers.get("link"), url) + .filter((link) => link.context === url && link.rel.includes(rel)) + .map((link) => link.target); +} +export function aclLink(response: Response, url: string, root: string): string | undefined { + const found = links(response, url, "acl"); + if (found.length > 1) throw new TypeError("Ambiguous ACL links"); + return found[0] === undefined ? undefined : assertWithinPodScope(root, found[0]); +} +export function requireOk(response: Response): Response { + if (!response.ok) throw new Error(`Pod request failed (${response.status})`); + return response; +} +export function parent(url: string, root: string): string | undefined { + if (url === root) return undefined; + return new URL(".", url.endsWith("/") ? url.slice(0, -1) : url).href; +} diff --git a/apps/pod-migrator/src/engine/systems/acp.ts b/apps/pod-migrator/src/engine/systems/acp.ts new file mode 100644 index 000000000..ef89e32de --- /dev/null +++ b/apps/pod-migrator/src/engine/systems/acp.ts @@ -0,0 +1,208 @@ +// AUTHORED-BY Codex GPT-6 +import type { DatasetCore } from "@rdfjs/types"; +import { AccessControlResource, type Matcher, Policy } from "@solid/object"; +import { DataFactory, Store } from "n3"; +import { ACL, ACP, modeIri, RDF, RdfNode, readModes, serialize } from "../rdf.js"; +import type { EffectivePolicy, Grant, Subject } from "../types.js"; +export class PolicyTooComplex extends Error { + override name = "PolicyTooComplex"; +} +export type AcpDialect = "css" | "ess"; +export function acpDialect(dataset: DatasetCore): AcpDialect { + return [...dataset].some((q) => + [`${ACP}Read`, `${ACP}Write`, `${ACP}Append`].includes(q.object.value), + ) + ? "ess" + : "css"; +} +function product(left: Subject[][], right: Subject[][]): Subject[][] { + if (left.length * right.length > 64) throw new PolicyTooComplex("ACP policy exceeds 64 clauses"); + return left.flatMap((a) => right.map((b) => [...a, ...b])); +} +function matcherClauses(matcher: Matcher): Subject[][] { + let result: Subject[][] = [[]]; + let present = false; + const attributes: Subject[][] = [ + [...matcher.agent].map((webId) => { + if (webId === `${ACP}PublicAgent`) return { kind: "public" }; + if (webId === `${ACP}AuthenticatedAgent`) return { kind: "authenticated" }; + if (webId === `${ACP}OwnerAgent`) return { kind: "owner" }; + if (webId === `${ACP}CreatorAgent`) return { kind: "creator" }; + return { kind: "agent", webId }; + }), + [...matcher.client].map((clientId) => ({ kind: "client", clientId })), + [...matcher.issuer].map((issuer) => ({ kind: "issuer", issuer })), + [...matcher.vc].map((vcType) => ({ kind: "vc", vcType })), + ]; + // CSS's reference implementation ANDs different attributes, ORs values of one attribute. + for (const attribute of attributes) + if (attribute.length) { + present = true; + result = product( + result, + attribute.map((subject) => [subject]), + ); + } + return present ? result : []; +} +export function readAcp(dataset: DatasetCore, resource: string, url: string): EffectivePolicy { + const result: EffectivePolicy = { grants: [], denials: [] }; + const roots = new Set( + [ + ...dataset.match( + null, + DataFactory.namedNode(`${ACP}resource`), + DataFactory.namedNode(resource), + ), + ].map((q) => q.subject), + ); + // ESS ACRs can identify themselves by type instead of acp:resource. + if (!roots.size) + for (const q of dataset.match( + null, + DataFactory.namedNode(`${RDF}type`), + DataFactory.namedNode(`${ACP}AccessControlResource`), + )) + roots.add(q.subject); + for (const term of roots) { + const acr = new AccessControlResource(term, dataset, DataFactory); + const node = new RdfNode(term, dataset, DataFactory); + function addPolicy(policy: Policy, appliesTo: Grant["appliesTo"], control = false): void { + const all = [...policy.allOf]; + const any = [...policy.anyOf]; + if (!all.length && !any.length) return; + let who: Subject[][] = [[]]; + for (const matcher of all) who = product(who, matcherClauses(matcher)); + if (any.length) who = product(who, any.flatMap(matcherClauses)); + // A conjunction inside noneOf cannot be flattened to OR exclusions. Fail closed. + const excluded = [...policy.noneOf].flatMap(matcherClauses); + if (excluded.some((clause) => clause.length !== 1)) + throw new PolicyTooComplex("Conjunctive ACP exclusions need a richer normal form"); + const convert = (values: Iterable) => + readModes( + [...values].map((value) => + value.startsWith(ACP) ? ACL + value.slice(ACP.length) : value, + ), + ); + for (const [field, values] of [ + ["grants", policy.allow], + ["denials", policy.deny], + ] as const) { + const modes = convert(values); + if (control) { + const hasControl = + field === "grants" + ? modes.has("read") && modes.has("write") + : modes.has("read") || modes.has("write"); + modes.clear(); + if (hasControl) modes.add("control"); + } + if (modes.size) + result[field].push({ + modes, + who, + except: excluded.flat(), + appliesTo, + source: { url, node: policy.value }, + }); + } + } + for (const ac of acr.accessControl) + for (const policy of ac.apply) addPolicy(policy, "resource"); + for (const ac of acr.memberAccessControl) + for (const policy of ac.apply) addPolicy(policy, "members"); + for (const policy of node.nodes(`${ACP}access`)) + addPolicy( + new Policy(policy as unknown as import("@rdfjs/types").Term, dataset, DataFactory), + "resource", + true, + ); + } + return result; +} + +export async function writeAcp( + policy: EffectivePolicy, + resource: string, + url: string, + dialect: AcpDialect = "css", + nodePrefix = url, +): Promise { + const dataset = new Store(); + const acr = new RdfNode(url, dataset, DataFactory); + acr.add(`${RDF}type`, `${ACP}AccessControlResource`); + acr.add(`${ACP}resource`, resource); + // CSS evaluates ACL-mode policies for the ACR URL itself; ESS uses acp:access. + const ownerControl = new RdfNode(`${nodePrefix}#acr-owner`, dataset, DataFactory); + ownerControl.add(`${ACP}resource`, url); + let index = 0; + function matcher(subject: Subject): string { + const node = new RdfNode(`${nodePrefix}#matcher-${index++}`, dataset, DataFactory); + node.add(`${RDF}type`, `${ACP}Matcher`); + switch (subject.kind) { + case "agent": + node.add(`${ACP}agent`, subject.webId); + break; + case "public": + node.add(`${ACP}agent`, `${ACP}PublicAgent`); + break; + case "authenticated": + node.add(`${ACP}agent`, `${ACP}AuthenticatedAgent`); + break; + case "owner": + node.add(`${ACP}agent`, `${ACP}OwnerAgent`); + break; + case "creator": + node.add(`${ACP}agent`, `${ACP}CreatorAgent`); + break; + case "client": + node.add(`${ACP}client`, subject.clientId); + break; + case "issuer": + node.add(`${ACP}issuer`, subject.issuer); + break; + case "vc": + node.add(`${ACP}vc`, subject.vcType); + break; + default: + throw new TypeError("Unsupported ACP subject"); + } + return node.value; + } + for (const [field, rules] of [ + ["allow", policy.grants], + ["deny", policy.denials], + ] as const) + for (const rule of rules) + for (const clause of rule.who) { + const p = new RdfNode(`${nodePrefix}#policy-${index++}`, dataset, DataFactory); + const ac = new RdfNode(`${nodePrefix}#control-${index++}`, dataset, DataFactory); + p.add(`${RDF}type`, `${ACP}Policy`); + ac.add(`${ACP}apply`, p.value); + for (const subject of clause) p.add(`${ACP}allOf`, matcher(subject)); + for (const subject of rule.except) p.add(`${ACP}noneOf`, matcher(subject)); + for (const mode of rule.modes) { + if (dialect === "ess" && mode === "control") continue; + p.add(ACP + field, dialect === "css" ? modeIri(mode) : modeIri(mode).replace(ACL, ACP)); + } + if (rule.appliesTo !== "members") acr.add(`${ACP}accessControl`, ac.value); + if (rule.appliesTo !== "resource") acr.add(`${ACP}memberAccessControl`, ac.value); + if (rule.modes.has("control")) { + // ACR edit permissions use the same conditions as the resource's Control grant. + const controlPolicy = new RdfNode( + `${nodePrefix}#acr-policy-${index++}`, + dataset, + DataFactory, + ); + for (const subject of clause) controlPolicy.add(`${ACP}allOf`, matcher(subject)); + for (const subject of rule.except) controlPolicy.add(`${ACP}noneOf`, matcher(subject)); + controlPolicy.add(ACP + field, `${dialect === "ess" ? ACP : ACL}Read`); + controlPolicy.add(ACP + field, `${dialect === "ess" ? ACP : ACL}Write`); + acr.add(`${ACP}access`, controlPolicy.value); + const access = new RdfNode(`${nodePrefix}#acr-control-${index++}`, dataset, DataFactory); + access.add(`${ACP}apply`, controlPolicy.value); + ownerControl.add(`${ACP}accessControl`, access.value); + } + } + return serialize(dataset); +} diff --git a/apps/pod-migrator/src/engine/systems/wac.ts b/apps/pod-migrator/src/engine/systems/wac.ts new file mode 100644 index 000000000..85ab05b19 --- /dev/null +++ b/apps/pod-migrator/src/engine/systems/wac.ts @@ -0,0 +1,85 @@ +// AUTHORED-BY Codex GPT-6 +import type { DatasetCore } from "@rdfjs/types"; +import { AclResource } from "@solid/object"; +import { DataFactory, Store } from "n3"; +import { ACL, FOAF, modeIri, RDF, RdfNode, readModes, serialize } from "../rdf.js"; +import type { EffectivePolicy, Grant, Subject } from "../types.js"; + +export function readWac(dataset: DatasetCore, resource: string, url: string): EffectivePolicy { + const grants: Grant[] = []; + for (const auth of new AclResource(dataset, DataFactory).authorizations) { + const node = new RdfNode(auth as unknown as import("@rdfjs/types").Term, dataset, DataFactory); + const own = node.iris(`${ACL}accessTo`).includes(resource); + const members = [...node.iris(`${ACL}default`), ...node.iris(`${ACL}defaultForNew`)].includes( + resource, + ); + if (!own && !members) continue; + const subjects: Subject[] = node.iris(`${ACL}agent`).map((webId) => ({ kind: "agent", webId })); + for (const value of node.iris(`${ACL}agentClass`)) { + if (value === `${FOAF}Agent`) subjects.push({ kind: "public" }); + else if (value === `${ACL}AuthenticatedAgent`) subjects.push({ kind: "authenticated" }); + else throw new TypeError("Unsupported WAC agent class"); + } + for (const group of node.iris(`${ACL}agentGroup`)) subjects.push({ kind: "group", group }); + const origins = node.iris(`${ACL}origin`); + const who = subjects.flatMap((subject) => + origins.length + ? origins.map((origin) => [subject, { kind: "origin", origin } as Subject]) + : [[subject]], + ); + grants.push({ + modes: readModes(node.iris(`${ACL}mode`)), + who, + except: [], + appliesTo: own && members ? "both" : own ? "resource" : "members", + source: { url, node: node.value }, + }); + } + return { grants, denials: [] }; +} + +export async function writeWac( + policy: EffectivePolicy, + resource: string, + url: string, + nodePrefix = url, +): Promise { + if (policy.denials.length) throw new TypeError("WAC cannot express denials"); + const dataset = new Store(); + let index = 0; + for (const grant of policy.grants) + for (const clause of grant.who) { + if (grant.except.length) throw new TypeError("WAC cannot express exclusions"); + const auth = new RdfNode(`${nodePrefix}#authorization-${index++}`, dataset, DataFactory); + auth.add(`${RDF}type`, `${ACL}Authorization`); + if (grant.appliesTo !== "members") auth.add(`${ACL}accessTo`, resource); + if (grant.appliesTo !== "resource") auth.add(`${ACL}default`, resource); + for (const mode of grant.modes) auth.add(`${ACL}mode`, modeIri(mode)); + const principals = clause.filter((subject) => subject.kind !== "origin"); + if (principals.length !== 1) throw new TypeError("WAC needs one principal per clause"); + if (clause.filter((subject) => subject.kind === "origin").length > 1) + throw new TypeError("WAC cannot express intersecting origins"); + for (const subject of clause) { + switch (subject.kind) { + case "agent": + auth.add(`${ACL}agent`, subject.webId); + break; + case "public": + auth.add(`${ACL}agentClass`, `${FOAF}Agent`); + break; + case "authenticated": + auth.add(`${ACL}agentClass`, `${ACL}AuthenticatedAgent`); + break; + case "group": + auth.add(`${ACL}agentGroup`, subject.group); + break; + case "origin": + auth.add(`${ACL}origin`, subject.origin); + break; + default: + throw new TypeError("Unsupported WAC subject"); + } + } + } + return serialize(dataset); +} diff --git a/apps/pod-migrator/src/engine/translation.ts b/apps/pod-migrator/src/engine/translation.ts new file mode 100644 index 000000000..b0b8272fb --- /dev/null +++ b/apps/pod-migrator/src/engine/translation.ts @@ -0,0 +1,280 @@ +// AUTHORED-BY Codex GPT-6 +import { emptyPolicy, subjectKey } from "./policy.js"; +import type { + AccessSystem, + EffectivePolicy, + Grant, + OptionId, + ReportItem, + ReportKind, + Resolutions, + Subject, +} from "./types.js"; +export interface TranslationContext { + scopeId?: string; + sourceRules?: ReadonlyMap; + resourceUrl: string; + targetOwner: string; + sourceOwner: string; + groups: ReadonlyMap; + sourceRuleTurtle?: string; +} +export const optionDescriptions: Record = { + skip: "Leave this resource or unsupported grant out of the migration.", + expand: "Grant only the listed current members; future membership changes will not follow.", + "owner-only": "Copy with access reserved to the target owner.", + "per-resource": + "Write explicit controls on each existing descendant; new children receive no shared grants.", + overwrite: + "Replace the content conditionally and keep the target permissions, only if those are no wider than the source.", + merge: + "Add root resource grants while preserving the existing root document and member controls; requires a safe access proof.", + "drop-overlapping": "Drop every grant clause that might overlap a denial.", + abort: "Stop the migration without writing anything.", + acknowledge: "Acknowledge this information; no decision is required.", +}; +export function reportItem( + kind: ReportKind, + resourceUrl: string, + explanation: string, + options: OptionId[], + suffix = "", + sourceRuleTurtle?: string, +): ReportItem { + return { + id: `${kind}:${resourceUrl}:${suffix}`, + kind, + severity: options[0] === "acknowledge" ? "info" : "needs-decision", + resourceUrl, + title: kind.replaceAll("-", " "), + explanation, + sourceRuleTurtle, + options: options.map((id) => ({ + id, + label: id.replaceAll("-", " "), + description: optionDescriptions[id], + })), + recommended: options[0] as OptionId, + }; +} +/** Pure translation; decisions select only narrowing alternatives. */ +export function translatePolicy( + source: EffectivePolicy, + target: AccessSystem, + context: TranslationContext, + resolutions?: Resolutions, +): { policy: EffectivePolicy; report: ReportItem[] } { + const policy = emptyPolicy(); + const report: ReportItem[] = []; + let sourceRuleTurtle = context.sourceRuleTurtle; + const choose = ( + kind: ReportKind, + explanation: string, + options: OptionId[], + suffix: string, + ): OptionId => { + const item = reportItem( + kind, + context.resourceUrl, + explanation, + options, + (context.scopeId ?? "") + suffix, + sourceRuleTurtle, + ); + report.push(item); + return resolutions?.[item.id] ?? item.recommended; + }; + for (const [ruleIndex, original] of source.grants.entries()) { + sourceRuleTurtle = context.sourceRules?.get(original.source.url) ?? context.sourceRuleTurtle; + if (original.except.some((subject) => subject.kind === "creator")) { + choose( + "exclusion-unsupported", + "The original creator identity is unavailable. Omit the entire grant rather than weakening its exclusion.", + ["skip", "abort"], + `${ruleIndex}:creator`, + ); + continue; + } + let clauses = original.who.map((clause) => + clause.map((subject) => { + if (subject.kind === "owner" || subject.kind === "creator") { + choose( + "owner-mapped", + "The source owner/creator role maps to the target owner WebID.", + ["acknowledge"], + `${ruleIndex}:${subject.kind}`, + ); + return { kind: "agent", webId: context.targetOwner } as Subject; + } + if (subject.kind === "agent" && subject.webId === context.sourceOwner) + choose( + "source-owner-kept", + "The source owner's WebID is kept verbatim.", + ["acknowledge"], + `${ruleIndex}`, + ); + return subject; + }), + ); + if (target === "acp") { + if (clauses.some((c) => c.some((s) => s.kind === "origin"))) { + choose( + "origin-unsupported", + "ACP has no origin restriction. Each origin-restricted clause is omitted.", + ["skip", "abort"], + `${ruleIndex}`, + ); + clauses = clauses.filter((c) => !c.some((s) => s.kind === "origin")); + } + for (const group of new Set( + clauses + .flat() + .filter((s) => s.kind === "group") + .map((s) => s.group), + )) { + const members = context.groups.get(group); + const decision = choose( + "group-unsupported", + `ACP has no WAC groups. Current members: ${members ? members.join(", ") || "(empty)" : "unavailable within the source pod fence"}.`, + members ? ["skip", "expand", "abort"] : ["skip", "abort"], + `${ruleIndex}:${group}`, + ); + clauses = clauses.flatMap((clause) => { + if (!clause.some((s) => s.kind === "group" && s.group === group)) return [clause]; + if (decision !== "expand" || !members) return []; + return members.map((webId) => + clause.map((s) => + s.kind === "group" && s.group === group ? ({ kind: "agent", webId } as Subject) : s, + ), + ); + }); + } + } + if (target === "wac") { + if (original.except.length) { + choose( + "exclusion-unsupported", + "WAC cannot express these exclusions. The entire grant is omitted.", + ["skip", "abort"], + `${ruleIndex}`, + ); + continue; + } + if (clauses.some((c) => c.some((s) => ["client", "issuer", "vc"].includes(s.kind)))) { + choose( + "condition-unsupported", + "WAC cannot express client, issuer or credential conditions. The affected clauses are omitted.", + ["skip", "abort"], + `${ruleIndex}`, + ); + clauses = clauses.filter( + (c) => !c.some((s) => ["client", "issuer", "vc"].includes(s.kind)), + ); + } + // Intersections of distinct WAC principals cannot be represented by unioning them. + clauses = clauses.flatMap((clause) => { + const principals = clause.filter((s) => s.kind !== "origin" && s.kind !== "public"); + const unique = [...new Map(principals.map((s) => [subjectKey(s), s])).values()]; + if (unique.filter((subject) => subject.kind === "agent").length > 1) return []; + if (unique.length > 1) { + const agent = unique.find((s) => s.kind === "agent"); + if (agent && unique.every((s) => s.kind === "agent" || s.kind === "authenticated")) + return [[agent, ...clause.filter((s) => s.kind === "origin")]]; + choose( + "condition-unsupported", + "This intersection of principals cannot be expressed in WAC; omit the clause.", + ["skip", "abort"], + `${ruleIndex}:intersection`, + ); + return []; + } + return [ + [ + ...(unique.length ? unique : [{ kind: "public" } as Subject]), + ...clause.filter((s) => s.kind === "origin"), + ], + ]; + }); + } + const grant: Grant = { + ...original, + who: clauses, + except: original.except.map((subject) => + subject.kind === "owner" ? { kind: "agent", webId: context.sourceOwner } : subject, + ), + appliesTo: "resource", + }; + if (target === "wac") + for (const [denyIndex, denial] of source.denials.entries()) { + const covered = denial.who + .filter((c) => !denial.except.length && c.length === 1) + .map((c) => subjectKey(c[0] as Subject)); + const before = grant.who; + const remainingModes = new Set(grant.modes); + const overlaps = [...grant.modes].some((mode) => denial.modes.has(mode)); + if (!overlaps) continue; + const exact = before.every( + (c) => + c.some((s) => s.kind === "public" && covered.includes(subjectKey(s))) || + c.some((s) => covered.includes(subjectKey(s))) || + covered.includes(subjectKey({ kind: "public" })), + ); + if (exact) { + for (const mode of denial.modes) remainingModes.delete(mode); + grant.modes = remainingModes; + } else { + choose( + "deny-unsupported", + "The denial cannot be subtracted faithfully. Drop every possibly overlapping grant clause.", + ["drop-overlapping", "abort"], + `${ruleIndex}:${denyIndex}`, + ); + // Conservative overlap: retain only clauses with provably distinct concrete agents. + grant.who = grant.who.filter((clause) => { + const agent = clause.find((s) => s.kind === "agent"); + return ( + agent && + denial.who.every((c) => c.some((s) => s.kind === "agent" && s.webId !== agent.webId)) + ); + }); + } + } + if (grant.who.length && grant.modes.size) policy.grants.push(grant); + } + if (target === "acp") { + for (const [index, rule] of source.denials.entries()) { + if (rule.who.flat().some((subject) => subject.kind === "creator")) { + choose( + "deny-unsupported", + "The original creator identity is unavailable. Drop grants of every possibly denied mode.", + ["drop-overlapping", "abort"], + `creator:${index}`, + ); + policy.grants = policy.grants.map((grant) => ({ + ...grant, + modes: new Set([...grant.modes].filter((mode) => !rule.modes.has(mode))), + })); + } else + policy.denials.push({ + ...rule, + who: rule.who.map((clause) => + clause.map((subject) => + subject.kind === "owner" ? { kind: "agent", webId: context.sourceOwner } : subject, + ), + ), + appliesTo: "resource", + except: [ + ...rule.except + .filter((subject) => subject.kind !== "creator") + .map((subject) => + subject.kind === "owner" + ? { kind: "agent" as const, webId: context.sourceOwner } + : subject, + ), + { kind: "agent", webId: context.targetOwner }, + ], + }); + } + } + return { policy, report }; +} diff --git a/apps/pod-migrator/src/engine/types.ts b/apps/pod-migrator/src/engine/types.ts new file mode 100644 index 000000000..7360f985d --- /dev/null +++ b/apps/pod-migrator/src/engine/types.ts @@ -0,0 +1,126 @@ +// AUTHORED-BY Codex GPT-6 +export type AccessSystem = "wac" | "acp" | "unknown"; +export interface PodHttp { + readonly root: string; + get(url: string, init?: { accept?: string; signal?: AbortSignal }): Promise; + head(url: string, init?: { signal?: AbortSignal }): Promise; + put( + url: string, + body: Uint8Array | string, + init: { contentType: string; ifNoneMatch?: "*"; ifMatch?: string; signal?: AbortSignal }, + ): Promise; + delete(url: string, init?: { signal?: AbortSignal }): Promise; +} +export interface PodSide { + root: string; + ownerWebId: string; + http: PodHttp; +} +export type ReportKind = + | "group-unsupported" + | "origin-unsupported" + | "condition-unsupported" + | "exclusion-unsupported" + | "deny-unsupported" + | "owner-mapped" + | "source-owner-kept" + | "inheritance-narrowed" + | "acl-unreadable" + | "acl-system-unknown" + | "target-exists" + | "protected-target" + | "metadata-skipped" + | "too-complex"; +export type OptionId = + | "skip" + | "expand" + | "owner-only" + | "per-resource" + | "overwrite" + | "merge" + | "drop-overlapping" + | "abort" + | "acknowledge"; +export interface ReportOption { + id: OptionId; + label: string; + description: string; +} +export interface ReportItem { + id: string; + kind: ReportKind; + severity: "info" | "needs-decision"; + resourceUrl: string; + title: string; + explanation: string; + sourceRuleTurtle?: string; + options: ReportOption[]; + recommended: OptionId; +} +export interface PlannedResource { + sourceUrl: string; + targetUrl: string; + kind: "container" | "resource"; + contentType?: string; + size?: number; +} +export interface MigrationPlan { + sourceSystem: AccessSystem; + targetSystem: AccessSystem; + resources: PlannedResource[]; + report: ReportItem[]; + stats: { containers: number; resources: number; bytes: number; aclDocuments: number }; +} +export type Resolutions = Readonly>; +export interface Progress { + phase: "crawl" | "read-access" | "plan" | "lock" | "copy" | "grant" | "verify"; + done: number; + total: number; + current?: string; +} +export interface ExecutablePlan { + readonly plan: MigrationPlan; + readonly resolutions: Resolutions; +} +export interface MigrationResult { + copied: string[]; + skipped: { url: string; reason: string }[]; + failed: { url: string; error: string }[]; + aclWritten: string[]; + verified: boolean; + cancelled: boolean; + log: { at: string; level: "info" | "warn" | "error"; message: string }[]; +} +export class MigrationAborted extends Error { + override name = "MigrationAborted"; +} +export type Mode = "read" | "append" | "write" | "control"; +export type Subject = + | { kind: "agent"; webId: string } + | { kind: "public" } + | { kind: "authenticated" } + | { kind: "group"; group: string } + | { kind: "creator" } + | { kind: "owner" } + | { kind: "client"; clientId: string } + | { kind: "issuer"; issuer: string } + | { kind: "vc"; vcType: string } + | { kind: "origin"; origin: string }; +export interface Grant { + modes: Set; + who: Subject[][]; + except: Subject[]; + appliesTo: "resource" | "members" | "both"; + source: { url: string; node: string }; +} +export type Denial = Grant; +export interface EffectivePolicy { + grants: Grant[]; + denials: Denial[]; +} +export interface AccessDocument { + policy: EffectivePolicy; + url: string; + turtle: string; + etag: string | null; +} diff --git a/apps/pod-migrator/test/engine/decisions.test.ts b/apps/pod-migrator/test/engine/decisions.test.ts new file mode 100644 index 000000000..73a3ca8f0 --- /dev/null +++ b/apps/pod-migrator/test/engine/decisions.test.ts @@ -0,0 +1,247 @@ +// AUTHORED-BY Codex GPT-6 +import { parseRdf } from "@jeswr/fetch-rdf"; +import { DataFactory, Store } from "n3"; +import { describe, expect, it, vi } from "vitest"; +import { executeMigration, finalizePlan, planMigration } from "../../src/engine/index.js"; +import { ACL, ACP, RdfNode, serialize } from "../../src/engine/rdf.js"; +import { acl, grant, pods, policy, resolve, stranger } from "./helpers.js"; + +async function scenario(from: "wac" | "acp" = "wac", to: "wac" | "acp" = "acp") { + const { source, target } = await pods(from, to); + source.store(`${source.root}file`, "secret"); + const sourceSide = source.side(); + const targetSide = target.side(); + const options = { source: sourceSide, target: targetSide }; + return { source, target, options, plan: () => planMigration(options) }; +} +describe("review, snapshots and exceptional execution", () => { + for (const system of ["wac", "acp"] as const) + it(`merges ${system} root grants without changing existing member controls or other triples`, async () => { + const { source, target, options, plan } = await scenario(system, system); + source.setAcl(source.root, await acl(source, source.root, policy(grant()))); + const original = await parseRdf( + new TextDecoder().decode(target.access.get(target.acl(target.root))?.bytes), + "text/turtle", + ); + new RdfNode("urn:unrelated", original, DataFactory).add("urn:metadata", "urn:retained"); + target.setAcl(target.root, await serialize(original)); + const report = await plan(); + const executable = finalizePlan(report, resolve(report, { "protected-target": "merge" })); + const result = await executeMigration(executable, options); + expect(result.failed).toEqual([]); + expect(result.verified).toBe(true); + expect(await target.permissions(target.root)).toEqual(new Set(["read"])); + expect(await target.permissions(`${target.root}file`)).toEqual(new Set()); + const merged = await target.dataset(target.root); + expect( + merged?.match( + DataFactory.namedNode("urn:unrelated"), + DataFactory.namedNode("urn:metadata"), + DataFactory.namedNode("urn:retained"), + ).size, + ).toBe(1); + }); + it("allows owner-only copy when source rules are unknown or unreadable", async () => { + for (const unknown of [false, true]) { + const { source, target, options, plan } = await scenario(); + if (unknown) + source.responses.set( + `HEAD ${source.root}`, + () => new Response(null, { headers: { "content-type": "text/turtle" } }), + ); + else source.denied.set(`GET ${source.acl(`${source.root}file`)}`, 403); + const report = await plan(); + expect( + report.report.some((i) => i.kind === (unknown ? "acl-system-unknown" : "acl-unreadable")), + ).toBe(true); + const result = await executeMigration(finalizePlan(report, resolve(report)), options); + expect(result.failed).toEqual([]); + expect(result.copied).toHaveLength(1); + expect(await target.permissions(`${target.root}file`)).toEqual(new Set()); + } + }); + it("handles unreadable ancestors, unsupported complexity, and skip decisions", async () => { + const { source, target, options, plan } = await scenario(); + source.store(`${source.root}folder/`, "", "text/turtle"); + source.store(`${source.root}folder/child`, "child"); + source.denied.set(`GET ${source.acl(`${source.root}folder/`)}`, 403); + const report = await plan(); + expect(report.report.filter((i) => i.kind === "acl-unreadable").length).toBeGreaterThanOrEqual( + 2, + ); + const result = await executeMigration(finalizePlan(report, resolve(report)), options); + expect(result.failed).toEqual([]); + const complex = new Store(); + const n = (id: string) => new RdfNode(id, complex, DataFactory); + n(target.acl(target.root)).add(`${ACP}resource`, target.root); + // Source ACP branch complexity is caught as a report item before execution. + const next = await scenario("acp", "wac"); + const dataset = new Store(); + const node = (id: string) => new RdfNode(id, dataset, DataFactory); + node("urn:acr").add(`${ACP}resource`, `${next.source.root}file`); + node("urn:acr").add(`${ACP}accessControl`, "urn:ac"); + node("urn:ac").add(`${ACP}apply`, "urn:p"); + node("urn:p").add(`${ACP}allow`, `${ACL}Read`); + node("urn:p").add(`${ACP}allOf`, "urn:m"); + for (let index = 0; index < 65; index++) node("urn:m").add(`${ACP}agent`, `urn:agent-${index}`); + next.source.setAcl(`${next.source.root}file`, await serialize(dataset)); + const complexPlan = await next.plan(); + expect(complexPlan.report.some((i) => i.kind === "too-complex")).toBe(true); + const skipped = await executeMigration( + finalizePlan(complexPlan, resolve(complexPlan, { "too-complex": "skip" })), + next.options, + ); + expect(skipped.copied).toEqual([]); + }); + it("requires identifiable target controls and refuses forged/modified plans and invalid decisions", async () => { + const { target, options, plan } = await scenario(); + const report = await plan(); + expect(() => finalizePlan(structuredClone(report), resolve(report))).toThrow("unmodified"); + expect(() => + finalizePlan(report, { ...resolve(report), [report.report[0]?.id as string]: "expand" }), + ).toThrow("Resolve"); + (report.resources[0] as import("../../src/engine/types.js").PlannedResource).targetUrl = + "https://evil.example/"; + expect(() => finalizePlan(report, resolve(report))).toThrow("unmodified"); + target.responses.set(`HEAD ${target.root}`, () => new Response()); + const unknown = await plan(); + expect(unknown.targetSystem).toBe("unknown"); + expect( + unknown.report.find((i) => i.kind === "acl-system-unknown")?.options.map((o) => o.id), + ).toEqual(["abort"]); + await expect(executeMigration({ plan: unknown, resolutions: {} }, options)).rejects.toThrow( + "finalized", + ); + }); + it("rejects changed sessions and checks the immutable private execution snapshot", async () => { + const { options, plan, target } = await scenario(); + const report = await plan(); + const exec = finalizePlan(report, resolve(report)); + for (const difference of [ + { http: target.side().http }, + { root: "https://elsewhere.example/" }, + { ownerWebId: stranger }, + ]) + await expect( + executeMigration(exec, { ...options, target: { ...options.target, ...difference } }), + ).rejects.toThrow("sessions"); + (exec.plan.resources[1] as import("../../src/engine/types.js").PlannedResource).targetUrl = + "https://evil.example/"; + exec.plan.targetSystem = "unknown"; + exec.plan.sourceSystem = "unknown"; + const result = await executeMigration(exec, options); + expect(result.verified).toBe(true); + expect(result.copied[0]).toBe(`${target.root}file`); + }); + it("detects content and policy changes after review without writing", async () => { + for (const change of ["created", "removed", "etag", "permission", "root-metadata"] as const) { + const { target, source, options, plan } = await scenario(); + if (["removed", "etag"].includes(change)) target.store(`${target.root}file`, "old"); + const report = await plan(); + const executable = finalizePlan( + report, + resolve( + report, + change === "root-metadata" + ? { "protected-target": "merge" } + : { "target-exists": "overwrite" }, + ), + ); + if (change === "created") target.store(`${target.root}file`, "concurrent"); + if (change === "removed") target.data.delete(`${target.root}file`); + if (change === "etag") target.store(`${target.root}file`, "concurrent"); + if (change === "permission") + target.setAcl( + target.root, + await acl(target, target.root, policy(grant(undefined, { appliesTo: "members" }))), + ); + if (change === "root-metadata") target.setAcl(target.root, await acl(target, target.root)); + const result = await executeMigration(executable, options); + expect(result.failed[0]?.error).toContain("changed after review"); + expect(target.requests.some((r) => r.method === "PUT")).toBe(false); + expect(source.requests.every((r) => ["HEAD", "GET"].includes(r.method))).toBe(true); + } + }); + it("rechecks source permissions and refuses a stale broad grant", async () => { + const { source, target, options, plan } = await scenario(); + source.setAcl(`${source.root}file`, await acl(source, `${source.root}file`, policy(grant()))); + const report = await plan(); + const executable = finalizePlan(report, resolve(report)); + source.setAcl(`${source.root}file`, await acl(source, `${source.root}file`)); + const result = await executeMigration(executable, options); + expect(result.failed[0]?.error).toContain("widens"); + expect(target.requests.some((r) => r.method === "PUT")).toBe(false); + }); + it("requires ETags for overwrite and root merge", async () => { + for (const kind of ["content", "acl"] as const) { + const { target, plan } = await scenario(); + target.store(`${target.root}file`, "old"); + const stored = + kind === "content" + ? target.data.get(`${target.root}file`) + : target.access.get(target.acl(target.root)); + if (stored) stored.etag = ""; + const report = await plan(); + expect(() => + finalizePlan( + report, + resolve( + report, + kind === "content" ? { "target-exists": "overwrite" } : { "protected-target": "merge" }, + ), + ), + ).toThrow("requires"); + } + }); + it("rejects a public orphan ACL before creating a private resource", async () => { + const { target, plan } = await scenario("wac", "wac"); + target.setAcl(`${target.root}file`, await acl(target, `${target.root}file`, policy(grant()))); + const report = await plan(); + expect(() => finalizePlan(report, resolve(report))).toThrow("widens"); + expect(target.data.has(`${target.root}file`)).toBe(false); + }); + it("copies RDF verbatim by default and rewrites term positions only when opted in", async () => { + for (const rewriteIris of [false, true]) { + const { source, target, options } = await scenario(); + const dataset = new Store(); + const node = new RdfNode(`${source.root}file`, dataset, DataFactory); + node.add(`${source.root}predicate`, `${source.root}object`); + node.add("urn:outside", "urn:untouched"); + source.store(`${source.root}file`, await serialize(dataset), "text/turtle; charset=utf-8"); + source.store(`${source.root}plain`, "literal bytes"); + const report = await planMigration({ + ...options, + rewriteIris, + targetContainer: `${target.root}destination/`, + }); + const result = await executeMigration(finalizePlan(report, resolve(report)), options); + expect(result.failed).toEqual([]); + const text = new TextDecoder().decode( + target.data.get(`${target.root}destination/file`)?.bytes, + ); + expect(text).toContain( + rewriteIris ? `${target.root}destination/object` : `${source.root}object`, + ); + } + }); + it("returns request failures and storage failures without abandoning safe locks", async () => { + const { source, options, plan } = await scenario(); + const report = await plan(); + source.responses.set(`GET ${source.root}file`, () => new Response(null, { status: 500 })); + const result = await executeMigration(finalizePlan(report, resolve(report)), options); + expect(result.failed[0]?.error).toContain("500"); + expect(result.skipped).toHaveLength(2); + const report2 = await plan(); + source.responses.set(`GET ${source.root}file`, () => { + throw "transport failed"; + }); + const storage = vi.stubGlobal("sessionStorage", { + setItem: () => { + throw new Error("quota"); + }, + }); + const failed = await executeMigration(finalizePlan(report2, resolve(report2)), options); + expect(failed.failed[0]?.error).toBe("transport failed"); + storage.unstubAllGlobals(); + }); +}); diff --git a/apps/pod-migrator/test/engine/edges.test.ts b/apps/pod-migrator/test/engine/edges.test.ts new file mode 100644 index 000000000..ccf66173e --- /dev/null +++ b/apps/pod-migrator/test/engine/edges.test.ts @@ -0,0 +1,470 @@ +// AUTHORED-BY Codex GPT-6 +import { DataFactory, Store, Writer } from "n3"; +import { describe, expect, it, vi } from "vitest"; +import { writeAccess } from "../../src/engine/access.js"; +import { + createPodHttp, + executeMigration, + finalizePlan, + planMigration, +} from "../../src/engine/index.js"; +import { assertNotWider, ownerPolicy, unionPolicies } from "../../src/engine/policy.js"; +import { RdfNode, serialize } from "../../src/engine/rdf.js"; +import { writeAcp } from "../../src/engine/systems/acp.js"; +import { writeWac } from "../../src/engine/systems/wac.js"; +import { translatePolicy } from "../../src/engine/translation.js"; +import type { PodSide } from "../../src/engine/types.js"; +import type { FakePod } from "./fake-pod.js"; +import { acl, grant, pods, policy, resolve, stranger } from "./helpers.js"; + +function transformed( + pod: FakePod, + transform: (url: string, method: string, response: Response) => Response, +): PodSide { + return { + root: pod.root, + ownerWebId: pod.owner, + http: createPodHttp({ + root: pod.root, + fetch: async (url, init) => + transform(url as string, init?.method ?? "GET", await pod.fetch(url, init)), + }), + }; +} +function without(response: Response, ...names: string[]): Response { + const headers = new Headers(response.headers); + for (const name of names) headers.delete(name); + return new Response(response.body, { status: response.status, headers }); +} +describe("adversarial transport, metadata and planning edges", () => { + it("validates roots, disjoint trees and destination parent existence", async () => { + const { source, target } = await pods(); + await expect( + planMigration({ source: { ...source.side(), root: target.root }, target: target.side() }), + ).rejects.toThrow("roots differ"); + await expect(planMigration({ source: source.side(), target: source.side() })).rejects.toThrow( + "disjoint", + ); + await expect( + planMigration({ + source: source.side(), + target: target.side(), + targetContainer: `${target.root}file`, + }), + ).rejects.toThrow("slash"); + await expect( + planMigration({ + source: source.side(), + target: target.side(), + targetContainer: `${target.root}missing/deep/`, + }), + ).rejects.toThrow("parent"); + const controller = new AbortController(); + controller.abort(); + await expect( + planMigration({ source: source.side(), target: target.side(), signal: controller.signal }), + ).rejects.toThrow(); + }); + it("skips auxiliary suffixes and advertised metadata, rejects crafted listings", async () => { + const { source, target } = await pods(); + for (const name of ["x.acl", "x.acr", "x.meta", "metadata", "file"]) + source.store(source.root + name, "bytes"); + const base = source.side(); + const wrapped = transformed(source, (url, method, response) => { + if (method !== "HEAD" || url !== source.root) return response; + const headers = new Headers(response.headers); + headers.append("link", `<${source.root}metadata>; rel="describedby"`); + return new Response(null, { headers }); + }); + const report = await planMigration({ source: wrapped, target: target.side() }); + expect(report.report.filter((i) => i.kind === "metadata-skipped")).toHaveLength(4); + expect(report.resources.map((r) => r.sourceUrl)).not.toContain(`${source.root}metadata`); + for (const child of [`${source.root}folder/deep`, "https://shared.example/other/secrets"]) { + const dataset = new Store(); + new RdfNode(source.root, dataset, DataFactory).add( + "http://www.w3.org/ns/ldp#contains", + child, + ); + const turtle = await serialize(dataset); + source.responses.set( + `GET ${source.root}`, + () => new Response(turtle, { headers: { "content-type": "text/turtle" } }), + ); + await expect(planMigration({ source: base, target: target.side() })).rejects.toThrow(); + expect(source.requests.some((r) => r.url === child)).toBe(false); + } + }); + it("bounds an attacker-controlled crawl before access processing", async () => { + const { source, target } = await pods(); + const dataset = new Store(); + const root = new RdfNode(source.root, dataset, DataFactory); + for (let i = 0; i < 10000; i++) + root.add("http://www.w3.org/ns/ldp#contains", `${source.root}f${i}`); + const turtle = await serialize(dataset); + const side = { + root: source.root, + ownerWebId: source.owner, + http: createPodHttp({ + root: source.root, + fetch: async (_url, init) => + init?.method === "HEAD" + ? new Response() + : new Response(turtle, { headers: { "content-type": "text/turtle" } }), + }), + }; + await expect(planMigration({ source: side, target: target.side() })).rejects.toThrow("10000"); + }, 30000); + it("handles untyped/unknown-size files and non-Error read failures", async () => { + const { source, target } = await pods(); + source.store(`${source.root}file`, "bytes"); + const sourceSide = transformed(source, (url, method, response) => + method === "HEAD" && url.endsWith("file") + ? without(response, "content-type", "content-length") + : response, + ); + source.responses.set(`GET ${source.acl(`${source.root}file`)}`, () => { + throw "unreadable"; + }); + const report = await planMigration({ source: sourceSide, target: target.side() }); + expect(report.stats.bytes).toBe(0); + expect(report.report.find((i) => i.kind === "acl-unreadable")?.explanation).toContain( + "could not be read", + ); + }); + it("reports unreadable target ancestors and skips without touching their contents", async () => { + const { source, target } = await pods(); + source.store(`${source.root}file`, "new"); + target.store(`${target.root}file`, "old"); + let rootReads = 0; + const targetSide = transformed(target, (url, method, response) => { + if (url === target.acl(target.root) && method === "GET" && ++rootReads > 1) + return new Response(null, { status: 403 }); + return response; + }); + const options = { source: source.side(), target: targetSide }; + const report = await planMigration(options); + expect( + report.report.find((i) => i.kind === "protected-target")?.options.map((o) => o.id), + ).toEqual(["skip", "abort"]); + expect(report.report.filter((i) => i.kind === "acl-unreadable")).toHaveLength(2); + const result = await executeMigration(finalizePlan(report, resolve(report)), options); + expect(result.verified).toBe(true); + expect(target.requests.some((r) => r.method === "PUT")).toBe(false); + }); + it("protects percent-encoded aliases of the target WebID document", async () => { + const { source, target } = await pods(); + source.store(`${source.root}%70rofile/`, "", "text/turtle"); + source.store(`${source.root}%70rofile/card`, "evil"); + const report = await planMigration({ source: source.side(), target: target.side() }); + expect(report.report.filter((i) => i.kind === "protected-target")).toHaveLength(2); + }); + it("reads only in-fence group membership snapshots and notices subsequent membership changes", async () => { + for (const status of ["ok", "changed", "unreadable", "foreign"] as const) { + const { source, target } = await pods(); + source.store(`${source.root}file`, "data"); + const group = + status === "foreign" ? "https://shared.example/other/group#id" : `${source.root}group#id`; + const dataset = new Store(); + new RdfNode(group, dataset, DataFactory).add( + "http://www.w3.org/2006/vcard/ns#hasMember", + stranger, + ); + source.store(`${source.root}group`, await serialize(dataset), "text/turtle"); + source.setAcl( + `${source.root}file`, + await acl( + source, + `${source.root}file`, + policy(grant([[{ kind: "group", group }], [{ kind: "group", group }]])), + ), + ); + if (status === "unreadable") source.denied.set(`GET ${source.root}group`, 403); + const options = { source: source.side(), target: target.side() }; + const report = await planMigration(options); + const resolutions = Object.fromEntries( + Object.entries(resolve(report)).map(([id, value]) => [ + id, + id.startsWith("group-unsupported:") && ["ok", "changed"].includes(status) + ? "expand" + : value, + ]), + ) as ReturnType; + const executable = finalizePlan(report, resolutions); + if (status === "changed") source.store(`${source.root}group`, "", "text/turtle"); + if (status !== "unreadable") { + if (status === "ok") + target.afterWrite = async () => { + for (const url of target.data.keys()) + if (url !== target.root) + for (const identity of [{}, { agent: stranger }]) { + const sourceUrl = source.root + url.slice(target.root.length); + const allowed = await source.permissions(sourceUrl, identity); + for (const mode of await target.permissions(url, identity)) + expect(allowed.has(mode)).toBe(true); + } + }; + const result = await executeMigration(executable, options); + expect(result.failed.length).toBe(status === "changed" ? 1 : 0); + if (status === "ok") + expect(await target.permissions(`${target.root}file`, { agent: stranger })).toEqual( + new Set(["read"]), + ); + } + expect(source.requests.some((r) => r.url.startsWith("https://shared.example/other/"))).toBe( + false, + ); + } + }); +}); + +describe("execution failure containment and verification", () => { + for (const failure of [ + "missing-link-file", + "missing-link-container", + "missing-etag", + "acl-etag", + "fallback-link", + "no-type", + "missing-acl", + "resource-acl", + "members-acl", + "short-content", + "wrong-content", + ] as const) + it(failure, async () => { + const { source, target } = await pods("wac", "wac"); + const isContainer = failure.includes("container") || failure === "members-acl"; + const path = isContainer ? "folder/" : "file"; + source.store( + source.root + path, + isContainer ? "" : "secret", + isContainer ? "text/turtle" : "text/plain", + ); + if (isContainer) source.store(`${source.root}${path}nested/`, "", "text/turtle"); + if (failure === "resource-acl" || failure === "members-acl") + source.setAcl( + source.root + path, + await acl( + source, + source.root + path, + policy(grant(undefined, { appliesTo: isContainer ? "both" : "resource" })), + ), + ); + const url = target.root + path; + const targetSide = transformed(target, (requestUrl, method, response) => { + if (requestUrl !== url && requestUrl !== target.acl(url)) return response; + if (failure.startsWith("missing-link") && requestUrl === url) + return without(response, "link"); + if (failure === "fallback-link" && method === "PUT" && requestUrl === url) + return without(response, "link"); + if (failure === "missing-etag" && method === "PUT" && requestUrl === url) + return without(response, "etag"); + if (failure === "acl-etag" && method === "PUT" && requestUrl === target.acl(url)) + return without(response, "etag"); + if ( + ["short-content", "wrong-content"].includes(failure) && + method === "GET" && + requestUrl === url + ) + return new Response(failure === "short-content" ? "short" : "WRONG!", { + headers: response.headers, + }); + return response; + }); + const sourceSide = + failure === "no-type" + ? transformed(source, (u, m, r) => + m === "GET" && u.endsWith("file") ? without(r, "content-type") : r, + ) + : source.side(); + const options = { source: sourceSide, target: targetSide }; + const report = await planMigration(options); + let aclWrites = 0; + target.afterWrite = async (written) => { + if (written !== target.acl(url) || ++aclWrites !== 2) return; + if (failure === "missing-acl") target.access.delete(target.acl(url)); + if (failure === "resource-acl") target.setAcl(url, await acl(target, url)); + if (failure === "members-acl") target.setAcl(url, await acl(target, url, policy(grant()))); + }; + const result = await executeMigration(finalizePlan(report, resolve(report)), options); + expect(result.verified).toBe(["fallback-link", "no-type"].includes(failure)); + if (!result.verified) expect(result.failed).toHaveLength(1); + if (["missing-etag", "acl-etag"].includes(failure)) + expect(await target.permissions(url)).toEqual(new Set()); + }); + it("bounds an exponentially expensive policy proof", () => { + const source = policy( + ...Array.from({ length: 350 }, (_, i) => + grant([[{ kind: "group", group: `urn:group-${i}` }]]), + ), + ); + expect(() => assertNotWider(source, source)).toThrow("too complex"); + }); + it("covers conservative symbolic exclusions and denials", () => { + const context = { + resourceUrl: "urn:resource", + sourceOwner: "urn:source", + targetOwner: "urn:target", + groups: new Map(), + }; + const source = { + grants: [grant(undefined, { except: [{ kind: "owner" }] })], + denials: [ + grant([[{ kind: "owner" }]], { + except: [{ kind: "owner" }, { kind: "creator" }, { kind: "agent", webId: stranger }], + }), + ], + }; + const result = translatePolicy(source, "acp", context); + assertNotWider(source, result.policy, context); + const excludedCreator = translatePolicy( + policy(grant(undefined, { except: [{ kind: "creator" }] })), + "acp", + context, + ); + expect(excludedCreator.policy.grants).toEqual([]); + const deniedCreator = { grants: [grant()], denials: [grant([[{ kind: "creator" }]])] }; + const narrowed = translatePolicy(deniedCreator, "acp", context); + assertNotWider(deniedCreator, narrowed.policy, context); + expect(narrowed.policy.grants[0]?.modes.size).toBe(0); + }); + it("refuses unnormalized emitted policies and intersecting WAC origins", async () => { + const { source } = await pods(); + const model = unionPolicies( + ownerPolicy(source.owner), + policy(grant([[{ kind: "agent", webId: stranger }]], { modes: new Set(["write"]) })), + ); + await expect( + writeAccess( + source.side(), + `${source.root}file`, + source.acl(`${source.root}file`), + model, + "wac", + "css", + ), + ).rejects.toThrow("differs"); + await expect( + writeWac( + policy( + grant([ + [ + { kind: "agent", webId: stranger }, + { kind: "origin", origin: "urn:a" }, + { kind: "origin", origin: "urn:b" }, + ], + ]), + ), + "urn:resource", + "urn:acl", + ), + ).rejects.toThrow("origins"); + await writeAcp( + policy( + grant(undefined, { + modes: new Set(["control"]), + except: [{ kind: "agent", webId: stranger }], + }), + ), + "urn:resource", + "urn:acl", + ); + }); + it("propagates RDF writer failures", async () => { + const spy = vi.spyOn(Writer.prototype, "end").mockImplementationOnce((callback: unknown) => { + (callback as (error: Error) => void)(new Error("writer failure")); + return undefined as never; + }); + await expect(serialize(new Store())).rejects.toThrow("writer failure"); + spy.mockRestore(); + }); +}); + +it("does not copy encoded auxiliaries or data mapped onto an advertised target ACR", async () => { + const { source, target } = await pods(); + source.store(`${source.root}forged%2Eacl`, "unsafe ACL"); + source.store(`${source.root}forged.%41CR`, "unsafe ACR"); + source.store(`${source.root}permissions`, "unsafe ACR content"); + const previous = target.access.get(target.acl(target.root)); + target.access.delete(target.acl(target.root)); + target.aclOverrides.set(target.root, `${target.root}permissions`); + if (previous) target.access.set(target.acl(target.root), previous); + const options = { source: source.side(), target: target.side() }; + const plan = await planMigration(options); + expect(plan.report.filter((item) => item.kind === "metadata-skipped")).toHaveLength(2); + expect(plan.report.find((item) => item.resourceUrl.endsWith("permissions"))?.kind).toBe( + "protected-target", + ); + const result = await executeMigration(finalizePlan(plan, resolve(plan)), options); + expect(result.verified).toBe(true); + expect(result.copied).toEqual([]); + expect(target.access.get(target.acl(target.root))).toBe(previous); +}); + +it("removes auxiliary resources and their descendants advertised later in the crawl", async () => { + const { source, target } = await pods(); + source.store(`${source.root}metadata/`, "", "text/turtle"); + source.store(`${source.root}metadata/child`, "metadata"); + source.store(`${source.root}late`, "ordinary data"); + const sourceSide = transformed(source, (url, method, response) => { + if (method !== "HEAD" || !url.endsWith("late")) return response; + const headers = new Headers(response.headers); + headers.append("link", `<${source.root}metadata/>; rel="describedby"`); + return new Response(null, { headers }); + }); + const plan = await planMigration({ source: sourceSide, target: target.side() }); + expect(plan.resources.some((item) => item.sourceUrl.includes("metadata"))).toBe(false); + expect(plan.report.filter((item) => item.kind === "metadata-skipped")).toHaveLength(2); +}); + +it("refuses partial source representations before copying content", async () => { + const { source, target } = await pods(); + source.store(`${source.root}file`, "secret"); + const options = { source: source.side(), target: target.side() }; + const plan = await planMigration(options); + source.responses.set(`GET ${source.root}file`, () => new Response("sec", { status: 206 })); + const result = await executeMigration(finalizePlan(plan, resolve(plan)), options); + expect(result.failed[0]?.error).toContain("complete representation (206)"); + expect(target.data.has(`${target.root}file`)).toBe(false); +}); + +it("rechecks source grants after copying and records the final cancellation report", async () => { + const { source, target } = await pods(); + const file = `${source.root}file`; + source.store(file, "secret"); + source.setAcl(file, await acl(source, file, policy(grant()))); + const options = { source: source.side(), target: target.side() }; + const plan = await planMigration(options); + const stored = new Map(); + vi.stubGlobal("sessionStorage", { + setItem: (key: string, value: string) => stored.set(key, value), + }); + let writes = 0; + target.afterWrite = async (url) => { + if (url === `${target.root}file` && ++writes === 2) + source.setAcl(file, await acl(source, file)); + }; + const result = await executeMigration(finalizePlan(plan, resolve(plan)), options); + expect(result.failed[0]?.error).toContain("widens"); + expect(await target.permissions(`${target.root}file`)).toEqual(new Set()); + expect(JSON.parse(stored.get("pod-migrator:last-run") as string)).toEqual(result); + vi.unstubAllGlobals(); +}); + +it("requires owner control even during placeholder creation", async () => { + const { source, target } = await pods("wac", "wac"); + const file = `${source.root}file`; + source.store(file, "public"); + source.setAcl(file, await acl(source, file, policy(grant()))); + target.setAcl( + target.root, + await writeWac( + unionPolicies(ownerPolicy(target.owner), policy(grant(undefined, { appliesTo: "members" }))), + target.root, + target.acl(target.root), + ), + ); + const plan = await planMigration({ source: source.side(), target: target.side() }); + expect(() => finalizePlan(plan, resolve(plan))).toThrow("widens"); + expect(target.requests.some((request) => request.method === "PUT")).toBe(false); +}); diff --git a/apps/pod-migrator/test/engine/fake-pod.ts b/apps/pod-migrator/test/engine/fake-pod.ts new file mode 100644 index 000000000..f97789190 --- /dev/null +++ b/apps/pod-migrator/test/engine/fake-pod.ts @@ -0,0 +1,262 @@ +// AUTHORED-BY Codex GPT-6 +import { parseRdf } from "@jeswr/fetch-rdf"; +import { + AccessControlResource, + AclResource, + Container, + Group, + type Matcher, + type Policy, +} from "@solid/object"; +import { DataFactory, Store } from "n3"; +import { createPodHttp } from "../../src/engine/pod-http.js"; +import { ACL, ACP, FOAF, modeIri, RDF, RdfNode, serialize } from "../../src/engine/rdf.js"; +import type { AccessSystem, Mode, PodSide } from "../../src/engine/types.js"; +export interface Stored { + bytes: Uint8Array; + type: string; + etag: string; +} +export interface RecordedRequest { + url: string; + method: string; + headers: Headers; + init: RequestInit; +} +export interface Identity { + agent?: string; + client?: string; + issuer?: string; + origin?: string; + groups?: string[]; + vc?: string[]; +} +/** Independent server evaluation: deliberately does not call the engine's readers/prover. */ +export class FakePod { + readonly data = new Map(); + readonly requests: RecordedRequest[] = []; + readonly access = new Map(); + readonly aclOverrides = new Map(); + readonly denied = new Map(); + readonly responses = new Map Response>(); + readonly root: string; + readonly owner: string; + readonly system: "wac" | "acp"; + afterWrite?: (url: string) => void | Promise; + next = 0; + constructor(root: string, system: "wac" | "acp", owner = `${root}profile/card#me`) { + this.root = root; + this.system = system; + this.owner = owner; + } + acl(url: string): string { + return this.aclOverrides.get(url) ?? url + (this.system === "wac" ? ".acl" : ".acr"); + } + store(url: string, body: string | Uint8Array, type = "text/plain"): void { + this.data.set(url, { + bytes: typeof body === "string" ? new TextEncoder().encode(body) : body, + type, + etag: `"v${++this.next}"`, + }); + } + setAcl(url: string, turtle: string): void { + this.access.set(this.acl(url), { + bytes: new TextEncoder().encode(turtle), + type: "text/turtle", + etag: `"v${++this.next}"`, + }); + } + async initialize(turtle: string): Promise { + this.store(this.root, "", "text/turtle"); + this.setAcl(this.root, turtle); + return this; + } + side(): PodSide { + return { + root: this.root, + ownerWebId: this.owner, + http: createPodHttp({ root: this.root, fetch: this.fetch }), + }; + } + async dataset(url: string): Promise { + const stored = this.access.get(this.acl(url)); + return stored + ? parseRdf(new TextDecoder().decode(stored.bytes), "text/turtle", { baseIRI: this.acl(url) }) + : undefined; + } + ancestors(url: string): string[] { + const result = [url]; + let current = url; + while (current !== this.root) { + current = new URL(".", current.endsWith("/") ? current.slice(0, -1) : current).href; + result.push(current); + } + return result; + } + async permissions(url: string, identity: Identity = {}): Promise> { + const allowed = new Set(); + const denied = new Set(); + for (const ancestor of this.ancestors(url)) { + const dataset = await this.dataset(ancestor); + if (!dataset) continue; + if (this.system === "wac") { + for (const auth of new AclResource(dataset, DataFactory).authorizations) { + const node = new RdfNode( + auth as unknown as import("@rdfjs/types").Term, + dataset, + DataFactory, + ); + if (!node.iris(ACL + (url === ancestor ? "accessTo" : "default")).includes(ancestor)) + continue; + const groupMatches = await Promise.all( + node.iris(`${ACL}agentGroup`).map(async (group) => { + if (identity.groups?.includes(group)) return true; + if (!identity.agent) return false; + const documentUrl = group.split("#")[0] as string; + const stored = this.data.get(documentUrl); + if (!stored) return false; + const dataset = await parseRdf(new TextDecoder().decode(stored.bytes), stored.type, { + baseIRI: documentUrl, + }); + return new Group(group, dataset, DataFactory).hasMember.has(identity.agent); + }), + ); + const matches = + node.iris(`${ACL}agent`).includes(identity.agent ?? "") || + node.iris(`${ACL}agentClass`).includes(`${FOAF}Agent`) || + (!!identity.agent && + node.iris(`${ACL}agentClass`).includes(`${ACL}AuthenticatedAgent`)) || + groupMatches.some(Boolean); + const origins = node.iris(`${ACL}origin`); + if (matches && (!origins.length || origins.includes(identity.origin ?? ""))) + for (const mode of node.iris(`${ACL}mode`)) allowed.add(mode); + } + break; // nearest-ACL replacement, including an empty ACL + } + const matcherMatches = (matcher: Matcher): boolean => { + const agents = [...matcher.agent]; + const clients = [...matcher.client]; + const issuers = [...matcher.issuer]; + const vcs = [...matcher.vc]; + return ( + agents.length + clients.length + issuers.length + vcs.length > 0 && + (!agents.length || + agents.some( + (agent) => + agent === `${ACP}PublicAgent` || + agent === identity.agent || + (!!identity.agent && agent === `${ACP}AuthenticatedAgent`) || + ((agent === `${ACP}OwnerAgent` || agent === `${ACP}CreatorAgent`) && + identity.agent === this.owner), + )) && + (!clients.length || clients.includes(identity.client ?? "")) && + (!issuers.length || issuers.includes(identity.issuer ?? "")) && + (!vcs.length || vcs.some((vc) => identity.vc?.includes(vc))) + ); + }; + const apply = (policy: Policy) => { + const all = [...policy.allOf]; + const any = [...policy.anyOf]; + const none = [...policy.noneOf]; + if ( + all.length + any.length > 0 && + all.every(matcherMatches) && + (!any.length || any.some(matcherMatches)) && + !none.some(matcherMatches) + ) { + for (const mode of policy.allow) allowed.add(mode.replace(ACP, ACL)); + for (const mode of policy.deny) denied.add(mode.replace(ACP, ACL)); + } + }; + for (const q of dataset.match( + null, + DataFactory.namedNode(`${ACP}resource`), + DataFactory.namedNode(ancestor), + )) { + const acr = new AccessControlResource(q.subject, dataset, DataFactory); + for (const control of url === ancestor ? acr.accessControl : acr.memberAccessControl) + for (const policy of control.apply) apply(policy); + } + } + for (const mode of denied) allowed.delete(mode); + const result = new Set(); + for (const mode of ["read", "append", "write", "control"] as const) + if (allowed.has(modeIri(mode))) result.add(mode); + if (result.has("write")) result.add("append"); + return result; + } + async request( + url: string, + init: RequestInit = {}, + identity: Identity = { agent: this.owner }, + ): Promise { + const method = init.method ?? "GET"; + const headers = new Headers(init.headers); + this.requests.push({ url, method, headers, init }); + const forced = this.responses.get(`${method} ${url}`); + if (forced) return forced(); + const status = this.denied.get(`${method} ${url}`); + if (status) return new Response(null, { status }); + const overridden = [...this.aclOverrides].find(([, acl]) => acl === url)?.[0]; + const auxiliary = + overridden !== undefined || url.endsWith(this.system === "wac" ? ".acl" : ".acr"); + const resource = overridden ?? (auxiliary ? url.slice(0, -4) : url); + const stored = auxiliary ? this.access.get(url) : this.data.get(url); + const link = `<${this.acl(resource)}>; rel="acl"`; + const responseHeaders = new Headers({ link, "content-type": stored?.type ?? "text/turtle" }); + if (stored) responseHeaders.set("etag", stored.etag); + if (!stored && (method === "HEAD" || method === "GET")) + return new Response(null, { status: 404, headers: responseHeaders }); + const permissions = await this.permissions(resource, identity); + const needed = auxiliary ? "control" : method === "GET" || method === "HEAD" ? "read" : "write"; + if (!permissions.has(needed)) + return new Response(null, { status: identity.agent ? 403 : 401, headers: responseHeaders }); + if (method === "HEAD") { + responseHeaders.set("content-length", String(stored?.bytes.length)); + return new Response(null, { headers: responseHeaders }); + } + if (method === "GET") { + if (!auxiliary && url.endsWith("/")) { + const dataset = new Store(); + const container = new RdfNode(url, dataset, DataFactory); + container.add(`${RDF}type`, "http://www.w3.org/ns/ldp#BasicContainer"); + for (const child of this.data.keys()) + if (child !== url && this.ancestors(child)[1] === url) + container.add("http://www.w3.org/ns/ldp#contains", child); + // Exercise the same real Container parser a server listing consumer uses. + new Container(url, dataset, DataFactory); + responseHeaders.set("content-type", "text/turtle"); + return new Response(await serialize(dataset), { headers: responseHeaders }); + } + return new Response(stored?.bytes, { headers: responseHeaders }); + } + if ( + (headers.get("if-none-match") === "*" && stored) || + (headers.has("if-match") && headers.get("if-match") !== stored?.etag) + ) + return new Response(null, { status: 412 }); + if (method === "PUT") { + const bytes = new Uint8Array(await new Response(init.body).arrayBuffer()); + const next: Stored = { + bytes, + type: headers.get("content-type") ?? "application/octet-stream", + etag: `"v${++this.next}"`, + }; + (auxiliary ? this.access : this.data).set(url, next); + responseHeaders.set("etag", next.etag); + await this.afterWrite?.(url); + return new Response(null, { status: stored ? 205 : 201, headers: responseHeaders }); + } + this.data.delete(url); + await this.afterWrite?.(url); + return new Response(null, { status: 204 }); + } + readonly fetch: typeof fetch = async (input, init) => { + if (typeof input !== "string") throw new TypeError("Fake transport requires strings"); + return this.request(input, init); + }; +} +export function model(system: AccessSystem): "wac" | "acp" { + if (system === "unknown") throw new TypeError("Unknown model"); + return system; +} diff --git a/apps/pod-migrator/test/engine/helpers.ts b/apps/pod-migrator/test/engine/helpers.ts new file mode 100644 index 000000000..7914dae9b --- /dev/null +++ b/apps/pod-migrator/test/engine/helpers.ts @@ -0,0 +1,60 @@ +// AUTHORED-BY Codex GPT-6 +import { ownerPolicy, unionPolicies } from "../../src/engine/policy.js"; +import { writeAcp } from "../../src/engine/systems/acp.js"; +import { writeWac } from "../../src/engine/systems/wac.js"; +import type { + EffectivePolicy, + Grant, + MigrationPlan, + Resolutions, + Subject, +} from "../../src/engine/types.js"; +import { FakePod } from "./fake-pod.js"; +export const stranger = "https://people.example/stranger#me"; +export function grant( + who: Subject[][] = [[{ kind: "public" }]], + extra: Partial = {}, +): Grant { + return { + who, + modes: new Set(["read"]), + except: [], + appliesTo: "resource", + source: { + url: "https://source.example/pod/.acl", + node: "https://source.example/pod/.acl#rule", + }, + ...extra, + }; +} +export function policy(...grants: Grant[]): EffectivePolicy { + return { grants, denials: [] }; +} +export async function acl( + pod: FakePod, + resource: string, + extra = policy(), + members = resource.endsWith("/"), +): Promise { + const model = unionPolicies(ownerPolicy(pod.owner, members), extra); + return pod.system === "wac" + ? writeWac(model, resource, pod.acl(resource)) + : writeAcp(model, resource, pod.acl(resource)); +} +export async function pods( + from: "wac" | "acp" = "wac", + to: "wac" | "acp" = "acp", +): Promise<{ source: FakePod; target: FakePod }> { + const source = new FakePod("https://shared.example/alice/", from); + const target = new FakePod("https://shared.example/bob/", to); + await source.initialize(await acl(source, source.root)); + await target.initialize(await acl(target, target.root)); + return { source, target }; +} +export function resolve(plan: MigrationPlan, overrides: Record = {}): Resolutions { + return Object.fromEntries( + plan.report + .filter((item) => item.severity === "needs-decision") + .map((item) => [item.id, overrides[item.kind] ?? item.recommended]), + ) as Resolutions; +} diff --git a/apps/pod-migrator/test/engine/http.test.ts b/apps/pod-migrator/test/engine/http.test.ts new file mode 100644 index 000000000..b13b5a818 --- /dev/null +++ b/apps/pod-migrator/test/engine/http.test.ts @@ -0,0 +1,97 @@ +// AUTHORED-BY Codex GPT-6 +import { describe, expect, it, vi } from "vitest"; +import { createPodHttp } from "../../src/engine/index.js"; + +const root = "https://shared.example/alice/"; +describe("PodHttp credential boundary", () => { + it("sends only primitive checked URLs and allowlisted request fields", async () => { + const fetch = vi.fn().mockImplementation(async () => new Response()); + const http = createPodHttp({ root, fetch }); + await http.get(`${root}x`, { + accept: "text/turtle", + headers: { Authorization: "evil", DPoP: "evil" }, + } as never); + await http.head(`${root}x`); + await http.put(`${root}x`, "x", { + contentType: "text/plain", + ifNoneMatch: "*", + ifMatch: '"etag"', + }); + await http.put(`${root}x`, new Uint8Array([1]), { contentType: "application/octet-stream" }); + await http.delete(`${root}x`); + expect(fetch).toHaveBeenCalledTimes(5); + for (const [url, init] of fetch.mock.calls) { + expect(typeof url).toBe("string"); + expect(init?.redirect).toBe("manual"); + expect(new Headers(init?.headers).has("authorization")).toBe(false); + expect(new Headers(init?.headers).has("dpop")).toBe(false); + } + expect(new Headers(fetch.mock.calls[2]?.[1]?.headers).get("if-none-match")).toBe("*"); + expect(http.root).toBe(root); + }); + it.each([ + `${root}../bob/private`, + "https://shared.example/bob/private", + "https://shared.example/alice-evil/private", + "https://evil.example/alice/x", + `${root}%2e%2e/bob/x`, + `${root}..%2fbob/x`, + `${root}%255c..%255cbob/x`, + `${root}..\\bob/x`, + `https://user:secret@shared.example/alice/x`, + `//shared.example/alice/x`, + "javascript:alert(1)", + `${root}x?query`, + `${root}x#hash`, + new Request(`${root}x`), + new URL(`${root}x`), + ])("refuses crafted input %s before credentials", async (input) => { + const fetch = vi.fn(); + const http = createPodHttp({ root, fetch }); + await expect(http.get(input as string)).rejects.toThrow(); + expect(fetch).not.toHaveBeenCalled(); + }); + it.each([301, 302, 303, 307, 308, 304])("refuses redirect status %s", async (status) => { + const fetch = vi + .fn() + .mockResolvedValue( + new Response(null, { status, headers: { location: "https://shared.example/bob/stolen" } }), + ); + await expect(createPodHttp({ root, fetch }).get(`${root}x`)).rejects.toThrow("redirect"); + expect(fetch).toHaveBeenCalledTimes(1); + }); + it("checks successful Location and final response URL", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce( + new Response(null, { status: 201, headers: { location: "https://shared.example/bob/" } }), + ) + .mockResolvedValueOnce( + Object.defineProperty(new Response(), "url", { value: `${root}different` }), + ) + .mockResolvedValueOnce(Object.defineProperty(new Response(), "redirected", { value: true })) + .mockResolvedValueOnce( + Object.defineProperty(new Response(), "type", { value: "opaqueredirect" }), + ); + const http = createPodHttp({ root, fetch }); + for (let i = 0; i < 4; i++) await expect(http.head(`${root}x`)).rejects.toThrow(); + }); + it("permits explicitly opted-in loopback only", () => { + for (const root of [ + "http://localhost:3000/pod/", + "http://127.0.0.1:3000/pod/", + "http://[::1]:3000/pod/", + ]) { + expect(() => createPodHttp({ root, fetch, allowInsecureLoopback: true })).not.toThrow(); + expect(() => createPodHttp({ root, fetch })).toThrow(); + } + for (const root of [ + "http://evil.example/pod/", + "https://shared.example/pod/?x", + "https://shared.example/pod/#x", + "https://shared.example\\pod/", + 1 as never, + ]) + expect(() => createPodHttp({ root, fetch, allowInsecureLoopback: true })).toThrow(); + }); +}); diff --git a/apps/pod-migrator/test/engine/pipeline.test.ts b/apps/pod-migrator/test/engine/pipeline.test.ts new file mode 100644 index 000000000..295e4a232 --- /dev/null +++ b/apps/pod-migrator/test/engine/pipeline.test.ts @@ -0,0 +1,196 @@ +// AUTHORED-BY Codex GPT-6 +import { describe, expect, it } from "vitest"; +import { + executeMigration, + finalizePlan, + MigrationAborted, + planMigration, +} from "../../src/engine/index.js"; +import { acl, grant, pods, policy, resolve, stranger } from "./helpers.js"; + +describe("migration with an independently enforcing LDP server", () => { + for (const from of ["wac", "acp"] as const) + for (const to of ["wac", "acp"] as const) + it(`${from} to ${to}: every intermediate write is no wider`, async () => { + const { source, target } = await pods(from, to); + source.store(`${source.root}folder/`, "", "text/turtle"); + source.store(`${source.root}folder/public.txt`, "hello public"); + source.store( + `${source.root}folder/private.bin`, + new Uint8Array([0, 255, 13, 10]), + "application/octet-stream", + ); + source.setAcl( + `${source.root}folder/public.txt`, + await acl(source, `${source.root}folder/public.txt`, policy(grant())), + ); + source.setAcl( + `${source.root}folder/private.bin`, + await acl(source, `${source.root}folder/private.bin`), + ); + const sourceSide = source.side(); + const targetSide = target.side(); + const progress: string[] = []; + const plan = await planMigration({ + source: sourceSide, + target: targetSide, + onProgress: (p) => progress.push(p.phase), + }); + expect(plan.stats).toMatchObject({ containers: 2, resources: 2, bytes: 16 }); + expect(target.requests.every((r) => r.method === "HEAD" || r.method === "GET")).toBe(true); + expect(() => finalizePlan(plan, {})).toThrow(TypeError); + expect(() => finalizePlan(plan, { arbitrary: "abort" })).toThrow(MigrationAborted); + const executable = finalizePlan(plan, resolve(plan)); + let snapshots = 0; + target.afterWrite = async () => { + snapshots++; + for (const url of target.data.keys()) { + if (url === target.root) continue; + const original = source.root + url.slice(target.root.length); + for (const identity of [{}, { agent: stranger }]) { + const before = await source.permissions(original, identity); + const after = await target.permissions(url, identity); + for (const mode of after) + expect(before.has(mode), `${url} ${mode} after write ${snapshots}`).toBe(true); + } + expect(await target.permissions(url, { agent: target.owner })).toEqual( + new Set(["read", "append", "write", "control"]), + ); + } + }; + const result = await executeMigration(executable, { + source: sourceSide, + target: targetSide, + onProgress: (p) => progress.push(p.phase), + }); + expect(result.failed).toEqual([]); + expect(result.verified).toBe(true); + expect(result.copied).toHaveLength(2); + expect(snapshots).toBeGreaterThan(8); + expect( + new TextDecoder().decode(target.data.get(`${target.root}folder/public.txt`)?.bytes), + ).toBe("hello public"); + expect((await target.request(`${target.root}folder/private.bin`, {}, {})).status).toBe(401); + expect((await target.request(`${target.root}folder/public.txt`, {}, {})).status).toBe(200); + const writes = target.requests.filter((r) => r.method === "PUT"); + expect(writes[0]?.url).toBe(`${target.root}folder/`); + const finalAcls = writes + .filter((r) => r.url.endsWith(to === "wac" ? ".acl" : ".acr")) + .slice(-3); + expect(finalAcls.at(-1)?.url).toBe(target.acl(`${target.root}folder/`)); + expect(progress).toEqual( + expect.arrayContaining([ + "crawl", + "read-access", + "plan", + "lock", + "copy", + "grant", + "verify", + ]), + ); + await expect( + executeMigration(executable, { source: sourceSide, target: targetSide }), + ).rejects.toThrow("finalized"); + }); + + it("cancels after an in-flight write and locks placeholders before stopping", async () => { + const { source, target } = await pods(); + source.store(`${source.root}private`, "secret"); + const sourceSide = source.side(); + const targetSide = target.side(); + const plan = await planMigration({ source: sourceSide, target: targetSide }); + const abort = new AbortController(); + target.afterWrite = (url) => { + if (url === `${target.root}private`) abort.abort(); + }; + const result = await executeMigration(finalizePlan(plan, resolve(plan)), { + source: sourceSide, + target: targetSide, + signal: abort.signal, + }); + expect(result.cancelled).toBe(true); + expect(result.verified).toBe(false); + expect(result.failed).toEqual([]); + expect((await target.permissions(`${target.root}private`)).size).toBe(0); + expect(result.aclWritten).toContain(target.acl(`${target.root}private`)); + expect(result.skipped).toHaveLength(2); // protected root and the uncopied file + }); + + it("keeps target profile and existing content until explicitly resolved", async () => { + const { source, target } = await pods("acp", "wac"); + source.store(`${source.root}profile/`, "", "text/turtle"); + source.store(`${source.root}profile/card`, "incoming profile"); + source.store(`${source.root}existing`, "new"); + target.store(`${target.root}profile/`, "", "text/turtle"); + target.store(`${target.root}profile/card`, "target profile"); + target.store(`${target.root}existing`, "old"); + const sourceSide = source.side(); + const targetSide = target.side(); + const plan = await planMigration({ source: sourceSide, target: targetSide }); + expect(plan.report.filter((i) => i.kind === "protected-target")).toHaveLength(2); + const result = await executeMigration( + finalizePlan(plan, resolve(plan, { "target-exists": "skip" })), + { source: sourceSide, target: targetSide }, + ); + expect(result.verified).toBe(true); + expect(new TextDecoder().decode(target.data.get(`${target.root}profile/card`)?.bytes)).toBe( + "target profile", + ); + expect(new TextDecoder().decode(target.data.get(`${target.root}existing`)?.bytes)).toBe("old"); + const next = await planMigration({ source: sourceSide, target: targetSide }); + const choices = { + ...resolve(next), + [next.report.find((i) => i.resourceUrl.endsWith("existing") && i.kind === "target-exists") + ?.id as string]: "overwrite" as const, + }; + const updated = await executeMigration(finalizePlan(next, choices), { + source: sourceSide, + target: targetSide, + }); + expect(updated.failed).toEqual([]); + expect(new TextDecoder().decode(target.data.get(`${target.root}existing`)?.bytes)).toBe("new"); + }); + + it("refuses unsafe inherited target access before any write", async () => { + const { source, target } = await pods(); + source.store(`${source.root}private`, "secret"); + target.setAcl( + target.root, + await acl(target, target.root, policy(grant(undefined, { appliesTo: "both" }))), + ); + const plan = await planMigration({ source: source.side(), target: target.side() }); + expect(() => finalizePlan(plan, resolve(plan))).toThrow("widens"); + expect(target.requests.some((r) => r.method === "PUT")).toBe(false); + }); +}); + +it("preserves additive ACP member controls and denies for existing and future children", async () => { + const { source, target } = await pods("acp", "acp"); + const folder = `${source.root}folder/`; + source.store(folder, "", "text/turtle"); + source.store(`${folder}file`, "content"); + const memberPolicy = policy(grant(undefined, { appliesTo: "both" })); + memberPolicy.denials.push( + grant([[{ kind: "agent", webId: stranger }]], { appliesTo: "members" }), + ); + source.setAcl(folder, await acl(source, folder, memberPolicy)); + const options = { source: source.side(), target: target.side() }; + const plan = await planMigration(options); + target.afterWrite = async () => { + for (const url of target.data.keys()) + if (url !== target.root) + for (const identity of [{}, { agent: stranger }]) { + const original = source.root + url.slice(target.root.length); + const allowed = await source.permissions(original, identity); + for (const mode of await target.permissions(url, identity)) + expect(allowed.has(mode)).toBe(true); + } + }; + const result = await executeMigration(finalizePlan(plan, resolve(plan)), options); + expect(result.failed).toEqual([]); + expect(await target.permissions(`${target.root}folder/file`, { agent: stranger })).toEqual( + new Set(), + ); + expect(await target.permissions(`${target.root}folder/future`)).toEqual(new Set(["read"])); +}); diff --git a/apps/pod-migrator/test/engine/policy.test.ts b/apps/pod-migrator/test/engine/policy.test.ts new file mode 100644 index 000000000..1521b4466 --- /dev/null +++ b/apps/pod-migrator/test/engine/policy.test.ts @@ -0,0 +1,293 @@ +// AUTHORED-BY Codex GPT-6 +import { describe, expect, it } from "vitest"; +import { + assertNotWider, + effectivePolicy, + emptyPolicy, + ownerPolicy, + policiesEqual, + scopePolicy, + unionPolicies, +} from "../../src/engine/policy.js"; +import { type TranslationContext, translatePolicy } from "../../src/engine/translation.js"; +import type { AccessDocument, EffectivePolicy, Subject } from "../../src/engine/types.js"; +import { grant, policy, stranger } from "./helpers.js"; + +const sourceOwner = "https://source.example/owner#me"; +const targetOwner = "https://target.example/owner#me"; +const context: TranslationContext = { + resourceUrl: "https://source.example/pod/file", + sourceOwner, + targetOwner, + groups: new Map(), +}; +const agent: Subject = { kind: "agent", webId: stranger }; +function translated(source: EffectivePolicy, model: "wac" | "acp", ctx = context) { + const result = translatePolicy(source, model, ctx); + assertNotWider(source, result.policy, { sourceOwner, targetOwner, groups: ctx.groups }); + return result; +} +describe("every translation-table construct", () => { + it("preserves named, public and authenticated subjects in either system", () => { + for (const model of ["wac", "acp"] as const) + for (const subject of [agent, { kind: "public" }, { kind: "authenticated" }] as Subject[]) + expect( + policiesEqual( + policy(grant([[subject]])), + translated(policy(grant([[subject]])), model).policy, + ), + ).toBe(true); + }); + it("offers only safe group choices and expands a bounded membership snapshot", () => { + const group = "https://source.example/pod/group#id"; + const source = policy(grant([[{ kind: "group", group }, { kind: "authenticated" }], [agent]])); + expect(translated(policy(grant([[{ kind: "group", group }]])), "wac").report).toEqual([]); + const unavailable = translated(source, "acp"); + expect(unavailable.report[0]?.options.map((o) => o.id)).toEqual(["skip", "abort"]); + const ctx = { ...context, groups: new Map([[group, [stranger, sourceOwner]]]) }; + const report = translatePolicy(source, "acp", ctx).report; + const expanded = translatePolicy(source, "acp", ctx, { [report[0]?.id as string]: "expand" }); + expect(expanded.policy.grants[0]?.who).toHaveLength(3); + assertNotWider(source, expanded.policy, { groups: ctx.groups }); + expect( + translatePolicy(source, "acp", { ...ctx, groups: new Map([[group, []]]) }).report[0] + ?.explanation, + ).toContain("empty"); + }); + it("never drops only an origin, client, issuer or credential condition", () => { + const origin: Subject = { kind: "origin", origin: "https://app.example" }; + expect( + translated(policy(grant([[agent, origin], [agent]])), "acp").policy.grants[0]?.who, + ).toEqual([[agent]]); + expect(translated(policy(grant([[agent, origin]])), "wac").report).toEqual([]); + for (const condition of [ + { kind: "client", clientId: "https://app.example/id" }, + { kind: "issuer", issuer: "https://issuer.example" }, + { kind: "vc", vcType: "https://vc.example/type" }, + ] as Subject[]) { + const source = policy(grant([[agent, condition], [agent]])); + expect(translated(source, "wac").report[0]?.kind).toBe("condition-unsupported"); + expect(translated(source, "wac").policy.grants[0]?.who).toEqual([[agent]]); + expect(policiesEqual(source, translated(source, "acp").policy)).toBe(true); + } + }); + it("drops entire excluded grants, and subtracts or conservatively drops denials", () => { + const excluded = policy(grant(undefined, { except: [agent] })); + expect(translated(excluded, "wac").policy.grants).toEqual([]); + expect(translated(excluded, "wac").report[0]?.kind).toBe("exclusion-unsupported"); + const exact = { + grants: [grant([[agent]], { modes: new Set(["read", "control"]) })], + denials: [grant([[agent]])], + }; + expect(translated(exact, "wac").policy.grants[0]?.modes).toEqual(new Set(["control"])); + expect( + translated({ grants: [grant()], denials: [grant([[agent]])] }, "wac").policy.grants, + ).toEqual([]); + const disjoint = { + grants: [grant([[agent]])], + denials: [grant([[{ kind: "agent", webId: sourceOwner }]])], + }; + expect(translated(disjoint, "wac").policy.grants).toHaveLength(1); + expect(translated(disjoint, "acp").policy.denials[0]?.except).toContainEqual({ + kind: "agent", + webId: targetOwner, + }); + expect( + translated( + { grants: [grant()], denials: [grant(undefined, { modes: new Set(["control"]) })] }, + "wac", + ).policy.grants, + ).toHaveLength(1); + expect( + translated({ grants: [grant([[agent]])], denials: [grant()] }, "wac").policy.grants, + ).toEqual([]); + }); + it("maps owner and creator roles and preserves the source owner's WebID", () => { + for (const model of ["wac", "acp"] as const) { + const source = policy( + grant([ + [{ kind: "owner" }], + [{ kind: "creator" }], + [{ kind: "agent", webId: sourceOwner }], + ]), + ); + const result = translated(source, model); + expect(result.report.map((i) => i.kind)).toEqual([ + "owner-mapped", + "owner-mapped", + "source-owner-kept", + ]); + expect(result.policy.grants[0]?.who[0]).toEqual([{ kind: "agent", webId: targetOwner }]); + } + }); + it("narrows principal intersections rather than unioning them", () => { + expect( + translated( + policy(grant([[agent, { kind: "authenticated" }, { kind: "public" }, agent]])), + "wac", + ).policy.grants[0]?.who, + ).toEqual([[agent]]); + const combined = policy( + grant([ + [ + { kind: "group", group: "https://g.example/a" }, + { kind: "group", group: "https://g.example/b" }, + ], + ]), + ); + expect(translated(combined, "wac").policy.grants).toEqual([]); + expect(translated(policy(grant([], { modes: new Set() })), "wac").policy.grants).toEqual([]); + }); +}); + +describe("access proof", () => { + it("rejects widening, condition removal, dropped exclusions and dropped denies", () => { + expect(() => assertNotWider(emptyPolicy(), policy(grant()))).toThrow("widens"); + for (const source of [ + policy(grant([[agent, { kind: "origin", origin: "https://app.example" }]])), + policy(grant(undefined, { except: [agent] })), + { grants: [grant()], denials: [grant([[agent]])] }, + ]) + expect(() => assertNotWider(source, policy(grant()))).toThrow(); + expect(policiesEqual(emptyPolicy(), policy(grant()))).toBe(false); + expect(() => + assertNotWider(emptyPolicy(), ownerPolicy(targetOwner), { targetOwner }), + ).not.toThrow(); + expect(() => + assertNotWider( + policy(grant([[{ kind: "owner" }]])), + policy(grant([[{ kind: "agent", webId: sourceOwner }]])), + { sourceOwner }, + ), + ).not.toThrow(); + expect(() => + assertNotWider( + policy(grant([[{ kind: "creator" }]])), + policy(grant([[{ kind: "creator" }]])), + ), + ).not.toThrow(); + }); + it("knows identity exclusivity and authentication implication", () => { + for (const [a, b] of [ + [agent, { kind: "agent", webId: sourceOwner }], + [ + { kind: "client", clientId: "urn:a" }, + { kind: "client", clientId: "urn:b" }, + ], + [ + { kind: "issuer", issuer: "urn:a" }, + { kind: "issuer", issuer: "urn:b" }, + ], + [ + { kind: "origin", origin: "urn:a" }, + { kind: "origin", origin: "urn:b" }, + ], + ] as Subject[][]) + expect(() => + assertNotWider(emptyPolicy(), policy(grant([[a as Subject, b as Subject]]))), + ).not.toThrow(); + expect(() => + assertNotWider(policy(grant([[{ kind: "authenticated" }]])), policy(grant([[agent]]))), + ).not.toThrow(); + }); + it("checks randomized policies against real finite identity contexts", () => { + let seed = 11; + const random = () => { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; + return seed; + }; + const subjects: Subject[] = [ + { kind: "public" }, + { kind: "authenticated" }, + agent, + { kind: "client", clientId: "urn:client" }, + ]; + for (let index = 0; index < 150; index++) { + const rules = Array.from({ length: random() % 4 }, () => + grant([[subjects[random() % subjects.length] as Subject]], { + modes: new Set(random() % 2 ? ["read"] : ["write", "append"]), + except: random() % 3 ? [] : [agent], + }), + ); + const source = { grants: rules, denials: random() % 2 ? [grant([[agent]])] : [] }; + for (const model of ["wac", "acp"] as const) { + const target = translated(source, model).policy; + for (const webId of [undefined, stranger, sourceOwner]) + for (const client of [undefined, "urn:client", "urn:other"]) { + const matches = (subject: Subject): boolean => { + switch (subject.kind) { + case "public": + return true; + case "authenticated": + return webId !== undefined; + case "agent": + return subject.webId === webId; + case "client": + return subject.clientId === client; + default: + throw new Error("Unexpected randomized subject"); + } + }; + const permits = ( + p: EffectivePolicy, + mode: import("../../src/engine/types.js").Mode, + ) => { + const applies = (rule: import("../../src/engine/types.js").Grant) => + rule.modes.has(mode) && + rule.who.some((clause) => clause.every(matches)) && + !rule.except.some(matches); + return p.grants.some(applies) && !p.denials.some(applies); + }; + for (const mode of ["read", "append", "write", "control"] as const) + if (permits(target, mode)) expect(permits(source, mode)).toBe(true); + } + } + } + }); + it("WAC replaces inherited defaults; ACP unions them with denies winning", () => { + const root = "https://source.example/pod/"; + const child = `${root}folder/`; + const file = `${child}file`; + const doc = (url: string, p: EffectivePolicy): AccessDocument => ({ + url, + policy: p, + turtle: "", + etag: null, + }); + const documents = new Map([ + [root, doc(root, policy(grant(undefined, { appliesTo: "both" })))], + [child, doc(child, ownerPolicy(sourceOwner, true))], + ]); + expect(effectivePolicy("wac", file, root, documents).grants).toHaveLength(1); + expect(effectivePolicy("acp", file, root, documents).grants).toHaveLength(2); + expect(effectivePolicy("wac", root, root, new Map())).toEqual(emptyPolicy()); + expect( + scopePolicy( + unionPolicies(policy(grant(undefined, { appliesTo: "members" })), { + grants: [], + denials: [grant(undefined, { appliesTo: "both" })], + }), + "members", + ).denials, + ).toHaveLength(1); + }); +}); + +it("uses RDF identity independently of JavaScript property order", () => { + const a: Subject = { kind: "agent", webId: stranger }; + const same: Subject = { webId: stranger, kind: "agent" }; + expect(() => assertNotWider(policy(grant([[a]])), policy(grant([[same]])))).not.toThrow(); + expect(() => assertNotWider(emptyPolicy(), policy(grant([[a, same]])))).toThrow("widens"); +}); + +it("drops impossible intersections of different agents when translating to WAC", () => { + const source = policy( + grant([ + [ + { kind: "agent", webId: stranger }, + { kind: "agent", webId: sourceOwner }, + ], + ]), + ); + expect(translated(source, "wac").policy.grants).toEqual([]); +}); diff --git a/apps/pod-migrator/test/engine/systems.test.ts b/apps/pod-migrator/test/engine/systems.test.ts new file mode 100644 index 000000000..daeb1b092 --- /dev/null +++ b/apps/pod-migrator/test/engine/systems.test.ts @@ -0,0 +1,317 @@ +// AUTHORED-BY Codex GPT-6 +import { parseRdf } from "@jeswr/fetch-rdf"; +import { DataFactory, Store } from "n3"; +import { describe, expect, it } from "vitest"; +import { detectAccessSystem, readAccess, writeAccess } from "../../src/engine/access.js"; +import { emptyPolicy, ownerPolicy, policiesEqual, unionPolicies } from "../../src/engine/policy.js"; +import { + ACL, + ACP, + aclLink, + modeIri, + RDF, + RdfNode, + readModes, + safeIri, + serialize, +} from "../../src/engine/rdf.js"; +import { acpDialect, PolicyTooComplex, readAcp, writeAcp } from "../../src/engine/systems/acp.js"; +import { readWac, writeWac } from "../../src/engine/systems/wac.js"; +import type { Subject } from "../../src/engine/types.js"; +import { grant, pods, policy, stranger } from "./helpers.js"; + +const resource = "https://pod.example/folder/"; +const url = `${resource}.acl`; +async function parsed(turtle: string) { + return parseRdf(turtle, "text/turtle", { baseIRI: url }); +} +function graph() { + const dataset = new Store(); + const node = (id: string, properties: Record = {}) => { + const result = new RdfNode(id, dataset, DataFactory); + for (const [key, values] of Object.entries(properties)) + for (const value of values) result.add(key, value); + return result; + }; + return { dataset, node }; +} +describe("RDF and WAC", () => { + it("reads every WAC scope, origin and subject with Write implying Append", async () => { + const subjects: Subject[][] = [ + [ + { kind: "agent", webId: stranger }, + { kind: "origin", origin: "https://app.example" }, + ], + [{ kind: "public" }], + [{ kind: "authenticated" }], + [{ kind: "group", group: "urn:group" }], + ]; + const source = policy( + grant(subjects, { + modes: new Set(["write", "append", "read", "control"]), + appliesTo: "both", + }), + ); + const turtle = await writeWac(source, resource, url); + const dataset = await parsed(turtle); + const read = readWac(dataset, resource, url); + expect(policiesEqual(source, read)).toBe(true); + expect(read.grants[0]?.appliesTo).toBe("both"); + expect(readWac(dataset, "https://unrelated.example/", url)).toEqual(emptyPolicy()); + const member = await writeWac( + policy(grant(undefined, { appliesTo: "members" })), + resource, + url, + ); + expect(readWac(await parsed(member), resource, url).grants[0]?.appliesTo).toBe("members"); + expect(readModes([modeIri("write")])).toEqual(new Set(["write", "append"])); + }); + it("recognizes defaultForNew and refuses unsupported or malformed classes", async () => { + const { node, dataset } = graph(); + node(`${url}#auth`, { + [`${RDF}type`]: [`${ACL}Authorization`], + [`${ACL}defaultForNew`]: [resource], + [`${ACL}agentClass`]: ["urn:unknown-class"], + [`${ACL}mode`]: [`${ACL}Read`], + }); + expect(() => readWac(dataset, resource, url)).toThrow("class"); + const malformed = await parsed( + '@prefix acl: . <#a> a acl:Authorization; acl:accessTo ; acl:mode "Read".', + ); + expect(() => readWac(malformed, resource, url)).toThrow(); + }); + it("refuses unrepresentable WAC graphs and unsafe IRIREFs", async () => { + await expect(writeWac({ grants: [], denials: [grant()] }, resource, url)).rejects.toThrow( + "denials", + ); + await expect( + writeWac(policy(grant(undefined, { except: [{ kind: "public" }] })), resource, url), + ).rejects.toThrow("exclusions"); + await expect( + writeWac(policy(grant([[{ kind: "public" }, { kind: "authenticated" }]])), resource, url), + ).rejects.toThrow("one principal"); + await expect( + writeWac(policy(grant([[{ kind: "client", clientId: "urn:client" }]])), resource, url), + ).rejects.toThrow("Unsupported"); + expect(() => safeIri("https://pod.example/> ")).toThrow(); + expect(() => safeIri("relative")).toThrow(); + expect(safeIri("urn:okay")).toBe("urn:okay"); + const dataset = await parsed(' "literal".'); + expect(await serialize(dataset)).toContain('"literal"'); + const { dataset: unsafe, node } = graph(); + node("urn:x").add("urn:p", "urn:okay"); + unsafe.addQuad( + DataFactory.namedNode("urn:a>bad"), + DataFactory.namedNode("urn:p"), + DataFactory.literal("value"), + ); + expect(() => serialize(unsafe)).toThrow("Unsafe"); + const bad = await parsed(' "not a node".'); + expect(() => new RdfNode("urn:x", bad, DataFactory).nodes("urn:p")).toThrow("node required"); + }); + it("rejects ambiguous and out-of-scope ACL advertisements and ignores anchored links", () => { + expect(() => + aclLink( + new Response(null, { headers: { link: '; rel="acl", ; rel="acl"' } }), + resource, + resource, + ), + ).toThrow("Ambiguous"); + expect( + aclLink( + new Response(null, { + headers: { link: '; rel="acl"; anchor="https://elsewhere.example/"' }, + }), + resource, + resource, + ), + ).toBeUndefined(); + expect(() => + aclLink( + new Response(null, { headers: { link: '; rel="acl"' } }), + resource, + resource, + ), + ).toThrow(); + }); +}); + +describe("ACP expressions and writers", () => { + it("round-trips every supported subject, condition, exclusion, denial and scope in CSS and ESS dialects", async () => { + for (const dialect of ["css", "ess"] as const) { + const subjects: Subject[] = [ + { kind: "agent", webId: stranger }, + { kind: "public" }, + { kind: "authenticated" }, + { kind: "owner" }, + { kind: "creator" }, + { kind: "client", clientId: "urn:client" }, + { kind: "issuer", issuer: "urn:issuer" }, + { kind: "vc", vcType: "urn:vc" }, + ]; + const source = unionPolicies( + ownerPolicy(stranger, true), + policy( + grant( + subjects.map((s) => [s]), + { appliesTo: "members", except: [{ kind: "agent", webId: "urn:excluded" }] }, + ), + ), + ); + source.denials.push( + grant([[{ kind: "client", clientId: "urn:client" }]], { + modes: new Set(["control"]), + appliesTo: "both", + }), + ); + const turtle = await writeAcp(source, resource, url, dialect); + const dataset = await parsed(turtle); + const actual = readAcp(dataset, resource, url); + expect(policiesEqual(source, actual)).toBe(true); + expect(acpDialect(dataset)).toBe(dialect); + expect(turtle).toContain("acr-policy"); + } + await expect( + writeAcp(policy(grant([[{ kind: "group", group: "urn:group" }]])), resource, url), + ).rejects.toThrow("Unsupported"); + await expect( + writeAcp( + policy(grant([[{ kind: "origin", origin: "https://origin.example" }]])), + resource, + url, + ), + ).rejects.toThrow("Unsupported"); + }); + it("ANDs attributes and allOf matchers, ORs anyOf values, and flattens simple noneOf", () => { + const { dataset, node } = graph(); + node(url, { [`${ACP}resource`]: [resource], [`${ACP}accessControl`]: ["urn:control"] }); + node("urn:control", { [`${ACP}apply`]: ["urn:policy"] }); + node("urn:policy", { + [`${ACP}allow`]: [`${ACL}Read`], + [`${ACP}deny`]: [`${ACL}Write`], + [`${ACP}allOf`]: ["urn:a"], + [`${ACP}anyOf`]: ["urn:b", "urn:c"], + [`${ACP}noneOf`]: ["urn:d"], + }); + node("urn:a", { + [`${ACP}agent`]: [stranger, `${ACP}AuthenticatedAgent`], + [`${ACP}client`]: ["urn:client"], + }); + node("urn:b", { [`${ACP}issuer`]: ["urn:issuer"] }); + node("urn:c", { [`${ACP}vc`]: ["urn:vc"] }); + node("urn:d", { [`${ACP}agent`]: ["urn:excluded"] }); + const result = readAcp(dataset, resource, url); + expect(result.grants[0]?.who).toHaveLength(4); + expect(result.grants[0]?.who.every((c) => c.length === 3)).toBe(true); + expect(result.grants[0]?.except).toEqual([{ kind: "agent", webId: "urn:excluded" }]); + expect(result.denials[0]?.modes).toEqual(new Set(["write", "append"])); + const onlyNone = graph(); + onlyNone.node(url, { [`${ACP}resource`]: [resource], [`${ACP}accessControl`]: ["urn:c"] }); + onlyNone.node("urn:c", { [`${ACP}apply`]: ["urn:p"] }); + onlyNone.node("urn:p", { [`${ACP}allow`]: [`${ACL}Read`], [`${ACP}noneOf`]: ["urn:no"] }); + expect(readAcp(onlyNone.dataset, resource, url)).toEqual(emptyPolicy()); + }); + it("uses the ESS typed ACR fallback, empty matchers grant nothing, and bounds expansion", () => { + const { dataset, node } = graph(); + node(url, { + [`${RDF}type`]: [`${ACP}AccessControlResource`], + [`${ACP}accessControl`]: ["urn:control"], + }); + node("urn:control", { [`${ACP}apply`]: ["urn:policy"] }); + const p = node("urn:policy", { + [`${ACP}allow`]: [`${ACL}Read`], + [`${ACP}allOf`]: ["urn:matcher"], + }); + expect(readAcp(dataset, resource, url).grants[0]?.who).toEqual([]); + const matcher = node("urn:matcher", { [`${ACP}agent`]: [`${ACP}PublicAgent`] }); + expect(readAcp(dataset, resource, url).grants[0]?.who).toEqual([[{ kind: "public" }]]); + p.add(`${ACP}noneOf`, "urn:exclusion"); + node("urn:exclusion", { [`${ACP}agent`]: [stranger], [`${ACP}client`]: ["urn:client"] }); + expect(() => readAcp(dataset, resource, url)).toThrow(PolicyTooComplex); + for (const q of [ + ...dataset.match(DataFactory.namedNode("urn:policy"), DataFactory.namedNode(`${ACP}noneOf`)), + ]) + dataset.delete(q); + for (let i = 0; i < 65; i++) matcher.add(`${ACP}agent`, `urn:agent-${i}`); + expect(() => readAcp(dataset, resource, url)).toThrow("64 clauses"); + }); +}); + +describe("system detection and access safety", () => { + it("detects RDF signals, unreadable advertised ACP, conflict, absent/empty/malformed documents", async () => { + for (const model of ["wac", "acp"] as const) { + const { source } = await pods(model, model); + expect(await detectAccessSystem(source.side())).toBe(model); + source.responses.set(`HEAD ${source.root}`, () => new Response()); + expect(await detectAccessSystem(source.side())).toBe("unknown"); + source.responses.clear(); + source.responses.set( + `GET ${source.acl(source.root)}`, + () => + new Response(null, { + status: 403, + headers: { link: `<${ACP}AccessControlResource>; rel="type"` }, + }), + ); + expect(await detectAccessSystem(source.side())).toBe("acp"); + source.responses.set( + `GET ${source.acl(source.root)}`, + () => new Response(null, { status: 403 }), + ); + expect(await detectAccessSystem(source.side())).toBe("unknown"); + source.responses.set( + `GET ${source.acl(source.root)}`, + () => new Response("not turtle", { headers: { "content-type": "text/turtle" } }), + ); + expect(await detectAccessSystem(source.side())).toBe("unknown"); + source.responses.set( + `GET ${source.acl(source.root)}`, + () => new Response("", { headers: { "content-type": "text/turtle" } }), + ); + expect(await detectAccessSystem(source.side())).toBe("unknown"); + const doc = new TextDecoder().decode(source.access.get(source.acl(source.root))?.bytes); + source.responses.set( + `GET ${source.acl(source.root)}`, + () => + new Response( + `${doc}\n a <${model === "wac" ? `${ACP}AccessControlResource` : `${ACL}Authorization`}>.`, + { headers: { "content-type": "text/turtle" } }, + ), + ); + expect(await detectAccessSystem(source.side())).toBe("unknown"); + } + }); + it("requires owner Control before writing, handles missing links/404, refuses unknown writes", async () => { + const { source } = await pods(); + const side = source.side(); + await expect( + writeAccess(side, source.root, source.acl(source.root), emptyPolicy(), "wac", "css"), + ).rejects.toThrow("widens"); + await expect( + writeAccess( + side, + source.root, + source.acl(source.root), + ownerPolicy(source.owner), + "wac", + "css", + ), + ).rejects.toThrow(); + await expect( + writeAccess( + side, + source.root, + source.acl(source.root), + ownerPolicy(source.owner, true), + "unknown", + "css", + ), + ).rejects.toThrow("unknown"); + await expect(readAccess(side, source.root, "unknown")).rejects.toThrow("unknown"); + source.responses.set(`HEAD ${source.root}`, () => new Response()); + expect(await readAccess(side, source.root, "wac")).toBeUndefined(); + source.responses.clear(); + source.access.clear(); + expect(await readAccess(side, source.root, "wac")).toBeUndefined(); + }); +}); diff --git a/apps/pod-migrator/tsconfig.json b/apps/pod-migrator/tsconfig.json new file mode 100644 index 000000000..8c1ffd79f --- /dev/null +++ b/apps/pod-migrator/tsconfig.json @@ -0,0 +1,13 @@ +// AUTHORED-BY Codex GPT-6 +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "types": ["vite/client", "node"], + "noEmit": true + }, + "include": ["src", "test", "vite.config.ts", "vitest.config.ts"] +} diff --git a/apps/pod-migrator/vitest.config.ts b/apps/pod-migrator/vitest.config.ts new file mode 100644 index 000000000..b539bf941 --- /dev/null +++ b/apps/pod-migrator/vitest.config.ts @@ -0,0 +1,13 @@ +// AUTHORED-BY Codex GPT-6 +import { defineConfig } from "vitest/config"; +export default defineConfig({ + test: { + include: ["test/**/*.test.{ts,tsx}"], + environment: "node", + coverage: { + provider: "v8", + include: ["src/engine/**/*.ts"], + thresholds: { lines: 100, branches: 100, functions: 100, statements: 100 }, + }, + }, +}); From e062f2329964c48abded230c3120003756ed3c64 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:21:24 +0000 Subject: [PATCH 03/20] feat(pod-migrator): two-pod sign-in, plan review and run UI Two fenced solid-auth-core sessions (distinct stores and redirect URIs), storage selection, report review with per-item decisions, run progress with cancel, and a Client Identifier Document for Vercel previews. Implemented by Codex GPT-6 (xhigh); local builds fall back to a loopback origin. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KLrUernPrjVRT4K7UiaGgK --- apps/pod-migrator/README.md | 33 + apps/pod-migrator/e2e/.gitignore | 3 + apps/pod-migrator/e2e/connections.ts | 38 + apps/pod-migrator/e2e/engine.ts | 104 +++ apps/pod-migrator/e2e/screens.spec.ts | 43 + apps/pod-migrator/index.html | 21 + apps/pod-migrator/playwright.config.ts | 23 + apps/pod-migrator/public/icon.svg | 2 + apps/pod-migrator/src/App.tsx | 882 ++++++++++++++++++++ apps/pod-migrator/src/auth/connections.ts | 231 +++++ apps/pod-migrator/src/main.tsx | 9 + apps/pod-migrator/src/styles.css | 740 ++++++++++++++++ apps/pod-migrator/src/ui/ConnectionCard.tsx | 162 ++++ apps/pod-migrator/src/ui/Report.tsx | 112 +++ apps/pod-migrator/test/ui/app.test.tsx | 306 +++++++ apps/pod-migrator/test/ui/auth.test.ts | 220 +++++ apps/pod-migrator/test/ui/client-id.test.ts | 78 ++ apps/pod-migrator/test/ui/fixtures.ts | 120 +++ apps/pod-migrator/turbo.json | 7 + apps/pod-migrator/vercel.json | 26 + apps/pod-migrator/vite.config.ts | 117 +++ 21 files changed, 3277 insertions(+) create mode 100644 apps/pod-migrator/README.md create mode 100644 apps/pod-migrator/e2e/.gitignore create mode 100644 apps/pod-migrator/e2e/connections.ts create mode 100644 apps/pod-migrator/e2e/engine.ts create mode 100644 apps/pod-migrator/e2e/screens.spec.ts create mode 100644 apps/pod-migrator/index.html create mode 100644 apps/pod-migrator/playwright.config.ts create mode 100644 apps/pod-migrator/public/icon.svg create mode 100644 apps/pod-migrator/src/App.tsx create mode 100644 apps/pod-migrator/src/auth/connections.ts create mode 100644 apps/pod-migrator/src/main.tsx create mode 100644 apps/pod-migrator/src/styles.css create mode 100644 apps/pod-migrator/src/ui/ConnectionCard.tsx create mode 100644 apps/pod-migrator/src/ui/Report.tsx create mode 100644 apps/pod-migrator/test/ui/app.test.tsx create mode 100644 apps/pod-migrator/test/ui/auth.test.ts create mode 100644 apps/pod-migrator/test/ui/client-id.test.ts create mode 100644 apps/pod-migrator/test/ui/fixtures.ts create mode 100644 apps/pod-migrator/turbo.json create mode 100644 apps/pod-migrator/vercel.json create mode 100644 apps/pod-migrator/vite.config.ts diff --git a/apps/pod-migrator/README.md b/apps/pod-migrator/README.md new file mode 100644 index 000000000..1adedf66c --- /dev/null +++ b/apps/pod-migrator/README.md @@ -0,0 +1,33 @@ + +# Pod Migrator + +An experimental React 19 app for copying a Solid pod and reviewing how permissions translate between providers. Source files remain in place. The migration engine enforces the permission checks; the UI requires an explicit choice for every decision and blocks any abort selection. + +Two independent sign-ins use `@jeswr/solid-auth-core`, with separate session databases, storage keys, and full-page callbacks at `/callback/source` and `/callback/target`. Enter a WebID or select identity provider mode; quick picks cover Inrupt PodSpaces and solidcommunity.net. Storage comes from the authenticated WebID subject’s `pim:storage`, with a required choice when several roots exist. Swapping direction keeps each account bound to its original credentials. + +From the workspace root, with Node 24 on PATH: + +```sh +export PATH=/opt/node24/bin:$PATH +pnpm --filter @jeswr/app-pod-migrator dev +pnpm --filter @jeswr/app-pod-migrator lint +pnpm --filter @jeswr/app-pod-migrator typecheck +pnpm --filter @jeswr/app-pod-migrator test +APP_ORIGIN=https://migrate.example pnpm exec turbo run build --filter=@jeswr/app-pod-migrator +``` + +Vercel project root: `apps/pod-migrator`. Set `APP_ORIGIN` to the public HTTPS origin for a custom domain. `VERCEL_URL` supplies the preview fallback. The Vite plugin emits `/clientid.jsonld` during each build and serves it during development, registering both callback paths. Missing production origin configuration fails the build. Vercel serves the document as JSON-LD with CORS and rewrites callbacks to the SPA. The provider must be able to dereference the public client document; a remote provider cannot dereference localhost. Use a public HTTPS deployment for live accounts. + +The app uses shared app-shell light/dark/system themes. The flow is connect source → connect target → options → plan → run → result. Linked-data IRI rewriting defaults off. Targets must be folders inside the selected target pod; overlapping source and destination trees are refused. Only informational report items have preselected choices. Each report item exposes its explanation and original Turtle rule, with bulk choices limited to its own kind and supported options. + +Cancel during planning discards the plan. Cancel during execution waits for the in-flight operation and displays the engine’s partial result. Progress and the final report are kept in this tab’s `sessionStorage`, including resource paths and permission decisions, without credentials. A reload labels an unfinished run interrupted and never resumes writes automatically. Download the JSON report to retain it after closing the tab. If browser storage is unavailable, a notice explains that the log cannot survive reload. + +Screenshot tests run against a development-only mocked engine and mocked accounts: + +```sh +pnpm --filter @jeswr/app-pod-migrator test:e2e +``` + +They cover 375, 768, and 1280 pixel widths in light and dark, capture connection/options/review/run/result screens into ignored `e2e/screens/`, and check overflow, decision gating, abort, and report download. `--mode screens` enables these mocks only in the dev server; builds in that mode are refused. They do not establish live PodSpaces or solidcommunity.net compatibility. + +Compared with the design, a completed callback uses the session already established by `handleRedirect`; `restore()` runs only when there is no completed callback, following the auth-core lifecycle. Private profiles use authenticated fetch only for the exact signed-in WebID document, without redirects. Ambiguous WebID issuers fail closed with instructions to enter a chosen provider URL. Unexpected engine failures show an interrupted run without inventing counts or claiming verification. diff --git a/apps/pod-migrator/e2e/.gitignore b/apps/pod-migrator/e2e/.gitignore new file mode 100644 index 000000000..465e1630b --- /dev/null +++ b/apps/pod-migrator/e2e/.gitignore @@ -0,0 +1,3 @@ +# AUTHORED-BY Codex GPT-6 +screens/ +results/ diff --git a/apps/pod-migrator/e2e/connections.ts b/apps/pod-migrator/e2e/connections.ts new file mode 100644 index 000000000..7bd743457 --- /dev/null +++ b/apps/pod-migrator/e2e/connections.ts @@ -0,0 +1,38 @@ +// AUTHORED-BY Codex GPT-6 +// Only loaded by Vite's explicit --mode screens development server. +import type { SolidAuth } from "@jeswr/solid-auth-core"; +import { PodConnection } from "../src/auth/connections.js"; + +export function createConnections(): [PodConnection, PodConnection] { + const signedOut = new URLSearchParams(location.search).has("signed-out"); + const make = (side: "source" | "target"): PodConnection => { + const webId = + side === "source" + ? "https://alice.solidcommunity.net/profile/card#me" + : "https://id.example/alice#me"; + const turtle = + side === "source" + ? " ." + : " ."; + const publicFetch: typeof fetch = async () => + new Response(turtle, { headers: { "content-type": "text/turtle" } }); + const auth = { + webId: signedOut ? null : webId, + issuer: "https://identity.example", + publicFetch, + authenticatedFetch: publicFetch, + restore: async () => + auth.webId + ? { outcome: "restored" as const, webId: auth.webId } + : { outcome: "login" as const }, + handleRedirect: async () => ({ outcome: "none" as const }), + beginRedirectLogin: async () => ({ authorizationUrl: "https://identity.example/authorize" }), + logout: async () => { + auth.webId = null; + }, + reArmAllowedOrigins: () => true, + }; + return new PodConnection(side, auth as unknown as SolidAuth); + }; + return [make("source"), make("target")]; +} diff --git a/apps/pod-migrator/e2e/engine.ts b/apps/pod-migrator/e2e/engine.ts new file mode 100644 index 000000000..3539e8e60 --- /dev/null +++ b/apps/pod-migrator/e2e/engine.ts @@ -0,0 +1,104 @@ +// AUTHORED-BY Codex GPT-6 +// Deterministic, inert screenshot engine; never included in production builds. +export function createPodHttp({ root }: { root: string }) { + return { root }; +} + +export async function planMigration({ onProgress }: { onProgress?: (progress: unknown) => void }) { + onProgress?.({ phase: "plan", done: 128, total: 128 }); + return { + sourceSystem: "wac", + targetSystem: "acp", + resources: [], + stats: { containers: 12, resources: 116, bytes: 2841024, aclDocuments: 24 }, + report: [ + { + id: "owner", + kind: "source-owner-kept", + severity: "info", + resourceUrl: "https://alice.solidcommunity.net/", + title: "Your source account keeps its access", + explanation: + "Your original WebID will keep the permissions it already has, so you can continue using both accounts.", + recommended: "acknowledge", + options: [ + { + id: "acknowledge", + label: "Keep this access", + description: "Keep the original account’s permissions.", + }, + ], + }, + { + id: "friends", + kind: "group-unsupported", + severity: "needs-decision", + resourceUrl: "https://alice.solidcommunity.net/shared/", + title: "Choose what happens to your friends group", + explanation: + "The destination cannot follow this group’s changing membership. Leaving out this grant means the group will not receive access from this rule. It does not make anything more public.", + sourceRuleTurtle: + "@prefix acl: . <#friends> acl:mode acl:Read.", + recommended: "skip", + options: [ + { + id: "skip", + label: "Leave out the group grant", + description: "Group members will not receive access from this rule.", + }, + { + id: "expand", + label: "Use the current members", + description: + "Give access to the people in the group today. Future membership changes will not follow.", + }, + { + id: "abort", + label: "Stop the migration", + description: "Do not write anything to the destination.", + }, + ], + }, + ], + }; +} + +export function finalizePlan(plan: unknown, resolutions: unknown) { + return { plan, resolutions }; +} + +export async function executeMigration( + _plan: unknown, + { onProgress, signal }: { onProgress?: (progress: unknown) => void; signal?: AbortSignal }, +) { + onProgress?.({ + phase: "copy", + done: 42, + total: 116, + current: "https://storage.example/pods/alice/documents/notes.ttl", + }); + await new Promise((resolve) => { + const finish = () => { + window.removeEventListener("finish-fixture-migration", finish); + signal?.removeEventListener("abort", finish); + resolve(); + }; + window.addEventListener("finish-fixture-migration", finish); + signal?.addEventListener("abort", finish, { once: true }); + if (signal?.aborted) finish(); + }); + return { + copied: ["https://storage.example/pods/alice/documents/notes.ttl"], + skipped: [ + { + url: "https://alice.solidcommunity.net/profile/card", + reason: "Destination profile protected", + }, + ], + failed: [], + aclWritten: ["https://storage.example/pods/alice/documents/notes.ttl.acr"], + verified: !signal?.aborted, + cancelled: signal?.aborted ?? false, + log: [{ at: "2026-10-27T12:00:00Z", level: "info", message: "Completed screenshot fixture." }], + }; +} diff --git a/apps/pod-migrator/e2e/screens.spec.ts b/apps/pod-migrator/e2e/screens.spec.ts new file mode 100644 index 000000000..1d9764421 --- /dev/null +++ b/apps/pod-migrator/e2e/screens.spec.ts @@ -0,0 +1,43 @@ +// AUTHORED-BY Codex GPT-6 +import { mkdir } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "@playwright/test"; + +test("connection, options, review, run, and result screens", async ({ page }, info) => { + const directory = fileURLToPath(new URL("./screens/", import.meta.url)); + await mkdir(directory, { recursive: true }); + const capture = async (screen: string) => { + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe( + true, + ); + await page.screenshot({ + path: `${directory}/${info.project.name}-${screen}.png`, + fullPage: true, + animations: "disabled", + }); + }; + await page.goto("/?signed-out"); + await expect(page.getByRole("button", { name: "Sign in to source" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Continue to target" })).toBeDisabled(); + await capture("connect"); + await page.goto("/"); + await page.getByRole("button", { name: "Continue to target" }).click(); + await page.getByRole("button", { name: "Continue to options" }).click(); + await capture("options"); + await page.getByRole("button", { name: "Create migration plan" }).click(); + await expect(page.getByRole("button", { name: "Start migration" })).toBeDisabled(); + await page.getByText("Choose what happens to your friends group", { exact: true }).click(); + await capture("review"); + await page.getByRole("radio", { name: /Stop the migration/ }).check(); + await expect(page.getByText("Migration will not run.")).toBeVisible(); + await page.getByRole("radio", { name: /Leave out the group grant/ }).check(); + await page.getByRole("button", { name: "Start migration" }).click(); + await expect(page.getByRole("progressbar")).toHaveAttribute("value", "42"); + await capture("run"); + await page.evaluate(() => window.dispatchEvent(new Event("finish-fixture-migration"))); + await expect(page.getByText("Verification passed", { exact: true })).toBeVisible(); + await capture("result"); + const download = page.waitForEvent("download"); + await page.getByRole("button", { name: "Download report JSON" }).click(); + expect((await download).suggestedFilename()).toBe("pod-migration-report.json"); +}); diff --git a/apps/pod-migrator/index.html b/apps/pod-migrator/index.html new file mode 100644 index 000000000..8acb95ebd --- /dev/null +++ b/apps/pod-migrator/index.html @@ -0,0 +1,21 @@ + + + + + + + + + + Pod Migrator — A new home for your Solid data + + + +
+ + + diff --git a/apps/pod-migrator/playwright.config.ts b/apps/pod-migrator/playwright.config.ts new file mode 100644 index 000000000..7607b4b24 --- /dev/null +++ b/apps/pod-migrator/playwright.config.ts @@ -0,0 +1,23 @@ +// AUTHORED-BY Codex GPT-6 +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + testMatch: "screens.spec.ts", + fullyParallel: true, + workers: 2, + reporter: "list", + outputDir: "./e2e/results", + use: { baseURL: "http://127.0.0.1:5184", browserName: "chromium", trace: "retain-on-failure" }, + projects: [375, 768, 1280].flatMap((width) => + (["light", "dark"] as const).map((colorScheme) => ({ + name: `${width}-${colorScheme}`, + use: { viewport: { width, height: 960 }, colorScheme }, + })), + ), + webServer: { + command: "pnpm exec vite --mode screens --host 127.0.0.1 --port 5184 --strictPort", + url: "http://127.0.0.1:5184", + reuseExistingServer: false, + }, +}); diff --git a/apps/pod-migrator/public/icon.svg b/apps/pod-migrator/public/icon.svg new file mode 100644 index 000000000..8d10a3f5b --- /dev/null +++ b/apps/pod-migrator/public/icon.svg @@ -0,0 +1,2 @@ + + diff --git a/apps/pod-migrator/src/App.tsx b/apps/pod-migrator/src/App.tsx new file mode 100644 index 000000000..982170ce7 --- /dev/null +++ b/apps/pod-migrator/src/App.tsx @@ -0,0 +1,882 @@ +// AUTHORED-BY Codex GPT-6 +import { ErrorBoundary, ThemeProvider, ThemeToggle } from "@jeswr/app-shell"; +import { assertWithinPodScope } from "@jeswr/guarded-fetch"; +import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; +import type { PodConnection } from "./auth/connections.js"; +import { + createPodHttp, + executeMigration, + finalizePlan, + type MigrationPlan, + type MigrationResult, + type PodSide, + type Progress, + planMigration, + type Resolutions, +} from "./engine/index.js"; +import { ConnectionCard } from "./ui/ConnectionCard.js"; +import { PlanReport } from "./ui/Report.js"; + +export type { MigrationPlan, OptionId, ReportKind, Resolutions } from "./engine/index.js"; + +const steps = ["Connect source", "Connect target", "Options", "Plan", "Run", "Result"]; +const phaseLabels: Record = { + crawl: "Finding your files", + "read-access": "Reading permissions", + plan: "Preparing your plan", + lock: "Protecting destination folders", + copy: "Copying your files", + grant: "Applying reviewed permissions", + verify: "Checking copied files and permissions", +}; +const journalKey = "pod-migrator:run-report:v1"; +const orderKey = "pod-migrator:swapped:v1"; + +interface RunReport { + version: 1; + status: "running" | "finished" | "interrupted"; + source: { root: string; webId: string }; + target: { root: string; webId: string }; + startedAt: string; + plan: MigrationPlan; + resolutions: Resolutions; + progress: Progress | null; + result: MigrationResult | null; + log: { at: string; message: string }[]; +} + +function readPreviousRun(): RunReport | null { + try { + const raw = sessionStorage.getItem(journalKey); + if (!raw) return null; + const data = JSON.parse(raw) as RunReport; + if ( + data?.version !== 1 || + !["running", "finished", "interrupted"].includes(data.status) || + typeof data.source?.root !== "string" || + typeof data.target?.root !== "string" || + !Array.isArray(data.log) || + !data.log.every((entry) => typeof entry?.at === "string" && typeof entry.message === "string") + ) + return null; + const result = data.result; + if ( + result !== null && + (!Array.isArray(result?.copied) || + !result.copied.every((url) => typeof url === "string") || + !Array.isArray(result.aclWritten) || + !result.aclWritten.every((url) => typeof url === "string") || + !Array.isArray(result.failed) || + !result.failed.every( + (entry) => typeof entry?.url === "string" && typeof entry.error === "string", + ) || + !Array.isArray(result.skipped) || + !result.skipped.every( + (entry) => typeof entry?.url === "string" && typeof entry.reason === "string", + ) || + !Array.isArray(result.log) || + !result.log.every( + (entry) => + typeof entry?.at === "string" && + typeof entry.message === "string" && + ["info", "warn", "error"].includes(entry.level), + ) || + typeof result.verified !== "boolean" || + typeof result.cancelled !== "boolean") + ) + return null; + return { ...data, status: data.status === "running" ? "interrupted" : data.status }; + } catch { + return null; + } +} + +function saveReport(report: RunReport): boolean { + try { + sessionStorage.setItem(journalKey, JSON.stringify(report)); + return true; + } catch { + return false; + } +} + +function downloadReport(report: RunReport): void { + const url = URL.createObjectURL( + new Blob([JSON.stringify(report, null, 2)], { type: "application/json" }), + ); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = "pod-migration-report.json"; + anchor.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +function ProgressView({ + progress, + cancelling, +}: { + progress: Progress | null; + cancelling: boolean; +}) { + return ( +
+

+ {cancelling + ? "Stopping after the current request…" + : progress + ? phaseLabels[progress.phase] + : "Getting ready…"} +

+ 0 ? Math.min(progress.done, progress.total) : undefined} + /> + {progress && ( +

+ {progress.done} of {progress.total} + {progress.current && {progress.current}} +

+ )} +
+ ); +} + +function ResultView({ report }: { report: RunReport }) { + const result = report.result; + return ( +
+ Migration report +

+ {!result + ? "Migration interrupted" + : result.cancelled + ? "Migration cancelled" + : result.failed.length + ? "Migration finished with failures" + : "Migration finished"} +

+

+ {!result + ? "The last request may have completed. Files may already exist at the destination. Review the log and create a fresh plan before trying again." + : result.cancelled + ? "The run has stopped. Files already copied remain at the destination. Review the report before starting again." + : "Your source files remain in place. Here is what happened at the destination."} +

+ {result && ( + <> +
+
+
Copied
+
{result.copied.length}
+
+
+
Skipped
+
{result.skipped.length}
+
+
+
Failed
+
{result.failed.length}
+
+
+
Permission documents written
+
{result.aclWritten.length}
+
+
+

+ + {result.verified ? "Verification passed" : "Verification has not passed"} + +
+ {result.verified + ? "The copied data and permission rules were checked." + : "Do not assume all files and permissions have been verified. Review the failures and run log."} +

+ {result.failed.length > 0 && ( +
+

Failures

+
    + {result.failed.map((failure) => ( +
  • + {failure.url} +

    {failure.error}

    +
  • + ))} +
+
+ )} + {result.skipped.length > 0 && ( +
+ Skipped files ({result.skipped.length}) +
    + {result.skipped.map((item) => ( +
  • + {item.url}: {item.reason} +
  • + ))} +
+
+ )} + + )} +
+ Run log +
    + {report.log.map((entry) => ( +
  • + {entry.message} +
  • + ))} + {result?.log.map((entry) => ( +
  • + [{entry.level}] {entry.message} +
  • + ))} +
+
+ +
+ ); +} + +export function App({ connections }: { connections: readonly [PodConnection, PodConnection] }) { + return ( + + + + + + ); +} + +function MigrationApp({ connections }: { connections: readonly [PodConnection, PodConnection] }) { + const first = useSyncExternalStore(connections[0].subscribe, connections[0].snapshot); + const second = useSyncExternalStore(connections[1].subscribe, connections[1].snapshot); + const [swapped, setSwapped] = useState(() => { + try { + return sessionStorage.getItem(orderKey) === "true"; + } catch { + return false; + } + }); + const sourceConnection = connections[swapped ? 1 : 0]; + const targetConnection = connections[swapped ? 0 : 1]; + const sourceState = swapped ? second : first; + const targetState = swapped ? first : second; + const [step, setStep] = useState(0); + const [targetContainer, setTargetContainer] = useState(""); + const [rewriteIris, setRewriteIris] = useState(false); + const [plan, setPlan] = useState(null); + const [resolutions, setResolutions] = useState({}); + const [busy, setBusy] = useState<"planning" | "running" | null>(null); + const [progress, setProgress] = useState(null); + const [cancelling, setCancelling] = useState(false); + const [error, setError] = useState(null); + const [report, setReport] = useState(null); + const [previous, setPrevious] = useState(readPreviousRun); + const [storageWarning, setStorageWarning] = useState(false); + const operation = useRef(null); + const generation = useRef(0); + const heading = useRef(null); + + useEffect(() => { + for (const connection of connections) void connection.initialize(location.href); + }, [connections]); + useEffect(() => { + if (steps[step]) heading.current?.focus(); + }, [step]); + useEffect( + () => () => { + generation.current++; + operation.current?.abort(); + }, + [], + ); + + const source = useMemo( + () => + sourceState.root && sourceState.webId + ? { + root: sourceState.root, + ownerWebId: sourceState.webId, + http: createPodHttp({ + root: sourceState.root, + fetch: sourceConnection.auth.authenticatedFetch, + }), + } + : null, + [sourceState.root, sourceState.webId, sourceConnection], + ); + const target = useMemo( + () => + targetState.root && targetState.webId + ? { + root: targetState.root, + ownerWebId: targetState.webId, + http: createPodHttp({ + root: targetState.root, + fetch: targetConnection.auth.authenticatedFetch, + }), + } + : null, + [targetState.root, targetState.webId, targetConnection], + ); + + // Invalidate reviewed decisions whenever credentials, storage, or direction change. + useEffect(() => { + generation.current++; + operation.current?.abort(); + operation.current = null; + setPlan(null); + setResolutions({}); + setError(null); + setBusy(null); + setCancelling(false); + setTargetContainer(target?.root ?? ""); + setStep((current) => + current === 5 ? current : source ? (target ? Math.min(current, 2) : Math.min(current, 1)) : 0, + ); + }, [source, target]); + + let destination: string | null = null; + let destinationError: string | null = null; + if (target && targetContainer.trim()) { + try { + destination = assertWithinPodScope(target.root, targetContainer.trim()); + const url = new URL(destination); + if (!url.pathname.endsWith("/") || url.hash || url.search) throw new Error("Not a container"); + if (source) { + // Overlapping trees can cause recursive copies or modify the source. + const overlaps = (a: string, b: string) => { + try { + assertWithinPodScope(a, b); + return true; + } catch { + return false; + } + }; + if (overlaps(source.root, destination) || overlaps(destination, source.root)) + throw new Error("Overlapping pods"); + } + } catch { + destination = null; + destinationError = + "Choose a folder URL ending in / inside the target pod, outside the source tree, with no query or fragment."; + } + } + const unresolved = + plan?.report.filter( + (item) => + item.severity === "needs-decision" && + !item.options.some((option) => option.id === resolutions[item.id]), + ).length ?? 0; + const aborted = plan?.report.some((item) => resolutions[item.id] === "abort") ?? false; + const ready = Boolean( + plan && source && target && destination && !unresolved && !aborted && !busy, + ); + + function invalidateOptions(): void { + setPlan(null); + setResolutions({}); + setError(null); + } + + async function prepare(): Promise { + if (!source || !target || !destination || operation.current) return; + const controller = new AbortController(); + const current = ++generation.current; + operation.current = controller; + setBusy("planning"); + setStep(3); + setPlan(null); + setProgress(null); + setError(null); + setCancelling(false); + try { + const next = await planMigration({ + source, + target, + targetContainer: destination, + rewriteIris, + signal: controller.signal, + onProgress: (value) => { + if (generation.current === current) setProgress(value); + }, + }); + if (generation.current !== current) return; + if (controller.signal.aborted) { + setStep(2); + return; + } + setPlan(next); + setResolutions( + Object.fromEntries( + next.report + .filter( + (item) => + item.severity === "info" && + item.options.some((option) => option.id === item.recommended), + ) + .map((item) => [item.id, item.recommended]), + ), + ); + } catch { + if (generation.current === current) { + if (controller.signal.aborted) setStep(2); + else + setError( + "The plan could not be prepared. Check both sign-ins and pod access, then try again.", + ); + } + } finally { + if (generation.current === current) { + operation.current = null; + setBusy(null); + setCancelling(false); + } + } + } + + async function start(): Promise { + if (!ready || !plan || !source || !target || operation.current) return; + let executable: ReturnType; + try { + executable = finalizePlan(plan, resolutions); + } catch { + setError( + "This plan cannot run safely with the current choices. Review the decisions or create a new plan.", + ); + return; + } + const controller = new AbortController(); + const current = ++generation.current; + operation.current = controller; + setBusy("running"); + setStep(4); + setProgress(null); + setError(null); + setCancelling(false); + setPrevious(null); + let journal: RunReport = { + version: 1, + status: "running", + source: { root: source.root, webId: source.ownerWebId }, + target: { root: target.root, webId: target.ownerWebId }, + startedAt: new Date().toISOString(), + plan, + resolutions, + progress: null, + result: null, + log: [{ at: new Date().toISOString(), message: "Migration started." }], + }; + const persist = () => { + if (!saveReport(journal)) setStorageWarning(true); + }; + persist(); + try { + const result = await executeMigration(executable, { + source, + target, + signal: controller.signal, + onProgress: (value) => { + if (generation.current !== current) return; + if (journal.progress?.phase !== value.phase) + journal.log.push({ at: new Date().toISOString(), message: phaseLabels[value.phase] }); + journal = { ...journal, progress: value }; + persist(); + setProgress(value); + }, + }); + journal = { ...journal, status: "finished", result }; + } catch { + journal = { + ...journal, + status: "interrupted", + log: [ + ...journal.log, + { + at: new Date().toISOString(), + message: controller.signal.aborted + ? "Cancellation requested. A complete report was not available." + : "The migration stopped unexpectedly. Partial writes may have completed.", + }, + ], + }; + } finally { + if (generation.current === current) { + persist(); + setReport(journal); + setStep(5); + setBusy(null); + setCancelling(false); + operation.current = null; + } + } + } + + function cancel(): void { + setCancelling(true); + operation.current?.abort(); + } + + return ( +
+ + Skip to migration + +
+ + + Pod Migrator + +
+ Experimental + +
+
+
+
+ A careful move, on your terms +

Your pod. A new home.

+

Copy your files between Solid pods, with a clear plan for who can access them.

+
+ Review first. Keep control. Your source stays in + place. +
+
+ +
+
+
+ Step {step + 1} of 6 +

+ {steps[step]} +

+
+ {step < 3 && ( + + )} +
+ {error && ( +

+ {error} +

+ )} + {step < 3 && ( + <> +

+ {step === 0 + ? "Start with the account that holds the files you want to copy." + : step === 1 + ? "Sign in to the account that will receive your files. You can use a different provider." + : "Choose where your files will go and how links should be handled."} +

+
+ + + +
+ + )} + {step === 0 && ( +
+ +
+ )} + {step === 1 && ( +
+ + +
+ )} + {step === 2 && ( + <> +
+ +

+ Defaults to your target storage root. A folder URL must end in /. +

+ {destinationError && ( +

+ {destinationError} +

+ )} + +
+
+ Planning only reads your pods. Nothing is written until you review + the plan and select Start migration. +
+
+ + +
+ + )} + {step === 3 && ( + <> + {busy === "planning" && ( + <> + + + + )} + {plan && !busy && ( + <> +

+ Review the destination and every permission choice. Nothing has been written + yet. +

+
+
+ From +

{source?.root}

+
+ +
+ To +

{destination}

+
+
+
+
+
Files
+
{plan.stats.resources}
+
+
+
Folders
+
{plan.stats.containers}
+
+
+
Bytes
+
{plan.stats.bytes.toLocaleString()}
+
+
+
Permission documents
+
{plan.stats.aclDocuments}
+
+
+

+ Permission systems:{" "} + + {plan.sourceSystem.toUpperCase()} → {plan.targetSystem.toUpperCase()} + +

+ +
+ {aborted ? ( + <> + Migration will not run. An abort choice is selected. Change + it to a safe option or go back. + + ) : unresolved ? ( + `${unresolved} permission ${unresolved === 1 ? "decision still needs" : "decisions still need"} your choice.` + ) : ( + "All required decisions are resolved. You can start when you are ready." + )} +
+
+ + +
+ + )} + {!busy && !plan && ( + + )} + + )} + {step === 4 && ( + <> +

Keep this tab open while the migration runs.

+ +
    + {(["lock", "copy", "grant", "verify"] as const).map((phase) => ( +
  1. + {phaseLabels[phase]} +
  2. + ))} +
+

+ Cancel stops after the current request. Files already copied remain at the + destination. +

+ + + )} + {step === 5 && report && ( + <> + +
+ +
+ + )} + {storageWarning && ( +

+ This browser could not save the run log for reload. Download the report before + leaving. +

+ )} +
+ {previous && ( +
+ + Report from your previous run{" "} + {previous.status === "interrupted" ? "(interrupted)" : ""} + + + +
+ )} +
+
+ Made for your Solid data. + Experimental software · Review your plan carefully. +
+
+ ); +} diff --git a/apps/pod-migrator/src/auth/connections.ts b/apps/pod-migrator/src/auth/connections.ts new file mode 100644 index 000000000..c1e10b59a --- /dev/null +++ b/apps/pod-migrator/src/auth/connections.ts @@ -0,0 +1,231 @@ +// AUTHORED-BY Codex GPT-6 +import { fetchRdf, RdfFetchError } from "@jeswr/fetch-rdf"; +import { normalizePodBase } from "@jeswr/guarded-fetch"; +import { createSolidAuth, type SolidAuth, type SolidAuthConfig } from "@jeswr/solid-auth-core"; +import { Agent } from "@solid/object"; +import { DataFactory } from "n3"; + +export type ConnectionId = "source" | "target"; +export interface ConnectionState { + status: "restoring" | "signed-out" | "loading" | "choose-storage" | "connected" | "error"; + webId: string | null; + roots: string[]; + root: string | null; + error: string | null; +} + +export function authConfig(id: ConnectionId, origin: string): SolidAuthConfig { + const prefix = `pod-migrator:${id}`; + const redirectUri = new URL(`/callback/${id}`, origin).href; + return { + callbackUri: redirectUri, + redirectUri, + clientId: new URL("/clientid.jsonld", origin).href, + dbName: `${prefix}:sessions`, + rememberedAccountsKey: `${prefix}:remembered`, + recentAccountsKey: `${prefix}:recent`, + redirectFlowKey: `${prefix}:redirect-flow`, + redirectSentinelKey: `${prefix}:redirect-sentinel`, + patchGlobalFetch: false, + includeIssuerOrigin: false, + // Only the authenticated WebID's own origin can serve a private profile. + includeWebIdOrigin: true, + allowedOrigins: [], + }; +} + +const emptyState = (): ConnectionState => ({ + status: "restoring", + webId: null, + roots: [], + root: null, + error: null, +}); + +/** One physical session; swapping UI roles never swaps its storage namespace. */ +export class PodConnection { + readonly id: ConnectionId; + readonly auth: SolidAuth; + private state = emptyState(); + private listeners = new Set<() => void>(); + private generation = 0; + private initialization: Promise | undefined; + + constructor(id: ConnectionId, auth: SolidAuth) { + this.id = id; + this.auth = auth; + } + + snapshot = (): ConnectionState => this.state; + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + private publish(state: ConnectionState): void { + this.state = state; + for (const listener of this.listeners) listener(); + } + + initialize(url: string): Promise { + this.initialization ??= this.initializeOnce(url); + return this.initialization; + } + + private async initializeOnce(url: string): Promise { + const generation = ++this.generation; + try { + if (new URL(url).pathname === `/callback/${this.id}`) { + const redirect = await this.auth.handleRedirect(url); + if (generation !== this.generation) return; + if (redirect.outcome === "error") throw new Error("Sign-in was not completed. Try again."); + if (redirect.outcome === "redirecting") return; + if (redirect.outcome === "completed") { + await this.readProfile(); + return; + } + } + await this.auth.restore(); + if (generation !== this.generation) return; + if (this.auth.webId) await this.readProfile(); + else this.publish({ ...emptyState(), status: "signed-out" }); + } catch { + if (generation === this.generation) { + this.publish({ + ...emptyState(), + status: "error", + error: "Could not restore this sign-in. Please sign in again.", + }); + } + } + } + + async login(value: string, kind: "webid" | "issuer"): Promise { + const generation = ++this.generation; + this.publish({ ...emptyState(), status: "loading" }); + try { + const url = new URL(value.trim()); + if (url.protocol !== "https:" || url.username || url.password) { + throw new Error( + "Use an HTTPS WebID or identity provider URL without a username or password.", + ); + } + await this.auth.beginRedirectLogin({ + ...(kind === "webid" ? { webId: url.href } : { oidcIssuer: url.href }), + prompt: "consent", + }); + } catch { + if (generation === this.generation) { + this.publish({ + ...emptyState(), + status: "error", + error: + "Could not start sign-in. Check the URL and try again. If your WebID has several providers, enter your chosen provider URL.", + }); + } + } + } + + async readProfile(): Promise { + const generation = ++this.generation; + const webId = this.auth.webId; + if (!webId) return; + this.publish({ ...emptyState(), status: "loading", webId }); + try { + const documentUrl = new URL(webId); + documentUrl.hash = ""; + if (documentUrl.protocol !== "https:" || documentUrl.username || documentUrl.password) { + throw new Error("Unsafe WebID"); + } + // Exact document, string input, no redirects. A public read cannot send cookies. + const profileFetch = + (authenticated: boolean): typeof fetch => + async (input, init) => { + if (typeof input !== "string" || new URL(input).href !== documentUrl.href) { + throw new Error("Profile request outside the authenticated WebID document"); + } + return (authenticated ? this.auth.authenticatedFetch : this.auth.publicFetch)(input, { + ...init, + credentials: "omit", + redirect: "manual", + }); + }; + let profile: Awaited>; + try { + profile = await fetchRdf(documentUrl.href, { fetch: profileFetch(false) }); + } catch (error) { + if (generation !== this.generation || this.auth.webId !== webId) return; + if (!(error instanceof RdfFetchError) || (error.status !== 401 && error.status !== 403)) + throw error; + profile = await fetchRdf(documentUrl.href, { fetch: profileFetch(true) }); + } + if (generation !== this.generation || this.auth.webId !== webId) return; + // Bind to the signed-in subject, never an arbitrary profile mainSubject. + const agent = new Agent(webId, profile.dataset, DataFactory); + const roots = [...agent.pimStorage].map((root) => { + const url = new URL(root); + if (url.protocol !== "https:" || url.search || url.hash) + throw new Error("Unsafe storage URL"); + return normalizePodBase(root); + }); + const unique = [...new Set(roots)]; + if (unique.length === 0) throw new Error("No storage roots"); + this.publish({ status: "choose-storage", webId, roots: unique, root: null, error: null }); + if (unique.length === 1 && unique[0]) this.selectStorage(unique[0]); + } catch { + if (generation === this.generation && this.auth.webId === webId) { + this.publish({ + ...emptyState(), + status: "error", + webId, + error: + "Could not read a safe storage location from your WebID profile. Check its storage links and access, then retry.", + }); + } + } + } + + selectStorage(root: string): void { + if ( + !this.state.webId || + this.auth.webId !== this.state.webId || + !this.state.roots.includes(root) + ) + return; + if (!this.auth.reArmAllowedOrigins([new URL(root).origin])) { + this.publish({ + ...this.state, + status: "error", + root: null, + error: "This sign-in cannot access that storage. Sign in again.", + }); + return; + } + this.publish({ ...this.state, status: "connected", root, error: null }); + } + + async logout(): Promise { + const generation = ++this.generation; + const previousWebId = this.state.webId; + this.publish({ ...emptyState(), status: "loading" }); + try { + await this.auth.logout(); + if (generation === this.generation) this.publish({ ...emptyState(), status: "signed-out" }); + } catch { + if (generation === this.generation) + this.publish({ + ...emptyState(), + status: "error", + webId: this.auth.webId ?? previousWebId, + error: "Sign-out could not finish. Try signing out again before switching accounts.", + }); + } + } +} + +export function createConnections(origin: string): [PodConnection, PodConnection] { + return ["source", "target"].map((id) => { + const side = id as ConnectionId; + return new PodConnection(side, createSolidAuth(authConfig(side, origin))); + }) as [PodConnection, PodConnection]; +} diff --git a/apps/pod-migrator/src/main.tsx b/apps/pod-migrator/src/main.tsx new file mode 100644 index 000000000..54fee225c --- /dev/null +++ b/apps/pod-migrator/src/main.tsx @@ -0,0 +1,9 @@ +// AUTHORED-BY Codex GPT-6 +import { createRoot } from "react-dom/client"; +import { App } from "./App.js"; +import { createConnections } from "./auth/connections.js"; +import "./styles.css"; + +const element = document.getElementById("root"); +if (!element) throw new Error("Missing root element"); +createRoot(element).render(); diff --git a/apps/pod-migrator/src/styles.css b/apps/pod-migrator/src/styles.css new file mode 100644 index 000000000..34044777b --- /dev/null +++ b/apps/pod-migrator/src/styles.css @@ -0,0 +1,740 @@ +/* AUTHORED-BY Codex GPT-6 */ +@import "tailwindcss"; +@import "@jeswr/app-shell/styles.css"; +@source "../node_modules/@jeswr/app-shell/dist"; +@custom-variant dark (&:where(.dark, .dark *)); + +body { + margin: 0; + background: var(--background); + color: var(--foreground); + font-family: "Inter", ui-sans-serif, system-ui, sans-serif; + font-size: 0.9375rem; + line-height: 1.6; +} +* { + box-sizing: border-box; +} +button:where(:not([data-app-shell-control])), +input, +select { + font: inherit; +} +button:where(:not([data-app-shell-control])) { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.65rem; + padding: 0.65rem 1rem; + border: 1px solid var(--border); + border-radius: 0.65rem; + background: var(--card); + color: var(--foreground); + font-weight: 550; + cursor: pointer; +} +button:where(:not([data-app-shell-control])):hover:enabled { + background: var(--accent); +} +button:disabled { + opacity: 0.48; + cursor: not-allowed; +} +button.primary { + background: var(--primary); + color: var(--primary-foreground); + border-color: var(--primary); +} +button.primary:hover:enabled { + background: var(--primary); + filter: brightness(0.94); +} +button.danger { + color: var(--destructive); +} +button.text-button { + background: transparent; + border: 0; + color: var(--primary); + padding-left: 0; + font-size: 0.85rem; +} +:focus-visible { + outline: 3px solid var(--ring); + outline-offset: 4px; +} +h1, +h2, +h3, +p { + margin-top: 0; +} +h1 { + font-size: clamp(2.5rem, 5vw, 4rem); + line-height: 1.1; + letter-spacing: -0.055em; + font-weight: 650; + margin-bottom: 1.2rem; +} +h2 { + font-size: 1.65rem; + letter-spacing: -0.035em; + line-height: 1.3; + font-weight: 630; + margin: 0.35rem 0 0; +} +h3 { + font-size: 1.2rem; + font-weight: 600; + letter-spacing: -0.02em; +} +a { + color: var(--primary); +} +abbr { + text-decoration-style: dotted; + text-underline-offset: 3px; + cursor: help; +} +.app-shell { + max-width: 76rem; + margin: auto; + padding: 0 3.5rem; +} +.app-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + min-height: 5.75rem; + border-bottom: 1px solid var(--border); +} +.brand { + display: inline-flex; + gap: 0.75rem; + align-items: center; + text-decoration: none; + color: var(--foreground); + font-size: 1.1rem; + font-weight: 650; + letter-spacing: -0.03em; +} +.brand-mark { + display: grid; + place-items: center; + width: 2.35rem; + height: 2.35rem; + border-radius: 0.8rem; + background: var(--primary); + color: var(--primary-foreground); + font-size: 1.8rem; + line-height: 1; +} +.header-actions { + display: flex; + align-items: center; + gap: 1rem; +} +.badge { + display: inline-flex; + align-items: center; + white-space: nowrap; + background: var(--muted); + color: var(--muted-foreground); + border-radius: 100px; + padding: 0.18rem 0.65rem; + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.005em; +} +.badge.experimental { + background: transparent; + border: 1px solid var(--border); +} +.badge.connected { + background: var(--accent); + color: var(--accent-foreground); +} +.badge.attention { + background: var(--muted); + color: var(--foreground); + border: 1px solid var(--border); +} +.intro { + padding: 3.6rem 0 2.75rem; + max-width: 50rem; +} +.eyebrow { + display: inline-block; + color: var(--primary); + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.13em; + text-transform: uppercase; +} +.intro .eyebrow { + margin-bottom: 1.05rem; +} +.intro > p { + font-size: 1.1rem; + color: var(--muted-foreground); + max-width: 36rem; + margin-bottom: 1.35rem; +} +.safety-note { + display: inline-flex; + gap: 0.65rem; + align-items: center; + font-size: 0.8rem; + color: var(--muted-foreground); +} +.safety-note > span { + color: var(--primary); + font-size: 1.2rem; +} +.stepper { + padding: 0; + margin: 0 0 2.5rem; + display: grid; + grid-template-columns: repeat(6, 1fr); + list-style: none; + border-block: 1px solid var(--border); +} +.stepper li { + display: flex; + gap: 0.6rem; + align-items: center; + padding: 1.15rem 0.25rem; + color: var(--muted-foreground); + font-size: 0.78rem; + position: relative; +} +.stepper li.active { + color: var(--foreground); + font-weight: 650; +} +.stepper li.active::after { + content: ""; + position: absolute; + inset: auto 0 -1px; + height: 3px; + background: var(--primary); + border-radius: 3px; +} +.step-number { + display: grid; + place-items: center; + flex-shrink: 0; + width: 1.5rem; + height: 1.5rem; + border: 1px solid var(--border); + border-radius: 50%; + font-size: 0.7rem; +} +.active .step-number { + color: var(--primary-foreground); + background: var(--primary); + border-color: var(--primary); +} +.complete .step-number { + color: var(--primary); + background: var(--accent); + border-color: var(--accent); +} +.workspace { + min-height: 26rem; +} +.section-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; +} +.section-heading h2:focus { + outline: none; +} +.swap-button { + font-size: 0.8rem; +} +.section-description { + color: var(--muted-foreground); + margin-bottom: 1.7rem; + max-width: 45rem; +} +.connections-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) 2.75rem minmax(0, 1fr); + align-items: stretch; + margin: 1.75rem 0; +} +.connection-card { + min-width: 0; + padding: 1.65rem; + background: var(--card); + border: 1px solid var(--border); + border-radius: 1rem; +} +.connection-card.is-connected { + border-color: var(--primary); +} +.card-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.8rem; +} +.card-top .eyebrow { + color: var(--muted-foreground); +} +.connection-card h3 { + margin-bottom: 1.5rem; + font-size: 1.35rem; +} +.direction { + display: grid; + place-items: center; + font-size: 1.4rem; + color: var(--muted-foreground); +} +.field { + display: flex; + flex-direction: column; + gap: 0.5rem; + font-size: 0.85rem; + font-weight: 550; +} +.field input, +.field select { + display: block; + width: 100%; + min-width: 0; + padding: 0.65rem 0.8rem; + border: 1px solid var(--input); + background: var(--background); + color: var(--foreground); + border-radius: 0.55rem; + font-size: 0.85rem; + font-weight: 400; +} +.field input::placeholder { + color: var(--muted-foreground); + opacity: 0.8; +} +.login-form { + display: flex; + flex-direction: column; + gap: 1rem; +} +.quick-pick-label { + font-size: 0.75rem; + margin: 0.3rem 0 -0.5rem; +} +.quick-picks { + display: flex; + gap: 0.6rem; + flex-wrap: wrap; +} +.quick-picks button { + flex: 1; + font-size: 0.72rem; + padding: 0.55rem 0.7rem; + white-space: nowrap; +} +.quick-picks button span { + color: var(--muted-foreground); + margin-left: auto; +} +.muted { + color: var(--muted-foreground); + font-weight: 400; +} +.connection-footnote { + border-top: 1px solid var(--border); + color: var(--muted-foreground); + margin: 1.5rem 0 0; + padding-top: 1rem; + font-size: 0.75rem; +} +.account-line { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + font-size: 0.75rem; +} +.identity, +.storage-location p, +.resource-url, +.progress-current { + overflow-wrap: anywhere; +} +.identity { + font-size: 0.85rem; + margin: 0.4rem 0 1.2rem; +} +.storage-location { + border-radius: 0.6rem; + background: var(--muted); + padding: 0.85rem; + font-size: 0.8rem; +} +.storage-location p { + margin: 0.35rem 0 0; +} +.actions { + border-top: 1px solid var(--border); + padding-top: 1.5rem; + margin-top: 1.75rem; + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + justify-content: flex-end; +} +.options-panel { + padding: 1.5rem; + border: 1px solid var(--border); + border-radius: 1rem; + margin-top: 1.5rem; + background: var(--card); +} +.options-panel > .muted { + font-size: 0.8rem; + margin: 0.6rem 0 1.75rem; +} +.toggle-option, +.radio-option { + display: flex; + gap: 0.8rem; + align-items: flex-start; + cursor: pointer; +} +input[type="checkbox"], +input[type="radio"] { + accent-color: var(--primary); + width: 1.05rem; + height: 1.05rem; + flex-shrink: 0; + margin: 0.25rem 0 0; +} +.toggle-option small, +.radio-option small { + display: block; + font-size: 0.8rem; + color: var(--muted-foreground); + font-weight: 400; + margin-top: 0.3rem; +} +.notice { + border: 1px solid var(--border); + border-left: 3px solid var(--primary); + background: var(--muted); + padding: 1rem 1.2rem; + border-radius: 0.5rem; + font-size: 0.85rem; + margin: 1.5rem 0; +} +.notice.error { + border-left-color: var(--destructive); +} +.notice.success { + border-left-color: var(--success); +} +.route-summary { + display: grid; + grid-template-columns: minmax(0, 1fr) 2rem minmax(0, 1fr); + align-items: center; + gap: 1rem; + padding: 1.2rem; + border-radius: 0.7rem; + background: var(--muted); + font-size: 0.85rem; +} +.route-summary p { + overflow-wrap: anywhere; + margin: 0.35rem 0 0; +} +.stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + border: 1px solid var(--border); + border-radius: 0.8rem; + margin: 1.5rem 0; + background: var(--card); +} +.stats > div { + padding: 1.15rem; + border-right: 1px solid var(--border); +} +.stats > div:last-child { + border: 0; +} +.stats dt { + color: var(--muted-foreground); + font-size: 0.75rem; +} +.stats dd { + font-size: 1.75rem; + font-weight: 600; + margin: 0.4rem 0 0; + letter-spacing: -0.04em; +} +.group-heading { + display: flex; + gap: 0.75rem; + align-items: center; + margin: 2rem 0 0.8rem; +} +.group-heading h3 { + margin: 0; + font-size: 1rem; +} +.report-item { + padding: 1.2rem 1.4rem; + border: 1px solid var(--border); + background: var(--card); + border-radius: 0.7rem; + margin-bottom: 0.8rem; +} +summary { + cursor: pointer; + font-weight: 550; +} +.report-item > details > summary .badge { + margin-left: 0.75rem; +} +.report-item details p { + margin: 0.8rem 0; +} +.report-item .resource-url { + font-size: 0.8rem; + color: var(--muted-foreground); +} +.source-rule { + font-size: 0.8rem; +} +pre { + white-space: pre-wrap; + overflow-wrap: anywhere; + background: var(--muted); + padding: 1rem; + border-radius: 0.5rem; +} +.report-options { + border: 0; + padding: 1rem 0 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 1rem; +} +.report-options legend { + position: absolute; + clip-path: inset(50%); + width: 1px; + height: 1px; + overflow: hidden; +} +.radio-option { + font-size: 0.85rem; +} +.recommended { + color: var(--primary); + font-size: 0.7rem; + margin-left: 0.6rem; +} +.progress-block { + margin: 2rem 0; + padding: 1.5rem; + border: 1px solid var(--border); + border-radius: 1rem; + background: var(--card); +} +.progress-heading { + font-size: 1.1rem; + font-weight: 600; + margin-bottom: 1rem; +} +progress { + display: block; + width: 100%; + height: 0.6rem; + accent-color: var(--primary); + border: 0; +} +.progress-block > .muted { + font-size: 0.8rem; + margin: 1rem 0 0; +} +.progress-current { + display: block; + margin-top: 0.5rem; +} +.run-phases { + display: grid; + gap: 0.75rem; + padding-left: 1.25rem; + color: var(--muted-foreground); +} +.run-phases [aria-current="step"] { + color: var(--primary); + font-weight: 650; +} +.result-view > h2 { + margin: 0.5rem 0 1rem; +} +.result-view > details { + margin: 1.4rem 0; +} +.result-view li { + overflow-wrap: anywhere; +} +.run-log { + font-size: 0.8rem; + padding-left: 1.2rem; +} +.run-log time { + color: var(--muted-foreground); +} +.previous-run { + padding: 1.5rem; + margin-top: 2rem; + border: 1px solid var(--border); + border-radius: 1rem; +} +.previous-run > .result-view { + padding-top: 1.5rem; +} +footer { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 0.75rem; + border-top: 1px solid var(--border); + padding: 1.75rem 0; + margin-top: 4rem; + color: var(--muted-foreground); + font-size: 0.72rem; +} +.skip-link { + position: absolute; + top: -5rem; + left: 1rem; + background: var(--card); + padding: 1rem; + z-index: 100; +} +.skip-link:focus { + top: 1rem; +} +@media (max-width: 850px) { + .app-shell { + padding-inline: 1.5rem; + } + .stepper li { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } + .connection-card { + padding: 1.2rem; + } + .connections-grid { + grid-template-columns: minmax(0, 1fr) 1.75rem minmax(0, 1fr); + } + .quick-picks { + flex-direction: column; + } +} +@media (max-width: 550px) { + .app-shell { + padding-inline: 1.1rem; + } + .app-header { + min-height: 4.75rem; + } + .brand { + font-size: 1rem; + } + .header-actions { + gap: 0.35rem; + } + .badge.experimental { + font-size: 0.62rem; + } + .intro { + padding: 2.75rem 0 2rem; + } + .intro > p { + font-size: 1rem; + } + .safety-note { + font-size: 0.72rem; + } + .stepper { + grid-template-columns: repeat(3, 1fr); + margin-bottom: 1.75rem; + } + .stepper li { + flex-direction: row; + align-items: center; + padding: 0.8rem 0; + font-size: 0.65rem; + gap: 0.4rem; + } + .section-heading { + align-items: flex-start; + flex-direction: column; + } + .swap-button { + font-size: 0.75rem; + padding: 0.45rem 0.65rem; + } + .connections-grid { + grid-template-columns: minmax(0, 1fr); + gap: 0.5rem; + } + .connection-card { + padding: 1.3rem; + } + .direction { + transform: rotate(90deg); + } + .quick-picks { + flex-direction: row; + } + .stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .stats > div { + border: 0; + } + .stats > div:nth-child(odd) { + border-right: 1px solid var(--border); + } + .stats > div:nth-child(-n + 2) { + border-bottom: 1px solid var(--border); + } + .route-summary { + grid-template-columns: minmax(0, 1fr); + gap: 0.5rem; + } + .route-summary > span { + transform: rotate(90deg); + justify-self: start; + } + .report-item { + padding: 1rem; + } + .report-item > details > summary .badge { + display: table; + margin: 0.5rem 0 0; + } + .actions > button { + flex-grow: 1; + } + footer { + margin-top: 3rem; + } +} diff --git a/apps/pod-migrator/src/ui/ConnectionCard.tsx b/apps/pod-migrator/src/ui/ConnectionCard.tsx new file mode 100644 index 000000000..72eba266f --- /dev/null +++ b/apps/pod-migrator/src/ui/ConnectionCard.tsx @@ -0,0 +1,162 @@ +// AUTHORED-BY Codex GPT-6 +import { AccountMenu } from "@jeswr/app-shell"; +import { useState } from "react"; +import type { ConnectionState, PodConnection } from "../auth/connections.js"; + +export function ConnectionCard({ + connection, + state, + side, + disabled, +}: { + connection: PodConnection; + state: ConnectionState; + side: "source" | "target"; + disabled: boolean; +}) { + const [value, setValue] = useState(""); + const [kind, setKind] = useState<"webid" | "issuer">("webid"); + const title = side === "source" ? "Copy from" : "Copy to"; + const pending = state.status === "restoring" || state.status === "loading"; + return ( +
+
+ {title} + + {state.root ? "Connected" : pending ? "Connecting…" : "Not connected"} + +
+

{side === "source" ? "Source pod" : "Target pod"}

+ {state.webId && ( + <> +
+ + WebID ⓘ + +
+ { + void connection.logout(); + }} + /> +
+
+

{state.webId}

+ + )} + {state.roots.length > 1 && ( + + )} + {state.root && ( +
+ Storage location +

{state.root}

+
+ )} + {state.error && ( +

+ {state.error} +

+ )} + {pending && ( +

+ {state.status === "restoring" ? "Restoring your sign-in…" : "Connecting to your account…"} +

+ )} + {!state.root && !pending && !state.webId && ( +
{ + event.preventDefault(); + void connection.login(value, kind); + }} + > + + + +

Or choose a provider

+
+ {[ + ["Inrupt PodSpaces", "https://login.inrupt.com"], + ["solidcommunity.net", "https://solidcommunity.net"], + ].map(([label, issuer]) => ( + + ))} +
+
+ )} + {state.webId && !pending && !state.root && state.status === "error" && ( + + )} +

+ {side === "source" + ? "Your source files stay in place." + : "You’ll review every permission change first."} +

+
+ ); +} diff --git a/apps/pod-migrator/src/ui/Report.tsx b/apps/pod-migrator/src/ui/Report.tsx new file mode 100644 index 000000000..5e61f4bf5 --- /dev/null +++ b/apps/pod-migrator/src/ui/Report.tsx @@ -0,0 +1,112 @@ +// AUTHORED-BY Codex GPT-6 +import type { MigrationPlan, OptionId, ReportKind, Resolutions } from "../App.js"; + +const kindLabels: Record = { + "group-unsupported": "Groups that need a choice", + "origin-unsupported": "Website restrictions", + "condition-unsupported": "Sign-in conditions", + "exclusion-unsupported": "Excluded people", + "deny-unsupported": "Rules that block access", + "owner-mapped": "Owner permissions", + "source-owner-kept": "Source account permissions", + "inheritance-narrowed": "Folder permission differences", + "acl-unreadable": "Unreadable permissions", + "acl-system-unknown": "Unrecognized permission system", + "target-exists": "Files already at the destination", + "protected-target": "Protected destination settings", + "metadata-skipped": "Server descriptions left behind", + "too-complex": "Rules too complex to translate", +}; + +export function PlanReport({ + plan, + resolutions, + onChange, +}: { + plan: MigrationPlan; + resolutions: Resolutions; + onChange: (values: Resolutions) => void; +}) { + const kinds = [...new Set(plan.report.map((item) => item.kind))]; + return ( +
+ {kinds.map((kind) => { + const items = plan.report.filter((item) => item.kind === kind); + return ( +
+
+

{kindLabels[kind]}

+ {items.length} +
+ {items.map((item) => ( +
+
+ + {item.title} + + {item.severity === "needs-decision" + ? "Your choice needed" + : "For your information"} + + +

{item.resourceUrl}

+

{item.explanation}

+ {item.sourceRuleTurtle && ( +
+ + View original permission rule{" "} + (Turtle: a text format for linked data) + +
{item.sourceRuleTurtle}
+
+ )} +
+
+ Choose how to handle: {item.title} + {item.options.map((option) => ( + + ))} +
+ {items.length > 1 && ( + + )} +
+ ))} +
+ ); + })} +
+ ); +} diff --git a/apps/pod-migrator/test/ui/app.test.tsx b/apps/pod-migrator/test/ui/app.test.tsx new file mode 100644 index 000000000..07db77d2f --- /dev/null +++ b/apps/pod-migrator/test/ui/app.test.tsx @@ -0,0 +1,306 @@ +// AUTHORED-BY Codex GPT-6 +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { App } from "../../src/App.js"; +import { executeMigration, finalizePlan, planMigration } from "../../src/engine/index.js"; +import { fakeConnections, samplePlan, sourceRoot, targetRoot } from "./fixtures.js"; + +vi.mock("../../src/engine/index.js", () => ({ + createPodHttp: vi.fn(({ root }) => ({ root })), + planMigration: vi.fn(), + finalizePlan: vi.fn((plan, resolutions) => ({ plan, resolutions })), + executeMigration: vi.fn(), +})); + +const result = { + copied: [`${targetRoot}notes.txt`], + skipped: [], + failed: [], + aclWritten: [`${targetRoot}.acr`], + verified: true, + cancelled: false, + log: [], +}; + +beforeEach(() => { + vi.clearAllMocks(); + sessionStorage.clear(); + localStorage.clear(); + vi.mocked(planMigration).mockResolvedValue(structuredClone(samplePlan)); + vi.mocked(executeMigration).mockResolvedValue(structuredClone(result)); + vi.mocked(finalizePlan).mockImplementation((plan, resolutions) => ({ plan, resolutions })); +}); +afterEach(cleanup); + +async function options() { + const connections = fakeConnections(); + render(); + const source = screen.getByRole("button", { name: /Continue to target/ }); + await waitFor(() => expect(source).toBeEnabled()); + fireEvent.click(source); + const target = screen.getByRole("button", { name: /Continue to options/ }); + await waitFor(() => expect(target).toBeEnabled()); + fireEvent.click(target); + return connections; +} + +async function review() { + const connections = await options(); + fireEvent.click(screen.getByRole("button", { name: /Create migration plan/ })); + await screen.findByRole("button", { name: "Start migration" }); + return connections; +} + +function chooseAll() { + for (const radio of screen.getAllByRole("radio", { name: /Leave out the group grant/ })) + fireEvent.click(radio); +} + +describe("review gating", () => { + it("requires both connections before proceeding", async () => { + const connections = fakeConnections(); + vi.spyOn(connections[0], "initialize").mockResolvedValue(); + vi.spyOn(connections[1], "initialize").mockResolvedValue(); + render(); + expect(screen.getByRole("button", { name: /Continue to target/ })).toBeDisabled(); + expect(screen.getByRole("button", { name: /Swap source and target/ })).toBeDisabled(); + expect(planMigration).not.toHaveBeenCalled(); + }); + + it("preselects only info recommendations and keeps Start disabled until every decision is resolved", async () => { + await review(); + expect(screen.getByRole("radio", { name: /Keep this access/ })).toBeChecked(); + const choices = screen.getAllByRole("radio", { name: /Leave out the group grant/ }); + for (const radio of choices) expect(radio).not.toBeChecked(); + const start = screen.getByRole("button", { name: "Start migration" }); + expect(start).toBeDisabled(); + if (choices[0]) fireEvent.click(choices[0]); + expect(start).toBeDisabled(); + if (choices[1]) fireEvent.click(choices[1]); + expect(start).toBeEnabled(); + expect(executeMigration).not.toHaveBeenCalled(); + }); + + it("shows the abort state and never finalizes or writes", async () => { + await review(); + chooseAll(); + const abort = screen.getAllByRole("radio", { name: /Stop the migration/ })[0]; + if (abort) fireEvent.click(abort); + expect(screen.getByText("Migration will not run.")).toBeVisible(); + expect(screen.getByRole("button", { name: "Start migration" })).toBeDisabled(); + expect(finalizePlan).not.toHaveBeenCalled(); + expect(executeMigration).not.toHaveBeenCalled(); + }); + + it("applies a choice only to matching kinds with that option", async () => { + await review(); + const first = screen.getAllByRole("radio", { name: /Leave out the group grant/ })[0]; + if (first) fireEvent.click(first); + const bulk = screen.getAllByRole("button", { name: "Apply to all of this kind" })[0]; + if (bulk) fireEvent.click(bulk); + expect( + screen + .getAllByRole("radio", { name: /Leave out the group grant/ }) + .every((radio) => (radio as HTMLInputElement).checked), + ).toBe(true); + expect(screen.getByRole("radio", { name: /Keep this access/ })).toBeChecked(); + expect(screen.getByRole("button", { name: "Start migration" })).toBeEnabled(); + fireEvent.click(screen.getByText("Group access for friends")); + expect( + screen.getAllByText(/The destination cannot follow this group/, { selector: "p" })[0], + ).toBeVisible(); + expect(screen.getAllByText(/View original permission rule/)).toHaveLength(2); + }); + + it("swaps credentials and roots together, persists direction, and resets destination options", async () => { + const connections = await options(); + fireEvent.click(screen.getByRole("button", { name: /Swap source and target/ })); + await waitFor(() => + expect(screen.getByRole("textbox", { name: "Target folder URL" })).toHaveValue(sourceRoot), + ); + expect( + within(screen.getByRole("region", { name: "source connection" })).getByText(targetRoot), + ).toBeVisible(); + expect( + within(screen.getByRole("region", { name: "target connection" })).getByText(sourceRoot), + ).toBeVisible(); + expect(sessionStorage.getItem("pod-migrator:swapped:v1")).toBe("true"); + fireEvent.click(screen.getByRole("button", { name: /Create migration plan/ })); + await screen.findByRole("button", { name: "Start migration" }); + expect(planMigration).toHaveBeenCalledWith( + expect.objectContaining({ + source: expect.objectContaining({ + root: targetRoot, + ownerWebId: connections[1].auth.webId, + }), + target: expect.objectContaining({ + root: sourceRoot, + ownerWebId: connections[0].auth.webId, + }), + targetContainer: sourceRoot, + rewriteIris: false, + }), + ); + }); + + it("does not bulk-select an option missing from another item of the same kind", async () => { + const plan = structuredClone(samplePlan); + const sibling = plan.report.find((item) => item.id === "colleagues"); + if (!sibling) throw new Error("Missing fixture"); + sibling.options = sibling.options.filter((option) => option.id !== "skip"); + vi.mocked(planMigration).mockResolvedValue(plan); + await review(); + fireEvent.click(screen.getByRole("radio", { name: /Leave out the group grant/ })); + const bulk = screen.getAllByRole("button", { name: "Apply to all of this kind" })[0]; + if (!bulk) throw new Error("Missing bulk control"); + fireEvent.click(bulk); + expect(screen.getByRole("button", { name: "Start migration" })).toBeDisabled(); + expect(screen.getByText("1 permission decision still needs your choice.")).toBeVisible(); + }); + + it("invalidates reviewed choices when options change", async () => { + await review(); + chooseAll(); + fireEvent.click(screen.getByRole("button", { name: "Back to options" })); + expect(screen.getByRole("checkbox")).not.toBeChecked(); + fireEvent.click(screen.getByRole("checkbox")); + fireEvent.click(screen.getByRole("button", { name: /Create migration plan/ })); + await screen.findByRole("button", { name: "Start migration" }); + expect(screen.getByRole("button", { name: "Start migration" })).toBeDisabled(); + expect(planMigration).toHaveBeenLastCalledWith(expect.objectContaining({ rewriteIris: true })); + }); + + it.each([ + "https://storage.example/bob-evil/", + "https://storage.example/alice/", + "https://evil.example/", + "https://storage.example/bob/%2fprivate/", + "https://storage.example/bob/no-slash", + "https://storage.example/bob/?query=1", + ])("refuses destination %s before planning", async (url) => { + await options(); + fireEvent.change(screen.getByRole("textbox", { name: "Target folder URL" }), { + target: { value: url }, + }); + expect(screen.getByRole("button", { name: /Create migration plan/ })).toBeDisabled(); + expect(planMigration).not.toHaveBeenCalled(); + }); +}); + +describe("running and results", () => { + it("finalizes before execution and displays actual counts and verification", async () => { + await review(); + chooseAll(); + fireEvent.click(screen.getByRole("button", { name: "Start migration" })); + await screen.findByRole("heading", { name: "Migration finished" }); + expect(finalizePlan).toHaveBeenCalledExactlyOnceWith(samplePlan, { + owner: "acknowledge", + friends: "skip", + colleagues: "skip", + }); + expect(executeMigration).toHaveBeenCalledOnce(); + expect(screen.getByText("Verification passed")).toBeVisible(); + const saved = JSON.parse(sessionStorage.getItem("pod-migrator:run-report:v1") ?? "null"); + expect(saved).toMatchObject({ + status: "finished", + result: { verified: true, copied: [`${targetRoot}notes.txt`] }, + }); + }); + + it("does not execute when the engine refuses finalization", async () => { + await review(); + chooseAll(); + vi.mocked(finalizePlan).mockImplementation(() => { + throw new Error("Unsafe plan"); + }); + fireEvent.click(screen.getByRole("button", { name: "Start migration" })); + expect(screen.getByRole("alert")).toHaveTextContent("This plan cannot run safely"); + expect(executeMigration).not.toHaveBeenCalled(); + }); + + it("cancels in-flight execution, records progress, and waits for the engine result", async () => { + let finish: (value: typeof result) => void = () => {}; + vi.mocked(executeMigration).mockImplementation((_plan, opts) => { + opts.onProgress?.({ phase: "copy", done: 1, total: 2, current: `${targetRoot}notes.txt` }); + return new Promise((resolve) => { + finish = resolve; + }); + }); + await review(); + chooseAll(); + fireEvent.click(screen.getByRole("button", { name: "Start migration" })); + await screen.findByRole("button", { name: "Cancel migration" }); + expect(screen.getByRole("progressbar")).toHaveAttribute("value", "1"); + expect(sessionStorage.getItem("pod-migrator:run-report:v1")).toContain("Copying your files"); + fireEvent.click(screen.getByRole("button", { name: "Cancel migration" })); + expect(vi.mocked(executeMigration).mock.calls[0]?.[1].signal?.aborted).toBe(true); + expect(screen.getByText("Stopping after the current request…")).toBeVisible(); + await act(async () => finish({ ...result, verified: false, cancelled: true })); + expect(screen.getByRole("heading", { name: "Migration cancelled" })).toBeVisible(); + expect(screen.getByText("Verification has not passed")).toBeVisible(); + }); + + it("discards a plan returned after cancellation", async () => { + let finish: (value: typeof samplePlan) => void = () => {}; + vi.mocked(planMigration).mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + await options(); + fireEvent.click(screen.getByRole("button", { name: /Create migration plan/ })); + fireEvent.click(await screen.findByRole("button", { name: "Cancel planning" })); + await act(async () => finish(samplePlan)); + expect(screen.queryByRole("button", { name: "Start migration" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Create migration plan/ })).toBeVisible(); + }); + + it("reports unexpected partial execution as interrupted and never verified", async () => { + vi.mocked(executeMigration).mockRejectedValue(new Error("network disconnected")); + await review(); + chooseAll(); + fireEvent.click(screen.getByRole("button", { name: "Start migration" })); + await screen.findByRole("heading", { name: "Migration interrupted" }); + expect(screen.queryByText("Verification passed")).not.toBeInTheDocument(); + expect(screen.getByText(/The last request may have completed/)).toBeVisible(); + expect(screen.getByRole("button", { name: "Download report JSON" })).toBeVisible(); + }); + + it("restores an interrupted log after reload without resuming writes", async () => { + sessionStorage.setItem( + "pod-migrator:run-report:v1", + JSON.stringify({ + version: 1, + status: "running", + source: { root: sourceRoot }, + target: { root: targetRoot }, + result: null, + log: [{ at: "2026-10-27T00:00:00Z", message: "Copying your files" }], + }), + ); + render(); + fireEvent.click(screen.getByText(/Report from your previous run/)); + expect(screen.getByRole("heading", { name: "Migration interrupted" })).toBeVisible(); + expect(executeMigration).not.toHaveBeenCalled(); + }); + + it("ignores malformed saved reports without breaking the sign-in screen", () => { + sessionStorage.setItem( + "pod-migrator:run-report:v1", + JSON.stringify({ + version: 1, + status: "finished", + source: { root: sourceRoot }, + target: { root: targetRoot }, + result: { verified: true }, + log: [], + }), + ); + render(); + expect(screen.queryByText(/Report from your previous run/)).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Continue to target/ })).toBeVisible(); + }); +}); diff --git a/apps/pod-migrator/test/ui/auth.test.ts b/apps/pod-migrator/test/ui/auth.test.ts new file mode 100644 index 000000000..9da4dba6d --- /dev/null +++ b/apps/pod-migrator/test/ui/auth.test.ts @@ -0,0 +1,220 @@ +// AUTHORED-BY Codex GPT-6 +import { createSolidAuth } from "@jeswr/solid-auth-core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { authConfig, createConnections, PodConnection } from "../../src/auth/connections.js"; +import { fakeAuth, profileTurtle, sourceRoot, sourceWebId } from "./fixtures.js"; + +vi.mock("@jeswr/solid-auth-core", () => ({ createSolidAuth: vi.fn() })); + +beforeEach(() => vi.clearAllMocks()); + +describe("independent sign-ins", () => { + it("creates two instances with disjoint durable and redirect keys, and no issuer/global fetch arming", () => { + vi.mocked(createSolidAuth).mockImplementation(() => fakeAuth(null)); + const connections = createConnections("https://migrate.example"); + expect(connections[0].auth).not.toBe(connections[1].auth); + const configs = vi.mocked(createSolidAuth).mock.calls.map(([config]) => config); + expect(configs).toHaveLength(2); + for (const key of [ + "dbName", + "rememberedAccountsKey", + "recentAccountsKey", + "redirectFlowKey", + "redirectSentinelKey", + "redirectUri", + ] as const) { + expect(configs[0]?.[key]).toBeTruthy(); + expect(configs[0]?.[key]).not.toBe(configs[1]?.[key]); + } + expect(configs[0]).toMatchObject({ + patchGlobalFetch: false, + includeIssuerOrigin: false, + allowedOrigins: [], + callbackUri: "https://migrate.example/callback/source", + redirectUri: "https://migrate.example/callback/source", + }); + expect(configs[1]?.redirectUri).toBe("https://migrate.example/callback/target"); + expect(configs[1]?.clientId).toBe("https://migrate.example/clientid.jsonld"); + expect(authConfig("source", "https://migrate.example").includeWebIdOrigin).toBe(true); + }); + + it("handles the callback only for its own side, before restore", async () => { + const source = fakeAuth(); + const target = fakeAuth(null); + await Promise.all([ + new PodConnection("source", source).initialize( + "https://app.example/callback/target?code=code&state=state", + ), + new PodConnection("target", target).initialize( + "https://app.example/callback/target?code=code&state=state", + ), + ]); + expect(source.handleRedirect).not.toHaveBeenCalled(); + expect(target.handleRedirect).toHaveBeenCalledOnce(); + expect(target.handleRedirect.mock.invocationCallOrder[0]).toBeLessThan( + target.restore.mock.invocationCallOrder[0] ?? 0, + ); + }); + + it("uses a completed callback's session directly and initializes once", async () => { + const auth = fakeAuth(); + auth.handleRedirect.mockResolvedValue({ outcome: "completed", webId: sourceWebId }); + const connection = new PodConnection("source", auth); + await Promise.all([ + connection.initialize("https://app.example/callback/source?code=x"), + connection.initialize("https://app.example/callback/source?code=x"), + ]); + expect(auth.handleRedirect).toHaveBeenCalledOnce(); + expect(auth.restore).not.toHaveBeenCalled(); + expect(connection.snapshot().root).toBe(sourceRoot); + }); + + it("does not fall back to an old session after a declined callback", async () => { + const auth = fakeAuth(); + auth.handleRedirect.mockResolvedValue({ outcome: "error", error: "declined" }); + const connection = new PodConnection("source", auth); + await connection.initialize("https://app.example/callback/source?error=denied"); + expect(auth.restore).not.toHaveBeenCalled(); + expect(auth.publicFetch).not.toHaveBeenCalled(); + expect(connection.snapshot()).toMatchObject({ status: "error", root: null }); + }); + + it("starts full-page login for a WebID or issuer", async () => { + const auth = fakeAuth(null); + const connection = new PodConnection("source", auth); + await connection.login(sourceWebId, "webid"); + expect(auth.beginRedirectLogin).toHaveBeenLastCalledWith({ + webId: sourceWebId, + prompt: "consent", + }); + await connection.login("https://login.inrupt.com", "issuer"); + expect(auth.beginRedirectLogin).toHaveBeenLastCalledWith({ + oidcIssuer: "https://login.inrupt.com/", + prompt: "consent", + }); + await connection.login("https://user:secret@issuer.example", "issuer"); + expect(auth.beginRedirectLogin).toHaveBeenCalledTimes(2); + }); +}); + +describe("profile and credential boundaries", () => { + it("uses the signed-in subject's pim:storage and arms only the storage origin", async () => { + const auth = fakeAuth(); + const connection = new PodConnection("source", auth); + await connection.initialize("https://app.example/"); + expect(connection.snapshot()).toMatchObject({ + status: "connected", + root: sourceRoot, + webId: sourceWebId, + }); + expect(auth.publicFetch).toHaveBeenCalledWith( + "https://source.example/profile/card", + expect.objectContaining({ credentials: "omit", redirect: "manual" }), + ); + expect(auth.authenticatedFetch).not.toHaveBeenCalled(); + expect(auth.reArmAllowedOrigins).toHaveBeenCalledExactlyOnceWith(["https://storage.example"]); + }); + + it("uses authenticated fetch only for the exact private WebID document", async () => { + const auth = fakeAuth(); + auth.publicFetch.mockResolvedValue(new Response(null, { status: 403 })); + await new PodConnection("source", auth).readProfile(); + expect(auth.authenticatedFetch).toHaveBeenCalledExactlyOnceWith( + "https://source.example/profile/card", + expect.objectContaining({ redirect: "manual", credentials: "omit" }), + ); + expect(auth.reArmAllowedOrigins).toHaveBeenCalledExactlyOnceWith(["https://storage.example"]); + }); + + it("does not attach credentials on redirects, parse errors or server failures", async () => { + for (const status of [302, 500]) { + const auth = fakeAuth(); + auth.publicFetch.mockResolvedValue( + new Response(null, { status, headers: { location: "https://evil.example" } }), + ); + const connection = new PodConnection("source", auth); + await connection.readProfile(); + expect(connection.snapshot().status).toBe("error"); + expect(auth.authenticatedFetch).not.toHaveBeenCalled(); + expect(auth.reArmAllowedOrigins).not.toHaveBeenCalled(); + } + }); + + it("requires an explicit selection for multiple storages; refuses injected choices", async () => { + const auth = fakeAuth(); + auth.publicFetch.mockResolvedValue( + new Response( + `@prefix pim: . pim:storage , .`, + { headers: { "content-type": "text/turtle" } }, + ), + ); + const connection = new PodConnection("source", auth); + await connection.readProfile(); + expect(connection.snapshot()).toMatchObject({ status: "choose-storage", root: null }); + expect(auth.reArmAllowedOrigins).not.toHaveBeenCalled(); + connection.selectStorage("https://attacker.example/"); + expect(auth.reArmAllowedOrigins).not.toHaveBeenCalled(); + connection.selectStorage("https://other.example/alice/"); + expect(auth.reArmAllowedOrigins).toHaveBeenCalledExactlyOnceWith(["https://other.example"]); + }); + + it.each([ + "http://storage.example/", + "https://user:pass@storage.example/", + "https://storage.example/%2fsecret/", + "javascript:alert(1)", + ])("rejects unsafe storage %s", async (root) => { + const auth = fakeAuth(); + auth.publicFetch.mockResolvedValue( + new Response( + ` <${root}>.`, + { headers: { "content-type": "text/turtle" } }, + ), + ); + const connection = new PodConnection("source", auth); + await connection.readProfile(); + expect(connection.snapshot()).toMatchObject({ status: "error", root: null }); + expect(auth.reArmAllowedOrigins).not.toHaveBeenCalled(); + }); + + it("fails closed when arming the storage is refused", async () => { + const auth = fakeAuth(); + auth.reArmAllowedOrigins.mockReturnValue(false); + const connection = new PodConnection("source", auth); + await connection.readProfile(); + expect(connection.snapshot()).toMatchObject({ status: "error", root: null }); + }); + + it("never finishes a late profile read after logout", async () => { + const auth = fakeAuth(); + let resolve: (response: Response) => void = () => {}; + auth.publicFetch.mockImplementation( + () => + new Promise((done) => { + resolve = done; + }), + ); + const connection = new PodConnection("source", auth); + const reading = connection.readProfile(); + await connection.logout(); + resolve(new Response(profileTurtle, { headers: { "content-type": "text/turtle" } })); + await reading; + expect(connection.snapshot()).toMatchObject({ status: "signed-out", root: null, webId: null }); + expect(auth.reArmAllowedOrigins).not.toHaveBeenCalled(); + }); + + it("keeps failed sign-out retryable while clearing the connected storage", async () => { + const auth = fakeAuth(); + const connection = new PodConnection("source", auth); + await connection.readProfile(); + auth.logout.mockRejectedValueOnce(new Error("Durable cleanup failed")); + await connection.logout(); + expect(connection.snapshot()).toMatchObject({ + status: "error", + root: null, + webId: sourceWebId, + }); + await connection.logout(); + expect(connection.snapshot()).toMatchObject({ status: "signed-out", root: null, webId: null }); + }); +}); diff --git a/apps/pod-migrator/test/ui/client-id.test.ts b/apps/pod-migrator/test/ui/client-id.test.ts new file mode 100644 index 000000000..3a2d3230b --- /dev/null +++ b/apps/pod-migrator/test/ui/client-id.test.ts @@ -0,0 +1,78 @@ +// AUTHORED-BY Codex GPT-6 +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { clientIdDocument, deploymentOrigin, originEnvironment } from "../../vite.config.js"; + +describe("static client identifier document", () => { + it("resolves the origin per env layer and ignores empty shell values", () => { + const layers = [ + { APP_ORIGIN: "" }, + { APP_ORIGIN: "https://local-file.example" }, + { APP_ORIGIN: "https://base-file.example" }, + ]; + expect(deploymentOrigin(originEnvironment(layers), true)).toBe("https://local-file.example"); + expect( + deploymentOrigin(originEnvironment([{ VERCEL_URL: "preview.vercel.app" }, ...layers]), true), + ).toBe("https://preview.vercel.app"); + expect( + deploymentOrigin( + originEnvironment([{ APP_ORIGIN: "https://shell.example" }, ...layers]), + true, + ), + ).toBe("https://shell.example"); + }); + it("uses one deployment origin for the client and both callback paths", () => { + const origin = deploymentOrigin( + { APP_ORIGIN: "https://migrate.example/", VERCEL_URL: "preview.vercel.app" }, + true, + ); + expect(clientIdDocument(origin)).toEqual({ + "@context": ["https://www.w3.org/ns/solid/oidc-context.jsonld"], + client_id: "https://migrate.example/clientid.jsonld", + client_name: "Pod Migrator", + client_uri: "https://migrate.example/", + redirect_uris: [ + "https://migrate.example/callback/source", + "https://migrate.example/callback/target", + ], + scope: "openid webid offline_access", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }); + expect(deploymentOrigin({ VERCEL_URL: "preview.vercel.app" }, true)).toBe( + "https://preview.vercel.app", + ); + expect(deploymentOrigin({ APP_ORIGIN: "", VERCEL_URL: "preview.vercel.app" }, true)).toBe( + "https://preview.vercel.app", + ); + expect(deploymentOrigin({})).toBe("http://localhost:5173"); + expect(() => deploymentOrigin({}, true)).toThrow("Set APP_ORIGIN"); + }); + + it.each([ + "http://app.example", + "https://user:pass@app.example", + "https://app.example/path", + "https://app.example?x=1", + "https://app.example#x", + "javascript:alert(1)", + ])("rejects an unsafe or mismatched origin %s", (origin) => { + expect(() => deploymentOrigin({ APP_ORIGIN: origin }, true)).toThrow(); + }); + + it("serves JSON-LD with CORS and rewrites both callback routes to the SPA", () => { + const config = JSON.parse(readFileSync(new URL("../../vercel.json", import.meta.url), "utf8")); + const headers = config.headers.find( + (entry: { source: string }) => entry.source === "/clientid.jsonld", + ).headers; + expect(headers).toContainEqual({ key: "Content-Type", value: "application/ld+json" }); + expect(headers).toContainEqual({ key: "Access-Control-Allow-Origin", value: "*" }); + const rule = config.rewrites[0]; + const pattern = new RegExp(`^${rule.source}$`); + expect(pattern.test("/callback/source")).toBe(true); + expect(pattern.test("/callback/target")).toBe(true); + expect(pattern.test("/clientid.jsonld")).toBe(false); + expect(rule.destination).toBe("/index.html"); + }); +}); diff --git a/apps/pod-migrator/test/ui/fixtures.ts b/apps/pod-migrator/test/ui/fixtures.ts new file mode 100644 index 000000000..a7be34019 --- /dev/null +++ b/apps/pod-migrator/test/ui/fixtures.ts @@ -0,0 +1,120 @@ +// AUTHORED-BY Codex GPT-6 +import type { SolidAuth } from "@jeswr/solid-auth-core"; +import { type Mock, vi } from "vitest"; +import type { MigrationPlan } from "../../src/App.js"; +import { PodConnection } from "../../src/auth/connections.js"; + +export const sourceWebId = "https://source.example/profile/card#me"; +export const targetWebId = "https://target.example/profile/card#me"; +export const sourceRoot = "https://storage.example/alice/"; +export const targetRoot = "https://storage.example/bob/"; +export const profileTurtle = `@prefix pim: . + pim:storage . + pim:storage .`; + +type FakeAuth = { + -readonly [K in keyof SolidAuth]: SolidAuth[K] extends (...args: infer Args) => infer Result + ? Mock<(...args: Args) => Result> + : SolidAuth[K]; +}; + +export function fakeAuth(webId: string | null = sourceWebId): FakeAuth { + const auth = { + webId, + issuer: "https://issuer.example/", + publicFetch: vi + .fn() + .mockImplementation( + async () => new Response(profileTurtle, { headers: { "content-type": "text/turtle" } }), + ), + authenticatedFetch: vi + .fn() + .mockImplementation( + async () => new Response(profileTurtle, { headers: { "content-type": "text/turtle" } }), + ), + restore: vi + .fn() + .mockResolvedValue(webId ? { outcome: "restored", webId } : { outcome: "login" }), + handleRedirect: vi.fn().mockResolvedValue({ outcome: "none" }), + beginRedirectLogin: vi + .fn() + .mockResolvedValue({ authorizationUrl: "https://issuer.example/authorize" }), + completeRedirectLogin: vi.fn(), + hasPendingRedirect: vi.fn().mockReturnValue(false), + reArmAllowedOrigins: vi.fn().mockReturnValue(true), + logout: vi.fn().mockImplementation(async () => { + auth.webId = null; + }), + dropLiveSession: vi.fn(), + dropSession: vi.fn(), + login: vi.fn(), + rememberAccount: vi.fn(), + recentAccounts: vi.fn().mockReturnValue([]), + onSessionChange: vi.fn().mockReturnValue(() => {}), + } satisfies SolidAuth; + return auth; +} + +export function fakeConnections() { + return [ + new PodConnection("source", fakeAuth()), + new PodConnection("target", fakeAuth(targetWebId)), + ] as const; +} + +export const samplePlan: MigrationPlan = { + sourceSystem: "wac", + targetSystem: "acp", + resources: [ + { + sourceUrl: `${sourceRoot}notes.txt`, + targetUrl: `${targetRoot}notes.txt`, + kind: "resource", + contentType: "text/plain", + size: 80, + }, + ], + stats: { resources: 1, containers: 1, bytes: 80, aclDocuments: 2 }, + report: [ + { + id: "owner", + kind: "source-owner-kept", + severity: "info", + resourceUrl: sourceRoot, + title: "Keep the source account’s access", + explanation: "The source account can still access these files with the same WebID.", + options: [ + { + id: "acknowledge", + label: "Keep this access", + description: "Keep the original account’s permissions.", + }, + ], + recommended: "acknowledge", + }, + ...["friends", "colleagues"].map((name) => ({ + id: name, + kind: "group-unsupported" as const, + severity: "needs-decision" as const, + resourceUrl: `${sourceRoot}${name}/`, + title: `Group access for ${name}`, + explanation: + "The destination cannot follow this group’s changing membership. Choose how to handle its access.", + sourceRuleTurtle: + "@prefix acl: . <#group> acl:mode acl:Read.", + options: [ + { + id: "skip" as const, + label: "Leave out the group grant", + description: "Group members will not gain access from this rule.", + }, + { + id: "abort" as const, + label: "Stop the migration", + description: "Do not write anything to the destination.", + }, + ], + recommended: "skip" as const, + })), + ], +}; diff --git a/apps/pod-migrator/turbo.json b/apps/pod-migrator/turbo.json new file mode 100644 index 000000000..6adace133 --- /dev/null +++ b/apps/pod-migrator/turbo.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://turborepo.dev/schema.json", + "extends": ["//"], + "tasks": { + "build": { "env": ["APP_ORIGIN", "VERCEL_URL", "VERCEL"], "inputs": ["$TURBO_DEFAULT$", ".env*"] } + } +} diff --git a/apps/pod-migrator/vercel.json b/apps/pod-migrator/vercel.json new file mode 100644 index 000000000..cdb12e536 --- /dev/null +++ b/apps/pod-migrator/vercel.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": "vite", + "installCommand": "cd ../.. && pnpm install --frozen-lockfile --ignore-scripts", + "buildCommand": "cd ../.. && pnpm exec turbo run build --filter=@jeswr/app-pod-migrator", + "outputDirectory": "dist", + "rewrites": [{ "source": "/((?!assets/|.*\\.[^/]+$).*)", "destination": "/index.html" }], + "headers": [ + { + "source": "/clientid.jsonld", + "headers": [ + { "key": "Content-Type", "value": "application/ld+json" }, + { "key": "Access-Control-Allow-Origin", "value": "*" }, + { "key": "Cache-Control", "value": "public, max-age=300" } + ] + }, + { + "source": "/(.*)", + "headers": [ + { "key": "X-Content-Type-Options", "value": "nosniff" }, + { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }, + { "key": "X-Frame-Options", "value": "DENY" } + ] + } + ] +} diff --git a/apps/pod-migrator/vite.config.ts b/apps/pod-migrator/vite.config.ts new file mode 100644 index 000000000..21e2da72e --- /dev/null +++ b/apps/pod-migrator/vite.config.ts @@ -0,0 +1,117 @@ +// AUTHORED-BY Codex GPT-6 +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { parseEnv } from "node:util"; +import tailwindcss from "@tailwindcss/vite"; +import react from "@vitejs/plugin-react"; +import { defineConfig, type Plugin } from "vite"; + +type OriginEnvironment = Record; + +export function originEnvironment(layers: OriginEnvironment[]): OriginEnvironment { + return layers.find((layer) => layer.APP_ORIGIN?.trim() || layer.VERCEL_URL?.trim()) ?? {}; +} + +function fileEnvironment(name: string): OriginEnvironment { + try { + return parseEnv(readFileSync(new URL(name, import.meta.url), "utf8")); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return {}; + throw error; + } +} + +export function deploymentOrigin( + env: Record, + production = false, +): string { + const raw = + env.APP_ORIGIN?.trim() || (env.VERCEL_URL?.trim() ? `https://${env.VERCEL_URL.trim()}` : ""); + if (!raw && production) throw new Error("Set APP_ORIGIN or VERCEL_URL for a deployable build."); + const url = new URL(raw || "http://localhost:5173"); + const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); + if ( + (url.protocol !== "https:" && !(url.protocol === "http:" && loopback && !production)) || + url.username || + url.password || + url.pathname !== "/" || + url.search || + url.hash + ) { + throw new Error( + "APP_ORIGIN must be an HTTPS origin without credentials, path, query or fragment (HTTP loopback is allowed for dev).", + ); + } + return url.origin; +} + +export function clientIdDocument(origin: string) { + return { + "@context": ["https://www.w3.org/ns/solid/oidc-context.jsonld"], + client_id: `${origin}/clientid.jsonld`, + client_name: "Pod Migrator", + client_uri: `${origin}/`, + redirect_uris: [`${origin}/callback/source`, `${origin}/callback/target`], + scope: "openid webid offline_access", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }; +} + +export function clientDocumentPlugin(origin: string): Plugin { + const body = `${JSON.stringify(clientIdDocument(origin), null, 2)}\n`; + return { + name: "pod-migrator-client-document", + configureServer(server) { + server.middlewares.use((request, response, next) => { + if (request.url?.split("?")[0] !== "/clientid.jsonld") { + next(); + return; + } + response.setHeader("Content-Type", "application/ld+json"); + response.setHeader("Access-Control-Allow-Origin", "*"); + response.setHeader("Cache-Control", "public, max-age=300"); + response.end(body); + }); + }, + generateBundle() { + this.emitFile({ type: "asset", fileName: "clientid.jsonld", source: body }); + }, + }; +} + +// Opt-in test mode substitutes modules at build time. No runtime demo/auth bypass. +function screenshotMocks(): Plugin { + return { + name: "pod-migrator-screenshot-mocks", + enforce: "pre", + resolveId(source, importer) { + if (importer?.endsWith("/src/App.tsx") && source === "./engine/index.js") + return fileURLToPath(new URL("./e2e/engine.ts", import.meta.url)); + if (importer?.endsWith("/src/main.tsx") && source === "./auth/connections.js") + return fileURLToPath(new URL("./e2e/connections.ts", import.meta.url)); + }, + }; +} + +export default defineConfig(({ mode, command }) => { + const env = originEnvironment([ + process.env, + fileEnvironment(`.env.${mode}.local`), + fileEnvironment(`.env.${mode}`), + fileEnvironment(".env.local"), + fileEnvironment(".env"), + ]); + const origin = deploymentOrigin(env, command === "build" && Boolean(process.env.VERCEL)); + if (mode === "screens" && command === "build") + throw new Error("Screenshot mocks are development-only."); + return { + plugins: [ + react(), + tailwindcss(), + clientDocumentPlugin(origin), + ...(mode === "screens" ? [screenshotMocks()] : []), + ], + }; +}); From 1370828ccaac878656b905a187877a0fae771091 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:22:57 +0000 Subject: [PATCH 04/20] chore(pod-migrator): register the solid-pod-migrator Vercel project Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KLrUernPrjVRT4K7UiaGgK --- apps/pod-migrator/vercel.json | 1 + scripts/vercel-apps.json | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/apps/pod-migrator/vercel.json b/apps/pod-migrator/vercel.json index cdb12e536..710b91886 100644 --- a/apps/pod-migrator/vercel.json +++ b/apps/pod-migrator/vercel.json @@ -4,6 +4,7 @@ "installCommand": "cd ../.. && pnpm install --frozen-lockfile --ignore-scripts", "buildCommand": "cd ../.. && pnpm exec turbo run build --filter=@jeswr/app-pod-migrator", "outputDirectory": "dist", + "ignoreCommand": "node ../../scripts/vercel-ignore-build.mjs @jeswr/app-pod-migrator", "rewrites": [{ "source": "/((?!assets/|.*\\.[^/]+$).*)", "destination": "/index.html" }], "headers": [ { diff --git a/scripts/vercel-apps.json b/scripts/vercel-apps.json index 8ac014a2d..6ed3c46fa 100644 --- a/scripts/vercel-apps.json +++ b/scripts/vercel-apps.json @@ -110,5 +110,13 @@ "package": "@jeswr/app-coeliac", "framework": "next", "vercelIgnore": "node ../../scripts/vercel-ignore-build.mjs @jeswr/app-coeliac" + }, + { + "project": "solid-pod-migrator", + "rootDirectory": "apps/pod-migrator", + "package": "@jeswr/app-pod-migrator", + "framework": "vite", + "originEnv": "APP_ORIGIN", + "vercelIgnore": "node ../../scripts/vercel-ignore-build.mjs @jeswr/app-pod-migrator" } ] From a65aa2fb4560dfadbd272db1364074c8c981777d Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:48:11 +0000 Subject: [PATCH 05/20] chore(pod-migrator): drop the Vercel project registration The suite moved Vite apps to GitHub Pages (sst-jgb2); the app will be hosted there instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KLrUernPrjVRT4K7UiaGgK --- apps/pod-migrator/vercel.json | 27 --------------------------- scripts/vercel-apps.json | 8 -------- 2 files changed, 35 deletions(-) delete mode 100644 apps/pod-migrator/vercel.json diff --git a/apps/pod-migrator/vercel.json b/apps/pod-migrator/vercel.json deleted file mode 100644 index 710b91886..000000000 --- a/apps/pod-migrator/vercel.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "$schema": "https://openapi.vercel.sh/vercel.json", - "framework": "vite", - "installCommand": "cd ../.. && pnpm install --frozen-lockfile --ignore-scripts", - "buildCommand": "cd ../.. && pnpm exec turbo run build --filter=@jeswr/app-pod-migrator", - "outputDirectory": "dist", - "ignoreCommand": "node ../../scripts/vercel-ignore-build.mjs @jeswr/app-pod-migrator", - "rewrites": [{ "source": "/((?!assets/|.*\\.[^/]+$).*)", "destination": "/index.html" }], - "headers": [ - { - "source": "/clientid.jsonld", - "headers": [ - { "key": "Content-Type", "value": "application/ld+json" }, - { "key": "Access-Control-Allow-Origin", "value": "*" }, - { "key": "Cache-Control", "value": "public, max-age=300" } - ] - }, - { - "source": "/(.*)", - "headers": [ - { "key": "X-Content-Type-Options", "value": "nosniff" }, - { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }, - { "key": "X-Frame-Options", "value": "DENY" } - ] - } - ] -} diff --git a/scripts/vercel-apps.json b/scripts/vercel-apps.json index 6ed3c46fa..8ac014a2d 100644 --- a/scripts/vercel-apps.json +++ b/scripts/vercel-apps.json @@ -110,13 +110,5 @@ "package": "@jeswr/app-coeliac", "framework": "next", "vercelIgnore": "node ../../scripts/vercel-ignore-build.mjs @jeswr/app-coeliac" - }, - { - "project": "solid-pod-migrator", - "rootDirectory": "apps/pod-migrator", - "package": "@jeswr/app-pod-migrator", - "framework": "vite", - "originEnv": "APP_ORIGIN", - "vercelIgnore": "node ../../scripts/vercel-ignore-build.mjs @jeswr/app-pod-migrator" } ] From 59af7fbd194db192e118213689cef77dd652687e Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:26:21 +0000 Subject: [PATCH 06/20] test(pod-migrator): real CSS 7.2 WAC and ACP integration harness Two CSS servers (WAC and ACP), JSON account API, client-credentials DPoP in node:crypto, seeded trees and permissions, and per-write permission audits for all four migration directions. Written by Codex GPT-6 (xhigh); Claude fixed the live run (NODE_ENV leak into CSS, localhost base URL for the token verifier). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KLrUernPrjVRT4K7UiaGgK --- apps/pod-migrator/test/css/README.md | 97 +++ apps/pod-migrator/test/css/account.ts | 107 ++++ apps/pod-migrator/test/css/auth.ts | 166 +++++ .../test/css/component-state.d.mts | 10 + .../pod-migrator/test/css/component-state.mjs | 25 + apps/pod-migrator/test/css/fixtures.ts | 284 +++++++++ apps/pod-migrator/test/css/helpers.test.ts | 570 ++++++++++++++++++ .../test/css/migration.integration.ts | 518 ++++++++++++++++ apps/pod-migrator/test/css/probe.ts | 135 +++++ apps/pod-migrator/test/css/run.mjs | 21 + apps/pod-migrator/test/css/server-process.mjs | 62 ++ apps/pod-migrator/test/css/server.ts | 123 ++++ apps/pod-migrator/test/css/vitest.config.ts | 17 + 13 files changed, 2135 insertions(+) create mode 100644 apps/pod-migrator/test/css/README.md create mode 100644 apps/pod-migrator/test/css/account.ts create mode 100644 apps/pod-migrator/test/css/auth.ts create mode 100644 apps/pod-migrator/test/css/component-state.d.mts create mode 100644 apps/pod-migrator/test/css/component-state.mjs create mode 100644 apps/pod-migrator/test/css/fixtures.ts create mode 100644 apps/pod-migrator/test/css/helpers.test.ts create mode 100644 apps/pod-migrator/test/css/migration.integration.ts create mode 100644 apps/pod-migrator/test/css/probe.ts create mode 100644 apps/pod-migrator/test/css/run.mjs create mode 100644 apps/pod-migrator/test/css/server-process.mjs create mode 100644 apps/pod-migrator/test/css/server.ts create mode 100644 apps/pod-migrator/test/css/vitest.config.ts diff --git a/apps/pod-migrator/test/css/README.md b/apps/pod-migrator/test/css/README.md new file mode 100644 index 000000000..c322c8315 --- /dev/null +++ b/apps/pod-migrator/test/css/README.md @@ -0,0 +1,97 @@ + + +# Real Community Solid Server integration tests + +From the workspace root, with Node 24 on PATH: + +```sh +export PATH=/opt/node24/bin:$PATH +pnpm --filter @jeswr/app-pod-migrator run test:css +``` + +This starts the **installed CSS 7.2.0** twice, on dynamically allocated IPv4 +loopback ports. Configs are composed from its shipped `file.json` and +`file-acp.json`, replacing the backend and resource locker with the shipped +memory variants. Registration, password login, pod creation, OIDC, authorization, +and the corresponding ACL/ACR auxiliary strategies remain enabled. CSS 7.2's +initializer uses `listen(port)`; the isolated child launcher supplies the missing +loopback bind address. Processes and temporary config directories are cleaned up +on success and failure, with a bounded shutdown and forced termination fallback. +Component discovery is explicitly scoped to CSS 7.2's components and the local +Components.js context. Automatic discovery would walk into the pnpm workspace, +mix unrelated installed versions, and attempt remote context downloads. Both +composed configurations have been instantiated offline without starting a listener. + +The suite creates accounts through `/.account/`, follows its returned controls, +uses the returned pod/WebID, and discovers the OIDC token endpoint. Client +credentials are exchanged using ES256 DPoP proofs made with `node:crypto`. +Resource proofs include `ath`; nonce retries are bounded, proofs are fresh on +retry, expiring tokens are renewed, and credentials cannot follow redirects or +leave the explicitly allowed test server origins. No credentials are logged. + +Source fixtures live in a container within each source owner's pod; each +migration gets a fresh destination pod. This keeps CSS's generated WebID profile +usable while giving the migrator a controlled source tree. Both servers also +have a stranger account. The same two strangers are probed against both sides, +so named-agent comparisons use the same WebID, including across issuers. + +The scenarios cover WAC → ACP (skip and expand), ACP → WAC, WAC → WAC, and +ACP → ACP. They exercise every resolution option type: skip, expand, owner-only, +per-resource, overwrite, merge, drop-overlapping, acknowledge, and abort. Every +offered abort decision is exercised for both cross-model directions; the write +counter must remain zero and the target snapshot must remain identical. The +suite logs each scenario, decisions, copied files, writes, and intermediate +permission comparison counts. Assertion failures and startup failures exit +nonzero; live scenarios are never silently skipped. + +Data includes nested containers, Turtle, JSON, all byte values in a binary file, +and a filename containing spaces and Unicode. Final checks compare exact file +bytes, container membership, effective permissions, owner access to each access +document, and owner ability to conditionally replace it. Grants to the named +stranger must preserve Read and Append, including a successful insertion PATCH +whose bytes are then restored by the owner. The stranger must also be refused +an access-document edit. Permission equality is required for +faithful resources; reviewed losses must remain subsets of the source. + +The engine's `onProgress` callback is synchronous and does not cover each +individual PUT. The harness therefore fences **every target transport write** +with an awaited `onProgress` observer and also records the engine's own progress +events. The observer probes every existing migrated resource after every write, +including empty placeholders, initial locks, content copies, and final grants. +It checks anonymous access and both stranger identities before the executor can +issue its next write. Observer errors fail execution and are rethrown by the +test even if the engine returns them in its result. + +WAC permissions come from CSS's HTTP `WAC-Allow` evidence. The shipped ACP config +does not install the WAC-Allow metadata writer. For ACP, or when a read is denied, +the probe also sends conditional PUT/PATCH requests with a random nonmatching +`If-Match`. Authorization precedes conditional checks in CSS 7.2, so HTTP 412 +proves the requested permission without a mutation. An existing-container PUT +returns 409 after authorization in CSS; only that specific context accepts 409. +PATCH has a nonempty insert serialized with `n3.Writer`, so append-only grants +cannot hide behind a denied GET. Unexpected errors, missing resources, malformed +headers, and successful conditional mutations fail the probe. + +CSS 7.2's WAC access-checker configuration does not enforce `acl:origin`. The +fixture still carries that condition, requires its omission decision for ACP, +and verifies that migration does not widen actual server permissions. This does +not certify origin enforcement on CSS. CSS's `OwnerPermissionReader` grants pod +owners recovery access to their access documents regardless of ACL contents. +The WAC source therefore uses a delegated migration session with Control on the +tree and body access without Control on the unreadable-ACL fixture. This exercises +a real HTTP 403 and both skip and owner-only resolutions. The actual pod owner +keeps recovery access; the target owner must retain Control everywhere. +ESS/PodSpaces and its UMA behavior are outside this local CSS suite. + +The live file uses `.integration.ts`, excluded by the default app test glob. +Offline helpers use `.test.ts` and also run in the default app tests. They can be +run independently without opening any listening sockets: + +```sh +pnpm --filter @jeswr/app-pod-migrator run test:css helpers.test.ts +``` + +The authoring sandbox cannot listen on sockets. Offline checks validate crypto, +HTTP request construction, probes, observation ordering, shipped config imports, +and the independent fixtures against CSS's own ACP evaluator. A reviewer with +loopback sockets must run the full command above to validate the live scenarios. diff --git a/apps/pod-migrator/test/css/account.ts b/apps/pod-migrator/test/css/account.ts new file mode 100644 index 000000000..7abe7f860 --- /dev/null +++ b/apps/pod-migrator/test/css/account.ts @@ -0,0 +1,107 @@ +// AUTHORED-BY Codex GPT-6 +import { randomUUID } from "node:crypto"; +import { createPodHttp, type PodSide } from "../../src/engine/index.js"; +import { boundedFetch, createSession, localUrl } from "./auth.js"; + +export interface CssAccount { + webId: string; + podRoot: string; + fetch: typeof fetch; + clientId: string; + side: PodSide; +} + +export function accountRequest( + base: string, + endpoint: string, + cookies: ReadonlyMap, + body?: unknown, +): { url: string; init: RequestInit } { + const url = localUrl(new URL(endpoint, base).href, new URL(base).origin); + if (!new URL(url).pathname.startsWith("/.account/")) + throw new Error("Account controls must stay inside /.account/"); + const headers = new Headers({ accept: "application/json" }); + if (cookies.size) headers.set("cookie", [...cookies].map(([k, v]) => `${k}=${v}`).join("; ")); + if (body !== undefined) headers.set("content-type", "application/json"); + return { + url, + init: { + method: body === undefined ? "GET" : "POST", + headers, + redirect: "manual", + ...(body === undefined ? {} : { body: JSON.stringify(body ?? {}) }), + }, + }; +} + +export function rememberCookies(headers: Headers, cookies: Map): void { + for (const cookie of headers.getSetCookie()) { + const pair = cookie.split(";", 1)[0] ?? ""; + const separator = pair.indexOf("="); + if (separator < 1) throw new Error("CSS returned a malformed account cookie"); + cookies.set(pair.slice(0, separator), pair.slice(separator + 1)); + } +} + +export async function createAccount( + base: string, + origins = [base], + fetcher: typeof fetch = boundedFetch, +): Promise { + const cookies = new Map(); + const name = `migration-${randomUUID()}`; + async function request(endpoint: string, body?: unknown): Promise> { + const { url, init } = accountRequest(base, endpoint, cookies, body); + const response = await fetcher(url, init); + if (!response.ok) throw new Error(`CSS account API ${init.method} failed (${response.status})`); + rememberCookies(response.headers, cookies); + return (await response.json()) as Record; + } + await request("/.account/account/", {}); + const index = await request("/.account/"); + const controls = index.controls as { + password?: { create?: string }; + account?: { pod?: string; clientCredentials?: string }; + }; + if (!controls?.password?.create || !controls.account?.pod || !controls.account.clientCredentials) + throw new Error("CSS account API is missing registration, pod or client credential controls"); + await request(controls.password.create, { + email: `${name}@example.test`, + password: randomUUID(), + }); + const pod = await request(controls.account.pod, { name }); + if (typeof pod.pod !== "string" || typeof pod.webId !== "string") + throw new Error("CSS pod creation did not return its pod and WebID"); + const podRoot = localUrl(pod.pod, new URL(base).origin); + const webId = localUrl(pod.webId, new URL(base).origin); + const credentials = await request(controls.account.clientCredentials, { + name: "pod-migrator-integration", + webId, + }); + if (typeof credentials.id !== "string" || typeof credentials.secret !== "string") + throw new Error("CSS did not return client credentials"); + const discovery = await fetcher(new URL("/.well-known/openid-configuration", base).href, { + headers: { accept: "application/json" }, + redirect: "manual", + }); + if (!discovery.ok) throw new Error(`CSS discovery failed (${discovery.status})`); + const metadata = (await discovery.json()) as { token_endpoint: string }; + const endpoint = localUrl(metadata.token_endpoint, new URL(base).origin); + const authenticated = await createSession( + endpoint, + { id: credentials.id, secret: credentials.secret }, + origins, + fetcher, + ); + return { + webId, + podRoot, + clientId: credentials.id, + fetch: authenticated, + side: { + root: podRoot, + ownerWebId: webId, + http: createPodHttp({ root: podRoot, fetch: authenticated, allowInsecureLoopback: true }), + }, + }; +} diff --git a/apps/pod-migrator/test/css/auth.ts b/apps/pod-migrator/test/css/auth.ts new file mode 100644 index 000000000..3a1d29bcb --- /dev/null +++ b/apps/pod-migrator/test/css/auth.ts @@ -0,0 +1,166 @@ +// AUTHORED-BY Codex GPT-6 +import { createHash, generateKeyPairSync, type KeyObject, randomUUID, sign } from "node:crypto"; + +export interface ProofKey { + privateKey: KeyObject; + jwk: { kty: string; crv: string; x: string; y: string }; +} + +export function createProofKey(): ProofKey { + const { privateKey, publicKey } = generateKeyPairSync("ec", { namedCurve: "P-256" }); + const exported = publicKey.export({ format: "jwk" }); + if (!exported.kty || !exported.crv || !exported.x || !exported.y) + throw new Error("Incomplete ES256 public key"); + return { + privateKey, + jwk: { kty: exported.kty, crv: exported.crv, x: exported.x, y: exported.y }, + }; +} + +export function mintProof( + key: ProofKey, + method: string, + url: string, + options: { token?: string; nonce?: string; now?: number } = {}, +): string { + const target = new URL(url); + target.search = ""; + target.hash = ""; + const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url"); + const input = [ + encode({ typ: "dpop+jwt", alg: "ES256", jwk: key.jwk }), + encode({ + htm: method.toUpperCase(), + htu: target.href, + iat: Math.floor((options.now ?? Date.now()) / 1000), + jti: randomUUID(), + ...(options.token === undefined + ? {} + : { ath: createHash("sha256").update(options.token, "ascii").digest("base64url") }), + ...(options.nonce === undefined ? {} : { nonce: options.nonce }), + }), + ].join("."); + // JWS ES256 uses fixed-width r || s, not OpenSSL's default DER encoding. + const signature = sign("sha256", Buffer.from(input), { + key: key.privateKey, + dsaEncoding: "ieee-p1363", + }); + return `${input}.${signature.toString("base64url")}`; +} + +export const boundedFetch: typeof fetch = (input, init = {}) => + fetch(input, { + ...init, + redirect: "manual", + signal: init.signal + ? AbortSignal.any([init.signal, AbortSignal.timeout(20_000)]) + : AbortSignal.timeout(20_000), + }); + +/** Only disposable local CSS origins may receive this harness's credentials. */ +export function localUrl(value: string, origin?: string): string { + const url = new URL(value); + if ( + url.protocol !== "http:" || + url.hostname !== "localhost" || + url.username || + url.password || + (origin !== undefined && url.origin !== origin) + ) + throw new Error("Harness credentials must stay on the expected loopback origin"); + return url.href; +} + +export async function exchangeToken( + endpoint: string, + credentials: { id: string; secret: string }, + key: ProofKey, + fetcher: typeof fetch = boundedFetch, +): Promise<{ token: string; expiresAt: number }> { + localUrl(endpoint); + let nonce: string | undefined; + for (let attempt = 0; attempt < 2; attempt++) { + const response = await fetcher(endpoint, { + method: "POST", + redirect: "manual", + headers: { + authorization: `Basic ${Buffer.from( + `${encodeURIComponent(credentials.id)}:${encodeURIComponent(credentials.secret)}`, + ).toString("base64")}`, + "content-type": "application/x-www-form-urlencoded", + dpop: mintProof(key, "POST", endpoint, { nonce }), + }, + body: new URLSearchParams({ grant_type: "client_credentials", scope: "webid" }), + }); + const challenge = response.headers.get("dpop-nonce"); + if ([400, 401].includes(response.status) && challenge && attempt === 0) { + nonce = challenge; + await response.arrayBuffer(); + continue; + } + if (response.status !== 200) throw new Error(`CSS token exchange failed (${response.status})`); + const value = (await response.json()) as Record; + if ( + typeof value.access_token !== "string" || + !value.access_token || + typeof value.token_type !== "string" || + value.token_type.toLowerCase() !== "dpop" || + typeof value.expires_in !== "number" || + value.expires_in <= 0 + ) + throw new Error("CSS did not issue an expiring DPoP-bound token"); + return { token: value.access_token, expiresAt: Date.now() + value.expires_in * 1000 }; + } + throw new Error("CSS token nonce retry exhausted"); +} + +export async function createSession( + endpoint: string, + credentials: { id: string; secret: string }, + origins: string[], + fetcher: typeof fetch = boundedFetch, +): Promise { + const key = createProofKey(); + let token = await exchangeToken(endpoint, credentials, key, fetcher); + let renewal: Promise | undefined; + const nonces = new Map(); + const allowed = new Set(origins.map((origin) => new URL(localUrl(origin)).origin)); + return async (input, init = {}) => { + if (typeof input !== "string") throw new TypeError("Harness fetch requires a string URL"); + const url = new URL(localUrl(input)); + if (!allowed.has(url.origin)) throw new Error("Session origin is outside the test servers"); + if (token.expiresAt - Date.now() < 30_000) { + renewal ??= exchangeToken(endpoint, credentials, key, fetcher); + try { + token = await renewal; + } finally { + renewal = undefined; + } + } + const suppliedHeaders = new Headers(init.headers); + if (suppliedHeaders.has("authorization") || suppliedHeaders.has("dpop")) + throw new Error("Caller-supplied session credentials are refused"); + for (let attempt = 0; attempt < 2; attempt++) { + const headers = new Headers(suppliedHeaders); + headers.set("authorization", `DPoP ${token.token}`); + headers.set( + "dpop", + mintProof(key, init.method ?? "GET", url.href, { + token: token.token, + nonce: nonces.get(url.origin), + }), + ); + const response = await fetcher(url.href, { ...init, headers, redirect: "manual" }); + const nonce = response.headers.get("dpop-nonce"); + if (nonce) nonces.set(url.origin, nonce); + if (response.status === 401 && nonce && attempt === 0) { + await response.arrayBuffer(); + continue; + } + if (response.status >= 300 && response.status < 400) + throw new Error("Authenticated harness redirects are refused"); + return response; + } + throw new Error("CSS resource nonce retry exhausted"); + }; +} diff --git a/apps/pod-migrator/test/css/component-state.d.mts b/apps/pod-migrator/test/css/component-state.d.mts new file mode 100644 index 000000000..5104ad7e2 --- /dev/null +++ b/apps/pod-migrator/test/css/component-state.d.mts @@ -0,0 +1,10 @@ +// AUTHORED-BY Codex GPT-6 +export function cssComponentState(cssDirectory: string): Promise<{ + mainModulePath: string; + nodeModuleImportPaths: string[]; + nodeModulePaths: string[]; + packageJsons: Record>; + componentModules: Record; + contexts: Record; + importPaths: Record; +}>; diff --git a/apps/pod-migrator/test/css/component-state.mjs b/apps/pod-migrator/test/css/component-state.mjs new file mode 100644 index 000000000..32caf1c98 --- /dev/null +++ b/apps/pod-migrator/test/css/component-state.mjs @@ -0,0 +1,25 @@ +// AUTHORED-BY Codex GPT-6 +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; + +/** Register only CSS 7.2's own components and its Components.js context. + * Automatic discovery walks up into the pnpm workspace and mixes unrelated + * CSS/Comunica majors, attempting remote JSON-LD context loads offline. All + * components used by the shipped memory configs belong to the CSS module. */ +export async function cssComponentState(cssDirectory) { + const requireCss = createRequire(join(cssDirectory, "package.json")); + const { ModuleStateBuilder } = requireCss("componentsjs"); + const builder = new ModuleStateBuilder(); + const paths = [cssDirectory, dirname(requireCss.resolve("componentsjs/package.json"))]; + const packageJsons = await builder.buildPackageJsons(paths); + await builder.preprocessPackageJsons(packageJsons); + return { + mainModulePath: cssDirectory, + nodeModuleImportPaths: [cssDirectory], + nodeModulePaths: paths, + packageJsons, + componentModules: await builder.buildComponentModules(packageJsons), + contexts: await builder.buildComponentContexts(packageJsons), + importPaths: await builder.buildComponentImportPaths(packageJsons), + }; +} diff --git a/apps/pod-migrator/test/css/fixtures.ts b/apps/pod-migrator/test/css/fixtures.ts new file mode 100644 index 000000000..3b81195e2 --- /dev/null +++ b/apps/pod-migrator/test/css/fixtures.ts @@ -0,0 +1,284 @@ +// AUTHORED-BY Codex GPT-6 +import assert from "node:assert/strict"; +import { LiteralFrom, NamedNodeAs, NamedNodeFrom, SetFrom, TermWrapper } from "@rdfjs/wrapper"; +import { DataFactory, Store, Writer } from "n3"; +import { createPodHttp, type Mode, type PodSide } from "../../src/engine/index.js"; +import type { CssAccount } from "./account.js"; +import { accessLink } from "./probe.js"; +import type { System } from "./server.js"; + +const ACL = "http://www.w3.org/ns/auth/acl#"; +const ACP = "http://www.w3.org/ns/solid/acp#"; +const RDF = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"; +const VCARD = "http://www.w3.org/2006/vcard/ns#"; +const FOAF = "http://xmlns.com/foaf/0.1/"; + +/** Fixture writer is independent of the migrator's readers and serializers. */ +class FixtureNode extends TermWrapper { + iri(predicate: string, value: string): void { + SetFrom.subjectPredicate(this, predicate, NamedNodeAs.string, NamedNodeFrom.string).add(value); + } + text(predicate: string, value: string): void { + SetFrom.subjectPredicate(this, predicate, (term) => term.value, LiteralFrom.string).add(value); + } +} + +async function turtle(store: Store): Promise { + const writer = new Writer(); + writer.addQuads([...store]); + return new Promise((resolve, reject) => + writer.end((error, result) => (error ? reject(error) : resolve(result))), + ); +} + +export interface FixtureRule { + agent?: string; + public?: boolean; + authenticated?: boolean; + group?: string; + origin?: string; + client?: string; + exceptAgent?: string; + allow?: Mode[]; + deny?: Mode[]; + scope?: "resource" | "members" | "both"; +} + +export async function fixtureAccess( + system: System, + resource: string, + acl: string, + rules: FixtureRule[], +): Promise { + const store = new Store(); + const node = (iri: string) => new FixtureNode(iri, store, DataFactory); + const root = node(acl); + if (system === "acp") { + root.iri(`${RDF}type`, `${ACP}AccessControlResource`); + root.iri(`${ACP}resource`, resource); + } + const modeIri = (mode: Mode) => ACL + mode[0]?.toUpperCase() + mode.slice(1); + for (const [index, rule] of rules.entries()) { + const scope = rule.scope ?? "resource"; + const id = `${acl}#fixture-${index}`; + const entry = node(id); + if (system === "wac") { + assert.ok(!rule.deny?.length && !rule.client && !rule.exceptAgent); + entry.iri(`${RDF}type`, `${ACL}Authorization`); + if (scope !== "members") entry.iri(`${ACL}accessTo`, resource); + if (scope !== "resource") entry.iri(`${ACL}default`, resource); + if (rule.agent) entry.iri(`${ACL}agent`, rule.agent); + if (rule.public) entry.iri(`${ACL}agentClass`, `${FOAF}Agent`); + if (rule.authenticated) entry.iri(`${ACL}agentClass`, `${ACL}AuthenticatedAgent`); + if (rule.group) entry.iri(`${ACL}agentGroup`, rule.group); + if (rule.origin) entry.iri(`${ACL}origin`, rule.origin); + for (const mode of rule.allow ?? []) entry.iri(`${ACL}mode`, modeIri(mode)); + continue; + } + assert.ok(!rule.group && !rule.origin); + const access = node(`${id}-access`); + access.iri(`${RDF}type`, `${ACP}AccessControl`); + access.iri(`${ACP}apply`, id); + if (scope !== "members") root.iri(`${ACP}accessControl`, access.value); + if (scope !== "resource") root.iri(`${ACP}memberAccessControl`, access.value); + entry.iri(`${RDF}type`, `${ACP}Policy`); + const matcher = node(`${id}-matcher`); + matcher.iri(`${RDF}type`, `${ACP}Matcher`); + if (rule.agent) matcher.iri(`${ACP}agent`, rule.agent); + if (rule.public) matcher.iri(`${ACP}agent`, `${ACP}PublicAgent`); + if (rule.authenticated) matcher.iri(`${ACP}agent`, `${ACP}AuthenticatedAgent`); + if (rule.client) matcher.iri(`${ACP}client`, rule.client); + entry.iri(`${ACP}allOf`, matcher.value); + if (rule.exceptAgent) { + const except = node(`${id}-except`); + except.iri(`${ACP}agent`, rule.exceptAgent); + entry.iri(`${ACP}noneOf`, except.value); + } + for (const mode of rule.allow ?? []) entry.iri(`${ACP}allow`, modeIri(mode)); + for (const mode of rule.deny ?? []) entry.iri(`${ACP}deny`, modeIri(mode)); + if (rule.allow?.includes("control")) { + // CSS evaluates an acp:resource entry for the ACR itself. Also include + // acp:access so the fixture represents the corresponding ACR semantics. + const acr = node(`${id}-acr`); + acr.iri(`${ACP}resource`, acl); + acr.iri(`${ACP}accessControl`, access.value); + root.iri(`${ACP}access`, id); + } + } + return turtle(store); +} + +export function ownerRule(owner: string, container = false): FixtureRule { + return { + agent: owner, + allow: ["read", "write", "control"], + scope: container ? "both" : "resource", + }; +} + +export async function setAccess( + side: PodSide, + system: System, + resource: string, + rules: FixtureRule[], +): Promise { + const head = await side.http.head(resource); + assert.equal(head.status, 200, `Owner cannot discover controls on ${resource}`); + const acl = accessLink(head, resource); + const response = await side.http.put(acl, await fixtureAccess(system, resource, acl, rules), { + contentType: "text/turtle", + }); + assert.ok(response.ok, `Fixture ACL PUT ${acl} failed (${response.status})`); + await response.arrayBuffer(); + return acl; +} + +export async function putFixture( + side: PodSide, + path: string, + body: string | Uint8Array, + contentType = "text/turtle", +): Promise { + const response = await side.http.put(new URL(path, side.root).href, body, { contentType }); + assert.ok(response.ok, `Fixture PUT ${path} failed (${response.status})`); + await response.arrayBuffer(); +} + +export interface SourceFixture { + system: System; + account: CssAccount; + side: PodSide; + paths: string[]; + files: Map; + faithful: Set; + stranger: CssAccount; +} + +export async function seedSource( + account: CssAccount, + stranger: CssAccount, + system: System, + reader?: CssAccount, +): Promise { + if (system === "wac") { + assert.ok(reader, "WAC's unreadable ACL requires a delegated migration session"); + assert.notEqual(reader.webId, account.webId, "The delegated reader cannot be the pod owner"); + } + const root = `${account.podRoot}migration/`; + const side: PodSide = { + root, + ownerWebId: account.webId, + http: createPodHttp({ root, fetch: account.fetch, allowInsecureLoopback: true }), + }; + await putFixture(account.side, "migration/", ""); + const owners = (container: boolean): FixtureRule[] => [ + ownerRule(account.webId, container), + ...(reader ? [ownerRule(reader.webId, container)] : []), + ]; + await setAccess(side, system, root, [...owners(true), { public: true, allow: ["read"] }]); + const paths = ["", "public/", "public/nested/", "private/", "authenticated/"]; + for (const path of paths.slice(1)) await putFixture(side, path, ""); + const files = new Map(); + const encode = (value: string) => new TextEncoder().encode(value); + const rdfStore = new Store(); + const note = new FixtureNode("urn:migration:note", rdfStore, DataFactory); + note.iri(`${RDF}type`, "http://schema.org/Note"); + note.text("http://schema.org/text", "Migration café — 漢字 🌱\nsecond line"); + files.set("public/nested/note.ttl", encode(await turtle(rdfStore))); + files.set( + "private/data.json", + encode(`${JSON.stringify({ nested: { ok: true }, items: [1, "é", null] })}\n`), + ); + files.set( + "private/bytes.bin", + Uint8Array.from({ length: 1024 }, (_, i) => (i * 73) % 256), + ); + files.set( + "private/caf%C3%A9%20%E6%BC%A2%E5%AD%97.txt", + encode("spaces and Unicode: café 漢字 🌱\n"), + ); + files.set("shared.ttl", encode(await turtle(rdfStore))); + files.set("authenticated/info.json", encode('{"authenticated":true}\n')); + files.set("conflict.json", encode('{"version":"source"}\n')); + if (system === "wac") { + files.set("public/narrow.txt", encode("This child overrides the public parent default.\n")); + files.set("group-only.txt", encode("Only current vCard group members can read this.\n")); + files.set("origin-only.txt", encode("This grant carries an origin condition.\n")); + files.set("unreadable.txt", encode("Readable body, inaccessible ACL.\n")); + const groups = new Store(); + const group = new FixtureNode(`${root}group.ttl#team`, groups, DataFactory); + group.iri(`${RDF}type`, `${VCARD}Group`); + group.iri(`${VCARD}hasMember`, stranger.webId); + files.set("group.ttl", encode(await turtle(groups))); + } else { + files.set("deny.ttl", encode(await turtle(rdfStore))); + files.set("client.ttl", encode(await turtle(rdfStore))); + files.set("exclusion.ttl", encode(await turtle(rdfStore))); + } + for (const [path, bytes] of files) { + const type = path.endsWith(".ttl") + ? "text/turtle" + : path.endsWith(".json") + ? "application/json" + : path.endsWith(".bin") + ? "application/octet-stream" + : "text/plain"; + await putFixture(side, path, bytes, type); + paths.push(path); + } + const set = (path: string, rules: FixtureRule[]) => + setAccess(side, system, `${root}${path}`, [...owners(path.endsWith("/")), ...rules]); + await set("public/", [{ public: true, allow: ["read"], scope: "both" }]); + // Leave the nested folder and its Turtle file without their own controls: + // source evaluation must actually traverse ancestors, including ACP members. + await set("authenticated/", [{ authenticated: true, allow: ["read"], scope: "both" }]); + await set("shared.ttl", [{ agent: stranger.webId, allow: ["read", "append"] }]); + if (system === "wac") { + await set("public/narrow.txt", []); + await set("group.ttl", [{ public: true, allow: ["read"] }]); + await set("group-only.txt", [{ group: `${root}group.ttl#team`, allow: ["read"] }]); + await set("origin-only.txt", [ + { agent: stranger.webId, origin: "https://app.example.test", allow: ["read"] }, + ]); + await setAccess(side, system, `${root}unreadable.txt`, [ + { agent: account.webId, allow: ["read", "write"] }, + ...(reader ? [{ agent: reader.webId, allow: ["read", "write"] as Mode[] }] : []), + ]); + } else { + await set("deny.ttl", [ + { public: true, allow: ["read", "append"] }, + { agent: stranger.webId, deny: ["read"] }, + ]); + await set("client.ttl", [ + { agent: stranger.webId, client: stranger.clientId, allow: ["read"] }, + ]); + await set("exclusion.ttl", [ + { authenticated: true, exceptAgent: stranger.webId, allow: ["read"] }, + ]); + } + const faithful = new Set( + paths.filter( + (path) => + ![ + "unreadable.txt", + "origin-only.txt", + "group-only.txt", + "deny.ttl", + "client.ttl", + "exclusion.ttl", + "conflict.json", + ].includes(path), + ), + ); + // CSS's OwnerPermissionReader lets a pod owner recover its ACLs regardless of + // their contents. A delegated session is necessary for a genuine unreadable + // source ACL; it has Control everywhere else and body access on this file. + const migrationSide = reader + ? { + root, + ownerWebId: reader.webId, + http: createPodHttp({ root, fetch: reader.fetch, allowInsecureLoopback: true }), + } + : side; + return { system, account, stranger, side: migrationSide, paths, files, faithful }; +} diff --git a/apps/pod-migrator/test/css/helpers.test.ts b/apps/pod-migrator/test/css/helpers.test.ts new file mode 100644 index 000000000..5e53e8c08 --- /dev/null +++ b/apps/pod-migrator/test/css/helpers.test.ts @@ -0,0 +1,570 @@ +// AUTHORED-BY Codex GPT-6 +import { createHash, createPublicKey, verify } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { join } from "node:path"; +import { parseRdf } from "@jeswr/fetch-rdf"; +import { AccessControlResource, AclResource } from "@solid/object"; +import { DataFactory } from "n3"; +import { describe, expect, it, vi } from "vitest"; +import { createPodHttp } from "../../src/engine/index.js"; +import { accountRequest, createAccount, rememberCookies } from "./account.js"; +import { createProofKey, createSession, exchangeToken, localUrl, mintProof } from "./auth.js"; +import { cssComponentState } from "./component-state.mjs"; +import { fixtureAccess, ownerRule } from "./fixtures.js"; +import { + accessLink, + appendProbeBody, + assertSubset, + conditionalAccess, + modesFromHead, + observeWrites, + probeAccess, +} from "./probe.js"; +import { cssDirectory, memoryConfig } from "./server.js"; + +const base = "http://localhost:12345/"; +const endpoint = `${base}.oidc/token`; +const credentials = { id: "id: /é", secret: "secret:+ &" }; +function jwt(value: string) { + const parts = value.split("."); + return { + header: JSON.parse(Buffer.from(parts[0] as string, "base64url").toString()), + claims: JSON.parse(Buffer.from(parts[1] as string, "base64url").toString()), + signed: Buffer.from(parts.slice(0, 2).join(".")), + signature: Buffer.from(parts[2] as string, "base64url"), + }; +} +const tokenResponse = (value = "test-token") => + Response.json({ access_token: value, token_type: "DPoP", expires_in: 600 }); + +describe("offline CSS DPoP transport", () => { + it("signs verifiable ES256 proofs with public JWK, seconds, unique jti, htm/htu and ath", () => { + const key = createProofKey(); + const proof = jwt( + mintProof(key, "patch", `${base}resource?ignored=true#fragment`, { + now: 1_800_000_123_456, + token: "token", + nonce: "server-nonce", + }), + ); + expect(proof.header).toEqual({ typ: "dpop+jwt", alg: "ES256", jwk: key.jwk }); + expect(Object.keys(key.jwk).sort()).toEqual(["crv", "kty", "x", "y"]); + expect(proof.claims).toMatchObject({ + htm: "PATCH", + htu: `${base}resource`, + iat: 1_800_000_123, + nonce: "server-nonce", + ath: createHash("sha256").update("token").digest("base64url"), + }); + expect(proof.signature).toHaveLength(64); + expect( + verify( + "sha256", + proof.signed, + { key: createPublicKey({ key: key.jwk, format: "jwk" }), dsaEncoding: "ieee-p1363" }, + proof.signature, + ), + ).toBe(true); + expect( + verify( + "sha256", + Buffer.from("tampered"), + { key: createPublicKey({ key: key.jwk, format: "jwk" }), dsaEncoding: "ieee-p1363" }, + proof.signature, + ), + ).toBe(false); + const next = jwt(mintProof(key, "POST", endpoint)); + expect(next.claims.jti).not.toBe(proof.claims.jti); + expect(next.claims).not.toHaveProperty("ath"); + expect(next.claims).not.toHaveProperty("nonce"); + }); + + it("builds OAuth Basic credentials correctly and retries a token nonce with a fresh proof", async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce( + new Response("nonce", { status: 400, headers: { "dpop-nonce": "challenge" } }), + ) + .mockResolvedValueOnce(tokenResponse()); + const token = await exchangeToken(endpoint, credentials, createProofKey(), fetcher); + expect(token.token).toBe("test-token"); + expect(fetcher).toHaveBeenCalledTimes(2); + const requests = fetcher.mock.calls.map(([, init]) => ({ + headers: new Headers(init?.headers), + init, + })); + expect(requests[0]?.headers.get("authorization")).toBe( + `Basic ${Buffer.from(`${encodeURIComponent(credentials.id)}:${encodeURIComponent(credentials.secret)}`).toString("base64")}`, + ); + expect(String(requests[0]?.init?.body)).toBe("grant_type=client_credentials&scope=webid"); + const first = jwt(requests[0]?.headers.get("dpop") as string); + const retry = jwt(requests[1]?.headers.get("dpop") as string); + expect(first.claims).not.toHaveProperty("ath"); + expect(retry.claims).toMatchObject({ nonce: "challenge", htm: "POST", htu: endpoint }); + expect(retry.claims.jti).not.toBe(first.claims.jti); + expect(retry.header.jwk).toEqual(first.header.jwk); + expect(requests.every(({ init }) => init?.redirect === "manual")).toBe(true); + }); + + it("bounds nonce retries and rejects non-DPoP, malformed and failed exchanges", async () => { + for (const response of [ + new Response(null, { status: 500 }), + Response.json({ access_token: "x", token_type: "Bearer", expires_in: 60 }), + Response.json({ access_token: "", token_type: "DPoP", expires_in: 60 }), + Response.json({ access_token: "x", token_type: "DPoP", expires_in: -1 }), + ]) { + await expect( + exchangeToken( + endpoint, + credentials, + createProofKey(), + vi.fn().mockResolvedValue(response), + ), + ).rejects.toThrow(); + } + const fetcher = vi + .fn() + .mockImplementation( + async () => new Response(null, { status: 401, headers: { "dpop-nonce": "again" } }), + ); + await expect(exchangeToken(endpoint, credentials, createProofKey(), fetcher)).rejects.toThrow( + "401", + ); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it("binds resource proofs to token/key, preserves PUT bytes, caches nonces and retries once", async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce(tokenResponse()) + .mockResolvedValueOnce( + new Response(null, { status: 401, headers: { "dpop-nonce": "rs-nonce" } }), + ) + .mockResolvedValueOnce(new Response("ok")) + .mockResolvedValueOnce(new Response("ok")); + const session = await createSession(endpoint, credentials, [base], fetcher); + const bytes = new Uint8Array([0, 255, 1]); + await session(`${base}resource?version=1`, { method: "PUT", body: bytes }); + await session(`${base}other`); + const calls = fetcher.mock.calls.slice(1); + const proofs = calls.map(([, init]) => jwt(new Headers(init?.headers).get("dpop") as string)); + expect(proofs[0]?.claims).toMatchObject({ + htu: `${base}resource`, + htm: "PUT", + ath: createHash("sha256").update("test-token").digest("base64url"), + }); + expect(proofs[1]?.claims.nonce).toBe("rs-nonce"); + expect(proofs[2]?.claims).toMatchObject({ nonce: "rs-nonce", htu: `${base}other`, htm: "GET" }); + expect(new Set(proofs.map((proof) => proof.claims.jti)).size).toBe(3); + expect(calls[0]?.[1]?.body).toBe(bytes); + expect(calls[1]?.[1]?.body).toBe(bytes); + expect(proofs[0]?.header.jwk).toEqual( + jwt(new Headers(fetcher.mock.calls[0]?.[1]?.headers).get("dpop") as string).header.jwk, + ); + expect(calls.every(([, init]) => init?.redirect === "manual")).toBe(true); + }); + + it("fails closed on foreign URLs, caller credentials, Request objects and redirects", async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce(tokenResponse()) + .mockResolvedValueOnce( + new Response(null, { status: 302, headers: { location: "https://foreign.test/" } }), + ); + const session = await createSession(endpoint, credentials, [base], fetcher); + for (const url of [ + "https://foreign.test/", + "http://localhost:54321/", + "http://user@localhost:12345/", + ]) + await expect(session(url)).rejects.toThrow(); + await expect(session(new Request(`${base}x`))).rejects.toThrow("string URL"); + for (const header of ["authorization", "dpop"]) + await expect(session(`${base}x`, { headers: { [header]: "injected" } })).rejects.toThrow( + "Caller-supplied", + ); + expect(fetcher).toHaveBeenCalledTimes(1); + await expect(session(`${base}redirect`)).rejects.toThrow("redirects"); + expect(fetcher).toHaveBeenCalledTimes(2); + expect(() => localUrl("http://127.0.0.1:12345/")).toThrow("loopback"); + }); + + it("renews expiring tokens once for concurrent requests and hashes the renewed token", async () => { + const now = vi.spyOn(Date, "now").mockReturnValue(100_000); + try { + const fetcher = vi + .fn() + .mockResolvedValueOnce(tokenResponse("old")) + .mockResolvedValueOnce(tokenResponse("new")) + .mockImplementation(async () => new Response("ok")); + const session = await createSession(endpoint, credentials, [base], fetcher); + now.mockReturnValue(680_000); + await Promise.all([session(`${base}a`), session(`${base}b`)]); + expect(fetcher.mock.calls.filter(([url]) => url === endpoint)).toHaveLength(2); + for (const [, init] of fetcher.mock.calls.slice(2)) + expect(jwt(new Headers(init?.headers).get("dpop") as string).claims.ath).toBe( + createHash("sha256").update("new").digest("base64url"), + ); + } finally { + now.mockRestore(); + } + }); +}); + +describe("offline CSS JSON account API", () => { + it("builds explicit JSON requests, carries multiple cookies, and fences hypermedia controls", () => { + const cookies = new Map(); + const headers = new Headers(); + headers.append("set-cookie", "session=first; Path=/.account/; HttpOnly"); + headers.append("set-cookie", "other=a=b; Expires=Wed, 21 Oct 2030 07:28:00 GMT; Path=/"); + rememberCookies(headers, cookies); + rememberCookies(new Headers({ "set-cookie": "session=rotated; Path=/" }), cookies); + expect(cookies).toEqual( + new Map([ + ["session", "rotated"], + ["other", "a=b"], + ]), + ); + const post = accountRequest(base, "/.account/account/", cookies, {}); + expect(post.init.body).toBe("{}"); + expect(post.init.method).toBe("POST"); + expect(new Headers(post.init.headers).get("cookie")).toBe("session=rotated; other=a=b"); + expect(new Headers(post.init.headers).get("content-type")).toBe("application/json"); + const get = accountRequest(base, "/.account/", cookies); + expect(get.init.method).toBe("GET"); + expect(get.init.body).toBeUndefined(); + for (const endpoint of [ + "https://foreign.test/.account/", + "http://localhost:54321/.account/", + "/pod/private", + "/.account/../private", + ]) + expect(() => accountRequest(base, endpoint, cookies, {})).toThrow(); + }); + + it("drives registration → discovered controls → pod → credentials → discovered token endpoint", async () => { + const responses = [ + Response.json({}, { headers: { "set-cookie": "session=one; Path=/" } }), + Response.json({ + controls: { + password: { create: `${base}.account/custom/password/` }, + account: { + pod: `${base}.account/custom/pod/`, + clientCredentials: `${base}.account/custom/credentials/`, + }, + }, + }), + Response.json({}), + Response.json({ pod: `${base}returned-pod/`, webId: `${base}returned-pod/profile/card#me` }), + Response.json(credentials), + Response.json({ token_endpoint: `${base}custom-token` }), + tokenResponse(), + new Response("private"), + ]; + const fetcher = vi.fn().mockImplementation(async () => { + const response = responses.shift(); + if (!response) throw new Error("Unexpected HTTP request"); + return response; + }); + const account = await createAccount(base, [base], fetcher); + expect(account.podRoot).toBe(`${base}returned-pod/`); + expect(account.webId).toBe(`${base}returned-pod/profile/card#me`); + expect(fetcher.mock.calls.map(([url]) => url)).toEqual([ + `${base}.account/account/`, + `${base}.account/`, + `${base}.account/custom/password/`, + `${base}.account/custom/pod/`, + `${base}.account/custom/credentials/`, + `${base}.well-known/openid-configuration`, + `${base}custom-token`, + ]); + for (const [, init] of fetcher.mock.calls.slice(1, 5)) + expect(new Headers(init?.headers).get("cookie")).toBe("session=one"); + const body = JSON.parse(String(fetcher.mock.calls[4]?.[1]?.body)); + expect(body.webId).toBe(account.webId); + await account.side.http.get(`${account.podRoot}private`); + expect(new Headers(fetcher.mock.calls[7]?.[1]?.headers).get("authorization")).toBe( + "DPoP test-token", + ); + expect(responses).toHaveLength(0); + }); + + it("fails when registration is unavailable rather than skipping authentication", async () => { + await expect( + createAccount( + base, + [base], + vi.fn().mockResolvedValue(new Response(null, { status: 403 })), + ), + ).rejects.toThrow("403"); + const fetcher = vi + .fn() + .mockResolvedValueOnce(Response.json({})) + .mockResolvedValueOnce(Response.json({ controls: {} })); + await expect(createAccount(base, [base], fetcher)).rejects.toThrow("missing registration"); + expect(fetcher).toHaveBeenCalledTimes(2); + }); +}); + +describe("offline effective-access and write auditing", () => { + it("extracts current user modes, normalizes Write, and rejects malformed/error responses", () => { + expect( + modesFromHead( + new Response(null, { headers: { "wac-allow": 'user="read write",public="read control"' } }), + ), + ).toEqual(new Set(["read", "write", "append"])); + expect(modesFromHead(new Response(null, { status: 403 }))).toBeUndefined(); + expect(modesFromHead(new Response())).toBeUndefined(); + for (const status of [404, 302, 500]) + expect(() => modesFromHead(new Response(null, { status }))).toThrow(); + expect(() => + modesFromHead(new Response(null, { headers: { "wac-allow": 'user="unterminated' } })), + ).toThrow("Malformed"); + expect(() => + modesFromHead(new Response(null, { headers: { "wac-allow": 'user="",public="read"' } })), + ).toThrow("advertise Read"); + expect(conditionalAccess(new Response(null, { status: 412 }))).toBe(true); + expect(conditionalAccess(new Response(null, { status: 409 }), true)).toBe(true); + for (const status of [401, 403]) + expect(conditionalAccess(new Response(null, { status }))).toBe(false); + for (const status of [200, 204, 404, 409, 500]) + expect(() => conditionalAccess(new Response(null, { status }))).toThrow(); + }); + + it("discovers ACL links with context and refuses ambiguity", () => { + const response = new Response(null, { + headers: { link: '; rel="acl"; anchor="/other", ; rel="acl"' }, + }); + expect(accessLink(response, `${base}file`)).toBe(`${base}file.acr`); + expect(() => accessLink(new Response(), `${base}file`)).toThrow("one ACL"); + expect(() => + accessLink( + new Response(null, { headers: { link: '; rel="acl", ; rel="acl"' } }), + `${base}file`, + ), + ).toThrow("one ACL"); + }); + + it("detects append/write/control even when Read is denied, without performing a mutation", async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce(new Response(null, { status: 403 })) + .mockResolvedValueOnce(new Response(null, { status: 403 })) + .mockResolvedValueOnce(new Response(null, { status: 412 })) + .mockResolvedValueOnce(new Response(null, { status: 403 })); + const patch = await appendProbeBody(); + expect(patch).toContain("INSERT DATA"); + expect(patch).toContain("urn:css-test:value"); + const modes = await probeAccess(fetcher, `${base}file`, `${base}file.acl`, patch); + expect(modes).toEqual(new Set(["append"])); + expect(() => assertSubset(new Set(), modes, "between writes")).toThrow("widened append"); + expect(fetcher.mock.calls.map(([, init]) => init?.method)).toEqual([ + "HEAD", + "PUT", + "PATCH", + "PUT", + ]); + for (const [, init] of fetcher.mock.calls.slice(1)) + expect(new Headers(init?.headers).get("if-match")).toMatch(/^"css-test-never-match-/); + expect(fetcher.mock.calls[2]?.[1]?.body).toBe(patch); + }); + + it("falls back to operations for ACP without headers and fails on unexpected probe errors", async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce(new Response()) + .mockResolvedValueOnce(new Response(null, { status: 409 })) + .mockResolvedValueOnce(new Response(null, { status: 412 })) + .mockResolvedValueOnce(new Response(null, { status: 403 })); + expect( + await probeAccess( + fetcher, + `${base}container/`, + `${base}container/.acr`, + await appendProbeBody(), + ), + ).toEqual(new Set(["read", "write", "append"])); + const failure = vi + .fn() + .mockResolvedValueOnce(new Response(null, { status: 403 })) + .mockResolvedValueOnce(new Response(null, { status: 500 })); + await expect(probeAccess(failure, `${base}file`, `${base}file.acl`, "patch")).rejects.toThrow( + "500", + ); + const header = vi + .fn() + .mockResolvedValue( + new Response(null, { headers: { "wac-allow": 'user="read append",public="read"' } }), + ); + expect(await probeAccess(header, `${base}file`, `${base}file.acl`, "patch")).toEqual( + new Set(["read", "append"]), + ); + expect(header).toHaveBeenCalledTimes(1); + }); + + it("awaits every write observer, including placeholders and ACL writes, and propagates audit failures", async () => { + const fetcher = vi + .fn() + .mockImplementation(async () => new Response(null, { status: 201 })); + const http = createPodHttp({ root: base, fetch: fetcher, allowInsecureLoopback: true }); + let release: (() => void) | undefined; + const observer = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve; + }), + ) + .mockRejectedValueOnce(new Error("widened")); + const audited = observeWrites(http, observer); + let done = false; + const write = audited.http.put(`${base}file`, "", { contentType: "text/plain" }).then(() => { + done = true; + }); + await vi.waitFor(() => expect(observer).toHaveBeenCalledTimes(1)); + expect(done).toBe(false); + release?.(); + await write; + expect(done).toBe(true); + await expect( + audited.http.put(`${base}file.acl`, "", { contentType: "text/turtle" }), + ).rejects.toThrow("widened"); + await audited.http.delete(`${base}file`); + expect(audited.writes.map((event) => [event.method, event.current, event.status])).toEqual([ + ["PUT", `${base}file`, 201], + ["PUT", `${base}file.acl`, 201], + ["DELETE", `${base}file`, 201], + ]); + expect(audited.writes.map((event) => event.done)).toEqual([1, 2, 3]); + assertSubset(new Set(["read", "append"]), new Set(["read"]), "safe"); + }); +}); + +describe("shipped CSS configuration and independent seed documents", () => { + it("isolates CSS 7.2 component discovery from other workspace versions and remote contexts", async () => { + const state = await cssComponentState(cssDirectory); + expect(Object.values(state.componentModules)).toEqual([ + join(cssDirectory, "dist/components/components.jsonld"), + ]); + expect(Object.keys(state.contexts).sort()).toEqual([ + "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^7.0.0/components/context.jsonld", + "https://linkedsoftwaredependencies.org/bundles/npm/componentsjs/^5.0.0/components/context.jsonld", + ]); + expect(state.mainModulePath).toBe(cssDirectory); + expect(state.nodeModuleImportPaths).toEqual([cssDirectory]); + expect(state.nodeModulePaths).toHaveLength(2); + }); + + it("CSS grants ACL recovery to the pod owner, so an unreadable fixture needs a delegate", async () => { + const requireCss = createRequire(join(cssDirectory, "package.json")); + const { OwnerPermissionReader } = requireCss("./dist/authorization/OwnerPermissionReader.js"); + const root = `${base}pod/`; + const acl = { path: `${root}file.acl` }; + const reader = new OwnerPermissionReader( + { + findByBaseUrl: async () => ({ id: "pod" }), + getOwners: async () => [{ webId: "urn:owner" }], + }, + { isAuxiliaryIdentifier: (value: { path: string }) => value.path.endsWith(".acl") }, + { getStorageIdentifier: async () => ({ path: root }) }, + ); + const requestedModes = { distinctKeys: () => [acl] }; + const owner = await reader.handle({ + requestedModes, + credentials: { agent: { webId: "urn:owner" } }, + }); + const delegated = await reader.handle({ + requestedModes, + credentials: { agent: { webId: "urn:delegate" } }, + }); + expect(owner.get(acl)).toMatchObject({ read: true, write: true, control: true }); + expect(delegated.get(acl)).toBeUndefined(); + }); + + it("composes memory configs from installed CSS with account APIs and matching authorization/auxiliaries", async () => { + for (const [file, authorization, auxiliary] of [ + ["file.json", "webacl", "acl"], + ["file-acp.json", "acp", "acr"], + ]) { + const shipped = JSON.parse( + await readFile(join(cssDirectory, "config", file as string), "utf8"), + ); + const config = memoryConfig(shipped); + for (const path of [ + `ldp/authorization/${authorization}.json`, + `util/auxiliary/${auxiliary}.json`, + "storage/backend/memory.json", + "util/resource-locker/memory.json", + "storage/location/pod.json", + "identity/handler/default.json", + "identity/access/public.json", + "identity/pod/static.json", + ]) + expect(config.import).toContain(`css:config/${path}`); + for (const entry of config.import) + expect(await readFile(join(cssDirectory, entry.replace("css:", "")), "utf8")).toBeTruthy(); + expect(config.import).not.toContain("css:config/storage/backend/file.json"); + } + }); + + it("seeds WAC through typed accessors with separate resource/default scope and origin/group conditions", async () => { + const root = `${base}pod/`; + const text = await fixtureAccess("wac", root, `${root}.acl`, [ + ownerRule("urn:owner", true), + { group: "urn:group", allow: ["read"] }, + { agent: "urn:stranger", origin: "https://app.test", allow: ["read", "append"] }, + ]); + const graph = await parseRdf(text, "text/turtle", { baseIRI: root }); + const acl = new AclResource(graph, DataFactory); + const rules = [...acl.authorizations]; + expect(rules).toHaveLength(3); + expect(text).toContain("http://www.w3.org/ns/auth/acl#default"); + expect(text).toContain("urn:group"); + expect(text).toContain("https://app.test"); + }); + + it("CSS's own ACP reader evaluates seeded deny/client/exclusion/member controls", async () => { + const root = `${base}pod/`; + const acl = `${root}.acr`; + const text = await fixtureAccess("acp", root, acl, [ + ownerRule("urn:owner", true), + { public: true, allow: ["read", "append"] }, + { agent: "urn:stranger", deny: ["read"] }, + { agent: "urn:stranger", client: "urn:client", allow: ["write"] }, + { authenticated: true, exceptAgent: "urn:stranger", allow: ["control"] }, + ]); + const graph = await parseRdf(text, "text/turtle", { baseIRI: root }); + const typed = new AccessControlResource(acl, graph, DataFactory); + expect([...typed.memberAccessControl]).toHaveLength(1); + const requireCss = createRequire(join(cssDirectory, "package.json")); + const { getAccessControlledResources } = requireCss("./dist/authorization/AcpUtil.js"); + const { allowAccessModes } = requireCss("@solid/access-control-policy"); + const resources = [...getAccessControlledResources(graph)]; + const policies = resources + .filter((entry) => entry.iri === root) + .flatMap((entry) => + entry.accessControlResource.accessControl.flatMap( + (control: { policy: unknown[] }) => control.policy, + ), + ); + const read = "http://www.w3.org/ns/auth/acl#Read"; + const write = "http://www.w3.org/ns/auth/acl#Write"; + expect( + allowAccessModes(policies, { target: root, agent: "urn:stranger", client: "urn:client" }).has( + write, + ), + ).toBe(true); + expect( + allowAccessModes(policies, { target: root, agent: "urn:stranger", client: "urn:other" }).has( + write, + ), + ).toBe(false); + expect(allowAccessModes(policies, { target: root, agent: "urn:stranger" }).has(read)).toBe( + false, + ); + expect(allowAccessModes(policies, { target: root }).has(read)).toBe(true); + expect(allowAccessModes(policies, { target: root, agent: "urn:owner" }).has(write)).toBe(true); + }); +}); diff --git a/apps/pod-migrator/test/css/migration.integration.ts b/apps/pod-migrator/test/css/migration.integration.ts new file mode 100644 index 000000000..5e5400c7a --- /dev/null +++ b/apps/pod-migrator/test/css/migration.integration.ts @@ -0,0 +1,518 @@ +// AUTHORED-BY Codex GPT-6 +import assert from "node:assert/strict"; +import { parseRdf } from "@jeswr/fetch-rdf"; +import { Container } from "@solid/object"; +import { DataFactory } from "n3"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + executeMigration, + finalizePlan, + MigrationAborted, + type MigrationPlan, + type Mode, + type OptionId, + type Progress, + planMigration, + type ReportKind, +} from "../../src/engine/index.js"; +import { type CssAccount, createAccount } from "./account.js"; +import { boundedFetch } from "./auth.js"; +import { ownerRule, putFixture, type SourceFixture, seedSource, setAccess } from "./fixtures.js"; +import { accessLink, appendProbeBody, assertSubset, observeWrites, probeAccess } from "./probe.js"; +import { type CssServer, type System, startServer } from "./server.js"; + +interface Scenario { + name: string; + from: System; + to: System; + group?: "skip" | "expand"; + conflict?: "skip" | "overwrite"; + root?: "skip" | "merge"; + unreadable?: "skip" | "owner-only"; +} +const scenarios: Scenario[] = [ + { + name: "WAC → ACP / omit unsupported grants", + from: "wac", + to: "acp", + group: "skip", + conflict: "skip", + root: "skip", + unreadable: "skip", + }, + { + name: "WAC → ACP / expand group, overwrite, merge, owner-only", + from: "wac", + to: "acp", + group: "expand", + conflict: "overwrite", + root: "merge", + unreadable: "owner-only", + }, + { + name: "ACP → WAC / deny, exclusion and client narrowing", + from: "acp", + to: "wac", + conflict: "overwrite", + root: "merge", + }, + { + name: "WAC → WAC / preserve groups and child override", + from: "wac", + to: "wac", + conflict: "overwrite", + root: "merge", + unreadable: "owner-only", + }, + { + name: "ACP → ACP / preserve denies, clients and member controls", + from: "acp", + to: "acp", + conflict: "overwrite", + root: "merge", + }, +]; + +const narrowingKinds = new Set([ + "group-unsupported", + "origin-unsupported", + "condition-unsupported", + "exclusion-unsupported", + "deny-unsupported", + "acl-unreadable", + "acl-system-unknown", + "too-complex", +]); +const allOptions: OptionId[] = [ + "skip", + "expand", + "owner-only", + "per-resource", + "overwrite", + "merge", + "drop-overlapping", + "abort", + "acknowledge", +]; + +describe.sequential("real CSS 7.2 migration security", () => { + const servers = new Map(); + const sources = new Map(); + const strangers = new Map(); + const covered = new Set(); + const sourceAccess = new Map[]>>(); + let patch = ""; + + beforeAll(async () => { + // One instance per authorization system for the entire suite. + for (const system of ["wac", "acp"] as const) servers.set(system, await startServer(system)); + const origins = [...servers.values()].map((server) => server.base); + patch = await appendProbeBody(); + for (const system of ["wac", "acp"] as const) { + const server = servers.get(system) as CssServer; + strangers.set(system, await createAccount(server.base, origins)); + } + for (const system of ["wac", "acp"] as const) { + const server = servers.get(system) as CssServer; + const account = await createAccount(server.base, origins); + const reader = system === "wac" ? await createAccount(server.base, origins) : undefined; + const fixture = await seedSource( + account, + strangers.get(system) as CssAccount, + system, + reader, + ); + sources.set(system, fixture); + const baseline = new Map[]>(); + for (const path of fixture.paths) { + const url = fixture.side.root + path; + const head = await fixture.side.http.head(url); + assert.equal(head.status, 200); + const acl = accessLink(head, url); + baseline.set( + path, + await Promise.all(actors().map((actor) => probeAccess(actor, url, acl, patch))), + ); + } + sourceAccess.set(system, baseline); + const namedIndex = system === "wac" ? 1 : 2; + assert.deepEqual(baseline.get("shared.ttl")?.[namedIndex], new Set(["read", "append"])); + assert.deepEqual(baseline.get("private/bytes.bin")?.[0], new Set()); + assert.deepEqual(baseline.get("public/nested/note.ttl")?.[0], new Set(["read"])); + assert.deepEqual(baseline.get("authenticated/info.json")?.[0], new Set()); + assert.ok(baseline.get("authenticated/info.json")?.[1]?.has("read")); + assert.ok(baseline.get("authenticated/info.json")?.[2]?.has("read")); + if (system === "wac") { + assert.deepEqual(baseline.get("public/narrow.txt")?.[0], new Set()); + assert.deepEqual(baseline.get("group-only.txt")?.[namedIndex], new Set(["read"])); + const unreadableUrl = `${fixture.side.root}unreadable.txt`; + const unreadableAcl = accessLink( + await fixture.side.http.head(unreadableUrl), + unreadableUrl, + ); + assert.equal((await fixture.side.http.get(unreadableAcl)).status, 403); + } else { + assert.deepEqual(baseline.get("deny.ttl")?.[namedIndex], new Set(["append"])); + assert.deepEqual(baseline.get("client.ttl")?.[namedIndex], new Set(["read"])); + assert.deepEqual(baseline.get("exclusion.ttl")?.[namedIndex], new Set()); + } + console.log( + `[SOURCE ${system.toUpperCase()}] ${fixture.paths.length} resources; private, public, authenticated and named grants proved`, + ); + } + }); + + afterAll(async () => { + const stopped = await Promise.allSettled([...servers.values()].map((server) => server.stop())); + for (const result of stopped) if (result.status === "rejected") throw result.reason; + }); + + function actors(): (typeof fetch)[] { + return [ + boundedFetch, + (strangers.get("wac") as CssAccount).fetch, + (strangers.get("acp") as CssAccount).fetch, + ]; + } + + async function target(system: System) { + const account = await createAccount((servers.get(system) as CssServer).base); + // CSS's stock root has public Read. Lock it before review so creation is + // safe even before a new child's own access document can be installed. + await setAccess(account.side, system, account.podRoot, [ownerRule(account.webId, true)]); + await putFixture(account.side, "conflict.json", '{"version":"target"}\n', "application/json"); + await setAccess(account.side, system, `${account.podRoot}conflict.json`, [ + ownerRule(account.webId), + ]); + return account; + } + + function resolutions(plan: MigrationPlan, scenario: Scenario): Record { + const choices: Record = {}; + for (const item of plan.report) { + const selected = + item.kind === "group-unsupported" + ? (scenario.group ?? "skip") + : item.kind === "target-exists" + ? (scenario.conflict ?? "skip") + : item.kind === "protected-target" + ? (scenario.root ?? "skip") + : item.kind === "acl-unreadable" + ? (scenario.unreadable ?? "owner-only") + : item.recommended; + assert.ok( + item.options.some((option) => option.id === selected), + `${item.kind} must offer ${selected}`, + ); + choices[item.id] = selected; + } + return choices; + } + + for (const scenario of scenarios) + it(scenario.name, async () => { + console.log(`[SCENARIO] ${scenario.name}`); + const source = sources.get(scenario.from) as SourceFixture; + const destination = await target(scenario.to); + const baseline = sourceAccess.get(scenario.from) as Map[]>; + let auditEnabled = false; + let comparisons = 0; + let auditedWrites = 0; + let auditFailure: unknown; + const progressEvents: Progress[] = []; + const aclUrls = new Map(); + const existing = new Set([ + destination.podRoot, + `${destination.podRoot}conflict.json`, + ]); + const onProgress = async () => { + if (!auditEnabled) return; + auditedWrites++; + try { + // Check EVERY existing migrated resource after EVERY write. Parent + // changes can widen descendants other than the resource just written. + for (const path of source.paths) { + const url = destination.podRoot + path; + if (!existing.has(url)) continue; + let acl = aclUrls.get(url); + if (!acl) { + const head = await destination.side.http.head(url); + assert.equal(head.status, 200); + acl = accessLink(head, url); + aclUrls.set(url, acl); + } + const access = await Promise.all( + actors().map((actor) => probeAccess(actor, url, acl, patch)), + ); + for (const [index, modes] of access.entries()) { + assertSubset( + baseline.get(path)?.[index] as Set, + modes, + `${scenario.name} intermediate ${path} actor ${index}`, + ); + comparisons++; + } + } + } catch (error) { + auditFailure = error; + throw error; + } + }; + const observed = observeWrites(destination.side.http, async (event) => { + if (event.status >= 200 && event.status < 300 && event.current) existing.add(event.current); + await onProgress(); + }); + const targetSide = { ...destination.side, http: observed.http }; + const options = { + source: source.side, + target: targetSide, + onProgress: (event: Progress) => progressEvents.push(event), + }; + const plan = await planMigration(options); + expect(plan.sourceSystem).toBe(scenario.from); + expect(plan.targetSystem).toBe(scenario.to); + // A crawl that silently misses data cannot make the permission checks pass. + expect(new Set(plan.resources.map((entry) => entry.sourceUrl))).toEqual( + new Set(source.paths.map((path) => source.side.root + path)), + ); + const kinds = new Set(plan.report.map((item) => item.kind)); + expect(kinds.has("protected-target")).toBe(true); + expect(kinds.has("target-exists")).toBe(true); + if (scenario.from === "wac") expect(kinds.has("acl-unreadable")).toBe(true); + if (scenario.from === "wac" && scenario.to === "acp") + for (const kind of [ + "group-unsupported", + "origin-unsupported", + "inheritance-narrowed", + ] as const) + expect(kinds.has(kind)).toBe(true); + if (scenario.from === "acp" && scenario.to === "wac") + for (const kind of [ + "deny-unsupported", + "condition-unsupported", + "exclusion-unsupported", + ] as const) + expect(kinds.has(kind)).toBe(true); + const choices = resolutions(plan, scenario); + const executable = finalizePlan(plan, choices); + expect(observed.writes).toHaveLength(0); + for (const choice of Object.values(choices)) covered.add(choice); + console.log( + `[DECISIONS] ${JSON.stringify(plan.report.map((item) => ({ kind: item.kind, choice: choices[item.id] })))}`, + ); + auditEnabled = true; + const result = await executeMigration(executable, options); + if (auditFailure) throw auditFailure; + expect(result.failed, JSON.stringify(result.log)).toEqual([]); + expect(result.verified).toBe(true); + expect(result.cancelled).toBe(false); + expect(observed.writes.length).toBeGreaterThan(source.files.size * 2); + expect(auditedWrites).toBe(observed.writes.length); + expect(comparisons).toBeGreaterThan(observed.writes.length); + for (const phase of ["lock", "copy", "grant", "verify"] as const) + expect(progressEvents.some((event) => event.phase === phase)).toBe(true); + + const intentionallySkipped = new Set(); + if (scenario.conflict === "skip") intentionallySkipped.add("conflict.json"); + if (scenario.unreadable === "skip") intentionallySkipped.add("unreadable.txt"); + expect(new Set(result.copied)).toEqual( + new Set( + [...source.files.keys()] + .filter((path) => !intentionallySkipped.has(path)) + .map((path) => destination.podRoot + path), + ), + ); + for (const path of intentionallySkipped) { + const response = await destination.side.http.get(destination.podRoot + path); + if (path === "conflict.json") expect(await response.text()).toBe('{"version":"target"}\n'); + else expect(response.status).toBe(404); + } + for (const path of source.paths) { + if (intentionallySkipped.has(path)) continue; + const url = destination.podRoot + path; + const head = await destination.side.http.head(url); + expect(head.status).toBe(200); + const acl = accessLink(head, url); + const ownerModes = await probeAccess(destination.fetch, url, acl, patch); + for (const mode of ["read", "write", "append", "control"] as const) + expect(ownerModes.has(mode), `owner retains ${mode} on ${path}`).toBe(true); + // Actually read the ACL and conditionally attempt its replacement, proving + // ACR/ACL management independently of the resource's advertised Control. + const aclResponse = await destination.fetch(acl, { headers: { accept: "text/turtle" } }); + expect(aclResponse.status).toBe(200); + await aclResponse.arrayBuffer(); + const control = await destination.fetch(acl, { + method: "PUT", + headers: { + "if-match": '"css-test-owner-control-never-match"', + "content-type": "text/turtle", + }, + body: "", + }); + expect(control.status).toBe(412); + await control.arrayBuffer(); + const data = await destination.fetch(url); + expect(data.status).toBe(200); + const bytes = new Uint8Array(await data.arrayBuffer()); + if (source.files.has(path)) expect(bytes, `${path} bytes`).toEqual(source.files.get(path)); + else { + const dataset = await parseRdf( + new TextDecoder().decode(bytes), + data.headers.get("content-type"), + { baseIRI: url }, + ); + const children = new Set( + [...new Container(url, dataset, DataFactory).contains].map((child) => child.id), + ); + for (const childPath of source.paths.filter( + (child) => + child !== path && + new URL( + ".", + child.endsWith("/") + ? (source.side.root + child).slice(0, -1) + : source.side.root + child, + ).href === + source.side.root + path, + )) + if (!intentionallySkipped.has(childPath)) + expect(children.has(destination.podRoot + childPath)).toBe(true); + } + const lossy = plan.report.some( + (item) => + item.resourceUrl === source.side.root + path && + narrowingKinds.has(item.kind) && + !(item.kind === "group-unsupported" && choices[item.id] === "expand"), + ); + const faithful = source.faithful.has(path) || (!lossy && path !== "conflict.json"); + for (const [index, actor] of actors().entries()) { + const modes = await probeAccess(actor, url, acl, patch); + const original = baseline.get(path)?.[index] as Set; + assertSubset(original, modes, `${scenario.name} final ${path} actor ${index}`); + if (faithful && !(path === "" && scenario.root === "skip")) + expect(modes, `faithful ${path} actor ${index}`).toEqual(original); + const read = await actor(url, { redirect: "manual" }); + expect( + modes.has("read") ? read.status === 200 : [401, 403].includes(read.status), + `GET agrees with effective Read on ${path} actor ${index}`, + ).toBe(true); + await read.arrayBuffer(); + } + } + const named = scenario.from === "wac" ? 1 : 2; + const shared = `${destination.podRoot}shared.ttl`; + const grants = await probeAccess( + actors()[named] as typeof fetch, + shared, + accessLink(await destination.side.http.head(shared), shared), + patch, + ); + expect(grants).toEqual(new Set(["read", "append"])); + // Also exercise a successful append, rather than relying only on a 412 + // authorization probe. Restore the bytes as owner after this positive test. + const stranger = actors()[named] as typeof fetch; + const append = await stranger(shared, { + method: "PATCH", + headers: { "content-type": "application/sparql-update" }, + body: patch, + }); + expect(append.ok, "The stranger's translated Append grant must permit a real PATCH").toBe( + true, + ); + await append.arrayBuffer(); + const changed = await destination.fetch(shared); + const etag = changed.headers.get("etag"); + const graph = await parseRdf(await changed.text(), changed.headers.get("content-type"), { + baseIRI: shared, + }); + expect( + graph.match( + DataFactory.namedNode("urn:css-test:probe"), + DataFactory.namedNode("urn:css-test:predicate"), + DataFactory.namedNode("urn:css-test:value"), + ).size, + ).toBe(1); + assert.ok(etag, "Restoring the append test requires the current content ETag"); + const restore = await destination.fetch(shared, { + method: "PUT", + headers: { "content-type": "text/turtle", "if-match": etag }, + body: new Uint8Array(source.files.get("shared.ttl") as Uint8Array), + }); + expect(restore.ok).toBe(true); + await restore.arrayBuffer(); + const restored = await destination.fetch(shared); + expect(new Uint8Array(await restored.arrayBuffer())).toEqual(source.files.get("shared.ttl")); + const sharedAcl = accessLink(await destination.side.http.head(shared), shared); + const forbiddenControl = await stranger(sharedAcl, { + method: "PUT", + headers: { "content-type": "text/turtle", "if-match": '"css-test-stranger-no-control"' }, + body: "", + }); + expect(forbiddenControl.status, "Append must not permit ACL/ACR edits").toBe(403); + await forbiddenControl.arrayBuffer(); + // All original bytes still match after the non-mutating permission probes. + for (const [path, bytes] of source.files) { + const response = await source.side.http.get(source.side.root + path); + expect( + new Uint8Array(await response.arrayBuffer()), + `probe changed source ${path}`, + ).toEqual(bytes); + } + console.log( + `[PASS] ${scenario.name}: ${result.copied.length} files; ${observed.writes.length} writes; ${comparisons} intermediate permission comparisons`, + ); + }); + + for (const [from, to] of [ + ["wac", "acp"], + ["acp", "wac"], + ] as const) + it(`${from.toUpperCase()} → ${to.toUpperCase()} / abort every offered decision without writes`, async () => { + const fixture = sources.get(from) as SourceFixture; + const destination = await target(to); + const observed = observeWrites(destination.side.http, async () => {}); + const targetSide = { ...destination.side, http: observed.http }; + const snapshot = async () => { + const urls = [ + destination.podRoot, + accessLink(await destination.side.http.head(destination.podRoot), destination.podRoot), + `${destination.podRoot}conflict.json`, + destination.webId.split("#")[0] as string, + ]; + return Promise.all( + urls.map(async (url) => { + const response = await destination.side.http.get(url); + return { + url, + status: response.status, + etag: response.headers.get("etag"), + bytes: [...new Uint8Array(await response.arrayBuffer())], + }; + }), + ); + }; + const before = await snapshot(); + const plan = await planMigration({ source: fixture.side, target: targetSide }); + const defaults = resolutions(plan, { name: "abort", from, to }); + const abortable = plan.report.filter((item) => + item.options.some((option) => option.id === "abort"), + ); + expect(abortable.length).toBeGreaterThan(3); + for (const item of abortable) + expect(() => finalizePlan(plan, { ...defaults, [item.id]: "abort" })).toThrow( + MigrationAborted, + ); + covered.add("abort"); + expect(observed.writes).toHaveLength(0); + expect(await snapshot()).toEqual(before); + for (const path of fixture.paths.filter((path) => path && path !== "conflict.json")) + expect((await destination.side.http.head(destination.podRoot + path)).status).toBe(404); + console.log( + `[PASS] ${from.toUpperCase()} → ${to.toUpperCase()} abort: ${abortable.length} decisions; zero writes; target snapshot unchanged`, + ); + }); + + it("exercises every decision option type", () => { + expect([...covered].sort()).toEqual([...allOptions].sort()); + }); +}); diff --git a/apps/pod-migrator/test/css/probe.ts b/apps/pod-migrator/test/css/probe.ts new file mode 100644 index 000000000..db7a3a1ad --- /dev/null +++ b/apps/pod-migrator/test/css/probe.ts @@ -0,0 +1,135 @@ +// AUTHORED-BY Codex GPT-6 +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { parseLinkHeaders, parseWacAllow } from "@jeswr/storage-capabilities"; +import { NamedNodeAs, NamedNodeFrom, SetFrom, TermWrapper } from "@rdfjs/wrapper"; +import { DataFactory, Store, Writer } from "n3"; +import type { Mode, PodHttp, Progress } from "../../src/engine/index.js"; + +export function accessLink(response: Response, resource: string): string { + const links = parseLinkHeaders(response.headers.get("link"), resource).filter( + (link) => link.context === resource && link.rel.includes("acl"), + ); + assert.equal(links.length, 1, `Expected one ACL link on ${resource}`); + return (links[0] as (typeof links)[number]).target; +} + +export function modesFromHead(response: Response): Set | undefined { + if ([401, 403].includes(response.status)) return undefined; + assert.equal(response.status, 200, "Access probe must not treat HTTP errors as denials"); + const header = response.headers.get("wac-allow"); + // The shipped ACP config omits WacAllowMetadataWriter. In that case use + // conditional operation probes, rather than inventing empty permissions. + if (!header) return undefined; + const parsed = parseWacAllow(header); + assert.ok(parsed, "Malformed WAC-Allow cannot establish permission safety"); + const modes = new Set(parsed.user); + assert.ok(modes.has("read"), "Successful HEAD must advertise Read"); + if (modes.has("write")) modes.add("append"); + return modes; +} + +/** Generate a non-empty insert through RDF accessors + Writer, never a no-op PATCH. */ +export async function appendProbeBody(): Promise { + const store = new Store(); + const term = new TermWrapper("urn:css-test:probe", store, DataFactory); + SetFrom.subjectPredicate( + term, + "urn:css-test:predicate", + NamedNodeAs.string, + NamedNodeFrom.string, + ).add("urn:css-test:value"); + const writer = new Writer({ format: "N-Triples" }); + writer.addQuads([...store]); + const triples = await new Promise((resolve, reject) => + writer.end((error, value) => (error ? reject(error) : resolve(value))), + ); + return `INSERT DATA {\n${triples}}`; +} + +export function conditionalAccess(response: Response, existingContainerPut = false): boolean { + // CSS authorizes before evaluating If-Match. A random, non-matching ETag + // proves authorization with 412 while ensuring the request cannot mutate data. + if (response.status === 412) return true; + // CSS rejects PUT on existing containers after authorization, before If-Match. + // This contextual 409 proves Write and cannot change that container. + if (existingContainerPut && response.status === 409) return true; + if ([401, 403].includes(response.status)) return false; + throw new Error(`Conditional permission probe returned unexpected HTTP ${response.status}`); +} + +export async function probeAccess( + fetcher: typeof fetch, + resource: string, + acl: string, + patch: string, +): Promise> { + const response = await fetcher(resource, { method: "HEAD", redirect: "manual" }); + const advertised = modesFromHead(response); + if (advertised) return advertised; + const modes = new Set(response.status === 200 ? ["read"] : []); + // Do not assume a denied read also denies append/write/control. These probes + // catch a transient write-only grant, which GET-only auditing would miss. + for (const [mode, url, method] of [ + ["write", resource, "PUT"], + ["append", resource, "PATCH"], + ["control", acl, "PUT"], + ] as const) { + const check = await fetcher(url, { + method, + redirect: "manual", + headers: { + "if-match": `"css-test-never-match-${randomUUID()}"`, + "content-type": method === "PATCH" ? "application/sparql-update" : "text/turtle", + }, + body: method === "PATCH" ? patch : "", + }); + const allowed = conditionalAccess( + check, + method === "PUT" && url === resource && resource.endsWith("/"), + ); + await check.arrayBuffer(); + if (allowed) modes.add(mode); + } + if (modes.has("write")) modes.add("append"); + return modes; +} + +export function assertSubset(source: ReadonlySet, target: ReadonlySet, label: string) { + for (const mode of target) assert.ok(source.has(mode), `${label}: target widened ${mode}`); +} + +export interface WriteProgress extends Progress { + method: "PUT" | "DELETE"; + status: number; +} + +/** The engine's void progress hook cannot await HTTP probes, so fence every + * transport write with the same observer before the executor can continue. */ +export function observeWrites( + http: PodHttp, + onProgress: (progress: WriteProgress) => Promise, +): { http: PodHttp; writes: WriteProgress[] } { + const writes: WriteProgress[] = []; + async function observe(url: string, method: "PUT" | "DELETE", response: Response) { + const progress: WriteProgress = { + phase: "copy", + current: url, + done: writes.length + 1, + total: writes.length + 1, + method, + status: response.status, + }; + writes.push(progress); + await onProgress(progress); + return response; + } + return { + writes, + http: { + ...http, + put: async (url, body, init) => observe(url, "PUT", await http.put(url, body, init)), + delete: async (url, init) => observe(url, "DELETE", await http.delete(url, init)), + }, + }; +} diff --git a/apps/pod-migrator/test/css/run.mjs b/apps/pod-migrator/test/css/run.mjs new file mode 100644 index 000000000..a6f272c3b --- /dev/null +++ b/apps/pod-migrator/test/css/run.mjs @@ -0,0 +1,21 @@ +// AUTHORED-BY Codex GPT-6 +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const runner = fileURLToPath(new URL("../../node_modules/vitest/vitest.mjs", import.meta.url)); +const config = fileURLToPath(new URL("./vitest.config.ts", import.meta.url)); +const child = spawn( + process.execPath, + [runner, "run", "--config", config, ...process.argv.slice(2)], + { + stdio: "inherit", + }, +); +for (const signal of ["SIGINT", "SIGTERM"]) process.on(signal, () => child.kill(signal)); +child.on("error", (error) => { + console.error(error.message); + process.exitCode = 1; +}); +child.on("exit", (code, signal) => { + process.exitCode = code ?? (signal === "SIGINT" ? 130 : 1); +}); diff --git a/apps/pod-migrator/test/css/server-process.mjs b/apps/pod-migrator/test/css/server-process.mjs new file mode 100644 index 000000000..ae7437afe --- /dev/null +++ b/apps/pod-migrator/test/css/server-process.mjs @@ -0,0 +1,62 @@ +// AUTHORED-BY Codex GPT-6 +import { createRequire } from "node:module"; +import { Server } from "node:net"; +import { dirname } from "node:path"; +import { AppRunner } from "@solid/community-server"; +import { cssComponentState } from "./component-state.mjs"; + +// CSS 7.2's ServerInitializer calls listen(port) without a host option. Restrict that +// call inside this disposable child process so neither test server binds publicly. +const listen = Server.prototype.listen; +const listening = []; +Server.prototype.listen = function loopbackListen(...args) { + if (typeof args[0] !== "number") throw new Error("Expected CSS to listen on a numeric port"); + listening.push( + new Promise((resolve, reject) => { + this.once("listening", resolve); + this.once("error", reject); + }), + ); + return Reflect.apply(listen, this, [{ port: args[0], host: "127.0.0.1" }, ...args.slice(1)]); +}; + +const [config, port, directory] = process.argv.slice(2); +let app; +let stopping = false; +async function stop() { + if (stopping) return; + stopping = true; + try { + await app?.stop(); + } finally { + process.exit(0); + } +} +process.on("SIGTERM", stop); +process.on("SIGINT", stop); +process.on("disconnect", stop); +try { + const require = createRequire(import.meta.url); + const cssDirectory = dirname(require.resolve("@solid/community-server/package.json")); + app = await new AppRunner().create({ + loaderProperties: { + moduleState: await cssComponentState(cssDirectory), + logLevel: process.env.CSS_DEBUG ? "warn" : "error", + }, + config, + shorthand: { + port: Number(port), + baseUrl: `http://localhost:${port}/`, + rootFilePath: directory, + loggingLevel: process.env.CSS_DEBUG ? "warn" : "error", + }, + }); + await app.start(); + await Promise.all(listening); + if (!listening.length) throw new Error("CSS did not start an HTTP listener"); + process.send?.({ ready: true }); +} catch (error) { + console.error(error); + process.exitCode = 1; + process.disconnect?.(); +} diff --git a/apps/pod-migrator/test/css/server.ts b/apps/pod-migrator/test/css/server.ts new file mode 100644 index 000000000..bfeeb15f8 --- /dev/null +++ b/apps/pod-migrator/test/css/server.ts @@ -0,0 +1,123 @@ +// AUTHORED-BY Codex GPT-6 +import { fork } from "node:child_process"; +import { once } from "node:events"; +import { realpathSync } from "node:fs"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { boundedFetch } from "./auth.js"; + +export type System = "wac" | "acp"; +export const cssDirectory = realpathSync( + fileURLToPath(new URL("../../node_modules/@solid/community-server/", import.meta.url)), +); + +export function memoryConfig(shipped: { import: string[]; [key: string]: unknown }) { + return { + ...shipped, + import: shipped.import.map((entry) => + entry + .replace("storage/backend/file.json", "storage/backend/memory.json") + .replace("util/resource-locker/file.json", "util/resource-locker/memory.json"), + ), + }; +} + +async function freePort(): Promise { + const reservation = createServer(); + reservation.listen(0, "127.0.0.1"); + await once(reservation, "listening"); + const address = reservation.address(); + if (!address || typeof address === "string") throw new Error("No free loopback port"); + await new Promise((resolve, reject) => + reservation.close((error) => (error ? reject(error) : resolve())), + ); + return address.port; +} + +export interface CssServer { + system: System; + base: string; + stop(): Promise; +} + +export async function startServer(system: System): Promise { + const manifest = JSON.parse(await readFile(join(cssDirectory, "package.json"), "utf8")); + if (manifest.version !== "7.2.0") throw new Error("This harness requires CSS 7.2.0"); + const shipped = JSON.parse( + await readFile( + join(cssDirectory, "config", system === "acp" ? "file-acp.json" : "file.json"), + "utf8", + ), + ); + const port = await freePort(); + const directory = await mkdtemp(join(tmpdir(), `pod-migrator-${system}-`)); + const configPath = join(directory, "config.json"); + try { + await writeFile(configPath, JSON.stringify(memoryConfig(shipped))); + } catch (error) { + await rm(directory, { recursive: true, force: true }); + throw error; + } + const base = `http://localhost:${port}/`; + const child = fork( + fileURLToPath(new URL("./server-process.mjs", import.meta.url)), + [configPath, String(port), directory], + { + stdio: ["ignore", "pipe", "pipe", "ipc"], + execArgv: [], + // CSS 7.2 switches to jest.requireActual("oidc-provider") when NODE_ENV is "test", + // which Vitest sets; run the server as a normal process. + env: { ...process.env, NODE_ENV: "production", JEST_WORKER_ID: undefined }, + }, + ); + let output = ""; + const record = (chunk: Buffer) => { + output = (output + chunk.toString()).slice(-12_000); + if (process.env.CSS_DEBUG) process.stderr.write(`[CSS ${system}] ${chunk.toString()}`); + }; + child.stdout?.on("data", record); + child.stderr?.on("data", record); + let stopped = false; + async function stop(): Promise { + if (stopped) return; + stopped = true; + if (child.exitCode === null && child.signalCode === null) { + const exited = once(child, "exit"); + child.kill("SIGTERM"); + const force = setTimeout(() => child.kill("SIGKILL"), 5_000); + try { + await exited; + } finally { + clearTimeout(force); + } + } + await rm(directory, { recursive: true, force: true }); + } + try { + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`${system} CSS startup timed out`)), 60_000); + child.once("error", reject); + child.once("exit", (code, signal) => { + clearTimeout(timer); + reject(new Error(`${system} CSS exited (${code ?? signal})`)); + }); + child.once("message", () => { + clearTimeout(timer); + resolve(); + }); + }); + const response = await boundedFetch(`${base}.account/`, { + headers: { accept: "application/json" }, + }); + if (!response.ok || !(await response.json()).controls) + throw new Error("Started process does not expose the CSS JSON account API"); + console.log(`[CSS ${system.toUpperCase()}] ready ${base}`); + return { system, base, stop }; + } catch (error) { + await stop(); + throw new Error(`${error instanceof Error ? error.message : error}\n${output}`); + } +} diff --git a/apps/pod-migrator/test/css/vitest.config.ts b/apps/pod-migrator/test/css/vitest.config.ts new file mode 100644 index 000000000..375e7c80e --- /dev/null +++ b/apps/pod-migrator/test/css/vitest.config.ts @@ -0,0 +1,17 @@ +// AUTHORED-BY Codex GPT-6 +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + root: fileURLToPath(new URL("../../", import.meta.url)), + test: { + include: ["test/css/*.test.ts", "test/css/*.integration.ts"], + environment: "node", + fileParallelism: false, + maxWorkers: 1, + testTimeout: 300_000, + hookTimeout: 150_000, + reporters: ["verbose"], + coverage: { enabled: false }, + }, +}); From 977eac80c42766b8220837eb01dcdeab5192a3fd Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:51:36 +0000 Subject: [PATCH 07/20] fix(pod-migrator): review findings and live CSS fixes; GitHub Pages build - Read ACP denials from every access control in an ACR document, as CSS does, so a CSS-effective deny is never dropped (review round 1, high). - Never write an ACL/ACR shared with another resource, and verify the target owner still controls the root at the end (review round 1, high). - Real CSS 7.2 omits ETags on PUT: take the placeholder's ETag from a HEAD and stop reusing create-only conditions in the grant phase. - Base-path aware build for https://jeswr.github.io/solid-sdk/pod-migrator/ (build:pages), with static callback pages and a matching Client ID document. - Live CSS harness: all four directions and both abort runs pass. Engine fixes by Codex GPT-6 (xhigh); ETag fallback and harness fixes by Claude. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KLrUernPrjVRT4K7UiaGgK --- apps/pod-migrator/README.md | 32 +- apps/pod-migrator/docs/DESIGN.md | 60 +++- apps/pod-migrator/index.html | 2 +- apps/pod-migrator/package.json | 1 + apps/pod-migrator/src/auth/connections.ts | 22 +- apps/pod-migrator/src/engine/README.md | 10 +- apps/pod-migrator/src/engine/access.ts | 10 +- apps/pod-migrator/src/engine/executor.ts | 113 ++++++- apps/pod-migrator/src/engine/planner.ts | 35 ++- apps/pod-migrator/src/engine/systems/acp.ts | 86 +++-- apps/pod-migrator/src/engine/target-access.ts | 67 ++++ apps/pod-migrator/src/main.tsx | 4 +- apps/pod-migrator/test/css/README.md | 15 +- apps/pod-migrator/test/css/fixtures.ts | 17 +- apps/pod-migrator/test/css/helpers.test.ts | 95 ++++++ .../test/css/migration.integration.ts | 27 +- apps/pod-migrator/test/css/probe.ts | 17 + .../test/engine/css-compat.test.ts | 233 ++++++++++++++ apps/pod-migrator/test/engine/edges.test.ts | 14 +- apps/pod-migrator/test/engine/fake-pod.ts | 23 +- apps/pod-migrator/test/engine/review.test.ts | 295 ++++++++++++++++++ .../test/engine/target-access.test.ts | 44 +++ apps/pod-migrator/test/ui/auth.test.ts | 19 ++ apps/pod-migrator/test/ui/client-id.test.ts | 113 ++++--- apps/pod-migrator/turbo.json | 2 +- apps/pod-migrator/vite.config.ts | 61 +++- 26 files changed, 1257 insertions(+), 160 deletions(-) create mode 100644 apps/pod-migrator/src/engine/target-access.ts create mode 100644 apps/pod-migrator/test/engine/css-compat.test.ts create mode 100644 apps/pod-migrator/test/engine/review.test.ts create mode 100644 apps/pod-migrator/test/engine/target-access.test.ts diff --git a/apps/pod-migrator/README.md b/apps/pod-migrator/README.md index 1adedf66c..56430b944 100644 --- a/apps/pod-migrator/README.md +++ b/apps/pod-migrator/README.md @@ -3,7 +3,7 @@ An experimental React 19 app for copying a Solid pod and reviewing how permissions translate between providers. Source files remain in place. The migration engine enforces the permission checks; the UI requires an explicit choice for every decision and blocks any abort selection. -Two independent sign-ins use `@jeswr/solid-auth-core`, with separate session databases, storage keys, and full-page callbacks at `/callback/source` and `/callback/target`. Enter a WebID or select identity provider mode; quick picks cover Inrupt PodSpaces and solidcommunity.net. Storage comes from the authenticated WebID subject’s `pim:storage`, with a required choice when several roots exist. Swapping direction keeps each account bound to its original credentials. +Two independent sign-ins use `@jeswr/solid-auth-core`, with separate session databases, storage keys, and full-page callbacks at `callback/source` and `callback/target` (base defaults to `/`). Enter a WebID or select identity provider mode; quick picks cover Inrupt PodSpaces and solidcommunity.net. Storage comes from the authenticated WebID subject’s `pim:storage`, with a required choice when several roots exist. Swapping direction keeps each account bound to its original credentials. From the workspace root, with Node 24 on PATH: @@ -13,10 +13,27 @@ pnpm --filter @jeswr/app-pod-migrator dev pnpm --filter @jeswr/app-pod-migrator lint pnpm --filter @jeswr/app-pod-migrator typecheck pnpm --filter @jeswr/app-pod-migrator test -APP_ORIGIN=https://migrate.example pnpm exec turbo run build --filter=@jeswr/app-pod-migrator +pnpm --filter @jeswr/app-pod-migrator build:pages +# Other deployments: +APP_ORIGIN=https://migrate.example APP_BASE_PATH=/ pnpm --filter @jeswr/app-pod-migrator build ``` -Vercel project root: `apps/pod-migrator`. Set `APP_ORIGIN` to the public HTTPS origin for a custom domain. `VERCEL_URL` supplies the preview fallback. The Vite plugin emits `/clientid.jsonld` during each build and serves it during development, registering both callback paths. Missing production origin configuration fails the build. Vercel serves the document as JSON-LD with CORS and rewrites callbacks to the SPA. The provider must be able to dereference the public client document; a remote provider cannot dereference localhost. Use a public HTTPS deployment for live accounts. +The public deployment is [GitHub Pages](https://jeswr.github.io/solid-sdk/pod-migrator/). +`build:pages` sets `APP_ORIGIN=https://jeswr.github.io` and +`APP_BASE_PATH=/solid-sdk/pod-migrator/`. Publish `dist/` at that base path; builds remain +uncommitted. For another host, set its HTTPS origin and an absolute base path ending in `/`. +Environment fields resolve independently, preferring shell values, mode-local files, mode +files, `.env.local`, then `.env`; empty values do not hide a lower-priority setting. + +Vite uses the same base for assets, the app icon, sign-in routing, and the Client Identifier +Document at `clientid.jsonld`. The document's `client_id` is its exact URL and it +registers `callback/source` and `callback/target`. Builds emit +`404.html` and static callback entries as copies of `index.html`, so callbacks load on Pages +without rewrite rules. Host-added trailing slashes on callback directories are accepted; +the token exchange retains the originally registered redirect URI. An `APP_ORIGIN` setting +or CI makes a build deployable and requires HTTPS; a CI build without `APP_ORIGIN` fails. +An unconfigured local build uses `http://localhost:5173` for development. The provider must +be able to dereference the public client document; use the HTTPS deployment for live accounts. The app uses shared app-shell light/dark/system themes. The flow is connect source → connect target → options → plan → run → result. Linked-data IRI rewriting defaults off. Targets must be folders inside the selected target pod; overlapping source and destination trees are refused. Only informational report items have preselected choices. Each report item exposes its explanation and original Turtle rule, with bulk choices limited to its own kind and supported options. @@ -31,3 +48,12 @@ pnpm --filter @jeswr/app-pod-migrator test:e2e They cover 375, 768, and 1280 pixel widths in light and dark, capture connection/options/review/run/result screens into ignored `e2e/screens/`, and check overflow, decision gating, abort, and report download. `--mode screens` enables these mocks only in the dev server; builds in that mode are refused. They do not establish live PodSpaces or solidcommunity.net compatibility. Compared with the design, a completed callback uses the session already established by `handleRedirect`; `restore()` runs only when there is no completed callback, following the auth-core lifecycle. Private profiles use authenticated fetch only for the exact signed-in WebID document, without redirects. Ambiguous WebID issuers fail closed with instructions to enter a chosen provider URL. Unexpected engine failures show an interrupted run without inventing counts or claiming verification. + +Access documents must belong exclusively to the resource being migrated. Planning inventories +the existing target tree and profile, and execution rechecks advertisements and document scopes +before writing. Shared/inherited ACL URLs produce skip/abort decisions or a safe execution +failure. Final verification also proves the target owner retains root access, including Control, +even when the root was skipped. CSS-effective denials under other ACR nodes are retained +conservatively; ambiguous grants and unplaced constructs produce owner-only/skip/abort decisions. +Verification refuses target documents with foreign scopes, so an ineffective denial cannot hide +an accidentally broader grant. diff --git a/apps/pod-migrator/docs/DESIGN.md b/apps/pod-migrator/docs/DESIGN.md index 66eb83cbf..0da1c2aee 100644 --- a/apps/pod-migrator/docs/DESIGN.md +++ b/apps/pod-migrator/docs/DESIGN.md @@ -1,4 +1,5 @@ + # Pod Migrator design @@ -16,8 +17,8 @@ strictly narrower, or stops. It never substitutes something broader. The app holds two independent `createSolidAuth()` instances from `@jeswr/solid-auth-core`, `source` and `target`. Each has its own `dbName`, `rememberedAccountsKey`, `recentAccountsKey`, -`redirectFlowKey`, `redirectSentinelKey` and `redirectUri` (`/callback/source`, -`/callback/target`), and `patchGlobalFetch: false`, so neither touches the global fetch and the +`redirectFlowKey`, `redirectSentinelKey` and `redirectUri` (`callback/source`, +`callback/target`, with `APP_BASE_PATH` defaulting to `/`), and `patchGlobalFetch: false`, so neither touches the global fetch and the two credentials can never be confused. Each instance is armed with `includeIssuerOrigin: false`. After login the app reads the @@ -52,8 +53,10 @@ Data crosses from source to target only as bytes plus a content type. No header, advertised ACL) become report items. 3. **Plan** (pure). Compute each source resource's effective permissions, translate them to the target's system, and collect every place the translation is not faithful as a `ReportItem`. - Also check the target: existing resources at the same path, the target's own root ACL and - WebID profile document. + Also inventory the existing target tree and the target's own root ACL and + WebID profile document, including paths outside the migration. ACL/ACR URLs shared by + resources, or documents binding another resource, are unsafe: offer skip/abort. An unreadable + target inventory stops planning because exclusivity cannot be established. 4. **Review** (UI). Show the plan: resource counts, bytes, and every report item with its options. Nothing has been written yet. The user resolves each item or aborts. Resolutions can be applied in bulk per item kind. @@ -63,7 +66,8 @@ Data crosses from source to target only as bytes plus a content type. No header, nothing else). Before writing any resource directly into a target container that already existed before the migration, first create it empty, lock it owner-only, then write its content. - - *Copy.* PUT each resource's bytes with its content type and `If-None-Match: *` (never + - *Copy.* Create each new resource's empty placeholder with its content type and + `If-None-Match: *`, lock it, then PUT its bytes with the placeholder's ETag in `If-Match` (never overwrite unless the user chose "overwrite" for that path). RDF documents may optionally have source-pod IRIs rewritten to target-pod IRIs (parse with `@jeswr/fetch-rdf`, rewrite terms, serialise with `n3.Writer`); off by default the bytes are copied verbatim. @@ -71,7 +75,8 @@ Data crosses from source to target only as bytes plus a content type. No header, members). Every intermediate state is a subset of the final state, which is a subset of the source. 6. **Verify.** Re-read each written ACL/ACR, re-parse it, and compare it with the planned rules. - Read back each copied resource's size or hash. Produce a final report (downloadable JSON). + Read back each copied resource with its copied media type in `Accept`; compare its bytes and + media type. Produce a final report (downloadable JSON). Cancel is available at every step. A cancel during execute stops after the in-flight request; everything already copied stays owner-only or at its final, never-wider state, and the report @@ -122,14 +127,26 @@ Per the ACP spec: an ACR's `acp:accessControl` applies to the resource, `acp:mem to all descendants and is **additive**: a descendant cannot remove an ancestor's member controls. Each access control applies policies; a policy has `acp:allow` / `acp:deny` modes and `acp:allOf` (all matchers must match), `acp:anyOf` (at least one), `acp:noneOf` (none). A -matcher matches when any of its `acp:agent` / `acp:client` / `acp:issuer` / `acp:vc` values -match; `acp:agent` may be `acp:PublicAgent`, `acp:AuthenticatedAgent`, `acp:CreatorAgent`, +matcher ORs the values within each of `acp:agent`, `acp:client`, `acp:issuer`, and `acp:vc`, +and ANDs the different attributes that are present (as CSS's reference evaluator does). +`acp:agent` may be `acp:PublicAgent`, `acp:AuthenticatedAgent`, `acp:CreatorAgent`, `acp:OwnerAgent`. A mode is granted when an applicable policy allows it and no applicable policy denies it. Control is access to the ACR itself (`acp:access` / control mode as the server implements it; verify against both the spec copy and CSS's ACP implementation). Translate `allOf`/`anyOf`/`noneOf` into the `who`/`except` normal form; a policy whose expansion exceeds 64 clauses is a report item (`too-complex`) rather than silently truncated. +CSS evaluates every `acp:resource` statement in each ancestor ACR. For each statement it +selects `accessControl` when that IRI equals the requested target and `memberAccessControl` +otherwise. Filtering the document down to the current resource's node is unsafe. The source +reader retains foreign-node denials conservatively for both resource and member scope, even +where placement is uncertain. Foreign grants that cannot be placed faithfully, detached +constructs, unknown ACP predicates, and ambiguous resource bindings make the document unknown +(owner-only/skip/abort). ACR-self policies remain mapped to Control. `acp:PublicClient` matches +any client context. Target documents must have exclusive resource/ACR-self scopes on read and +verification: conservative denials cannot mask a broader server-effective grant. Typed ESS +ACR roots without `acp:resource` are supported only without competing resource bindings. + ## 4. Detecting the system Follow `Link: <…>; rel="acl"` from the root. If that resource is advertised as @@ -174,7 +191,22 @@ Serialise with `n3.Writer` only, never by string concatenation. WAC: one `acl:Au per (subject set, modes, scope). ACP: one policy + matcher per grant clause, attached with `acp:accessControl` and, where planned, `acp:memberAccessControl`. ACR writes go to the ACR URL from the resource's `Link rel="acl"`; ESS and CSS both accept PUT of Turtle there. Never write an -ACL/ACR that lacks the owner's Control. +ACL/ACR that lacks the owner's Control. Never write an access URL advertised for another +resource, including a skipped root, profile or existing sibling. Recheck the advertisement +and the current document's resource bindings immediately before each access PUT; apply the +same guard when an empty placeholder first reveals its ACL URL. A safe refusal may leave an +empty placeholder with its already-proven safe inherited permissions, but never copies content +through an unsafe access URL. Verification checks target document scopes and the owner's root +permissions, including Control, before claiming success. + +CSS 7.2's `PutOperationHandler` returns 201/205 without an ETag for content and access +documents. An empty placeholder's ETag may be recovered by HEAD only when its content length +is zero. Before replacing an already written access document, recover a missing response ETag +from the fresh negotiated read only if both its resource and member policies still equal the +previously written policy. Missing validators, changed policies, and deleted access documents +stop execution; never retry a failed condition unconditionally. A pre-existing specific access +document, including a server-created default, is replaced using its read ETag. Access reads +parse the returned media type and normalize to Turtle for later scope checks and merges. ## 7. Testing @@ -193,3 +225,13 @@ ACL/ACR that lacks the owner's Control. PodSpaces itself can only be tested with a real account; ESS specifics (ACR URL form, any extra policies it requires) are isolated in `src/engine/systems/acp.ts` so they can be adjusted after a live run. + +## 8. Static hosting + +`APP_ORIGIN` is an origin without a path; deployable builds (`APP_ORIGIN` set or CI) require +HTTPS. `APP_BASE_PATH` defaults to `/` and is an absolute path ending in `/`. Vite assets, +icons, the Client Identifier Document, both registered redirect URIs and callback detection +share this base. `build:pages` builds for `https://jeswr.github.io/solid-sdk/pod-migrator/`. +The client document's URL equals its `client_id`. Emit `404.html` and static callback entry +pages as copies of the built index, keeping absolute base-prefixed assets. Callback directory +trailing slashes are accepted without changing the redirect URI persisted for token exchange. diff --git a/apps/pod-migrator/index.html b/apps/pod-migrator/index.html index 8acb95ebd..ba8148764 100644 --- a/apps/pod-migrator/index.html +++ b/apps/pod-migrator/index.html @@ -6,7 +6,7 @@ - + Pod Migrator — A new home for your Solid data '; + run.call({ emitFile }, {}, { "index.html": { type: "asset", source: html } }); + expect(emitFile).toHaveBeenCalledWith({ type: "asset", fileName: "404.html", source: html }); + for (const id of ["source", "target"]) + expect(emitFile).toHaveBeenCalledWith({ + type: "asset", + fileName: `callback/${id}/index.html`, + source: html, + }); + const cid = emitFile.mock.calls.find(([asset]) => asset.fileName === "clientid.jsonld")?.[0]; + expect(JSON.parse(cid.source)).toEqual( + clientIdDocument("https://jeswr.github.io", "/solid-sdk/pod-migrator/"), + ); + expect(() => run.call({ emitFile }, {}, {})).toThrow("Missing built index.html"); }); }); diff --git a/apps/pod-migrator/turbo.json b/apps/pod-migrator/turbo.json index 6adace133..c216188ff 100644 --- a/apps/pod-migrator/turbo.json +++ b/apps/pod-migrator/turbo.json @@ -2,6 +2,6 @@ "$schema": "https://turborepo.dev/schema.json", "extends": ["//"], "tasks": { - "build": { "env": ["APP_ORIGIN", "VERCEL_URL", "VERCEL"], "inputs": ["$TURBO_DEFAULT$", ".env*"] } + "build": { "env": ["APP_ORIGIN", "APP_BASE_PATH", "CI"], "inputs": ["$TURBO_DEFAULT$", ".env*"] } } } diff --git a/apps/pod-migrator/vite.config.ts b/apps/pod-migrator/vite.config.ts index 21e2da72e..c12ca3cfc 100644 --- a/apps/pod-migrator/vite.config.ts +++ b/apps/pod-migrator/vite.config.ts @@ -9,7 +9,14 @@ import { defineConfig, type Plugin } from "vite"; type OriginEnvironment = Record; export function originEnvironment(layers: OriginEnvironment[]): OriginEnvironment { - return layers.find((layer) => layer.APP_ORIGIN?.trim() || layer.VERCEL_URL?.trim()) ?? {}; + return Object.assign( + {}, + ...layers + .toReversed() + .map((layer) => + Object.fromEntries(Object.entries(layer).filter(([, value]) => value?.trim())), + ), + ); } function fileEnvironment(name: string): OriginEnvironment { @@ -25,9 +32,8 @@ export function deploymentOrigin( env: Record, production = false, ): string { - const raw = - env.APP_ORIGIN?.trim() || (env.VERCEL_URL?.trim() ? `https://${env.VERCEL_URL.trim()}` : ""); - if (!raw && production) throw new Error("Set APP_ORIGIN or VERCEL_URL for a deployable build."); + const raw = env.APP_ORIGIN?.trim(); + if (!raw && production) throw new Error("Set APP_ORIGIN for a deployable build."); const url = new URL(raw || "http://localhost:5173"); const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); if ( @@ -45,13 +51,22 @@ export function deploymentOrigin( return url.origin; } -export function clientIdDocument(origin: string) { +export function deploymentBasePath(value = "/"): string { + if (!/^\/(?:[a-zA-Z0-9_-]+\/)*$/.test(value)) + throw new Error( + "APP_BASE_PATH must be an absolute path with a trailing slash and plain path segments.", + ); + return value; +} + +export function clientIdDocument(origin: string, base = "/") { + const app = `${origin}${deploymentBasePath(base)}`; return { "@context": ["https://www.w3.org/ns/solid/oidc-context.jsonld"], - client_id: `${origin}/clientid.jsonld`, + client_id: `${app}clientid.jsonld`, client_name: "Pod Migrator", - client_uri: `${origin}/`, - redirect_uris: [`${origin}/callback/source`, `${origin}/callback/target`], + client_uri: app, + redirect_uris: [`${app}callback/source`, `${app}callback/target`], scope: "openid webid offline_access", grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], @@ -59,13 +74,13 @@ export function clientIdDocument(origin: string) { }; } -export function clientDocumentPlugin(origin: string): Plugin { - const body = `${JSON.stringify(clientIdDocument(origin), null, 2)}\n`; +export function clientDocumentPlugin(origin: string, base = "/"): Plugin { + const body = `${JSON.stringify(clientIdDocument(origin, base), null, 2)}\n`; return { name: "pod-migrator-client-document", configureServer(server) { server.middlewares.use((request, response, next) => { - if (request.url?.split("?")[0] !== "/clientid.jsonld") { + if (request.url?.split("?")[0] !== `${base}clientid.jsonld`) { next(); return; } @@ -75,8 +90,19 @@ export function clientDocumentPlugin(origin: string): Plugin { response.end(body); }); }, - generateBundle() { - this.emitFile({ type: "asset", fileName: "clientid.jsonld", source: body }); + generateBundle: { + order: "post", + handler(_options, bundle) { + const index = bundle["index.html"]; + if (index?.type !== "asset") throw new Error("Missing built index.html"); + for (const fileName of [ + "404.html", + "callback/source/index.html", + "callback/target/index.html", + ]) + this.emitFile({ type: "asset", fileName, source: index.source }); + this.emitFile({ type: "asset", fileName: "clientid.jsonld", source: body }); + }, }, }; } @@ -103,14 +129,19 @@ export default defineConfig(({ mode, command }) => { fileEnvironment(".env.local"), fileEnvironment(".env"), ]); - const origin = deploymentOrigin(env, command === "build" && Boolean(process.env.VERCEL)); + const origin = deploymentOrigin( + env, + command === "build" && Boolean(env.APP_ORIGIN || process.env.CI), + ); + const base = deploymentBasePath(env.APP_BASE_PATH); if (mode === "screens" && command === "build") throw new Error("Screenshot mocks are development-only."); return { + base, plugins: [ react(), tailwindcss(), - clientDocumentPlugin(origin), + clientDocumentPlugin(origin, base), ...(mode === "screens" ? [screenshotMocks()] : []), ], }; From e1a78ef4cbef4d45ac749b49ab2e0f48f97d48cb Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:57:25 +0000 Subject: [PATCH 08/20] build(pod-migrator): publish through the suite's GitHub Pages build Register the app in scripts/vercel-apps.json with git deployments disabled so scripts/build-pages.mjs ships it at /solid-sdk/pod-migrator/, honour CLIENT_BASE_PATH, and treat only an explicit APP_ORIGIN as a deployable build (CI gate builds fall back to loopback). Root guard tests updated for the new app. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KLrUernPrjVRT4K7UiaGgK --- apps/pod-migrator/turbo.json | 2 +- apps/pod-migrator/vercel.json | 50 +++++++++++++++++++++++++++++++ apps/pod-migrator/vite.config.ts | 8 ++--- scripts/vercel-apps.json | 9 ++++++ test/build-pages.test.mjs | 3 +- test/check-vercel-config.test.mjs | 7 ++++- 6 files changed, 71 insertions(+), 8 deletions(-) create mode 100644 apps/pod-migrator/vercel.json diff --git a/apps/pod-migrator/turbo.json b/apps/pod-migrator/turbo.json index c216188ff..9bb6b68df 100644 --- a/apps/pod-migrator/turbo.json +++ b/apps/pod-migrator/turbo.json @@ -2,6 +2,6 @@ "$schema": "https://turborepo.dev/schema.json", "extends": ["//"], "tasks": { - "build": { "env": ["APP_ORIGIN", "APP_BASE_PATH", "CI"], "inputs": ["$TURBO_DEFAULT$", ".env*"] } + "build": { "env": ["APP_ORIGIN", "APP_BASE_PATH", "CLIENT_BASE_PATH"], "inputs": ["$TURBO_DEFAULT$", ".env*"] } } } diff --git a/apps/pod-migrator/vercel.json b/apps/pod-migrator/vercel.json new file mode 100644 index 000000000..4821a2ea7 --- /dev/null +++ b/apps/pod-migrator/vercel.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "git": { + "deploymentEnabled": false + }, + "framework": "vite", + "outputDirectory": "dist", + "rewrites": [ + { + "source": "/((?!assets/|.*\\.[^/]+$).*)", + "destination": "/index.html" + } + ], + "headers": [ + { + "source": "/clientid.jsonld", + "headers": [ + { + "key": "Content-Type", + "value": "application/ld+json" + }, + { + "key": "Access-Control-Allow-Origin", + "value": "*" + }, + { + "key": "Cache-Control", + "value": "public, max-age=300" + } + ] + }, + { + "source": "/(.*)", + "headers": [ + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "Referrer-Policy", + "value": "strict-origin-when-cross-origin" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + } + ] + } + ] +} diff --git a/apps/pod-migrator/vite.config.ts b/apps/pod-migrator/vite.config.ts index c12ca3cfc..fec23da1d 100644 --- a/apps/pod-migrator/vite.config.ts +++ b/apps/pod-migrator/vite.config.ts @@ -129,11 +129,9 @@ export default defineConfig(({ mode, command }) => { fileEnvironment(".env.local"), fileEnvironment(".env"), ]); - const origin = deploymentOrigin( - env, - command === "build" && Boolean(env.APP_ORIGIN || process.env.CI), - ); - const base = deploymentBasePath(env.APP_BASE_PATH); + const origin = deploymentOrigin(env, command === "build" && Boolean(env.APP_ORIGIN)); + // scripts/build-pages.mjs passes the Pages base as CLIENT_BASE_PATH (and --base). + const base = deploymentBasePath(env.CLIENT_BASE_PATH || env.APP_BASE_PATH); if (mode === "screens" && command === "build") throw new Error("Screenshot mocks are development-only."); return { diff --git a/scripts/vercel-apps.json b/scripts/vercel-apps.json index 09a244b00..bc4445795 100644 --- a/scripts/vercel-apps.json +++ b/scripts/vercel-apps.json @@ -127,5 +127,14 @@ "framework": "next", "vercelIgnore": "node ../../scripts/vercel-ignore-build.mjs @jeswr/app-coeliac", "gitDeploymentEnabled": false + }, + { + "project": "solid-pod-migrator", + "rootDirectory": "apps/pod-migrator", + "package": "@jeswr/app-pod-migrator", + "framework": "vite", + "originEnv": "APP_ORIGIN", + "vercelIgnore": "node ../../scripts/vercel-ignore-build.mjs @jeswr/app-pod-migrator", + "gitDeploymentEnabled": false } ] diff --git a/test/build-pages.test.mjs b/test/build-pages.test.mjs index 7cb79bce7..31a1b892f 100644 --- a/test/build-pages.test.mjs +++ b/test/build-pages.test.mjs @@ -72,7 +72,7 @@ function redirect(fallback, href) { } describe("Pages artifact", () => { - it("selects all ten Vite apps with the Vercel project prefixes removed", () => { + it("selects all eleven Vite apps with the Vercel project prefixes removed", () => { const root = fileURLToPath(new URL("..", import.meta.url)); expect( pagesApps(root) @@ -86,6 +86,7 @@ describe("Pages artifact", () => { "pod-drive", "pod-health", "pod-mail", + "pod-migrator", "pod-money", "pod-music", "pod-photos", diff --git a/test/check-vercel-config.test.mjs b/test/check-vercel-config.test.mjs index f0e1c3e7b..06febcd5c 100644 --- a/test/check-vercel-config.test.mjs +++ b/test/check-vercel-config.test.mjs @@ -745,7 +745,7 @@ export function GET(request) { it("checks the actual manifest independent of the invoking directory", () => { const result = runNode([guard], { cwd: tmpdir() }); expect(result.status).toBe(0); - expect(result.stdout).toContain("14 deployments checked"); + expect(result.stdout).toContain("15 deployments checked"); expect(result.stderr).toBe(""); }); }); @@ -760,6 +760,11 @@ describe("Vite app Vercel routes", () => { expect(vite).not.toMatch(/\bbase\s*:/); return; } + if (app.rootDirectory === "apps/pod-migrator") { + // Absolute base from CLIENT_BASE_PATH ("/" unless a Pages build sets it). + expect(vite).toContain("deploymentBasePath(env.CLIENT_BASE_PATH || env.APP_BASE_PATH)"); + return; + } expect(vite).toContain('base: process.env.VERCEL === "1" ? "/" : "./",'); const turbo = JSON.parse( readFileSync(join(checkout, app.rootDirectory, "turbo.json"), "utf8"), From 28f13732f0a4cb2ffa0cbad19db5e888950f2674 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Wed, 28 Oct 2026 02:26:29 +0000 Subject: [PATCH 09/20] fix(pod-migrator): strictest reading of server-dependent rules (review round 2) Uncertain source grants are dropped with a report item and uncertain denials apply; acl:origin is unsupported on every target; the safety proof ignores target documents the executor replaces; the fake pod follows CSS 7.2 by default with ESS/NSS dialect options. Codex GPT-6 (xhigh). Live CSS rerun still has failures, addressed next. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KLrUernPrjVRT4K7UiaGgK --- apps/pod-migrator/docs/DESIGN.md | 48 +- apps/pod-migrator/src/engine/README.md | 45 +- apps/pod-migrator/src/engine/access.ts | 15 +- apps/pod-migrator/src/engine/executor.ts | 6 +- apps/pod-migrator/src/engine/planner.ts | 134 ++--- apps/pod-migrator/src/engine/policy.ts | 2 +- apps/pod-migrator/src/engine/systems/acp.ts | 75 ++- apps/pod-migrator/src/engine/systems/wac.ts | 22 +- apps/pod-migrator/src/engine/translation.ts | 22 +- apps/pod-migrator/src/engine/types.ts | 2 + apps/pod-migrator/test/css/account.ts | 1 + .../test/engine/dialect-safety.test.ts | 511 ++++++++++++++++++ apps/pod-migrator/test/engine/edges.test.ts | 1 + apps/pod-migrator/test/engine/fake-pod.ts | 83 ++- apps/pod-migrator/test/engine/helpers.ts | 2 +- apps/pod-migrator/test/engine/policy.test.ts | 5 +- apps/pod-migrator/test/engine/systems.test.ts | 51 +- 17 files changed, 889 insertions(+), 136 deletions(-) create mode 100644 apps/pod-migrator/test/engine/dialect-safety.test.ts diff --git a/apps/pod-migrator/docs/DESIGN.md b/apps/pod-migrator/docs/DESIGN.md index 0da1c2aee..97b947a0e 100644 --- a/apps/pod-migrator/docs/DESIGN.md +++ b/apps/pod-migrator/docs/DESIGN.md @@ -142,10 +142,15 @@ otherwise. Filtering the document down to the current resource's node is unsafe. reader retains foreign-node denials conservatively for both resource and member scope, even where placement is uncertain. Foreign grants that cannot be placed faithfully, detached constructs, unknown ACP predicates, and ambiguous resource bindings make the document unknown -(owner-only/skip/abort). ACR-self policies remain mapped to Control. `acp:PublicClient` matches +(owner-only/skip/abort). ACR-self policies map to Control only for an independently established +ESS source. CSS derives ACR access from Control on the protected resource through +`AuthAuxiliaryReader`; ineffective ACR-self or `acp:access` grants must never become destination +Control. `acp:PublicClient` matches any client context. Target documents must have exclusive resource/ACR-self scopes on read and verification: conservative denials cannot mask a broader server-effective grant. Typed ESS -ACR roots without `acp:resource` are supported only without competing resource bindings. +ACR roots without `acp:resource` are supported only for an established ESS source and without +competing resource bindings. ESS mode IRIs must likewise never identify the server dialect: +CSS ignores `acp:Read`/`Write`/`Append` modes. Uncertain denials remain restrictive. ## 4. Detecting the system @@ -157,6 +162,43 @@ still copy data with everything owner-only). ## 5. Translation rules +**Read SOURCE conservatively, write only established TARGET semantics, and prove the documents +that will actually exist.** Whenever server implementations disagree (ESS/CSS/NSS dialects, +legacy predicates, ACR-self policies, `acp:access`, `acp:Read`-style modes, typed ACRs without +`acp:resource`), an uncertain grant counts as not granted and produces a report item offering +skip/abort. An uncertain denial or exclusion counts as applying. A document whose grants cannot +be interpreted safely is reported as `acl-unreadable`, with owner-only/skip/abort; its descendants +also fail closed. `acl:defaultForNew` requires an independently established NSS source. Without +that evidence it cannot be promoted to an inherited grant, because CSS 7.2 reads only `acl:default`. + +`PodSide.implementation` is an optional, trusted caller assertion (`css`, `ess`, `nss`), established +independently of editable access RDF. An unknown source uses conservative readings; ACP target +writes require established CSS or ESS semantics and otherwise offer only abort. Never infer ESS +from an `acp:*` mode IRI. Source ESS variants are isolated in `systems/acp.ts`; live PodSpaces +validation still requires a real account. Existing target restrictions that cannot be proven +effective are refused, so an ignored denial cannot mask a broader target grant. + +Never rely on a condition the target might not enforce. In particular CSS 7.2's configured WAC +checker chain ignores `acl:origin`: origin-restricted grants are unsupported on **every WAC and +ACP target**, with skip/abort. Preserving the condition's RDF text is not a safe translation. +CSS ACP output uses resource `acl:Control`; it does not emit ineffective ACR-self or `acp:access` +controls. Only established CSS 7.2 enforcement or the explicitly selected ESS behavior documented +in `systems/acp.ts` may be written. + +The target proof replays empty creation, owner-only locking, content copying and bottom-up +granting in executor order. It recomputes effective permissions from the access documents left +after **each** mutation, for every affected resource. Replacing a document removes its old grants, +denials and exclusions from all descendant proofs. Repeat this proof against freshly read target +documents before the first write; semantically equal initial permissions can conceal changed +ancestor policies behind a denial that will be replaced. Existing paths kept by skip/overwrite and +root merges retain their actual documents. No proof may rely on an orphan ACR denial after locking +has replaced that ACR. Every partial execution state must remain safe. + +CSS applies Write→Append implication after ACP denial subtraction. To keep this representable, +translated denials of either mode deny both; existing target denials of just one mode are refused. +CSS supplies no owner/creator/VC matcher context: uncertain exclusions cannot weaken a source +denial and cannot be relied on as target restrictions. + Translation works on effective policies, never on raw documents. The target owner (the WebID signed in on the target) always gets `read`, `write`, `control` on everything written, so the migration can never lock the user out; this is not a widening because the owner already @@ -166,7 +208,7 @@ controls the target pod. |---|---|---|---| | agent, public, authenticated | same | same | — | | WAC `agentGroup` | same | no equivalent | `group-unsupported`: **skip** (drop the grant) / **expand** to the group's current members, read at plan time, shown as a list, with a warning that later membership changes won't follow / **abort** | -| WAC `acl:origin` | same | no equivalent (`acp:client` is a client id, not an origin) | `origin-unsupported`: **skip** / **abort**. Dropping the origin condition would widen, so it is never offered | +| WAC `acl:origin` | enforcement unestablished (CSS ignores it) | no equivalent (`acp:client` is a client id, not an origin) | `origin-unsupported`: **skip** / **abort**. Drop the affected grant clause; never retain or remove only the condition | | ACP `client`, `issuer`, `vc` in a clause | no equivalent | same | `condition-unsupported`: **skip the clause** / **abort**. Never drop just the condition | | ACP `noneOf` exclusions | no equivalent | same | `exclusion-unsupported`: **skip the grant** / **abort** | | ACP `deny` | subtract it when the denial's subjects exactly cover a grant's clause; otherwise | same | `deny-unsupported`: **drop every grant the denial overlaps** (narrowest) / **abort** | diff --git a/apps/pod-migrator/src/engine/README.md b/apps/pod-migrator/src/engine/README.md index 5a4fe4cb4..aa44643e2 100644 --- a/apps/pod-migrator/src/engine/README.md +++ b/apps/pod-migrator/src/engine/README.md @@ -14,9 +14,32 @@ DESIGN.md as follows. These notes are colocated with the engine and reflected in statement that all matcher attributes are alternatives would widen access. The reader preserves the intersections. Clause expansion is bounded at 64. Conjunctive `noneOf` expressions that the neutral model cannot represent produce a `too-complex` decision, with owner-only/skip/abort. -- CSS uses ACL mode IRIs in ACP policies and evaluates policies protecting the ACR URL itself. - ESS-style ACP mode IRIs and `acp:access` policies are supported separately in `systems/acp.ts`. - The root document determines the dialect. Live PodSpaces behavior still needs account testing. +- **Reading SOURCE:** take the most restrictive plausible interpretation across server dialects. + An uncertain grant is not granted and produces a report with skip/abort (an uninterpretable + document offers owner-only/skip/abort); an uncertain denial or exclusion counts as applying. + This includes ESS/CSS/NSS differences, legacy `acl:defaultForNew`, ACR-self policies, + `acp:access`, ACP-namespace modes and typed ACRs without `acp:resource`. +- CSS 7.2 uses ACL mode IRIs and resource Control. Its configured `AuthAuxiliaryReader` derives + ACR access from that Control; ACR-self Read/Write and `acp:access` do not grant resource Control. + ESS source forms are accepted only with independently established ESS semantics, documented in + `systems/acp.ts`. `PodSide.implementation` is a trusted caller assertion, never inferred from + editable RDF or mode IRIs. ACP target writes without established CSS/ESS semantics abort. + Live PodSpaces behavior still needs account testing; the browser connection currently leaves + implementation unestablished, so ACP target writes fail closed until a trusted integration + supplies that assertion. Missing owner Control at creation also fails closed on ESS. +- **Writing TARGET:** never rely on a condition the target might not enforce. CSS 7.2's configured + WAC checker chain ignores `acl:origin`, so origin-restricted grants are unsupported on every + WAC and ACP target: skip the affected clause or abort, never preserve or remove only its origin. + CSS ignores `acl:defaultForNew`; it is accepted only for an established NSS source. Writers + emit `acl:default`, and CSS ACP output uses resource Control without ineffective ACR-self or + `acp:access` grants. Target reads reject ambiguous denials and role matchers rather than relying + on them to mask actual grants. Only CSS 7.2 source-established enforcement or the explicitly + selected ESS behavior in `systems/acp.ts` may be emitted. +- CSS applies Write→Append implication after ACP denial subtraction. Source denials remain + conservative; output denials of either Write or Append cover both. Existing target denials + of just one are refused because their derived Append behavior needs a richer neutral model. + CSS supplies no owner/creator/VC matcher context; uncertain exclusions cannot weaken a + source denial, and cannot be trusted as target restrictions. - CSS selects policies from every `acp:resource` statement, choosing access controls for matching IRIs and member controls otherwise. Foreign-node denials are conservatively retained; unplaceable grants and unknown/detached constructs fail closed. Target scopes must be exclusive @@ -38,12 +61,19 @@ DESIGN.md as follows. These notes are colocated with the engine and reflected in owner-only member controls, with a `per-resource` decision; future children receive no shared grants. Other model pairs retain safely translated member controls. Finalization also proves the effect of all generated ACP ancestor controls together. +- **Planning against TARGET:** replay the actual access documents after each creation, lock, + copy and bottom-up grant, checking every affected resource. Replaced documents contribute none + of their old denials or exclusions. Retained skip/overwrite documents and root merges keep + their actual policies. Repeat the phase proof on fresh target documents before writing; + unchanged initial permissions alone cannot expose a root change masked by a replaced denial. - Root merging preserves original RDF, including unrelated triples and existing member controls; it adds only resource grants under distinct rule identifiers and requires the reviewed ACL ETag. Target WebID documents and advertised auxiliary resources are protected. Encoded auxiliary suffixes and encoded profile path aliases receive the same protection as their decoded forms. - Owner/creator grants map to the target owner. Source-owner grants remain verbatim. Owner roles - in exclusions and denials retain the source owner's identity. The original creator identity + in grant exclusions and denial subjects retain the source owner's identity. Uncertain + owner/creator/VC exclusions on source denials are removed to keep those denials restrictive. + The original creator identity cannot be established from an ACR alone: creator exclusions drop the grant, and creator denials remove every possibly denied mode. Denials always exempt the target owner on the target side. - Group expansion uses only an in-fence, plan-time membership snapshot and warns that future @@ -61,8 +91,11 @@ Cancellation stops subsequent requests after the current safe unit, finishing an when necessary. No rollback broadens or restores old permissions. Logs and the final result are saved to sessionStorage when available, without depending on browser storage succeeding. -The in-memory LDP tests implement WAC nearest-ACL replacement and ACP additive inheritance with -deny precedence independently of the engine's readers and proof code. They check anonymous and +The in-memory LDP tests default to CSS 7.2 semantics: origin ignored, defaultForNew ignored, +ACL mode IRIs used verbatim, and ACR access derived from resource Control. Explicit `ess`/`nss` +fixture options exercise the alternative mode, ACR-self/`acp:access`, typed-root and legacy WAC +semantics where relevant. WAC nearest-ACL replacement and ACP additive inheritance with deny +precedence are evaluated independently of the engine's readers and proof code. Tests check anonymous and stranger access after each write, ownership, stale plans, path/redirect fences, conditional writes, cancellation, every translation construct, RDF rewriting, and read-back verification. The engine coverage gate requires 100% lines, branches, functions, and statements. diff --git a/apps/pod-migrator/src/engine/access.ts b/apps/pod-migrator/src/engine/access.ts index f381ce6ce..1c2dda693 100644 --- a/apps/pod-migrator/src/engine/access.ts +++ b/apps/pod-migrator/src/engine/access.ts @@ -70,7 +70,16 @@ export async function readAccess( const dataset = await parse(new Response(turtle, { headers: response.headers }), url); if (system === "unknown") throw new TypeError("Cannot read unknown access system"); const document: AccessDocument = { - policy: system === "wac" ? readWac(dataset, resource, url) : readAcp(dataset, resource, url), + policy: + system === "wac" + ? readWac(dataset, resource, url, side.implementation, requireSpecific) + : readAcp( + dataset, + resource, + url, + side.implementation === "ess" ? "ess" : "css", + requireSpecific, + ), url, // All later scope checks and merges consume Turtle, even if the server // returned JSON-LD despite our Accept preference. @@ -112,7 +121,9 @@ export async function writeAccess( turtle = await serialize(dataset); } const emitted = - system === "wac" ? readWac(dataset, resource, url) : readAcp(dataset, resource, url); + system === "wac" + ? readWac(dataset, resource, url, side.implementation, true) + : readAcp(dataset, resource, url, dialect, true); for (const scope of ["resource", "members"] as const) if (!policiesEqual(scopePolicy(policy, scope), scopePolicy(emitted, scope))) throw new TypeError("Serialized access policy differs from the plan"); diff --git a/apps/pod-migrator/src/engine/executor.ts b/apps/pod-migrator/src/engine/executor.ts index c620e2bf6..f744b7bbb 100644 --- a/apps/pod-migrator/src/engine/executor.ts +++ b/apps/pod-migrator/src/engine/executor.ts @@ -3,7 +3,7 @@ import { extractMediaType, parseRdf, SUPPORTED_RDF_MEDIA_TYPES } from "@jeswr/fe import { Group } from "@solid/object"; import { DataFactory, Store } from "n3"; import { readAccess, writeAccess } from "./access.js"; -import { executions } from "./planner.js"; +import { executions, proveExecutionPhases } from "./planner.js"; import { assertNotWider, effectivePolicy, @@ -69,7 +69,8 @@ export async function executeMigration( if ( opts[name].http !== state[name].http || opts[name].root !== state[name].root || - opts[name].ownerWebId !== state[name].ownerWebId + opts[name].ownerWebId !== state[name].ownerWebId || + opts[name].implementation !== state[name].implementation ) throw new TypeError("Execution sessions differ from planning sessions"); executions.delete(executable); // A plan is single-use, including cancelled/failed attempts. @@ -260,6 +261,7 @@ export async function executeMigration( throw new TypeError("Root access document changed after review"); if (doc?.etag) aclEtags.set(current, doc.etag); } + proveExecutionPhases(state, resources, targetDocs); const containers = active .filter((e) => e.state.resource.kind === "container") .sort((a, b) => a.state.resource.targetUrl.length - b.state.resource.targetUrl.length); diff --git a/apps/pod-migrator/src/engine/planner.ts b/apps/pod-migrator/src/engine/planner.ts index 1740d86ca..ee6bddcaf 100644 --- a/apps/pod-migrator/src/engine/planner.ts +++ b/apps/pod-migrator/src/engine/planner.ts @@ -13,7 +13,7 @@ import { unionPolicies, } from "./policy.js"; import { aclLink, links, parent, parse, requireOk } from "./rdf.js"; -import { type AcpDialect, acpDialect, PolicyTooComplex } from "./systems/acp.js"; +import { type AcpDialect, PolicyTooComplex } from "./systems/acp.js"; import { assertSpecificAccess, targetAccessLinks } from "./target-access.js"; import { reportItem, type TranslationContext, translatePolicy } from "./translation.js"; import { @@ -37,7 +37,6 @@ export interface ResourceState { exists: boolean; targetDocument?: AccessDocument; targetEffective: EffectivePolicy; - targetInherited: EffectivePolicy; targetEtag?: string; targetUnreadable: boolean; } @@ -51,6 +50,7 @@ export interface PlanState { dialect: AcpDialect; rewriteIris: boolean; snapshot: string; + targetDocuments: Map; } export interface ExecutionState { state: PlanState; @@ -170,14 +170,21 @@ export async function planMigration(opts: { ); const resources = discovered.filter((item) => !isAuxiliary(item.resource.sourceUrl)); const sourceSystem = await detectAccessSystem(source, opts.signal); - const targetSystem = await detectAccessSystem(target, opts.signal); + const detectedTargetSystem = await detectAccessSystem(target, opts.signal); + // ACP dialect changes Control enforcement; RDF vocabulary cannot identify the server. + const targetSystem = + detectedTargetSystem === "acp" && + target.implementation !== "css" && + target.implementation !== "ess" + ? "unknown" + : detectedTargetSystem; if (sourceSystem === "unknown" || targetSystem === "unknown") report.push( reportItem( "acl-system-unknown", source.root, targetSystem === "unknown" - ? "The target access system cannot be locked safely; execution must stop until it is identifiable." + ? "The target access system or ACP implementation cannot be established independently of access RDF; execution must stop until it is identifiable." : "The source access system is unknown. All copied resources will remain owner-only.", targetSystem === "unknown" ? ["abort"] : ["owner-only", "abort"], ), @@ -306,9 +313,6 @@ export async function planMigration(opts: { target.root, targetDocs, ); - const ownless = new Map(targetDocs); - ownless.delete(resource.targetUrl); - const targetInherited = effectivePolicy(targetSystem, resource.targetUrl, target.root, ownless); const exists = (targetHeads.get(resource.targetUrl) as Response).ok; let targetUnreadable = hasError(resource.targetUrl, target.root, targetErrors); const targetAcl = targetInventory.get(resource.targetUrl); @@ -404,21 +408,12 @@ export async function planMigration(opts: { exists, targetDocument: targetDocs.get(resource.targetUrl), targetEffective, - targetInherited, targetEtag: (targetHeads.get(resource.targetUrl) as Response).headers.get("etag") ?? undefined, targetUnreadable, }); } - const rootDoc = targetDocs.get(target.root); - const dialect = rootDoc - ? acpDialect( - await parse( - new Response(rootDoc.turtle, { headers: { "content-type": "text/turtle" } }), - rootDoc.url, - ), - ) - : "css"; + const dialect = target.implementation === "ess" ? "ess" : "css"; const plan: MigrationPlan = { sourceSystem, targetSystem, @@ -443,6 +438,7 @@ export async function planMigration(opts: { dialect, rewriteIris: opts.rewriteIris ?? false, snapshot: JSON.stringify(plan), + targetDocuments: targetDocs, }); opts.onProgress?.({ phase: "plan", done: resources.length, total: resources.length }); return plan; @@ -528,26 +524,6 @@ export function finalizePlan(plan: MigrationPlan, resolutions: Resolutions): Exe ownerPolicy(state.target.ownerWebId, resource.kind === "container"), ); if (!skip) { - const context = { - sourceOwner: state.source.ownerWebId, - targetOwner: state.target.ownerWebId, - groups: state.groups, - }; - const actual = - existingDecision === "overwrite" - ? resourceState.targetEffective - : protectedDecision === "merge" - ? unionPolicies(resourceState.targetEffective, scopePolicy(policy, "resource")) - : plan.targetSystem === "acp" - ? unionPolicies(policy, resourceState.targetInherited) - : policy; - assertNotWider(resourceState.sourceEffective, scopePolicy(actual, "resource"), context); - assertNotWider(scopePolicy(actual, "resource"), ownerPolicy(state.target.ownerWebId)); - // A write under an existing ancestor must be safe even before its own ACL exists. - if (!resourceState.exists) { - assertNotWider(resourceState.sourceEffective, resourceState.targetEffective, context); - assertNotWider(resourceState.targetEffective, ownerPolicy(state.target.ownerWebId)); - } if (protectedDecision === "merge" && !resourceState.targetDocument?.etag) throw new TypeError("Root merge requires an access-document ETag"); if (existingDecision === "overwrite" && !resourceState.targetEtag) @@ -560,32 +536,7 @@ export function finalizePlan(plan: MigrationPlan, resolutions: Resolutions): Exe decision: protectedDecision ?? existingDecision, }; }); - if (state.targetSystem === "acp") - for (const entry of selected.filter((entry) => !entry.skipped)) { - const inherited = selected - .filter( - (other) => - !other.skipped && - other !== entry && - other.decision !== "merge" && - other.state.resource.kind === "container" && - entry.state.resource.targetUrl.startsWith(other.state.resource.targetUrl), - ) - .map((other) => scopePolicy(other.policy, "members")); - assertNotWider( - entry.state.sourceEffective, - unionPolicies( - scopePolicy(entry.policy, "resource"), - entry.state.targetInherited, - ...inherited, - ), - { - sourceOwner: state.source.ownerWebId, - targetOwner: state.target.ownerWebId, - groups: state.groups, - }, - ); - } + proveExecutionPhases(state, selected, state.targetDocuments); const executable: ExecutablePlan = Object.freeze({ plan: structuredClone(plan), resolutions: Object.freeze({ ...resolutions }), @@ -593,3 +544,60 @@ export function finalizePlan(plan: MigrationPlan, resolutions: Resolutions): Exe executions.set(executable, { state, resources: structuredClone(selected) }); return executable; } + +export function proveExecutionPhases( + state: PlanState, + selected: ExecutionState["resources"], + targetDocuments: ReadonlyMap, +): void { + // Replay the executor's access-document mutations. Replacing a document removes + // ALL its old grants, denials and exclusions from every descendant's proof. + const active = selected.filter((entry) => !entry.skipped); + const documents = new Map | undefined>(targetDocuments); + const present = new Set(active.filter((entry) => entry.state.exists)); + const context = { + sourceOwner: state.source.ownerWebId, + targetOwner: state.target.ownerWebId, + groups: state.groups, + }; + const prove = () => { + for (const entry of present) { + const actual = effectivePolicy( + state.targetSystem, + entry.state.resource.targetUrl, + state.target.root, + documents, + ); + assertNotWider(entry.state.sourceEffective, actual, context); + assertNotWider(actual, ownerPolicy(state.target.ownerWebId)); + } + }; + prove(); + const containers = active + .filter((entry) => entry.state.resource.kind === "container") + .sort((a, b) => a.state.resource.targetUrl.length - b.state.resource.targetUrl.length); + const files = active.filter((entry) => entry.state.resource.kind === "resource"); + for (const entry of [...containers, ...files]) { + if (entry.state.exists) continue; + present.add(entry); + prove(); // Empty creation, including any pre-existing orphan access document. + documents.set(entry.state.resource.targetUrl, { + policy: ownerPolicy(state.target.ownerWebId, entry.state.resource.kind === "container"), + }); + prove(); // Lock, before any descendant creation or content copy. + } + for (const entry of active + .filter((entry) => entry.decision !== "overwrite") + .sort((a, b) => b.state.resource.targetUrl.length - a.state.resource.targetUrl.length)) { + documents.set(entry.state.resource.targetUrl, { + policy: + entry.decision === "merge" + ? unionPolicies( + (entry.state.targetDocument as AccessDocument).policy, + scopePolicy(entry.policy, "resource"), + ) + : entry.policy, + }); + prove(); // Grant bottom up; validate all resources, not only the changed node. + } +} diff --git a/apps/pod-migrator/src/engine/policy.ts b/apps/pod-migrator/src/engine/policy.ts index 7ede6969d..273f409f9 100644 --- a/apps/pod-migrator/src/engine/policy.ts +++ b/apps/pod-migrator/src/engine/policy.ts @@ -28,7 +28,7 @@ export function effectivePolicy( system: AccessSystem, url: string, root: string, - documents: ReadonlyMap, + documents: ReadonlyMap | undefined>, ): EffectivePolicy { const found: EffectivePolicy[] = []; let current: string | undefined = url; diff --git a/apps/pod-migrator/src/engine/systems/acp.ts b/apps/pod-migrator/src/engine/systems/acp.ts index 8137829ff..cf8a5419a 100644 --- a/apps/pod-migrator/src/engine/systems/acp.ts +++ b/apps/pod-migrator/src/engine/systems/acp.ts @@ -8,13 +8,6 @@ export class PolicyTooComplex extends Error { override name = "PolicyTooComplex"; } export type AcpDialect = "css" | "ess"; -export function acpDialect(dataset: DatasetCore): AcpDialect { - return [...dataset].some((q) => - [`${ACP}Read`, `${ACP}Write`, `${ACP}Append`].includes(q.object.value), - ) - ? "ess" - : "css"; -} function product(left: Subject[][], right: Subject[][]): Subject[][] { if (left.length * right.length > 64) throw new PolicyTooComplex("ACP policy exceeds 64 clauses"); return left.flatMap((a) => right.map((b) => [...a, ...b])); @@ -47,7 +40,21 @@ function matcherClauses(matcher: Matcher): Subject[][] { } return present ? result : []; } -export function readAcp(dataset: DatasetCore, resource: string, url: string): EffectivePolicy { +/** + * CSS 7.2 AcpReader + AuthAuxiliaryReader use acl:* modes and resource Control. + * The explicit ESS dialect accepts the separately documented PodSpaces forms: + * acp:* modes, a typed root without acp:resource, and ACR-self/acp:access Read+Write + * as Control. Editable RDF is not evidence that a server implements these forms. + * Unknown/CSS source grants using them fail closed; denials use their broadest + * plausible scope. Target reads reject ambiguous denials instead of trusting them. + */ +export function readAcp( + dataset: DatasetCore, + resource: string, + url: string, + dialect?: AcpDialect, + target = false, +): EffectivePolicy { const result: EffectivePolicy = { grants: [], denials: [] }; // CSS enumerates EVERY acp:resource statement, not only the requested IRI. // Track the entire reachable graph so detached/unknown controls fail closed. @@ -82,6 +89,7 @@ export function readAcp(dataset: DatasetCore, resource: string, url: string): Ef appliesTo: Grant["appliesTo"], control = false, foreign = false, + fallback = false, ): void { visit(policy, ["allow", "deny", "allOf", "anyOf", "noneOf"]); const all = [...policy.allOf]; @@ -106,7 +114,30 @@ export function readAcp(dataset: DatasetCore, resource: string, url: string): Ef ["grants", policy.allow], ["denials", policy.deny], ] as const) { - const modes = convert(values); + const rawModes = [...values]; + const modes = convert(rawModes); + const uncertain = + dialect !== "ess" && + (control || fallback || rawModes.some((value) => value.startsWith(ACP))); + if (modes.size && uncertain && (target || field === "grants")) + throw new TypeError( + "Unsupported ACP grant or target restriction requires an established ESS dialect", + ); + // CSS supplies neither owner, creator nor vc in AcpReader.createContext. + // Such an exclusion can narrow a grant, but cannot weaken a source denial + // or be trusted to restrict existing target permissions. + const uncertainRole = (subject: Subject) => + subject.kind === "owner" || subject.kind === "creator" || subject.kind === "vc"; + if (target && dialect !== "ess" && [...who.flat(), ...excluded.flat()].some(uncertainRole)) + throw new TypeError("Target ACP owner/creator/vc matcher enforcement is not established"); + const canonical = rawModes.map((mode) => mode.replace(ACP, ACL)); + if ( + target && + !control && + field === "denials" && + canonical.includes(`${ACL}Append`) !== canonical.includes(`${ACL}Write`) + ) + throw new TypeError("Target ACP Write/Append denial implication is ambiguous"); if (control) { const hasControl = field === "grants" @@ -121,7 +152,10 @@ export function readAcp(dataset: DatasetCore, resource: string, url: string): Ef result[field].push({ modes, who, - except: excluded.flat(), + except: + field === "denials" && !target + ? excluded.flat().filter((subject) => !uncertainRole(subject)) + : excluded.flat(), appliesTo, source: { url, node: policy.value }, }); @@ -135,11 +169,13 @@ export function readAcp(dataset: DatasetCore, resource: string, url: string): Ef own ? "resource" : auxiliary ? "resource" : "both", !own && auxiliary, !own && !auxiliary, + !resources.length, ); } for (const ac of acr.memberAccessControl) { visit(ac, ["apply"]); - for (const policy of ac.apply) addPolicy(policy, own ? "members" : "both", false, !own); + for (const policy of ac.apply) + addPolicy(policy, own ? "members" : "both", false, !own, !resources.length); } for (const policy of node.nodes(`${ACP}access`)) addPolicy( @@ -147,6 +183,7 @@ export function readAcp(dataset: DatasetCore, resource: string, url: string): Ef "resource", true, !own && !auxiliary, + !resources.length, ); } for (const quad of dataset) @@ -170,9 +207,6 @@ export async function writeAcp( const acr = new RdfNode(url, dataset, DataFactory); acr.add(`${RDF}type`, `${ACP}AccessControlResource`); acr.add(`${ACP}resource`, resource); - // CSS evaluates ACL-mode policies for the ACR URL itself; ESS uses acp:access. - const ownerControl = new RdfNode(`${nodePrefix}#acr-owner`, dataset, DataFactory); - ownerControl.add(`${ACP}resource`, url); let index = 0; function matcher(subject: Subject): string { const node = new RdfNode(`${nodePrefix}#matcher-${index++}`, dataset, DataFactory); @@ -213,6 +247,10 @@ export async function writeAcp( ] as const) for (const rule of rules) for (const clause of rule.who) { + if (field === "deny" && rule.modes.has("append") !== rule.modes.has("write")) + throw new TypeError( + "ACP denials must deny both Write and Append to prevent derived Append access", + ); const p = new RdfNode(`${nodePrefix}#policy-${index++}`, dataset, DataFactory); const ac = new RdfNode(`${nodePrefix}#control-${index++}`, dataset, DataFactory); p.add(`${RDF}type`, `${ACP}Policy`); @@ -225,7 +263,7 @@ export async function writeAcp( } if (rule.appliesTo !== "members") acr.add(`${ACP}accessControl`, ac.value); if (rule.appliesTo !== "resource") acr.add(`${ACP}memberAccessControl`, ac.value); - if (rule.modes.has("control")) { + if (dialect === "ess" && rule.modes.has("control")) { // ACR edit permissions use the same conditions as the resource's Control grant. const controlPolicy = new RdfNode( `${nodePrefix}#acr-policy-${index++}`, @@ -234,12 +272,9 @@ export async function writeAcp( ); for (const subject of clause) controlPolicy.add(`${ACP}allOf`, matcher(subject)); for (const subject of rule.except) controlPolicy.add(`${ACP}noneOf`, matcher(subject)); - controlPolicy.add(ACP + field, `${dialect === "ess" ? ACP : ACL}Read`); - controlPolicy.add(ACP + field, `${dialect === "ess" ? ACP : ACL}Write`); + controlPolicy.add(ACP + field, `${ACP}Read`); + controlPolicy.add(ACP + field, `${ACP}Write`); acr.add(`${ACP}access`, controlPolicy.value); - const access = new RdfNode(`${nodePrefix}#acr-control-${index++}`, dataset, DataFactory); - access.add(`${ACP}apply`, controlPolicy.value); - ownerControl.add(`${ACP}accessControl`, access.value); } } return serialize(dataset); diff --git a/apps/pod-migrator/src/engine/systems/wac.ts b/apps/pod-migrator/src/engine/systems/wac.ts index 85ab05b19..581e5f5b1 100644 --- a/apps/pod-migrator/src/engine/systems/wac.ts +++ b/apps/pod-migrator/src/engine/systems/wac.ts @@ -5,14 +5,21 @@ import { DataFactory, Store } from "n3"; import { ACL, FOAF, modeIri, RDF, RdfNode, readModes, serialize } from "../rdf.js"; import type { EffectivePolicy, Grant, Subject } from "../types.js"; -export function readWac(dataset: DatasetCore, resource: string, url: string): EffectivePolicy { +export function readWac( + dataset: DatasetCore, + resource: string, + url: string, + implementation?: "css" | "ess" | "nss", + target = false, +): EffectivePolicy { const grants: Grant[] = []; for (const auth of new AclResource(dataset, DataFactory).authorizations) { const node = new RdfNode(auth as unknown as import("@rdfjs/types").Term, dataset, DataFactory); const own = node.iris(`${ACL}accessTo`).includes(resource); - const members = [...node.iris(`${ACL}default`), ...node.iris(`${ACL}defaultForNew`)].includes( - resource, - ); + const legacy = node.iris(`${ACL}defaultForNew`).includes(resource); + if (legacy && implementation !== "nss") + throw new TypeError("acl:defaultForNew grants require an established NSS source dialect"); + const members = node.iris(`${ACL}default`).includes(resource) || legacy; if (!own && !members) continue; const subjects: Subject[] = node.iris(`${ACL}agent`).map((webId) => ({ kind: "agent", webId })); for (const value of node.iris(`${ACL}agentClass`)) { @@ -21,7 +28,9 @@ export function readWac(dataset: DatasetCore, resource: string, url: string): Ef else throw new TypeError("Unsupported WAC agent class"); } for (const group of node.iris(`${ACL}agentGroup`)) subjects.push({ kind: "group", group }); - const origins = node.iris(`${ACL}origin`); + // Source: a possibly enforced condition narrows the grant. Target: CSS ignores it, + // so it must never mask actual access during planning or verification. + const origins = target ? [] : node.iris(`${ACL}origin`); const who = subjects.flatMap((subject) => origins.length ? origins.map((origin) => [subject, { kind: "origin", origin } as Subject]) @@ -74,8 +83,7 @@ export async function writeWac( auth.add(`${ACL}agentGroup`, subject.group); break; case "origin": - auth.add(`${ACL}origin`, subject.origin); - break; + throw new TypeError("WAC targets do not establish enforcement of origin restrictions"); default: throw new TypeError("Unsupported WAC subject"); } diff --git a/apps/pod-migrator/src/engine/translation.ts b/apps/pod-migrator/src/engine/translation.ts index b0b8272fb..9139c3b5f 100644 --- a/apps/pod-migrator/src/engine/translation.ts +++ b/apps/pod-migrator/src/engine/translation.ts @@ -116,16 +116,16 @@ export function translatePolicy( return subject; }), ); + if (clauses.some((c) => c.some((s) => s.kind === "origin"))) { + choose( + "origin-unsupported", + "Origin enforcement is not established on any target; CSS 7.2 ignores acl:origin. Each origin-restricted clause is omitted.", + ["skip", "abort"], + `${ruleIndex}`, + ); + clauses = clauses.filter((c) => !c.some((s) => s.kind === "origin")); + } if (target === "acp") { - if (clauses.some((c) => c.some((s) => s.kind === "origin"))) { - choose( - "origin-unsupported", - "ACP has no origin restriction. Each origin-restricted clause is omitted.", - ["skip", "abort"], - `${ruleIndex}`, - ); - clauses = clauses.filter((c) => !c.some((s) => s.kind === "origin")); - } for (const group of new Set( clauses .flat() @@ -257,6 +257,10 @@ export function translatePolicy( } else policy.denials.push({ ...rule, + modes: + rule.modes.has("append") || rule.modes.has("write") + ? new Set([...rule.modes, "write", "append"]) + : rule.modes, who: rule.who.map((clause) => clause.map((subject) => subject.kind === "owner" ? { kind: "agent", webId: context.sourceOwner } : subject, diff --git a/apps/pod-migrator/src/engine/types.ts b/apps/pod-migrator/src/engine/types.ts index 7360f985d..e816cfc26 100644 --- a/apps/pod-migrator/src/engine/types.ts +++ b/apps/pod-migrator/src/engine/types.ts @@ -15,6 +15,8 @@ export interface PodSide { root: string; ownerWebId: string; http: PodHttp; + /** Established by the caller independently of editable access RDF; never inferred from mode IRIs. */ + implementation?: "css" | "ess" | "nss"; } export type ReportKind = | "group-unsupported" diff --git a/apps/pod-migrator/test/css/account.ts b/apps/pod-migrator/test/css/account.ts index 7abe7f860..4aea75957 100644 --- a/apps/pod-migrator/test/css/account.ts +++ b/apps/pod-migrator/test/css/account.ts @@ -101,6 +101,7 @@ export async function createAccount( side: { root: podRoot, ownerWebId: webId, + implementation: "css", http: createPodHttp({ root: podRoot, fetch: authenticated, allowInsecureLoopback: true }), }, }; diff --git a/apps/pod-migrator/test/engine/dialect-safety.test.ts b/apps/pod-migrator/test/engine/dialect-safety.test.ts new file mode 100644 index 000000000..0d45dbdad --- /dev/null +++ b/apps/pod-migrator/test/engine/dialect-safety.test.ts @@ -0,0 +1,511 @@ +// AUTHORED-BY Codex GPT-6 +import { createRequire } from "node:module"; +import { DataFactory } from "n3"; +import { describe, expect, it } from "vitest"; +import { executeMigration, finalizePlan, planMigration } from "../../src/engine/index.js"; +import { ACL, ACP, RDF, RdfNode, serialize } from "../../src/engine/rdf.js"; +import { readAcp, writeAcp } from "../../src/engine/systems/acp.js"; +import { translatePolicy } from "../../src/engine/translation.js"; +import { FakePod } from "./fake-pod.js"; +import { acl, grant, pods, policy, resolve, stranger } from "./helpers.js"; + +const requireCss = createRequire(import.meta.url); +const { WebAclReader } = requireCss("@solid/community-server/dist/authorization/WebAclReader.js"); +const { AgentAccessChecker } = requireCss( + "@solid/community-server/dist/authorization/access/AgentAccessChecker.js", +); +const { AgentClassAccessChecker } = requireCss( + "@solid/community-server/dist/authorization/access/AgentClassAccessChecker.js", +); +const { AgentGroupAccessChecker } = requireCss( + "@solid/community-server/dist/authorization/access/AgentGroupAccessChecker.js", +); +const { BooleanHandler } = requireCss( + "@solid/community-server/dist/util/handlers/BooleanHandler.js", +); +const wacConfig = requireCss("@solid/community-server/config/ldp/authorization/readers/acl.json"); + +describe("round 2: source dialects and target execution phases", () => { + it("rechecks phase safety when a changed root grant was masked by an orphan denial", async () => { + const { source, target } = await pods("acp", "acp"); + const folder = `${source.root}folder/`; + source.store(folder, "", "text/turtle"); + source.setAcl(folder, await acl(source, folder, policy(grant()))); + source.store(`${folder}secret`, "secret"); + const restricted = policy(grant()); + restricted.denials.push( + grant(undefined, { appliesTo: "members", except: [{ kind: "agent", webId: target.owner }] }), + ); + target.setAcl(`${target.root}folder/`, await acl(target, `${target.root}folder/`, restricted)); + const options = { source: source.side(), target: target.side() }; + const plan = await planMigration(options); + const executable = finalizePlan(plan, resolve(plan)); + target.setAcl( + target.root, + await acl(target, target.root, policy(grant(undefined, { appliesTo: "members" }))), + ); + const result = await executeMigration(executable, options); + expect(result.verified).toBe(false); + expect(result.failed[0]?.error).toContain("widens read"); + expect(target.requests.filter((r) => r.method === "PUT")).toEqual([]); + }); + it.each([ + "deny", + "exclude", + ])("does not retain a replaced ancestor %s in the ACP proof", async (restriction) => { + const { source, target } = await pods("acp", "acp"); + const folder = `${source.root}folder/`; + source.store(folder, "", "text/turtle"); + source.setAcl(folder, await acl(source, folder, policy(grant()))); + source.store(`${folder}secret`, "secret"); + target.setAcl( + target.root, + await acl( + target, + target.root, + policy( + grant(undefined, { + appliesTo: "members", + except: restriction === "exclude" ? [{ kind: "agent", webId: stranger }] : [], + }), + ), + ), + ); + const restricted = policy(); + restricted.denials.push( + grant(undefined, { + appliesTo: "members", + except: [{ kind: "agent", webId: target.owner }], + }), + ); + target.setAcl(`${target.root}folder/`, await acl(target, `${target.root}folder/`, restricted)); + const options = { source: source.side(), target: target.side() }; + const plan = await planMigration(options); + expect(() => finalizePlan(plan, resolve(plan))).toThrow("widens read"); + expect(target.requests.filter((r) => r.method === "PUT")).toEqual([]); + }); + + it.each([ + "self", + "access", + ])("does not manufacture CSS Control from %s policies", async (construct) => { + const { source, target } = await pods("acp", "wac"); + const file = `${source.root}file`; + source.store(file, "secret"); + source.setAcl(file, await acl(source, file)); + const dataset = await source.dataset(file); + if (!dataset) throw new Error("Expected ACR"); + const n = (id: string) => new RdfNode(id, dataset, DataFactory); + if (construct === "self") { + n("urn:self").add(`${ACP}resource`, source.acl(file)); + n("urn:self").add(`${ACP}accessControl`, "urn:ac"); + n("urn:ac").add(`${ACP}apply`, "urn:p"); + } else n(source.acl(file)).add(`${ACP}access`, "urn:p"); + n("urn:p").add(`${ACP}allow`, `${ACL}Read`); + n("urn:p").add(`${ACP}allow`, `${ACL}Write`); + n("urn:p").add(`${ACP}allOf`, "urn:m"); + n("urn:m").add(`${ACP}agent`, stranger); + source.setAcl(file, await serialize(dataset)); + expect(await source.permissions(file, { agent: stranger })).toEqual(new Set()); + expect((await source.request(source.acl(file), {}, { agent: stranger })).status).toBe(403); + const options = { source: source.side(), target: target.side() }; + const plan = await planMigration(options); + expect( + plan.report.some( + (item) => item.resourceUrl === file && item.options.some((o) => o.id === "abort"), + ), + ).toBe(true); + const result = await executeMigration( + finalizePlan(plan, resolve(plan, { "acl-unreadable": "owner-only" })), + options, + ); + expect(result.failed).toEqual([]); + expect(result.verified).toBe(true); + expect(await target.permissions(`${target.root}file`, { agent: stranger })).toEqual(new Set()); + }); + + it.each(["typed", "modes"])("does not activate CSS-ignored ACP %s grants", async (construct) => { + const { source, target } = await pods("acp", "wac"); + const file = `${source.root}file`; + source.store(file, "secret"); + source.setAcl(file, await acl(source, file)); + const dataset = await source.dataset(file); + if (!dataset) throw new Error("Expected ACR"); + const n = (id: string) => new RdfNode(id, dataset, DataFactory); + const root = construct === "typed" ? "urn:typed" : source.acl(file); + if (construct === "typed") { + // A wholly typed fallback (the old reader supports it when there are no bindings). + for (const quad of [...dataset]) dataset.delete(quad); + n(root).add(`${RDF}type`, `${ACP}AccessControlResource`); + } + n(root).add(`${ACP}accessControl`, "urn:ac"); + n("urn:ac").add(`${ACP}apply`, "urn:p"); + n("urn:p").add(`${ACP}allow`, `${construct === "modes" ? ACP : ACL}Read`); + n("urn:p").add(`${ACP}allOf`, "urn:m"); + n("urn:m").add(`${ACP}agent`, `${ACP}PublicAgent`); + source.setAcl(file, await serialize(dataset)); + expect(await source.permissions(file)).toEqual(new Set()); + const options = { + source: { ...source.side(), implementation: undefined }, + target: target.side(), + }; + const plan = await planMigration(options); + expect( + plan.report.some((item) => item.resourceUrl === file && item.kind === "acl-unreadable"), + ).toBe(true); + const result = await executeMigration( + finalizePlan(plan, resolve(plan, { "acl-unreadable": "owner-only" })), + options, + ); + expect(result.failed).toEqual([]); + expect(result.verified).toBe(true); + expect(await target.permissions(`${target.root}file`)).toEqual(new Set()); + }); + + it.each([ + "css", + "nss", + ] as const)("does not invent defaultForNew access from a %s source", async (implementation) => { + const source = new FakePod("https://shared.example/alice/", "wac", undefined, implementation); + const { target } = await pods("wac", "wac"); + await source.initialize(await acl(source, source.root)); + source.store(`${source.root}file`, "secret"); + const dataset = await source.dataset(source.root); + if (!dataset) throw new Error("Expected ACL"); + const auth = new RdfNode("urn:legacy", dataset, DataFactory); + auth.add(`${RDF}type`, `${ACL}Authorization`); + auth.add(`${ACL}defaultForNew`, source.root); + auth.add(`${ACL}agentClass`, "http://xmlns.com/foaf/0.1/Agent"); + auth.add(`${ACL}mode`, `${ACL}Read`); + source.setAcl(source.root, await serialize(dataset)); + expect((await source.permissions(`${source.root}file`)).has("read")).toBe( + implementation === "nss", + ); + // The engine has no established dialect here, even when the fixture enforces NSS. + const options = { + source: { ...source.side(), implementation: undefined }, + target: target.side(), + }; + const plan = await planMigration(options); + expect(plan.report.some((item) => item.kind === "acl-unreadable")).toBe(true); + const result = await executeMigration( + finalizePlan(plan, resolve(plan, { "acl-unreadable": "owner-only" })), + options, + ); + expect(result.failed).toEqual([]); + expect(result.verified).toBe(true); + expect(await target.permissions(`${target.root}file`)).toEqual(new Set()); + }); + + it.each(["wac", "acp"] as const)("drops origin grants on a CSS %s target", async (system) => { + const source = new FakePod("https://shared.example/alice/", "wac", undefined, "nss"); + const { target } = await pods("wac", system); + await source.initialize(await acl(source, source.root)); + const file = `${source.root}file`; + source.store(file, "secret"); + source.setAcl(file, await acl(source, file)); + const graph = await source.dataset(file); + if (!graph) throw new Error("Expected ACL"); + const auth = new RdfNode("urn:origin", graph, DataFactory); + auth.add(`${RDF}type`, `${ACL}Authorization`); + auth.add(`${ACL}accessTo`, file); + auth.add(`${ACL}agent`, stranger); + auth.add(`${ACL}origin`, "https://allowed.example"); + auth.add(`${ACL}mode`, `${ACL}Read`); + source.setAcl(file, await serialize(graph)); + expect(await source.permissions(file, { agent: stranger })).toEqual(new Set()); + expect( + await source.permissions(file, { agent: stranger, origin: "https://allowed.example" }), + ).toEqual(new Set(["read"])); + // Use the exact shipped checker chain, so fixture enforcement cannot hide this gap. + const configured = wacConfig["@graph"].find( + (node: { "@type": string }) => node["@type"] === "WebAclReader", + ); + expect( + configured.accessChecker.handlers.map((handler: { "@id": string }) => handler["@id"]), + ).toEqual([ + "urn:solid-server:default:AgentAccessChecker", + "urn:solid-server:default:AgentClassAccessChecker", + "urn:solid-server:default:AgentGroupAccessChecker", + ]); + const css = new WebAclReader( + {}, + {}, + {}, + {}, + new BooleanHandler([ + new AgentAccessChecker(), + new AgentClassAccessChecker(), + new AgentGroupAccessChecker(), + ]), + ); + const cssPermissions = await css.determinePermissions(await source.dataset(file), { + agent: { webId: stranger }, + }); + expect(cssPermissions.read).toBe(true); + const options = { source: source.side(), target: target.side() }; + const plan = await planMigration(options); + const item = plan.report.find((item) => item.kind === "origin-unsupported"); + expect(item?.options.map((o) => o.id)).toEqual(["skip", "abort"]); + const result = await executeMigration(finalizePlan(plan, resolve(plan)), options); + expect(result.failed).toEqual([]); + expect(result.verified).toBe(true); + expect( + await target.permissions(`${target.root}file`, { + agent: stranger, + origin: "https://allowed.example", + }), + ).toEqual(new Set()); + expect( + await target.permissions(`${target.root}file`, { + agent: stranger, + origin: "https://elsewhere.example", + }), + ).toEqual(new Set()); + }); + + it("requires independent ACP target implementation evidence, even with ESS mode IRIs", async () => { + const { source, target } = await pods("wac", "acp"); + const dataset = await target.dataset(target.root); + if (!dataset) throw new Error("Expected ACR"); + new RdfNode("urn:decoy", dataset, DataFactory).add("urn:metadata", `${ACP}Read`); + target.setAcl(target.root, await serialize(dataset)); + for (const implementation of [undefined, "nss"] as const) { + const plan = await planMigration({ + source: source.side(), + target: { ...target.side(), implementation }, + }); + expect(plan.targetSystem).toBe("unknown"); + expect( + plan.report.find((item) => item.kind === "acl-system-unknown")?.options.map((o) => o.id), + ).toEqual(["abort"]); + expect(() => finalizePlan(plan, resolve(plan))).toThrow("aborted"); + } + const plan = await planMigration({ source: source.side(), target: target.side() }); + expect(plan.targetSystem).toBe("acp"); + }); + + it.each([ + "self", + "access", + "modes", + "typed", + ])("evaluates ESS %s forms independently and drops them when the source dialect is unestablished", async (construct) => { + const source = new FakePod("https://shared.example/alice/", "acp", undefined, "ess"); + const { target } = await pods("acp", "wac"); + await source.initialize(await acl(source, source.root)); + const file = `${source.root}file`; + source.store(file, "secret"); + source.setAcl(file, await acl(source, file)); + const dataset = await source.dataset(file); + if (!dataset) throw new Error("Expected ACR"); + const n = (id: string) => new RdfNode(id, dataset, DataFactory); + let root = source.acl(file); + if (construct === "typed") { + for (const quad of [...dataset.match(null, DataFactory.namedNode(`${ACP}resource`))]) + dataset.delete(quad); + } + if (construct === "self") { + root = "urn:self"; + n(root).add(`${ACP}resource`, source.acl(file)); + } + if (construct === "access") n(root).add(`${ACP}access`, "urn:p"); + else { + n(root).add(`${ACP}accessControl`, "urn:ac"); + n("urn:ac").add(`${ACP}apply`, "urn:p"); + } + n("urn:p").add(`${ACP}allow`, `${ACP}Read`); + if (construct === "self" || construct === "access") + n("urn:p").add(`${ACP}allow`, `${ACP}Write`); + n("urn:p").add(`${ACP}allOf`, "urn:m"); + n("urn:m").add(`${ACP}agent`, stranger); + source.setAcl(file, await serialize(dataset)); + const expected = new Set([construct === "self" || construct === "access" ? "control" : "read"]); + expect(await source.permissions(file, { agent: stranger })).toEqual(expected); + expect( + readAcp(dataset, file, source.acl(file), "ess").grants.some((rule) => + rule.who.some((clause) => clause.some((s) => s.kind === "agent" && s.webId === stranger)), + ), + ).toBe(true); + const options = { + source: { ...source.side(), implementation: undefined }, + target: target.side(), + }; + const plan = await planMigration(options); + expect( + plan.report.some( + (item) => + item.kind === "acl-unreadable" && item.options.some((option) => option.id === "abort"), + ), + ).toBe(true); + const result = await executeMigration( + finalizePlan(plan, resolve(plan, { "acl-unreadable": "owner-only" })), + options, + ); + expect(result.failed).toEqual([]); + expect(result.verified).toBe(true); + expect(await target.permissions(`${target.root}file`, { agent: stranger })).toEqual(new Set()); + }); + + it("translates defaultForNew only for an explicitly established NSS source", async () => { + const source = new FakePod("https://shared.example/alice/", "wac", undefined, "nss"); + const { target } = await pods("wac", "wac"); + await source.initialize(await acl(source, source.root)); + source.store(`${source.root}file`, "public"); + const dataset = await source.dataset(source.root); + if (!dataset) throw new Error("Expected ACL"); + const auth = new RdfNode("urn:legacy", dataset, DataFactory); + auth.add(`${RDF}type`, `${ACL}Authorization`); + auth.add(`${ACL}defaultForNew`, source.root); + auth.add(`${ACL}agentClass`, "http://xmlns.com/foaf/0.1/Agent"); + auth.add(`${ACL}mode`, `${ACL}Read`); + source.setAcl(source.root, await serialize(dataset)); + expect(await source.permissions(`${source.root}file`)).toEqual(new Set(["read"])); + const options = { source: source.side(), target: target.side() }; + const plan = await planMigration(options); + expect(plan.report.some((item) => item.kind === "acl-unreadable")).toBe(false); + const result = await executeMigration(finalizePlan(plan, resolve(plan)), options); + expect(result.failed).toEqual([]); + expect(result.verified).toBe(true); + expect(await target.permissions(`${target.root}file`)).toEqual(new Set(["read"])); + }); + + it("uses explicitly established ESS semantics without adding ineffective CSS Control policies", async () => { + const source = new FakePod("https://shared.example/alice/", "acp", undefined, "ess"); + const { target } = await pods("acp", "wac"); + await source.initialize(await acl(source, source.root)); + const file = `${source.root}file`; + source.store(file, "secret"); + source.setAcl( + file, + await acl( + source, + file, + policy( + grant([[{ kind: "agent", webId: stranger }]], { + modes: new Set(["read", "write", "append", "control"]), + }), + ), + ), + ); + expect(await source.permissions(file, { agent: stranger })).toEqual( + new Set(["read", "write", "append", "control"]), + ); + const options = { source: source.side(), target: target.side() }; + const plan = await planMigration(options); + const result = await executeMigration(finalizePlan(plan, resolve(plan)), options); + expect(result.failed).toEqual([]); + expect(result.verified).toBe(true); + expect(await target.permissions(`${target.root}file`, { agent: stranger })).toEqual( + await source.permissions(file, { agent: stranger }), + ); + }); + + it("writes established ESS controls only when initial owner Control is provable", async () => { + const { source } = await pods("wac", "wac"); + source.store(`${source.root}file`, "secret"); + const target = new FakePod("https://shared.example/bob/", "acp", undefined, "ess"); + await target.initialize(await acl(target, target.root)); + const targetFile = `${target.root}file`; + target.setAcl(targetFile, await acl(target, targetFile)); + const options = { source: source.side(), target: target.side() }; + const plan = await planMigration(options); + const result = await executeMigration(finalizePlan(plan, resolve(plan)), options); + expect(result.failed).toEqual([]); + expect(result.verified).toBe(true); + expect(new TextDecoder().decode(target.data.get(targetFile)?.bytes)).toBe("secret"); + expect(await target.permissions(targetFile, { agent: stranger })).toEqual(new Set()); + }); + + it.each([ + "modes", + "typed", + "self", + "access", + ])("retains uncertain source %s denials and rejects them as target enforcement", async (construct) => { + const { source } = await pods("acp", "acp"); + const file = `${source.root}file`; + source.setAcl(file, await acl(source, file)); + const dataset = await source.dataset(file); + if (!dataset) throw new Error("Expected ACR"); + const n = (id: string) => new RdfNode(id, dataset, DataFactory); + let root = source.acl(file); + if (construct === "typed") { + for (const quad of [...dataset]) dataset.delete(quad); + n(root).add(`${RDF}type`, `${ACP}AccessControlResource`); + } + if (construct === "self") { + root = "urn:self"; + n(root).add(`${ACP}resource`, source.acl(file)); + } + if (construct === "access") n(root).add(`${ACP}access`, "urn:p"); + else { + n(root).add(`${ACP}accessControl`, "urn:ac"); + n("urn:ac").add(`${ACP}apply`, "urn:p"); + } + n("urn:p").add(`${ACP}deny`, `${construct === "modes" ? ACP : ACL}Read`); + n("urn:p").add(`${ACP}allOf`, "urn:m"); + n("urn:m").add(`${ACP}agent`, `${ACP}PublicAgent`); + expect(readAcp(dataset, file, source.acl(file)).denials.at(-1)?.modes).toEqual( + new Set([construct === "self" || construct === "access" ? "control" : "read"]), + ); + expect(() => readAcp(dataset, file, source.acl(file), "css", true)).toThrow("ESS"); + }); + + it.each([ + "OwnerAgent", + "CreatorAgent", + "vc", + ])("does not exempt a source denial based on uncertain %s matching", async (role) => { + const { source } = await pods("acp", "acp"); + const file = `${source.root}file`; + source.setAcl(file, await acl(source, file, policy(grant()))); + const dataset = await source.dataset(file); + if (!dataset) throw new Error("Expected ACR"); + const n = (id: string) => new RdfNode(id, dataset, DataFactory); + n(source.acl(file)).add(`${ACP}accessControl`, "urn:ac"); + n("urn:ac").add(`${ACP}apply`, "urn:p"); + n("urn:p").add(`${ACP}deny`, `${ACL}Read`); + n("urn:p").add(`${ACP}allOf`, "urn:public"); + n("urn:p").add(`${ACP}noneOf`, "urn:role"); + n("urn:public").add(`${ACP}agent`, `${ACP}PublicAgent`); + n("urn:role").add(`${ACP}${role === "vc" ? "vc" : "agent"}`, ACP + role); + source.setAcl(file, await serialize(dataset)); + expect((await source.permissions(file, { agent: source.owner })).has("read")).toBe(false); + expect(readAcp(dataset, file, source.acl(file)).denials.at(-1)?.except).toEqual([]); + expect(() => readAcp(dataset, file, source.acl(file), "css", true)).toThrow("owner/creator"); + }); + + it.each([ + "append", + "write", + ] as const)("refuses a target %s-only denial that could mask derived Append", async (mode) => { + const { source } = await pods("acp", "acp"); + const file = `${source.root}file`; + const extra = policy( + grant(undefined, { modes: new Set(mode === "append" ? ["write", "append"] : ["append"]) }), + ); + extra.denials.push(grant(undefined, { modes: new Set([mode]) })); + await expect(writeAcp(extra, file, source.acl(file))).rejects.toThrow("both Write and Append"); + const translated = translatePolicy(extra, "acp", { + resourceUrl: file, + sourceOwner: source.owner, + targetOwner: "urn:owner", + groups: new Map(), + }); + expect(translated.policy.denials[0]?.modes).toEqual(new Set(["write", "append"])); + extra.denials = []; + source.setAcl(file, await acl(source, file, extra)); + const dataset = await source.dataset(file); + if (!dataset) throw new Error("Expected ACR"); + const n = (id: string) => new RdfNode(id, dataset, DataFactory); + n(source.acl(file)).add(`${ACP}accessControl`, "urn:ac"); + n("urn:ac").add(`${ACP}apply`, "urn:p"); + n("urn:p").add(`${ACP}deny`, mode === "append" ? `${ACL}Append` : `${ACL}Write`); + n("urn:p").add(`${ACP}allOf`, "urn:public"); + n("urn:public").add(`${ACP}agent`, `${ACP}PublicAgent`); + source.setAcl(file, await serialize(dataset)); + expect((await source.permissions(file)).has("append")).toBe(true); + expect(() => readAcp(dataset, file, source.acl(file), "css", true)).toThrow("Write/Append"); + }); +}); diff --git a/apps/pod-migrator/test/engine/edges.test.ts b/apps/pod-migrator/test/engine/edges.test.ts index 4ac5dd0f3..dc2a142e0 100644 --- a/apps/pod-migrator/test/engine/edges.test.ts +++ b/apps/pod-migrator/test/engine/edges.test.ts @@ -24,6 +24,7 @@ function transformed( return { root: pod.root, ownerWebId: pod.owner, + implementation: pod.implementation, http: createPodHttp({ root: pod.root, fetch: async (url, init) => diff --git a/apps/pod-migrator/test/engine/fake-pod.ts b/apps/pod-migrator/test/engine/fake-pod.ts index eff50ea10..1c8353d5a 100644 --- a/apps/pod-migrator/test/engine/fake-pod.ts +++ b/apps/pod-migrator/test/engine/fake-pod.ts @@ -6,7 +6,7 @@ import { Container, Group, type Matcher, - type Policy, + Policy, } from "@solid/object"; import { DataFactory, Store } from "n3"; import { createPodHttp } from "../../src/engine/pod-http.js"; @@ -42,14 +42,21 @@ export class FakePod { readonly root: string; readonly owner: string; readonly system: "wac" | "acp"; + readonly implementation: "css" | "ess" | "nss"; afterWrite?: (url: string) => void | Promise; /** CSS PutOperationHandler returns 201/205 without representation ETags. */ writeEtags = true; next = 0; - constructor(root: string, system: "wac" | "acp", owner = `${root}profile/card#me`) { + constructor( + root: string, + system: "wac" | "acp", + owner = `${root}profile/card#me`, + implementation: "css" | "ess" | "nss" = "css", + ) { this.root = root; this.system = system; this.owner = owner; + this.implementation = implementation; } acl(url: string): string { return this.aclOverrides.get(url) ?? url + (this.system === "wac" ? ".acl" : ".acr"); @@ -78,6 +85,7 @@ export class FakePod { root: this.root, ownerWebId: this.owner, http: createPodHttp({ root: this.root, fetch: this.fetch }), + implementation: this.implementation, }; } async dataset(url: string): Promise { @@ -108,8 +116,10 @@ export class FakePod { dataset, DataFactory, ); - if (!node.iris(ACL + (url === ancestor ? "accessTo" : "default")).includes(ancestor)) - continue; + const scopes = node.iris(ACL + (url === ancestor ? "accessTo" : "default")); + if (url !== ancestor && this.implementation === "nss") + scopes.push(...node.iris(`${ACL}defaultForNew`)); + if (!scopes.includes(ancestor)) continue; const groupMatches = await Promise.all( node.iris(`${ACL}agentGroup`).map(async (group) => { if (identity.groups?.includes(group)) return true; @@ -130,7 +140,12 @@ export class FakePod { node.iris(`${ACL}agentClass`).includes(`${ACL}AuthenticatedAgent`)) || groupMatches.some(Boolean); const origins = node.iris(`${ACL}origin`); - if (matches && (!origins.length || origins.includes(identity.origin ?? ""))) + if ( + matches && + (this.implementation === "css" || + !origins.length || + origins.includes(identity.origin ?? "")) + ) for (const mode of node.iris(`${ACL}mode`)) allowed.add(mode); } break; // nearest-ACL replacement, including an empty ACL @@ -148,17 +163,19 @@ export class FakePod { agent === `${ACP}PublicAgent` || agent === identity.agent || (!!identity.agent && agent === `${ACP}AuthenticatedAgent`) || - ((agent === `${ACP}OwnerAgent` || agent === `${ACP}CreatorAgent`) && + (this.implementation === "ess" && + (agent === `${ACP}OwnerAgent` || agent === `${ACP}CreatorAgent`) && identity.agent === this.owner), )) && (!clients.length || clients.includes(`${ACP}PublicClient`) || clients.includes(identity.client ?? "")) && (!issuers.length || issuers.includes(identity.issuer ?? "")) && - (!vcs.length || vcs.some((vc) => identity.vc?.includes(vc))) + (!vcs.length || + (this.implementation === "ess" && vcs.some((vc) => identity.vc?.includes(vc)))) ); }; - const apply = (policy: Policy) => { + const apply = (policy: Policy, control = false) => { const all = [...policy.allOf]; const any = [...policy.anyOf]; const none = [...policy.noneOf]; @@ -168,14 +185,60 @@ export class FakePod { (!any.length || any.some(matcherMatches)) && !none.some(matcherMatches) ) { - for (const mode of policy.allow) allowed.add(mode.replace(ACP, ACL)); - for (const mode of policy.deny) denied.add(mode.replace(ACP, ACL)); + const convert = (values: Iterable) => + [...values].map((mode) => + this.implementation === "ess" ? mode.replace(ACP, ACL) : mode, + ); + const grants = convert(policy.allow); + const denials = convert(policy.deny); + if (control) { + if (grants.includes(`${ACL}Read`) && grants.includes(`${ACL}Write`)) + allowed.add(`${ACL}Control`); + if (denials.includes(`${ACL}Read`) || denials.includes(`${ACL}Write`)) + denied.add(`${ACL}Control`); + } else { + for (const mode of grants) allowed.add(mode); + for (const mode of denials) denied.add(mode); + } } }; for (const q of dataset.match(null, DataFactory.namedNode(`${ACP}resource`), null)) { const acr = new AccessControlResource(q.subject, dataset, DataFactory); for (const control of url === q.object.value ? acr.accessControl : acr.memberAccessControl) for (const policy of control.apply) apply(policy); + if (this.implementation === "ess" && q.object.value === this.acl(url)) + for (const control of acr.accessControl) + for (const policy of control.apply) apply(policy, true); + } + if (this.implementation === "ess") { + const roots = new Set( + [ + ...dataset.match( + null, + DataFactory.namedNode(`${ACP}resource`), + DataFactory.namedNode(ancestor), + ), + ...dataset.match( + null, + DataFactory.namedNode(`${RDF}type`), + DataFactory.namedNode(`${ACP}AccessControlResource`), + ), + ].map((q) => q.subject), + ); + for (const term of roots) { + const node = new RdfNode(term, dataset, DataFactory); + if (!node.iris(`${ACP}resource`).length) { + const acr = new AccessControlResource(term, dataset, DataFactory); + for (const control of url === ancestor ? acr.accessControl : acr.memberAccessControl) + for (const policy of control.apply) apply(policy); + } + if (url === ancestor) + for (const p of node.nodes(`${ACP}access`)) + apply( + new Policy(p as unknown as import("@rdfjs/types").Term, dataset, DataFactory), + true, + ); + } } } for (const mode of denied) allowed.delete(mode); diff --git a/apps/pod-migrator/test/engine/helpers.ts b/apps/pod-migrator/test/engine/helpers.ts index 7914dae9b..ca9fa55ae 100644 --- a/apps/pod-migrator/test/engine/helpers.ts +++ b/apps/pod-migrator/test/engine/helpers.ts @@ -39,7 +39,7 @@ export async function acl( const model = unionPolicies(ownerPolicy(pod.owner, members), extra); return pod.system === "wac" ? writeWac(model, resource, pod.acl(resource)) - : writeAcp(model, resource, pod.acl(resource)); + : writeAcp(model, resource, pod.acl(resource), pod.implementation === "ess" ? "ess" : "css"); } export async function pods( from: "wac" | "acp" = "wac", diff --git a/apps/pod-migrator/test/engine/policy.test.ts b/apps/pod-migrator/test/engine/policy.test.ts index 1521b4466..ce29ceb32 100644 --- a/apps/pod-migrator/test/engine/policy.test.ts +++ b/apps/pod-migrator/test/engine/policy.test.ts @@ -59,7 +59,10 @@ describe("every translation-table construct", () => { expect( translated(policy(grant([[agent, origin], [agent]])), "acp").policy.grants[0]?.who, ).toEqual([[agent]]); - expect(translated(policy(grant([[agent, origin]])), "wac").report).toEqual([]); + expect(translated(policy(grant([[agent, origin]])), "wac").report[0]?.kind).toBe( + "origin-unsupported", + ); + expect(translated(policy(grant([[agent, origin]])), "wac").policy.grants).toEqual([]); for (const condition of [ { kind: "client", clientId: "https://app.example/id" }, { kind: "issuer", issuer: "https://issuer.example" }, diff --git a/apps/pod-migrator/test/engine/systems.test.ts b/apps/pod-migrator/test/engine/systems.test.ts index daeb1b092..b5333e3d0 100644 --- a/apps/pod-migrator/test/engine/systems.test.ts +++ b/apps/pod-migrator/test/engine/systems.test.ts @@ -15,7 +15,7 @@ import { safeIri, serialize, } from "../../src/engine/rdf.js"; -import { acpDialect, PolicyTooComplex, readAcp, writeAcp } from "../../src/engine/systems/acp.js"; +import { PolicyTooComplex, readAcp, writeAcp } from "../../src/engine/systems/acp.js"; import { readWac, writeWac } from "../../src/engine/systems/wac.js"; import type { Subject } from "../../src/engine/types.js"; import { grant, pods, policy, stranger } from "./helpers.js"; @@ -52,8 +52,25 @@ describe("RDF and WAC", () => { appliesTo: "both", }), ); - const turtle = await writeWac(source, resource, url); + await expect(writeWac(source, resource, url)).rejects.toThrow("origin restrictions"); + const turtle = await writeWac( + policy( + grant( + subjects.map((clause) => clause.filter((s) => s.kind !== "origin")), + { + modes: new Set(["write", "append", "read", "control"]), + appliesTo: "both", + }, + ), + ), + resource, + url, + ); const dataset = await parsed(turtle); + new RdfNode(`${url}#authorization-0`, dataset, DataFactory).add( + `${ACL}origin`, + "https://app.example", + ); const read = readWac(dataset, resource, url); expect(policiesEqual(source, read)).toBe(true); expect(read.grants[0]?.appliesTo).toBe("both"); @@ -74,7 +91,8 @@ describe("RDF and WAC", () => { [`${ACL}agentClass`]: ["urn:unknown-class"], [`${ACL}mode`]: [`${ACL}Read`], }); - expect(() => readWac(dataset, resource, url)).toThrow("class"); + expect(() => readWac(dataset, resource, url)).toThrow("NSS"); + expect(() => readWac(dataset, resource, url, "nss")).toThrow("class"); const malformed = await parsed( '@prefix acl: . <#a> a acl:Authorization; acl:accessTo ; acl:mode "Read".', ); @@ -150,7 +168,17 @@ describe("ACP expressions and writers", () => { { kind: "vc", vcType: "urn:vc" }, ]; const source = unionPolicies( - ownerPolicy(stranger, true), + dialect === "css" + ? ownerPolicy(stranger, true) + : unionPolicies( + ownerPolicy(stranger), + policy( + grant([[{ kind: "agent", webId: stranger }]], { + modes: new Set(["read", "write", "append"]), + appliesTo: "members", + }), + ), + ), policy( grant( subjects.map((s) => [s]), @@ -161,15 +189,15 @@ describe("ACP expressions and writers", () => { source.denials.push( grant([[{ kind: "client", clientId: "urn:client" }]], { modes: new Set(["control"]), - appliesTo: "both", + appliesTo: dialect === "css" ? "both" : "resource", + except: [{ kind: "agent", webId: "urn:excluded-control" }], }), ); const turtle = await writeAcp(source, resource, url, dialect); const dataset = await parsed(turtle); - const actual = readAcp(dataset, resource, url); + const actual = readAcp(dataset, resource, url, dialect); expect(policiesEqual(source, actual)).toBe(true); - expect(acpDialect(dataset)).toBe(dialect); - expect(turtle).toContain("acr-policy"); + expect(turtle.includes("acr-policy")).toBe(dialect === "ess"); } await expect( writeAcp(policy(grant([[{ kind: "group", group: "urn:group" }]])), resource, url), @@ -222,12 +250,13 @@ describe("ACP expressions and writers", () => { [`${ACP}allow`]: [`${ACL}Read`], [`${ACP}allOf`]: ["urn:matcher"], }); - expect(readAcp(dataset, resource, url).grants[0]?.who).toEqual([]); + expect(() => readAcp(dataset, resource, url)).toThrow("ESS"); + expect(readAcp(dataset, resource, url, "ess").grants[0]?.who).toEqual([]); const matcher = node("urn:matcher", { [`${ACP}agent`]: [`${ACP}PublicAgent`] }); - expect(readAcp(dataset, resource, url).grants[0]?.who).toEqual([[{ kind: "public" }]]); + expect(readAcp(dataset, resource, url, "ess").grants[0]?.who).toEqual([[{ kind: "public" }]]); p.add(`${ACP}noneOf`, "urn:exclusion"); node("urn:exclusion", { [`${ACP}agent`]: [stranger], [`${ACP}client`]: ["urn:client"] }); - expect(() => readAcp(dataset, resource, url)).toThrow(PolicyTooComplex); + expect(() => readAcp(dataset, resource, url, "ess")).toThrow(PolicyTooComplex); for (const q of [ ...dataset.match(DataFactory.namedNode("urn:policy"), DataFactory.namedNode(`${ACP}noneOf`)), ]) From 7ea93b20be73ac95e1ac3b78a6f4fcd8928a9d36 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Wed, 28 Oct 2026 02:59:44 +0000 Subject: [PATCH 10/20] fix(pod-migrator): server-type detection and live CSS green after strict rules Detect ESS from an *.inrupt.com storage host and CSS/NSS from X-Powered-By (never from the issuer or RDF), show it on each connection, and explain a blocked ACP target. The live CSS 7.2 suite passes 31/31 again. Codex GPT-6 (xhigh); Claude dropped issuer-based ESS detection. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KLrUernPrjVRT4K7UiaGgK --- apps/pod-migrator/docs/DESIGN.md | 29 ++- apps/pod-migrator/e2e/connections.ts | 9 +- apps/pod-migrator/e2e/screens.spec.ts | 5 + apps/pod-migrator/src/App.tsx | 6 +- apps/pod-migrator/src/auth/connections.ts | 69 ++++- apps/pod-migrator/src/engine/README.md | 11 +- apps/pod-migrator/src/engine/executor.ts | 6 +- apps/pod-migrator/src/ui/ConnectionCard.tsx | 25 +- apps/pod-migrator/src/ui/Report.tsx | 7 + apps/pod-migrator/test/css/README.md | 18 +- apps/pod-migrator/test/css/fixtures.ts | 10 +- .../test/css/migration.integration.ts | 77 +++++- apps/pod-migrator/test/engine/edges.test.ts | 2 +- apps/pod-migrator/test/engine/review.test.ts | 34 ++- apps/pod-migrator/test/ui/app.test.tsx | 49 +++- apps/pod-migrator/test/ui/auth.test.ts | 246 +++++++++++++++++- .../test/ui/connection-card.test.tsx | 97 +++++++ apps/pod-migrator/test/ui/fixtures.ts | 11 +- 18 files changed, 670 insertions(+), 41 deletions(-) create mode 100644 apps/pod-migrator/test/ui/connection-card.test.tsx diff --git a/apps/pod-migrator/docs/DESIGN.md b/apps/pod-migrator/docs/DESIGN.md index 97b947a0e..1211d4f9d 100644 --- a/apps/pod-migrator/docs/DESIGN.md +++ b/apps/pod-migrator/docs/DESIGN.md @@ -157,8 +157,21 @@ CSS ignores `acp:Read`/`Write`/`Append` modes. Uncertain denials remain restrict Follow `Link: <…>; rel="acl"` from the root. If that resource is advertised as `acp:AccessControlResource` (Link `type`) or parses to triples using the ACP namespace, the side is ACP; if it uses `acl:Authorization`, WAC. If the root has no ACL link, or the two signals -conflict, the side is `unknown` and the plan refuses to translate permissions (the user can -still copy data with everything owner-only). +conflict, the side is `unknown` and the plan refuses to translate permissions. An unknown +source may be copied owner-only into an established target system; an unknown target must abort. + +Access-control language and server implementation are separate discoveries. On each storage +selection, the browser establishes `PodSide.implementation` from non-RDF evidence: an HTTPS +storage hostname under `*.inrupt.com` (default port) establishes ESS. An OIDC issuer, including +the live session's `https://login.inrupt.com/`, identifies the login provider and cannot +establish where the pod is hosted or its implementation. Otherwise a +successful root HEAD response's exposed `X-Powered-By: Community Solid Server` (optionally +with `/version`) establishes CSS, and `solid-server/version` establishes NSS. If HEAD supplies +no usable header (or is unsupported), try a root GET. Redirects, failures, unavailable CORS +headers, RDF claims and page/user input cannot establish the implementation. Otherwise it +remains unknown. Each connection card shows the result; unknown ACP targets explain why they +are blocked and how to reconnect after the provider exposes its header or choose another pod. +Detection is repeated for each session/storage selection, with stale results discarded. ## 5. Translation rules @@ -216,10 +229,12 @@ controls the target pod. | Grant to the source owner's WebID | kept verbatim (it is the same person, and they may still use that WebID) | same | info item | | WAC child ACL narrower than an inherited default | write the child ACL | ACP inheritance is additive, so the parent's member rules would reach the child | `inheritance-narrowed`: **per-resource** (no member controls on that parent; every existing descendant gets its own explicit controls; new files added later inherit nothing) / **abort** | | ACP member controls + own controls | child ACL = union of own and inherited | same | — | -| Unreadable ACL/ACR | — | — | `acl-unreadable`: **owner-only** / **skip the resource** / **abort** | -| Unknown access-control system on either side | — | — | `acl-system-unknown`: **owner-only for everything** / **abort** | +| Unreadable source ACL/ACR | — | — | `acl-unreadable`: **owner-only** / **skip the resource** / **abort** | +| Unreadable, ambiguous or shared target ACL/ACR | — | — | `acl-unreadable`: **skip the resource** / **abort**. Owner-only cannot remove unproven existing restrictions or additive ancestor grants | +| Unknown source access-control system | — | — | `acl-system-unknown`: **owner-only for everything** / **abort** | +| Unknown target access-control system or unestablished ACP implementation | — | — | `acl-system-unknown`: **abort** | | Target path already exists | — | — | `target-exists`: **skip** / **overwrite content, keep target permissions** / **abort** | -| Target WebID profile document, target root ACL/ACR | never overwritten | never overwritten | `protected-target`: **skip** / **merge grants** (root only: add the translated root grants to the existing root ACL, keeping its owner Control) / **abort** | +| Target WebID profile document, target root ACL/ACR | never overwritten | never overwritten | `protected-target`: **skip** / **merge grants** (root only, when the existing controls are readable, exclusive and proven safe: add translated root grants while keeping owner Control) / **abort** | | Source `.meta` / description resources | not copied | not copied | info item | Each report item records the source URL, the source rule text (the triples involved, as Turtle), @@ -239,7 +254,9 @@ and the current document's resource bindings immediately before each access PUT; same guard when an empty placeholder first reveals its ACL URL. A safe refusal may leave an empty placeholder with its already-proven safe inherited permissions, but never copies content through an unsafe access URL. Verification checks target document scopes and the owner's root -permissions, including Control, before claiming success. +permissions, including Control, before claiming success. A root read/parse failure preserves +its actual cause; only a readable policy without the required owner permissions produces the +owner-Control-loss diagnostic. Unsupported mixed-dialect documents still fail closed. CSS 7.2's `PutOperationHandler` returns 201/205 without an ETag for content and access documents. An empty placeholder's ETag may be recovered by HEAD only when its content length diff --git a/apps/pod-migrator/e2e/connections.ts b/apps/pod-migrator/e2e/connections.ts index 7bd743457..b68a1f1f8 100644 --- a/apps/pod-migrator/e2e/connections.ts +++ b/apps/pod-migrator/e2e/connections.ts @@ -15,10 +15,15 @@ export function createConnections(): [PodConnection, PodConnection] { ? " ." : " ."; const publicFetch: typeof fetch = async () => - new Response(turtle, { headers: { "content-type": "text/turtle" } }); + new Response(turtle, { + headers: { + "content-type": "text/turtle", + ...(side === "source" ? { "x-powered-by": "Community Solid Server" } : {}), + }, + }); const auth = { webId: signedOut ? null : webId, - issuer: "https://identity.example", + issuer: side === "target" ? "https://login.inrupt.com/" : "https://identity.example", publicFetch, authenticatedFetch: publicFetch, restore: async () => diff --git a/apps/pod-migrator/e2e/screens.spec.ts b/apps/pod-migrator/e2e/screens.spec.ts index 1d9764421..e90895d8e 100644 --- a/apps/pod-migrator/e2e/screens.spec.ts +++ b/apps/pod-migrator/e2e/screens.spec.ts @@ -4,6 +4,11 @@ import { fileURLToPath } from "node:url"; import { expect, test } from "@playwright/test"; test("connection, options, review, run, and result screens", async ({ page }, info) => { + await page.route("**/*", (route) => + ["127.0.0.1", "localhost"].includes(new URL(route.request().url()).hostname) + ? route.continue() + : route.abort(), + ); const directory = fileURLToPath(new URL("./screens/", import.meta.url)); await mkdir(directory, { recursive: true }); const capture = async (screen: string) => { diff --git a/apps/pod-migrator/src/App.tsx b/apps/pod-migrator/src/App.tsx index 982170ce7..3506ad37f 100644 --- a/apps/pod-migrator/src/App.tsx +++ b/apps/pod-migrator/src/App.tsx @@ -301,13 +301,14 @@ function MigrationApp({ connections }: { connections: readonly [PodConnection, P ? { root: sourceState.root, ownerWebId: sourceState.webId, + implementation: sourceState.implementation, http: createPodHttp({ root: sourceState.root, fetch: sourceConnection.auth.authenticatedFetch, }), } : null, - [sourceState.root, sourceState.webId, sourceConnection], + [sourceState.root, sourceState.webId, sourceState.implementation, sourceConnection], ); const target = useMemo( () => @@ -315,13 +316,14 @@ function MigrationApp({ connections }: { connections: readonly [PodConnection, P ? { root: targetState.root, ownerWebId: targetState.webId, + implementation: targetState.implementation, http: createPodHttp({ root: targetState.root, fetch: targetConnection.auth.authenticatedFetch, }), } : null, - [targetState.root, targetState.webId, targetConnection], + [targetState.root, targetState.webId, targetState.implementation, targetConnection], ); // Invalidate reviewed decisions whenever credentials, storage, or direction change. diff --git a/apps/pod-migrator/src/auth/connections.ts b/apps/pod-migrator/src/auth/connections.ts index 172611848..658060436 100644 --- a/apps/pod-migrator/src/auth/connections.ts +++ b/apps/pod-migrator/src/auth/connections.ts @@ -4,6 +4,9 @@ import { normalizePodBase } from "@jeswr/guarded-fetch"; import { createSolidAuth, type SolidAuth, type SolidAuthConfig } from "@jeswr/solid-auth-core"; import { Agent } from "@solid/object"; import { DataFactory } from "n3"; +import { detectAccessSystem } from "../engine/access.js"; +import { createPodHttp } from "../engine/pod-http.js"; +import type { AccessSystem, PodSide } from "../engine/types.js"; export type ConnectionId = "source" | "target"; export interface ConnectionState { @@ -11,9 +14,27 @@ export interface ConnectionState { webId: string | null; roots: string[]; root: string | null; + implementation: PodSide["implementation"]; + accessSystem: AccessSystem; error: string | null; } +/** Only call with the selected authenticated storage and its response header. + * An issuer identifies the login provider, not the storage implementation. + * Profile RDF and login/page input are not evidence. + */ +export function detectImplementation( + root: string, + poweredBy: string | null, +): PodSide["implementation"] { + const storage = new URL(root); + if (storage.protocol !== "https:" || storage.username || storage.password) return undefined; + if (storage.hostname.endsWith(".inrupt.com") && storage.port === "") return "ess"; + if (/^Community Solid Server(?:\/[^\s,]+)?$/i.test(poweredBy?.trim() ?? "")) return "css"; + if (/^solid-server\/[^\s,]+$/i.test(poweredBy?.trim() ?? "")) return "nss"; + return undefined; +} + export function authConfig(id: ConnectionId, origin: string, base = "/"): SolidAuthConfig { const prefix = `pod-migrator:${id}`; const redirectUri = new URL(`${base}callback/${id}`, origin).href; @@ -39,6 +60,8 @@ const emptyState = (): ConnectionState => ({ webId: null, roots: [], root: null, + implementation: undefined, + accessSystem: "unknown", error: null, }); @@ -178,8 +201,8 @@ export class PodConnection { }); const unique = [...new Set(roots)]; if (unique.length === 0) throw new Error("No storage roots"); - this.publish({ status: "choose-storage", webId, roots: unique, root: null, error: null }); - if (unique.length === 1 && unique[0]) this.selectStorage(unique[0]); + this.publish({ ...emptyState(), status: "choose-storage", webId, roots: unique }); + if (unique.length === 1 && unique[0]) await this.selectStorage(unique[0]); } catch { if (generation === this.generation && this.auth.webId === webId) { this.publish({ @@ -193,13 +216,15 @@ export class PodConnection { } } - selectStorage(root: string): void { + async selectStorage(root: string): Promise { if ( !this.state.webId || this.auth.webId !== this.state.webId || !this.state.roots.includes(root) ) return; + const generation = ++this.generation; + const webId = this.state.webId; if (!this.auth.reArmAllowedOrigins([new URL(root).origin])) { this.publish({ ...this.state, @@ -209,7 +234,43 @@ export class PodConnection { }); return; } - this.publish({ ...this.state, status: "connected", root, error: null }); + this.publish({ + ...this.state, + status: "loading", + root: null, + implementation: undefined, + accessSystem: "unknown", + error: null, + }); + const authenticatedFetch = this.auth.authenticatedFetch; + const http = createPodHttp({ + root, + fetch: (input, init) => authenticatedFetch(input, { ...init, credentials: "omit" }), + }); + const signal = AbortSignal.timeout(10_000); + let implementation = detectImplementation(root, null); + let accessSystem: AccessSystem = "unknown"; + try { + const head = await http.head(root, { signal }); + if (generation !== this.generation || this.auth.webId !== webId) return; + if (head.ok) implementation ??= detectImplementation(root, head.headers.get("x-powered-by")); + if (!implementation && (head.ok || head.status === 405 || head.status === 501)) { + const response = await http.get(root, { signal }); + if (response.ok) + implementation = detectImplementation(root, response.headers.get("x-powered-by")); + await response.body?.cancel(); + } + if (generation !== this.generation || this.auth.webId !== webId) return; + accessSystem = await detectAccessSystem( + { root, ownerWebId: webId, implementation, http }, + signal, + ); + } catch { + // Missing CORS exposure, errors and redirects supply no header evidence. + // Storage host evidence is retained; the engine still checks permissions. + } + if (generation !== this.generation || this.auth.webId !== webId) return; + this.publish({ ...this.state, status: "connected", root, implementation, accessSystem }); } async logout(): Promise { diff --git a/apps/pod-migrator/src/engine/README.md b/apps/pod-migrator/src/engine/README.md index aa44643e2..1c6d7ea3c 100644 --- a/apps/pod-migrator/src/engine/README.md +++ b/apps/pod-migrator/src/engine/README.md @@ -24,9 +24,13 @@ DESIGN.md as follows. These notes are colocated with the engine and reflected in ESS source forms are accepted only with independently established ESS semantics, documented in `systems/acp.ts`. `PodSide.implementation` is a trusted caller assertion, never inferred from editable RDF or mode IRIs. ACP target writes without established CSS/ESS semantics abort. - Live PodSpaces behavior still needs account testing; the browser connection currently leaves - implementation unestablished, so ACP target writes fail closed until a trusted integration - supplies that assertion. Missing owner Control at creation also fails closed on ESS. + The browser establishes ESS from the selected HTTPS `*.inrupt.com` storage host or the live + auth session's exact `https://login.inrupt.com/` issuer; CSS/NSS require an exposed root + `X-Powered-By` header. Login/page input and RDF claims cannot set implementation. Unknown + sources retain conservative readings; unknown ACP targets explain the refusal and abort. + Live PodSpaces behavior still needs account testing. Its ACP-namespace modes, typed ACR + roots and ACR-self/`acp:access` Control handling remain isolated in `systems/acp.ts`. + Missing owner Control at creation also fails closed on ESS. - **Writing TARGET:** never rely on a condition the target might not enforce. CSS 7.2's configured WAC checker chain ignores `acl:origin`, so origin-restricted grants are unsupported on every WAC and ACP target: skip the affected clause or abort, never preserve or remove only its origin. @@ -47,6 +51,7 @@ DESIGN.md as follows. These notes are colocated with the engine and reflected in - Target inventory includes existing siblings and the profile even when review skips them. Shared access URLs and foreign resource bindings are refused before ACL PUTs, including newly created placeholders. Final verification proves the owner retains root access including Control. + Root read/parse refusals preserve their underlying diagnostic; they do not claim Control loss. - A WAC 404 means that an own ACL is absent, so the nearest ancestor's defaults apply. ACP 404s contribute no own controls. A 403, parsing failure, or failed request makes the policy unknown; it never causes permissive inheritance. Uncertainty propagates conservatively to descendants. diff --git a/apps/pod-migrator/src/engine/executor.ts b/apps/pod-migrator/src/engine/executor.ts index f744b7bbb..d64820346 100644 --- a/apps/pod-migrator/src/engine/executor.ts +++ b/apps/pod-migrator/src/engine/executor.ts @@ -419,9 +419,11 @@ export async function executeMigration( progress("verify", index + 1, active.length, current); } current = state.target.root; + // A read/parse refusal is not evidence that the owner lost Control. Preserve + // its cause, and reserve the lock-out diagnostic for a readable policy. + const rootDocuments = await currentDocuments(state.target, [current], state.targetSystem); + const rootPolicy = effectivePolicy(state.targetSystem, current, current, rootDocuments); try { - const rootDocuments = await currentDocuments(state.target, [current], state.targetSystem); - const rootPolicy = effectivePolicy(state.targetSystem, current, current, rootDocuments); assertNotWider(rootPolicy, ownerPolicy(state.target.ownerWebId)); } catch { throw new TypeError("Target owner no longer controls the root"); diff --git a/apps/pod-migrator/src/ui/ConnectionCard.tsx b/apps/pod-migrator/src/ui/ConnectionCard.tsx index 72eba266f..a304290f4 100644 --- a/apps/pod-migrator/src/ui/ConnectionCard.tsx +++ b/apps/pod-migrator/src/ui/ConnectionCard.tsx @@ -55,7 +55,9 @@ export function ConnectionCard({