From 0a88fc5f683ee6c14f34b4e12931ba27c0895b47 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Fri, 3 Apr 2026 22:48:30 +0100 Subject: [PATCH 01/50] Initial commit --- apps/extension/LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 apps/extension/LICENSE diff --git a/apps/extension/LICENSE b/apps/extension/LICENSE new file mode 100644 index 000000000..2f396fc37 --- /dev/null +++ b/apps/extension/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Jesse Wright + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From 7b9a53ad333aaeb3811f6d6ff3bc56d3ac73fa5e Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Sat, 4 Apr 2026 13:41:28 +0100 Subject: [PATCH 02/50] feat: initial commit --- apps/extension/.claude/settings.json | 27 + apps/extension/.github/workflows/ci.yml | 47 + apps/extension/.gitignore | 6 + apps/extension/.skills/data-modelling.md | 195 + apps/extension/.skills/integration-guide.md | 678 + apps/extension/.skills/playwright.md | 453 + apps/extension/.skills/servers.md | 221 + apps/extension/.skills/spec.md | 315 + apps/extension/.skills/style-guide.md | 128 + .../.skills/test-driven-development.md | 371 + apps/extension/CLAUDE.md | 64 + apps/extension/PLAN.md | 393 + apps/extension/e2e/fixtures.ts | 33 + apps/extension/e2e/playwright.config.ts | 13 + apps/extension/e2e/setup/css-server.ts | 67 + apps/extension/e2e/setup/global-setup.ts | 59 + apps/extension/e2e/setup/global-teardown.ts | 15 + apps/extension/e2e/setup/seed.json | 11 + .../e2e/tests/authenticated-fetch.spec.ts | 21 + apps/extension/e2e/tests/helpers.ts | 41 + apps/extension/e2e/tests/login.spec.ts | 12 + .../e2e/tests/session-restore.spec.ts | 22 + apps/extension/e2e/tsconfig.json | 13 + apps/extension/package-lock.json | 10410 ++++++++++++++++ apps/extension/package.json | 30 + apps/extension/src/background/auth-flow.ts | 307 + .../src/background/service-worker.ts | 172 + .../src/background/session-database.ts | 85 + apps/extension/src/content/content-script.ts | 86 + apps/extension/src/inject/inject.ts | 139 + apps/extension/src/manifest.json | 42 + apps/extension/src/popup/popup.css | 143 + apps/extension/src/popup/popup.html | 47 + apps/extension/src/popup/popup.ts | 65 + apps/extension/src/redirect/redirect.html | 45 + apps/extension/src/redirect/redirect.ts | 36 + apps/extension/test-site/app.js | 58 + apps/extension/test-site/index.html | 58 + apps/extension/tsconfig.json | 18 + apps/extension/webpack.config.js | 41 + 40 files changed, 14987 insertions(+) create mode 100644 apps/extension/.claude/settings.json create mode 100644 apps/extension/.github/workflows/ci.yml create mode 100644 apps/extension/.gitignore create mode 100644 apps/extension/.skills/data-modelling.md create mode 100644 apps/extension/.skills/integration-guide.md create mode 100644 apps/extension/.skills/playwright.md create mode 100644 apps/extension/.skills/servers.md create mode 100644 apps/extension/.skills/spec.md create mode 100644 apps/extension/.skills/style-guide.md create mode 100644 apps/extension/.skills/test-driven-development.md create mode 100644 apps/extension/CLAUDE.md create mode 100644 apps/extension/PLAN.md create mode 100644 apps/extension/e2e/fixtures.ts create mode 100644 apps/extension/e2e/playwright.config.ts create mode 100644 apps/extension/e2e/setup/css-server.ts create mode 100644 apps/extension/e2e/setup/global-setup.ts create mode 100644 apps/extension/e2e/setup/global-teardown.ts create mode 100644 apps/extension/e2e/setup/seed.json create mode 100644 apps/extension/e2e/tests/authenticated-fetch.spec.ts create mode 100644 apps/extension/e2e/tests/helpers.ts create mode 100644 apps/extension/e2e/tests/login.spec.ts create mode 100644 apps/extension/e2e/tests/session-restore.spec.ts create mode 100644 apps/extension/e2e/tsconfig.json create mode 100644 apps/extension/package-lock.json create mode 100644 apps/extension/package.json create mode 100644 apps/extension/src/background/auth-flow.ts create mode 100644 apps/extension/src/background/service-worker.ts create mode 100644 apps/extension/src/background/session-database.ts create mode 100644 apps/extension/src/content/content-script.ts create mode 100644 apps/extension/src/inject/inject.ts create mode 100644 apps/extension/src/manifest.json create mode 100644 apps/extension/src/popup/popup.css create mode 100644 apps/extension/src/popup/popup.html create mode 100644 apps/extension/src/popup/popup.ts create mode 100644 apps/extension/src/redirect/redirect.html create mode 100644 apps/extension/src/redirect/redirect.ts create mode 100644 apps/extension/test-site/app.js create mode 100644 apps/extension/test-site/index.html create mode 100644 apps/extension/tsconfig.json create mode 100644 apps/extension/webpack.config.js diff --git a/apps/extension/.claude/settings.json b/apps/extension/.claude/settings.json new file mode 100644 index 000000000..33522c94c --- /dev/null +++ b/apps/extension/.claude/settings.json @@ -0,0 +1,27 @@ +{ + "permissions": { + "allow": [ + "WebFetch(domain:davidwalsh.name)", + "Bash(curl -s http://localhost:3000/.account/login/)", + "Bash(npx @solid/community-server -p 3000 --seedConfig e2e/setup/seed.json -l warn)", + "Bash(curl -s http://localhost:3000/)", + "Bash(curl -s -H \"Accept: text/html\" http://localhost:3000/.account/login/)", + "Bash(curl -s -H \"Accept: text/html\" http://localhost:3000/.account/login/password/)", + "Bash(curl -s http://localhost:3000/.well-known/openid-configuration)", + "Bash(python3 -m json.tool)", + "Bash(npx tsx:*)", + "Bash(curl -s -X POST http://localhost:3000/.oidc/reg -H 'Content-Type: application/json' -d '{:*)", + "Bash(curl -s -H \"Accept: text/html\" \"http://localhost:3000/.oidc/auth?response_type=code&client_id=test&redirect_uri=http://localhost:8080/callback&scope=openid+webid&state=test\")", + "Bash(curl -s -H \"Accept: text/html\" \"http://localhost:3000/.account/oidc/consent/\")" + ] + }, + "mcpServers": { + "context7": { + "command": "npx", + "args": [ + "-y", + "@upstash/context7-mcp@latest" + ] + } + } +} diff --git a/apps/extension/.github/workflows/ci.yml b/apps/extension/.github/workflows/ci.yml new file mode 100644 index 000000000..b7977e5ee --- /dev/null +++ b/apps/extension/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build extension + run: npm run build + + - name: Install Playwright browsers + run: npx playwright install chromium + + - name: Run E2E tests + run: xvfb-run npm run test:e2e + + - name: Upload extension artifact + uses: actions/upload-artifact@v4 + if: success() + with: + name: solid-browser-extension + path: dist/ + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: failure() + with: + name: test-results + path: | + test-results/ + playwright-report/ diff --git a/apps/extension/.gitignore b/apps/extension/.gitignore new file mode 100644 index 000000000..7a1884dc0 --- /dev/null +++ b/apps/extension/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +*.js.map +.DS_Store +test-results/ +playwright-report/ diff --git a/apps/extension/.skills/data-modelling.md b/apps/extension/.skills/data-modelling.md new file mode 100644 index 000000000..47416c367 --- /dev/null +++ b/apps/extension/.skills/data-modelling.md @@ -0,0 +1,195 @@ +# Solid Data Modelling Skill + +You are an expert on data modelling for the Solid ecosystem. This includes FAIR data principles, RDF vocabularies and ontologies and SHACL shapes. + +--- + +## FAIR Data Principles in Solid + +FAIR Data Principles (*Findable, Accessible, Interoperable, Reusable*) describe guidelines for improving data usability in open and distributed systems. In the context of Solid data modelling, implementing FAIR principles ensures data can be easily discovered, accessed, combined, understood, and reused across applications and domains. + +| Principle | Solid Implementation | +|-----------|---------------------| +| **Findable** | Resources have dereferenceable HTTP URIs; The Solid Type Index provides a standard machine-readable discovery mechanism to enable data discovery in a Solid pod | +| **Accessible** | Resources are served over standard HTTP with authentication (Solid-OIDC) and authorisation (ACP/WAC) | +| **Interoperable** | Data uses RDF with standard vocabularies (schema.org, FOAF, LDP, etc.) | +| **Reusable** | Shapes (SHACL) define reusable data schemas; linked data enables cross-app data reuse | + +### Applying FAIR in Practice + +1. **Mint stable URIs** — use consistent URI patterns for resources +2. **Use standard vocabularies** — reuse standard vocabularies e.g. schema.org, FOAF, Dublin Core, SKOS where possible +3. **Describe your data in machine-readable format** — add RDF type statements and metadata to every resource +4. **Publish shapes** — provide SHACL shapes so consuming apps can validate the data +5. **Link datasets** — use `rdfs:seeAlso`, and `skos:exactMatch` to connect related data + +--- + +## RDF Vocabulary Selection + +### Core Vocabularies for Solid + +| Prefix | Vocabulary | Description | Namespace URL | +| -------------------- | ------------------ | -------------------------------------------- | ------------------------------------------------------------------------------------------ | +| ACP | `acp:` | Access Control Policy Language | [http://www.w3.org/ns/solid/acp#](http://www.w3.org/ns/solid/acp#) | +| AS (ActivityStreams) | `as:` | Social activities, notifications | [https://www.w3.org/ns/activitystreams#](https://www.w3.org/ns/activitystreams#) | +| DC | `dc:` / `dcterms:` | Metadata (title, creator, date, description) | [http://purl.org/dc/terms/](http://purl.org/dc/terms/) | +| FOAF | `foaf:` | Person, identity, social graph | [http://xmlns.com/foaf/0.1/](http://xmlns.com/foaf/0.1/) | +| ICAL | `ical:` | Calendar data | [http://www.w3.org/2002/12/cal/ical#](http://www.w3.org/2002/12/cal/ical#) | +| LDP | `ldp:` | Containers, membership | [http://www.w3.org/ns/ldp#](http://www.w3.org/ns/ldp#) | +| ORG | `org:` | Organization ontology | [http://www.w3.org/ns/org#](http://www.w3.org/ns/org#) | +| OWL | `owl:` | Ontology relations, sameAs | [http://www.w3.org/2002/07/owl#](http://www.w3.org/2002/07/owl#) | +| PIM | `pim:` | Storage, preferences, configuration | [http://www.w3.org/ns/pim/space#](http://www.w3.org/ns/pim/space#) | +| POSIX | `posix:` | POSIX file system permissions | [http://www.w3.org/ns/posix/stat#](http://www.w3.org/ns/posix/stat#) | +| RDF | `rdf:` | RDF syntax concepts | [http://www.w3.org/1999/02/22-rdf-syntax-ns#](http://www.w3.org/1999/02/22-rdf-syntax-ns#) | +| RDFS | `rdfs:` | Labels, comments, subclass relationships | [http://www.w3.org/2000/01/rdf-schema#](http://www.w3.org/2000/01/rdf-schema#) | +| Schema.org | `schema:` | Structured data vocabulary | [https://schema.org/](https://schema.org/) | +| Solid | `solid:` | OIDC, notifications, identity | [https://www.w3.org/ns/solid/](https://www.w3.org/ns/solid/) | +| vCard | `vcard:` | Contact information | [http://www.w3.org/2006/vcard/ns#](http://www.w3.org/2006/vcard/ns#) | + + +### Vocabulary Lookup + +Before creating custom RDF classes and properties, search existing ontologies/vocabularies: + +- **Linked Open Vocabularies (LOV)**: https://lov.linkeddata.es/ +- **schema.org**: https://schema.org/ — widely adopted, search-engine friendly + +--- + +## Shapes: SHACL + +Shapes define the expected structure of RDF data. They serve as schemas for validation and code generation. + +### SHACL (Shapes Constraint Language) + +[SHACL](https://www.w3.org/TR/shacl/) is the W3C Recommendation for RDF validation, more expressive for complex constraints. + +```turtle +@prefix sh: . +@prefix foaf: . + + + a sh:NodeShape ; + sh:targetClass foaf:Person ; + sh:property [ + sh:path foaf:name ; + sh:datatype xsd:string ; + sh:minCount 1 ; + ] ; + sh:property [ + sh:path foaf:mbox ; + sh:nodeKind sh:IRI ; + sh:maxCount 1 ; + ] . +``` + +### SHACL Shape Discovery + + +A number of **public SHACL shapes collections and published shapes** are available that you can reuse or study: + +- **[Solid Shapes Catalogue](https://github.com/solid/shapes)** + Provides reusable SHACL shapes used in Solid ecosystem applications. + +- **[W3C Data Shapes Working Group Resources](https://github.com/w3c/data-shapes)** + Official working group repository containing specifications, examples, and discussion materials related to RDF data shapes. + +- **[Awesome Semantic Shapes Curated List](https://github.com/w3c-cg/awesome-semantic-shapes)** + Broad index of SHACL and ShEx shape resources across domains and vocabularies. + +- **Shapes-of-You Semantic Index** + A public index of semantic web resources that helps researchers discover linked data artefacts including SHACL shapes, ontologies, mappings, and SPARQL assets. + https://index.semanticscience.org/ + +- **[SHACL Play! Shapes Catalog (SKOS & vocab constraint shapes)](https://shacl-play.sparna.fr/play/shapes-catalog)** + Provides ready-to-use SKOS validation shapes implementing many SKOS integrity constraints. + +- **[SEMICeu DCAT-AP SHACL Shapes](https://github.com/SEMICeu/dcat-ap_shacl/blob/master/README.md)** + Repository containing SHACL shapes for the DCAT-AP data catalogue application profile. +--- + +## MCP Adapters for Ontology Discovery + +Model Context Protocol (MCP) adapters allow AI tools and development environments to discover and work with ontologies and shapes programmatically. + +### Discovering Ontologies via MCP + +When building an MCP adapter for ontology discovery, key capabilities to expose: + +1. **Search by term** — find classes and properties matching a keyword +2. **Shape retrieval** — fetch SHACL shapes for a known type +3. **Type index traversal** — discover where a user stores instances of a given type on their pod + +### Recommended Approach for Ontology Discovery in Apps + +``` +1. Check LOV (lov.linkeddata.es) for existing vocabulary terms +2. Use SHACL shapes (from Solid shapes repo) to validate data +3. For pod-specific discovery, traverse the pod's Type Index +``` + +--- + +### Structure + +```turtle +# Public type index at /settings/publicTypeIndex.ttl + +@prefix solid: . +@prefix schema: . + +<#registration-contacts> + a solid:TypeRegistration ; + solid:forClass schema:Person ; + solid:instanceContainer . + +<#registration-notes> + a solid:TypeRegistration ; + solid:forClass schema:TextObject ; + solid:instance . +``` + +### Two Registration Types + +| Property | Meaning | +|----------|---------| +| `solid:instanceContainer` | All instances of this type live in this container | +| `solid:instance` | A single document containing instances of this type | + +### Accessing the Solid Type Index + +1. Fetch the user's WebID profile +2. Find `solid:publicTypeIndex` and/or `solid:privateTypeIndex` predicates +3. Fetch the Solid Type Index document +4. Query for `solid:TypeRegistration` entries with matching `solid:forClass` + +--- + +## Data Modelling Checklist + +When modelling a new data type for Solid: + +- [ ] Check for existing SHACL shapes with types that meet your requirements +- [ ] Check LOV for an existing vocabulary/class +- [ ] Define a SHACL shape for the type +- [ ] Add `rdf:type` statements to all instances +- [ ] Register the type in the pod's Solid Type Index +- [ ] Use stable, dereferenceable URIs for subjects +- [ ] Add `dcterms:created`, `dcterms:modified` metadata +- [ ] Document the shape in a public shapes repository +- [ ] Validate data against the shape before writing to the pod + +--- + +## Resources + +| Resource | URL | +|----------|-----| +| FAIR Principles | https://www.go-fair.org/fair-principles/ | +| Linked Open Vocabularies | https://lov.linkeddata.es/ | +| W3C SHACL Spec | https://www.w3.org/TR/shacl/ | +| W3C Data Shapes repo | https://github.com/w3c/data-shapes | +| Solid type indexes | https://solid.github.io/type-indexes/ | +| Shapes resources | https://github.com/w3c-cg/awesome-semantic-shapes | +| Solid Developer Documentation | https://dev.solidproject.org/ | diff --git a/apps/extension/.skills/integration-guide.md b/apps/extension/.skills/integration-guide.md new file mode 100644 index 000000000..ed08d2436 --- /dev/null +++ b/apps/extension/.skills/integration-guide.md @@ -0,0 +1,678 @@ +# Solid Integration Guide Skill + +You are an expert on integrating JavaScript/TypeScript applications with the Solid ecosystem. You know the Inrupt SDK, LDO, and solid-client-authn-js libraries in depth, and can guide developers through authentication, reading, writing, and managing access to Solid pods. + +Based application being built by user, recommend library to use and get confirmation from the user. + +--- + +## Library Overview + +| Library | Package | Best for | +|---------|---------|----------| +| @inrupt/solid-client | `@inrupt/solid-client` | Reading/writing structured RDF data (SolidDataset/Thing API) | +| solid-client-authn-browser | `@inrupt/solid-client-authn-browser` | Browser authentication (OIDC + DPoP) | +| solid-client-authn-node | `@inrupt/solid-client-authn-node` | Node.js / server-side authentication | +| LDO | `ldo` / `@ldo/solid` | TypeScript-first RDF with ShEx shapes; type-safe linked data | +| @ldo/connected-solid | `@ldo/connected-solid` | LDO + Solid pods: containers, WAC, live updates | +| N3.js | `n3` | Low-level RDF parsing, serialising, and in-memory quad storage | + +--- + +## Authentication with solid-client-authn-js + +**Docs**: https://inrupt.github.io/solid-client-authn-js/ +**Repo**: https://github.com/inrupt/solid-client-authn-js + +### Browser Authentication + +```bash +npm install @inrupt/solid-client-authn-browser +``` + +```typescript +import { + login, + logout, + handleIncomingRedirect, + getDefaultSession, + fetch, // authenticated fetch — use this for all pod requests +} from "@inrupt/solid-client-authn-browser"; + +// 1. Handle redirect on page load (restore session after OIDC redirect) +await handleIncomingRedirect({ restorePreviousSession: true }); + +// 2. Check if already logged in +const session = getDefaultSession(); +if (!session.info.isLoggedIn) { + // 3. Redirect user to their OIDC provider + await login({ + oidcIssuer: "https://solidcommunity.net", + redirectUrl: window.location.href, + clientName: "My Solid App", + }); +} + +// 4. Use the authenticated session's fetch for all pod requests +const response = await fetch("https://alice.solidcommunity.net/profile/card"); +``` + +### Node.js Authentication (Client Credentials) + +```bash +npm install @inrupt/solid-client-authn-node +``` + +```typescript +import { Session } from "@inrupt/solid-client-authn-node"; + +const session = new Session(); + +await session.login({ + oidcIssuer: "https://solidcommunity.net", + clientId: "your-client-id", + clientSecret: "your-client-secret", + clientName: "My Node App", +}); + +// Use session.fetch for authenticated requests +const response = await session.fetch("https://alice.solidcommunity.net/data/"); +``` + +### Key Rules + +- Always use the session's `fetch` (not global `fetch`) for requests to Solid pods +- Call `handleIncomingRedirect` on every page load to restore session after OIDC callback +- Store the `oidcIssuer` — prompt users to enter their pod provider if unknown +- Tokens are DPoP-bound; do not strip or modify Authorization headers + +--- + +## Reading and Writing with @inrupt/solid-client + +**Docs**: https://docs.inrupt.com/developer-tools/javascript/client-libraries/ +**Package**: `@inrupt/solid-client` + +```bash +npm install @inrupt/solid-client @inrupt/solid-client-authn-browser +``` + +### Core Concepts + +| Concept | Description | +|---------|-------------| +| `SolidDataset` | An RDF document fetched from a pod | +| `Thing` | A named set of RDF triples within a SolidDataset (identified by URL) | +| `saveSolidDatasetAt` | Write a modified SolidDataset back to the pod | +| `getThing` / `setThing` | Read/modify a Thing within a dataset | +| `buildThing` / `createThing` | Construct new Things | + +### Read a Resource + +```typescript +import { + getSolidDataset, + getThing, + getStringNoLocale, + getUrl, +} from "@inrupt/solid-client"; +import { fetch } from "@inrupt/solid-client-authn-browser"; +import { FOAF, SCHEMA_INRUPT } from "@inrupt/vocab-common-rdf"; + +const dataset = await getSolidDataset( + "https://alice.solidcommunity.net/profile/card", + { fetch } +); + +const profile = getThing( + dataset, + "https://alice.solidcommunity.net/profile/card#me" +); + +const name = getStringNoLocale(profile, FOAF.name); +const storage = getUrl(profile, "http://www.w3.org/ns/pim/space#storage"); +``` + +### Write a Resource + +```typescript +import { + getSolidDataset, + getThing, + setThing, + setStringNoLocale, + saveSolidDatasetAt, + buildThing, + createThing, +} from "@inrupt/solid-client"; +import { fetch } from "@inrupt/solid-client-authn-browser"; +import { SCHEMA_INRUPT } from "@inrupt/vocab-common-rdf"; + +// Fetch existing dataset +const dataset = await getSolidDataset( + "https://alice.solidcommunity.net/notes/note1.ttl", + { fetch } +); + +// Modify an existing Thing +let note = getThing( + dataset, + "https://alice.solidcommunity.net/notes/note1.ttl#note" +); +note = setStringNoLocale(note, SCHEMA_INRUPT.text, "Updated note content"); + +// Save back to the pod +const updatedDataset = setThing(dataset, note); +await saveSolidDatasetAt( + "https://alice.solidcommunity.net/notes/note1.ttl", + updatedDataset, + { fetch } +); +``` + +### Create a New Resource + +```typescript +import { + createSolidDataset, + setThing, + buildThing, + createThing, + saveSolidDatasetAt, +} from "@inrupt/solid-client"; +import { fetch } from "@inrupt/solid-client-authn-browser"; +import { RDF, SCHEMA_INRUPT } from "@inrupt/vocab-common-rdf"; + +const newNote = buildThing(createThing({ name: "my-note" })) + .addUrl(RDF.type, "https://schema.org/TextObject") + .addStringNoLocale(SCHEMA_INRUPT.text, "My first note") + .build(); + +const dataset = setThing(createSolidDataset(), newNote); + +// saveSolidDatasetAt creates intermediate containers automatically +await saveSolidDatasetAt( + "https://alice.solidcommunity.net/notes/my-note.ttl", + dataset, + { fetch } +); +``` + +### ACL Management + +```typescript +import { + getSolidDatasetWithAcl, + getResourceAcl, + setAgentResourceAccess, + saveAclFor, +} from "@inrupt/solid-client"; + +const datasetWithAcl = await getSolidDatasetWithAcl(resourceUrl, { fetch }); +const resourceAcl = getResourceAcl(datasetWithAcl); + +const updatedAcl = setAgentResourceAccess( + resourceAcl, + "https://bob.solidcommunity.net/profile/card#me", + { read: true, write: false, append: false, control: false } +); + +await saveAclFor(datasetWithAcl, updatedAcl, { fetch }); +``` + +--- + +## LDO (Linked Data Objects) + +**Docs**: https://ldo.js.org/ +**Package**: `ldo`, `@ldo/solid`, `@ldo/connected-solid` + +LDO provides TypeScript-typed access to RDF data using ShEx shapes. It generates TypeScript interfaces from shapes, so you work with familiar object literals instead of raw RDF APIs. + +### Install + +```bash +npm install ldo @ldo/solid +npm install --save-dev @ldo/cli +``` + +### Generate TypeScript from ShEx + +```bash +# Given a ShEx file at ./shapes/Person.shex +npx ldo generate ./shapes +``` + +This generates: +- TypeScript interfaces (`.typings.ts`) +- JSON-LD context (`.context.ts`) +- Shape type (`.shapeTypes.ts`) + +### Read RDF as TypeScript Object + +```typescript +import { parseRdf } from "ldo"; +import { PersonShapeType } from "./shapes/PersonShapeType"; + +const turtle = ` +@prefix foaf: . + a foaf:Person ; foaf:name "Alice" . +`; + +const dataset = await parseRdf(turtle, { baseIRI: "https://alice.example.com/" }); +const ldoDataset = createLdoDataset([...dataset]); + +const person = ldoDataset + .usingType(PersonShapeType) + .fromSubject("https://alice.example.com/#me"); + +console.log(person.name); // "Alice" +``` + +### Write via LDO + +```typescript +const [person, setThing] = createLdoDataset().usingType(PersonShapeType).write(); +person.["@id"] = "https://alice.example.com/#me"; +person.name = "Alice Updated"; + +// Serialize back to Turtle +const turtle = await serialize(dataset, { format: "text/turtle" }); +``` + +### @ldo/connected-solid — LDO + Solid Pods + +Combines LDO with Solid pods for type-safe, live pod interaction: + +```typescript +import { createSolidLdoDataset } from "@ldo/connected-solid"; + +const solidLdoDataset = createSolidLdoDataset(); +await solidLdoDataset.auth.login({ oidcIssuer: "https://solidcommunity.net" }); + +// Read a resource into the dataset +await solidLdoDataset.getResource("https://alice.solidcommunity.net/notes/note1.ttl").read(); + +// Access typed object +const note = solidLdoDataset + .usingType(NoteShapeType) + .fromSubject("https://alice.solidcommunity.net/notes/note1.ttl#note"); + +// Mutate and commit +note.text = "Updated via LDO"; +await solidLdoDataset.commitChanges(); +``` + +**Additional @ldo/connected-solid features:** +- Container listing and creation +- WAC access control management +- Live update subscriptions (WebSocket notifications) + +--- + +## N3.js — Low-Level RDF Parsing and Storage + +**Repo**: https://github.com/rdfjs/N3.js +**Docs**: https://rdf.js.org/N3.js/ +**Package**: `n3` + +N3.js is a low-level, spec-compliant RDF library for JavaScript. It handles parsing, serialising, and in-memory storage of RDF quads. It implements the [RDF/JS interfaces](https://rdf.js.org/) and is the foundation other libraries build on. + +Use N3.js when you need direct control over RDF data — raw PATCH body construction, custom parsers, graph reasoning, or working with formats the higher-level libraries don't expose. + +### Install + +```bash +npm install n3 +``` + +```typescript +import * as N3 from "n3"; +// or specific imports: +import { Parser, Writer, Store, DataFactory, StreamParser, StreamWriter } from "n3"; +const { namedNode, literal, defaultGraph, quad } = DataFactory; +``` + +### DataFactory — Constructing RDF Terms + +```typescript +import { DataFactory } from "n3"; +const { namedNode, literal, blankNode, defaultGraph, quad } = DataFactory; + +// Named node (IRI) +const alice = namedNode("https://alice.solidcommunity.net/profile/card#me"); + +// Literal with language tag +const name = literal("Alice", "en"); + +// Literal with datatype +import { xsd } from "@tpluscode/rdf-ns-builders"; +const dob = literal("1990-01-01", namedNode("http://www.w3.org/2001/XMLSchema#date")); + +// Build a quad (subject, predicate, object, graph) +const myQuad = quad( + alice, + namedNode("http://xmlns.com/foaf/0.1/name"), + name, + defaultGraph() +); + +console.log(myQuad.subject.value); // "https://alice.solidcommunity.net/profile/card#me" +``` + +### Parser — Turtle / N-Triples / TriG / N-Quads to Quads + +```typescript +import { Parser } from "n3"; + +const turtle = ` + @prefix foaf: . + foaf:name "Alice" . +`; + +// Callback-based (async, streaming-friendly) +const parser = new Parser({ baseIRI: "https://alice.example.com/" }); +parser.parse(turtle, (error, quad, prefixes) => { + if (quad) { + console.log(quad.subject.value, quad.predicate.value, quad.object.value); + } else { + console.log("Done. Prefixes:", prefixes); + } +}); + +// Synchronous (returns array) +const quads = new Parser().parse(turtle); +``` + +**Format options** (pass as `{ format: "..." }`): + +| String | Parses | +|--------|--------| +| `"Turtle"` (default) | Turtle + permissive superset | +| `"N-Triples"` | N-Triples | +| `"TriG"` | TriG (named graphs) | +| `"N-Quads"` | N-Quads | +| `"N3"` / `"text/n3"` | N3 (with rules) | + +### Writer — Quads to Turtle / N-Triples / TriG + +```typescript +import { Writer, DataFactory } from "n3"; +const { namedNode, literal } = DataFactory; + +// Write to string +const writer = new Writer({ + prefixes: { foaf: "http://xmlns.com/foaf/0.1/" }, + // format: "N-Triples" // override default Turtle +}); + +writer.addQuad( + namedNode("https://alice.example.com/#me"), + namedNode("http://xmlns.com/foaf/0.1/name"), + literal("Alice", "en") +); + +writer.end((error, result) => { + console.log(result); + // @prefix foaf: . + // foaf:name "Alice"@en. +}); +``` + +### Store — In-Memory Quad Index + +`N3.Store` is an efficient indexed in-memory store with quad-pattern lookups. + +```typescript +import { Store, Parser, DataFactory } from "n3"; +const { namedNode } = DataFactory; + +const store = new Store(); + +// Add quads directly +store.addQuad( + namedNode("https://alice.example.com/#me"), + namedNode("http://xmlns.com/foaf/0.1/name"), + DataFactory.literal("Alice") +); + +// Or parse directly into the store +const parser = new Parser(); +const quads = parser.parse(` + @prefix foaf: . + foaf:name "Alice" ; foaf:knows . +`); +store.addQuads(quads); + +// Query: getQuads(subject, predicate, object, graph) — null = wildcard +const names = store.getQuads( + namedNode("https://alice.example.com/#me"), + namedNode("http://xmlns.com/foaf/0.1/name"), + null, + null +); +console.log(names[0].object.value); // "Alice" + +// Count matching quads +const count = store.countQuads(null, namedNode("http://xmlns.com/foaf/0.1/knows"), null, null); + +// Iterate over unique subjects +store.forSubjects( + (subject) => console.log(subject.value), + namedNode("http://xmlns.com/foaf/0.1/knows"), + null, + null +); + +// Remove quads +store.removeQuads(names); +// or by pattern: +store.removeMatches(namedNode("https://alice.example.com/#me"), null, null, null); +``` + +### StreamParser and StreamWriter — Large Files + +```typescript +import { StreamParser, StreamWriter } from "n3"; +import { createReadStream, createWriteStream } from "fs"; + +// Parse a large Turtle file as a stream +const inputStream = createReadStream("data.ttl"); +const streamParser = new StreamParser({ format: "Turtle" }); + +inputStream.pipe(streamParser); + +streamParser.on("data", (quad) => { + console.log(quad.subject.value); +}); +streamParser.on("end", () => console.log("Done")); + +// Transform stream: re-serialise to N-Triples +const streamWriter = new StreamWriter({ format: "N-Triples" }); +inputStream + .pipe(new StreamParser()) + .pipe(streamWriter) + .pipe(createWriteStream("output.nt")); +``` + +### SPARQL Queries with Comunica + +N3.js does not include a SPARQL engine, but its `Store` implements the RDF/JS interfaces so it works directly with [Comunica](https://comunica.dev/). + +```bash +npm install @comunica/query-sparql-rdfjs +``` + +```typescript +import { QueryEngine } from "@comunica/query-sparql-rdfjs"; +import { Store, Parser } from "n3"; + +const store = new Store(); +store.addQuads(new Parser().parse(` + @prefix foaf: . + <#alice> foaf:name "Alice" ; foaf:knows <#bob> . + <#bob> foaf:name "Bob" . +`)); + +const engine = new QueryEngine(); +const bindingsStream = await engine.queryBindings( + "SELECT ?name WHERE { ?s ?name }", + { sources: [store] } +); + +bindingsStream.on("data", (bindings) => { + console.log(bindings.get("name").value); +}); +``` + +### N3.js in a Solid Context + +N3.js is useful when working directly with Solid at the HTTP level — parsing response bodies, constructing N3 PATCH payloads, or processing raw Turtle resources. + +```typescript +import { fetch } from "@inrupt/solid-client-authn-browser"; +import { Parser, Writer, Store, DataFactory } from "n3"; +const { namedNode, literal } = DataFactory; + +// Fetch and parse a Solid resource as raw RDF +const response = await fetch("https://alice.solidcommunity.net/profile/card", { + headers: { Accept: "text/turtle" }, +}); +const turtle = await response.text(); + +const store = new Store(); +store.addQuads(new Parser({ baseIRI: response.url }).parse(turtle)); + +// Query the profile +const names = store.getQuads( + null, + namedNode("http://xmlns.com/foaf/0.1/name"), + null, + null +); +console.log(names.map((q) => q.object.value)); + +// Build an N3 Patch body +const patchWriter = new Writer({ format: "text/n3" }); +patchWriter.addQuad( + namedNode(""), + namedNode("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"), + namedNode("http://www.w3.org/ns/solid/terms#InsertDeletePatch") +); +// ... add solid:inserts quads +patchWriter.end(async (err, patchBody) => { + await fetch("https://alice.solidcommunity.net/profile/card", { + method: "PATCH", + headers: { "Content-Type": "text/n3" }, + body: patchBody, + }); +}); +``` + +--- + +## Choosing the Right Library + +| Scenario | Recommended Library | +|----------|-------------------| +| Browser app, structured data | `@inrupt/solid-client` + `solid-client-authn-browser` | +| Node.js script or API | `@inrupt/solid-client` + `solid-client-authn-node` | +| TypeScript-first, shape-driven | `ldo` + `@ldo/solid` | +| React app with live updates | `@ldo/connected-solid` | +| Raw HTTP, no library overhead | Native `fetch` with `solid-client-authn` session fetch | +| Low-level RDF parsing / serialising | `n3` (N3.js) | +| In-memory graph querying | `n3` Store + Comunica | +| Custom PATCH body construction | `n3` Writer (`text/n3` format) | +| Stream processing large RDF files | `n3` StreamParser + StreamWriter | + +--- + +## Common Patterns + +### Check if User is Logged In + +```typescript +import { getDefaultSession } from "@inrupt/solid-client-authn-browser"; + +const { isLoggedIn, webId } = getDefaultSession().info; +``` + +### Discover User's Storage + +```typescript +import { getSolidDataset, getThing, getUrl } from "@inrupt/solid-client"; + +const profile = await getSolidDataset(webId, { fetch }); +const profileThing = getThing(profile, webId); +const storageUrl = getUrl(profileThing, "http://www.w3.org/ns/pim/space#storage"); +``` + +For robust pod root discovery (handles servers that don't advertise `pim:storage` on the profile), use the recursive `getPodRoot` pattern from [Bashlib util.ts L73-104](https://github.com/SolidLabResearch/Bashlib/blob/80de25cbb4b3ed057f95e25bc057f1be9b00cef3/src/utils/util.ts#L73-L104). It uses a three-stage approach: + +1. HEAD request — checks the `Link` header for a `pim:Storage` relation on the resource +2. Dataset query — fetches the resource and looks for a `pim:storage` triple +3. Recursive traversal — walks up the URL path one segment at a time, repeating steps 1-2 + +```typescript +async function getPodRoot(url: string, fetch: typeof globalThis.fetch): Promise { + // Stage 1: check Link header + const headRes = await fetch(url, { method: "HEAD" }); + const linkHeader = headRes.headers.get("Link") ?? ""; + if (linkHeader.includes("http://www.w3.org/ns/pim/space#Storage")) { + return url; + } + + // Stage 2: check dataset for pim:storage triple + try { + const dataset = await getSolidDataset(url, { fetch }); + const thing = getThing(dataset, url); + const storage = getUrl(thing, "http://www.w3.org/ns/pim/space#storage"); + if (storage) return storage; + } catch {} + + // Stage 3: recurse up the URL path + const parent = new URL(url); + const segments = parent.pathname.replace(/\/$/, "").split("/"); + if (segments.length <= 1) return null; + parent.pathname = segments.slice(0, -1).join("/") + "/"; + return getPodRoot(parent.toString(), fetch); +} +``` + +### List a Container + +```typescript +import { getSolidDataset, getContainedResourceUrlAll } from "@inrupt/solid-client"; + +const container = await getSolidDataset(containerUrl, { fetch }); +const resourceUrls = getContainedResourceUrlAll(container); +``` + +--- + +## Troubleshooting + +| Problem | Likely Cause | Fix | +|---------|-------------|-----| +| 401 Unauthorized | Not using session `fetch` | Import `fetch` from authn library; pass to all solid-client calls | +| 403 Forbidden | Insufficient ACL/ACP | Check access control on the resource | +| 409 Conflict on save | Outdated dataset / ETag mismatch | Re-fetch the dataset before saving | +| CORS error in browser | Server CORS config | Use a server that supports CORS or proxy in dev | +| Session lost on refresh | `handleIncomingRedirect` not called | Call with `restorePreviousSession: true` on every page load | +| DPoP error | Token not DPoP-bound | Always use the authn library's `fetch`; never strip headers | + +--- + +## Key Links + +| Resource | URL | +|----------|-----| +| Inrupt SDK docs | https://docs.inrupt.com/sdk/javascript-sdk | +| @inrupt/solid-client npm | https://www.npmjs.com/package/@inrupt/solid-client | +| solid-client-authn-js | https://inrupt.github.io/solid-client-authn-js/ | +| solid-client-authn-js repo | https://github.com/inrupt/solid-client-authn-js | +| LDO docs | https://ldo.js.org/1.0.0-alpha.X/ | +| @ldo/connected-solid | https://ldo.js.org/1.0.0-alpha.X/api/connected-solid/ | +| N3.js repo | https://github.com/rdfjs/N3.js | +| N3.js docs | https://rdf.js.org/N3.js/ | +| Comunica (SPARQL over N3 Store) | https://comunica.dev/docs/query/advanced/rdfjs_querying/ | diff --git a/apps/extension/.skills/playwright.md b/apps/extension/.skills/playwright.md new file mode 100644 index 000000000..98c82145c --- /dev/null +++ b/apps/extension/.skills/playwright.md @@ -0,0 +1,453 @@ +--- +name: playwright-skill +description: Complete browser automation with Playwright. Auto-detects dev servers, writes clean test scripts to /tmp. Test pages, fill forms, take screenshots, check responsive design, validate UX, test login flows, check links, automate any browser task. Use when user wants to test websites, automate browser interactions, validate web functionality, or perform any browser-based testing. +--- + +**IMPORTANT - Path Resolution:** +This skill can be installed in different locations (plugin system, manual installation, global, or project-specific). Before executing any commands, determine the skill directory based on where you loaded this SKILL.md file, and use that path in all commands below. Replace `$SKILL_DIR` with the actual discovered path. + +Common installation paths: + +- Plugin system: `~/.claude/plugins/marketplaces/playwright-skill/skills/playwright-skill` +- Manual global: `~/.claude/skills/playwright-skill` +- Project-specific: `/.claude/skills/playwright-skill` + +# Playwright Browser Automation + +General-purpose browser automation skill. I'll write custom Playwright code for any automation task you request and execute it via the universal executor. + +**CRITICAL WORKFLOW - Follow these steps in order:** + +1. **Auto-detect dev servers** - For localhost testing, ALWAYS run server detection FIRST: + + ```bash + cd $SKILL_DIR && node -e "require('./lib/helpers').detectDevServers().then(servers => console.log(JSON.stringify(servers)))" + ``` + + - If **1 server found**: Use it automatically, inform user + - If **multiple servers found**: Ask user which one to test + - If **no servers found**: Ask for URL or offer to help start dev server + +2. **Write scripts to /tmp** - NEVER write test files to skill directory; always use `/tmp/playwright-test-*.js` + +3. **Use visible browser by default** - Always use `headless: false` unless user specifically requests headless mode + +4. **Parameterize URLs** - Always make URLs configurable via environment variable or constant at top of script + +## How It Works + +1. You describe what you want to test/automate +2. I auto-detect running dev servers (or ask for URL if testing external site) +3. I write custom Playwright code in `/tmp/playwright-test-*.js` (won't clutter your project) +4. I execute it via: `cd $SKILL_DIR && node run.js /tmp/playwright-test-*.js` +5. Results displayed in real-time, browser window visible for debugging +6. Test files auto-cleaned from /tmp by your OS + +## Setup (First Time) + +```bash +cd $SKILL_DIR +npm run setup +``` + +This installs Playwright and Chromium browser. Only needed once. + +## Execution Pattern + +**Step 1: Detect dev servers (for localhost testing)** + +```bash +cd $SKILL_DIR && node -e "require('./lib/helpers').detectDevServers().then(s => console.log(JSON.stringify(s)))" +``` + +**Step 2: Write test script to /tmp with URL parameter** + +```javascript +// /tmp/playwright-test-page.js +const { chromium } = require('playwright'); + +// Parameterized URL (detected or user-provided) +const TARGET_URL = 'http://localhost:3001'; // <-- Auto-detected or from user + +(async () => { + const browser = await chromium.launch({ headless: false }); + const page = await browser.newPage(); + + await page.goto(TARGET_URL); + console.log('Page loaded:', await page.title()); + + await page.screenshot({ path: '/tmp/screenshot.png', fullPage: true }); + console.log('📸 Screenshot saved to /tmp/screenshot.png'); + + await browser.close(); +})(); +``` + +**Step 3: Execute from skill directory** + +```bash +cd $SKILL_DIR && node run.js /tmp/playwright-test-page.js +``` + +## Common Patterns + +### Test a Page (Multiple Viewports) + +```javascript +// /tmp/playwright-test-responsive.js +const { chromium } = require('playwright'); + +const TARGET_URL = 'http://localhost:3001'; // Auto-detected + +(async () => { + const browser = await chromium.launch({ headless: false, slowMo: 100 }); + const page = await browser.newPage(); + + // Desktop test + await page.setViewportSize({ width: 1920, height: 1080 }); + await page.goto(TARGET_URL); + console.log('Desktop - Title:', await page.title()); + await page.screenshot({ path: '/tmp/desktop.png', fullPage: true }); + + // Mobile test + await page.setViewportSize({ width: 375, height: 667 }); + await page.screenshot({ path: '/tmp/mobile.png', fullPage: true }); + + await browser.close(); +})(); +``` + +### Test Login Flow + +```javascript +// /tmp/playwright-test-login.js +const { chromium } = require('playwright'); + +const TARGET_URL = 'http://localhost:3001'; // Auto-detected + +(async () => { + const browser = await chromium.launch({ headless: false }); + const page = await browser.newPage(); + + await page.goto(`${TARGET_URL}/login`); + + await page.fill('input[name="email"]', 'test@example.com'); + await page.fill('input[name="password"]', 'password123'); + await page.click('button[type="submit"]'); + + // Wait for redirect + await page.waitForURL('**/dashboard'); + console.log('✅ Login successful, redirected to dashboard'); + + await browser.close(); +})(); +``` + +### Fill and Submit Form + +```javascript +// /tmp/playwright-test-form.js +const { chromium } = require('playwright'); + +const TARGET_URL = 'http://localhost:3001'; // Auto-detected + +(async () => { + const browser = await chromium.launch({ headless: false, slowMo: 50 }); + const page = await browser.newPage(); + + await page.goto(`${TARGET_URL}/contact`); + + await page.fill('input[name="name"]', 'John Doe'); + await page.fill('input[name="email"]', 'john@example.com'); + await page.fill('textarea[name="message"]', 'Test message'); + await page.click('button[type="submit"]'); + + // Verify submission + await page.waitForSelector('.success-message'); + console.log('✅ Form submitted successfully'); + + await browser.close(); +})(); +``` + +### Check for Broken Links + +```javascript +const { chromium } = require('playwright'); + +(async () => { + const browser = await chromium.launch({ headless: false }); + const page = await browser.newPage(); + + await page.goto('http://localhost:3000'); + + const links = await page.locator('a[href^="http"]').all(); + const results = { working: 0, broken: [] }; + + for (const link of links) { + const href = await link.getAttribute('href'); + try { + const response = await page.request.head(href); + if (response.ok()) { + results.working++; + } else { + results.broken.push({ url: href, status: response.status() }); + } + } catch (e) { + results.broken.push({ url: href, error: e.message }); + } + } + + console.log(`✅ Working links: ${results.working}`); + console.log(`❌ Broken links:`, results.broken); + + await browser.close(); +})(); +``` + +### Take Screenshot with Error Handling + +```javascript +const { chromium } = require('playwright'); + +(async () => { + const browser = await chromium.launch({ headless: false }); + const page = await browser.newPage(); + + try { + await page.goto('http://localhost:3000', { + waitUntil: 'networkidle', + timeout: 10000, + }); + + await page.screenshot({ + path: '/tmp/screenshot.png', + fullPage: true, + }); + + console.log('📸 Screenshot saved to /tmp/screenshot.png'); + } catch (error) { + console.error('❌ Error:', error.message); + } finally { + await browser.close(); + } +})(); +``` + +### Test Responsive Design + +```javascript +// /tmp/playwright-test-responsive-full.js +const { chromium } = require('playwright'); + +const TARGET_URL = 'http://localhost:3001'; // Auto-detected + +(async () => { + const browser = await chromium.launch({ headless: false }); + const page = await browser.newPage(); + + const viewports = [ + { name: 'Desktop', width: 1920, height: 1080 }, + { name: 'Tablet', width: 768, height: 1024 }, + { name: 'Mobile', width: 375, height: 667 }, + ]; + + for (const viewport of viewports) { + console.log( + `Testing ${viewport.name} (${viewport.width}x${viewport.height})`, + ); + + await page.setViewportSize({ + width: viewport.width, + height: viewport.height, + }); + + await page.goto(TARGET_URL); + await page.waitForTimeout(1000); + + await page.screenshot({ + path: `/tmp/${viewport.name.toLowerCase()}.png`, + fullPage: true, + }); + } + + console.log('✅ All viewports tested'); + await browser.close(); +})(); +``` + +## Inline Execution (Simple Tasks) + +For quick one-off tasks, you can execute code inline without creating files: + +```bash +# Take a quick screenshot +cd $SKILL_DIR && node run.js " +const browser = await chromium.launch({ headless: false }); +const page = await browser.newPage(); +await page.goto('http://localhost:3001'); +await page.screenshot({ path: '/tmp/quick-screenshot.png', fullPage: true }); +console.log('Screenshot saved'); +await browser.close(); +" +``` + +**When to use inline vs files:** + +- **Inline**: Quick one-off tasks (screenshot, check if element exists, get page title) +- **Files**: Complex tests, responsive design checks, anything user might want to re-run + +## Available Helpers + +Optional utility functions in `lib/helpers.js`: + +```javascript +const helpers = require('./lib/helpers'); + +// Detect running dev servers (CRITICAL - use this first!) +const servers = await helpers.detectDevServers(); +console.log('Found servers:', servers); + +// Safe click with retry +await helpers.safeClick(page, 'button.submit', { retries: 3 }); + +// Safe type with clear +await helpers.safeType(page, '#username', 'testuser'); + +// Take timestamped screenshot +await helpers.takeScreenshot(page, 'test-result'); + +// Handle cookie banners +await helpers.handleCookieBanner(page); + +// Extract table data +const data = await helpers.extractTableData(page, 'table.results'); +``` + +See `lib/helpers.js` for full list. + +## Custom HTTP Headers + +Configure custom headers for all HTTP requests via environment variables. Useful for: + +- Identifying automated traffic to your backend +- Getting LLM-optimized responses (e.g., plain text errors instead of styled HTML) +- Adding authentication tokens globally + +### Configuration + +**Single header (common case):** + +```bash +PW_HEADER_NAME=X-Automated-By PW_HEADER_VALUE=playwright-skill \ + cd $SKILL_DIR && node run.js /tmp/my-script.js +``` + +**Multiple headers (JSON format):** + +```bash +PW_EXTRA_HEADERS='{"X-Automated-By":"playwright-skill","X-Debug":"true"}' \ + cd $SKILL_DIR && node run.js /tmp/my-script.js +``` + +### How It Works + +Headers are automatically applied when using `helpers.createContext()`: + +```javascript +const context = await helpers.createContext(browser); +const page = await context.newPage(); +// All requests from this page include your custom headers +``` + +For scripts using raw Playwright API, use the injected `getContextOptionsWithHeaders()`: + +```javascript +const context = await browser.newContext( + getContextOptionsWithHeaders({ viewport: { width: 1920, height: 1080 } }), +); +``` + +## Advanced Usage + +For comprehensive Playwright API documentation, see [API_REFERENCE.md](API_REFERENCE.md): + +- Selectors & Locators best practices +- Network interception & API mocking +- Authentication & session management +- Visual regression testing +- Mobile device emulation +- Performance testing +- Debugging techniques +- CI/CD integration + +## Tips + +- **CRITICAL: Detect servers FIRST** - Always run `detectDevServers()` before writing test code for localhost testing +- **Custom headers** - Use `PW_HEADER_NAME`/`PW_HEADER_VALUE` env vars to identify automated traffic to your backend +- **Use /tmp for test files** - Write to `/tmp/playwright-test-*.js`, never to skill directory or user's project +- **Parameterize URLs** - Put detected/provided URL in a `TARGET_URL` constant at the top of every script +- **DEFAULT: Visible browser** - Always use `headless: false` unless user explicitly asks for headless mode +- **Headless mode** - Only use `headless: true` when user specifically requests "headless" or "background" execution +- **Slow down:** Use `slowMo: 100` to make actions visible and easier to follow +- **Wait strategies:** Use `waitForURL`, `waitForSelector`, `waitForLoadState` instead of fixed timeouts +- **Error handling:** Always use try-catch for robust automation +- **Console output:** Use `console.log()` to track progress and show what's happening + +## Troubleshooting + +**Playwright not installed:** + +```bash +cd $SKILL_DIR && npm run setup +``` + +**Module not found:** +Ensure running from skill directory via `run.js` wrapper + +**Browser doesn't open:** +Check `headless: false` and ensure display available + +**Element not found:** +Add wait: `await page.waitForSelector('.element', { timeout: 10000 })` + +## Example Usage + +``` +User: "Test if the marketing page looks good" + +Claude: I'll test the marketing page across multiple viewports. Let me first detect running servers... +[Runs: detectDevServers()] +[Output: Found server on port 3001] +I found your dev server running on http://localhost:3001 + +[Writes custom automation script to /tmp/playwright-test-marketing.js with URL parameterized] +[Runs: cd $SKILL_DIR && node run.js /tmp/playwright-test-marketing.js] +[Shows results with screenshots from /tmp/] +``` + +``` +User: "Check if login redirects correctly" + +Claude: I'll test the login flow. First, let me check for running servers... +[Runs: detectDevServers()] +[Output: Found servers on ports 3000 and 3001] +I found 2 dev servers. Which one should I test? +- http://localhost:3000 +- http://localhost:3001 + +User: "Use 3001" + +[Writes login automation to /tmp/playwright-test-login.js] +[Runs: cd $SKILL_DIR && node run.js /tmp/playwright-test-login.js] +[Reports: ✅ Login successful, redirected to /dashboard] +``` + +## Notes + +- Each automation is custom-written for your specific request +- Not limited to pre-built scripts - any browser task possible +- Auto-detects running dev servers to eliminate hardcoded URLs +- Test scripts written to `/tmp` for automatic cleanup (no clutter) +- Code executes reliably with proper module resolution via `run.js` +- Progressive disclosure - API_REFERENCE.md loaded only when advanced features needed diff --git a/apps/extension/.skills/servers.md b/apps/extension/.skills/servers.md new file mode 100644 index 000000000..8a48e456a --- /dev/null +++ b/apps/extension/.skills/servers.md @@ -0,0 +1,221 @@ +# Solid Servers Skill + +You are an expert on Solid server implementations — both public hosted servers and local development setups. Use this knowledge to help developers choose, configure, run, and debug Solid servers. + +--- + +## Public Solid Servers + +| Server | URL | Notes | +|--------|-----|-------| +| solidcommunity.net | https://solidcommunity.net | Powered by Pivot CSS; widely used community server | +| solidweb.org | https://solidweb.org | Community-hosted | +| start.inrupt.com | https://start.inrupt.com | Inrupt's hosted pod service (ESS) | + +### Choosing a Public Server or allow interoperability for all servers. Ask the user + +- **solidcommunity.net** — best for open community apps; uses older DPoP-based OIDC (Pivot flavour) +- **solidweb.org** — alternative community option +- **start.inrupt.com (ESS)** — enterprise-grade, uses Inrupt's Enterprise Solid Server; best compatibility with Inrupt SDK + +--- + +## Community Solid Server (CSS) + +**Repo**: https://github.com/CommunitySolidServer/CommunitySolidServer +**Docs**: https://communitysolidserver.github.io/CommunitySolidServer/latest/ + +The reference open-source implementation of the Solid specification. Highly modular and configurable. + +### Requirements + +- Node.js 18+ +- npm or npx + +### Quick Start (in-memory, ephemeral) + +```bash +npx @solid/community-server +``` + +Server available at http://localhost:3000/ + +### Persistent File-Based Storage + +```bash +npx @solid/community-server -c @css:config/file.json -f ./data +``` + +Data stored in `./data/` directory. + +### Global Install + +```bash +npm install -g @solid/community-server +community-solid-server -c @css:config/file.json -f ./data +``` + +### Key CLI Flags + +| Flag | Default | Description | +|------|---------|-------------| +| `--port, -p` | `3000` | TCP port to listen on | +| `--baseUrl, -b` | `http://localhost:3000/` | Base URL used in generated resource URIs | +| `--config, -c` | `@css:config/default.json` | Configuration preset | +| `--rootFilePath, -f` | `./` | Directory for file-based storage | +| `--loggingLevel, -l` | `info` | Log level (`debug`, `info`, `warn`, `error`) | +| `--workers, -w` | `1` | Number of worker threads | +| `--sparqlEndpoint` | — | SPARQL endpoint URL for SPARQL-backed storage | + +### Environment Variables + +All flags can be set as environment variables using the `CSS_` prefix: + +```bash +CSS_PORT=4000 +CSS_LOGGING_LEVEL=debug +CSS_ROOT_FILE_PATH=/var/solid/data +``` + +### Storage Backends + +| Config | Backend | Use case | +|--------|---------|---------| +| `@css:config/default.json` | In-memory | Quick testing, no persistence | +| `@css:config/file.json` | File system | Local dev with persistence | +| `--sparqlEndpoint` | SPARQL | Production or graph DB backends | + +### Docker + +```bash +# Run with file-based storage mounted at ~/Solid +docker run --rm \ + -v ~/Solid:/data \ + -p 3000:3000 \ + solidproject/community-server:latest \ + -c @css:config/file.json -f /data +``` + +### Kubernetes + +Use the official Helm chart: https://github.com/CommunitySolidServer/css-helm-chart + +### Build from Source + +```bash +git clone https://github.com/CommunitySolidServer/CommunitySolidServer +cd CommunitySolidServer +npm ci +npm start +``` + +### Access Control + +CSS supports both **WAC** (Web Access Control) and **ACP** (Access Control Policy) depending on configuration. Default config uses WAC. + +--- + +## Pivot CSS + +**Repo**: https://github.com/solid-contrib/pivot +**Used by**: solidcommunity.net + +Pivot is a spec-compliant Solid server built as a thin wrapper/remix of CSS. It takes a different philosophy on consent and access control. + +### Key Differences from CSS + +| Aspect | CSS | Pivot | +|--------|-----|-------| +| OIDC version | Latest Solid-OIDC (UMA) | Older DPoP-based OIDC | +| Access control model | WAC (default) or ACP | User-centric, DPoP | +| Auth approach | Standard | App gets full pod access when authenticated | +| Focus | Reference implementation | Simplified consent management | + +### When to Use Pivot + +- Building apps that target **solidcommunity.net** users +- When you need the exact server behaviour of solidcommunity.net locally +- Testing compatibility with the older Solid-OIDC DPoP flow + +### Running Pivot Locally + +```bash +git clone https://github.com/solid-contrib/pivot +cd pivot +npm install +npm start +``` + +Default port: 3000 (same as CSS) + +--- + +## Local Development Tips + +### Running Two Servers Side-by-Side + +To test cross-server behaviour (e.g., Alice on CSS, Bob on Pivot): + +```bash +# CSS on port 3000 +npx @solid/community-server -p 3000 -c @css:config/file.json -f ./data-css + +# Pivot on port 3001 +cd pivot && PORT=3001 npm start +``` + +### Setting baseUrl for Deployed Environments + +When deploying behind a reverse proxy (Nginx, Caddy): + +```bash +npx @solid/community-server \ + -b https://solid.example.com/ \ + -c @css:config/file.json \ + -f /var/solid/data +``` + +The `--baseUrl` must match the public-facing URL exactly — resources are minted using this value. + +### Debugging + +```bash +npx @solid/community-server -l debug +``` + +Or via environment variable: + +```bash +CSS_LOGGING_LEVEL=debug npx @solid/community-server +``` + +### Creating a Test Account (CSS) + +Navigate to http://localhost:3000/ — CSS provides a UI for pod registration when running locally. + +--- + +## Conformance Testing + +Use the Solid conformance test harness to verify your server implementation: + +- Repo: https://github.com/solid-contrib/conformance-test-harness +- Inrupt test suite info: https://www.inrupt.com/blog/conformance-test-suite + +```bash +git clone https://github.com/solid-contrib/conformance-test-harness +cd conformance-test-harness +# See repo README for configuration and execution steps +``` + +--- + +## Quick Reference + +| Task | Command | +|------|---------| +| Start CSS (ephemeral) | `npx @solid/community-server` | +| Start CSS (persistent) | `npx @solid/community-server -c @css:config/file.json -f ./data` | +| Start CSS (custom port) | `npx @solid/community-server -p 4000` | +| Start CSS (Docker) | `docker run --rm -v ~/Solid:/data -p 3000:3000 solidproject/community-server:latest -c @css:config/file.json -f /data` | +| Debug logging | `npx @solid/community-server -l debug` | diff --git a/apps/extension/.skills/spec.md b/apps/extension/.skills/spec.md new file mode 100644 index 000000000..67234bd15 --- /dev/null +++ b/apps/extension/.skills/spec.md @@ -0,0 +1,315 @@ +# Solid Specifications Skill + +You are an expert on the Solid Protocol and its companion specifications. Use this knowledge to help developers understand, implement, and debug Solid-compliant applications. + +## Core Specifications + +| Spec | Version | URL | Purpose | +|------|---------|-----|---------| +| Solid Protocol | v0.11.0 | https://solidproject.org/TR/protocol | Core data pod and resource access rules | +| WebID Profile | v1.0.0 | https://solid.github.io/webid-profile/ | Identity and agent description | +| Solid-OIDC | v0.1.0 | https://solidproject.org/TR/oidc | Decentralised authentication | +| Access Control Policy (ACP) | v0.9.0 | https://solidproject.org/TR/acp | Fine-grained authorisation | + +--- + +## 1. Solid Protocol + +**What it is**: The foundation spec defining how data pods expose resources over HTTP using Linked Data principles. + +### Key Concepts + +**Storage**: A space of URIs giving agents controlled access to resources. Each storage has a root container. + +**Resources and Containers**: Resources live at HTTP URIs. Container URIs end with `/`. Containers hold other resources and containers, forming a hierarchy. + +**WebID**: An HTTP URI that identifies an agent. Dereferencing it returns an RDF document describing them. + +### HTTP Behaviour + +- Servers MUST support GET, HEAD, OPTIONS, PUT, POST, PATCH, DELETE +- All write requests MUST include `Content-Type`; missing headers → 400 +- Servers MUST support Turtle (`text/turtle`) and JSON-LD (`application/ld+json`) +- HTTPS is required; HTTP → HTTPS redirect if both schemes are exposed + +### Content Negotiation + +``` +GET /profile/card HTTP/1.1 +Accept: text/turtle +``` + +Servers must honour `Accept` headers and respond with matching RDF serialisation. + +### PATCH with N3 + +``` +PATCH /data/notes.ttl HTTP/1.1 +Content-Type: text/n3 + +@prefix solid: . +<> a solid:InsertDeletePatch; + solid:inserts { <#note1> "Hello world" . }. +``` + +- 409 Conflict if conditions produce zero or multiple matches. + +### Auxiliary Resources + +- **ACL resources**: control access (`Link: ; rel="acl"`) +- **Description resources**: metadata about a resource (`Link: ; rel="describedby"`) +- Auxiliary resources are deleted automatically when their parent is deleted. + +### Notifications + +The Solid Notifications Protocol allows clients to subscribe to resource changes. Servers act as Resource Server, Subscription Server, Notification Sender, and Notification Receiver. + +### Common Mistakes + +- Forgetting trailing `/` on container URIs +- Not sending `Content-Type` on PATCH/PUT +- Using WAC and ACP interchangeably — servers implement one or both, check which +- Assuming CORS is sufficient for access control — always use ACL/ACP + +--- + +## 2. WebID Profile + +**What it is**: An RDF document at a WebID URI describing a social agent's identity, storage locations, and preferences. + +### Minimum Required Triples + +```turtle +@prefix foaf: . +@prefix pim: . + + + a foaf:Agent ; + pim:preferencesFile ; + pim:storage . +``` + +### Key Predicates + +| Predicate | Required | Description | +|-----------|----------|-------------| +| `rdf:type foaf:Agent` | Yes | Identifies the subject as an agent | +| `pim:preferencesFile` | Yes | Pointer to private preferences document | +| `pim:storage` | Recommended | Location of the agent's pod storage | +| `solid:oidcIssuer` | Recommended | The OIDC Identity Provider for this WebID | +| `ldp:inbox` | Optional | Inbox for incoming notifications | +| `rdfs:seeAlso` | Optional | Links to extended profile documents | + +### Preferences Document + +- Must be typed as `pim:ConfigurationFile` +- Private (not publicly readable) +- Holds app settings and personalisation data + +### Type Index + +- Public and private type index documents link resource types to storage locations +- Discovered via the WebID profile +- Enables apps to find where a user stores specific types of data + +### Modifying a Profile + +Use PATCH (N3 Patch) for targeted updates. Servers protect `solid:oidcIssuer` — attempting to change it returns 409. + +```turtle +# Read profile +GET /profile/card HTTP/1.1 +Accept: text/turtle + +# Patch profile to add name +PATCH /profile/card HTTP/1.1 +Content-Type: text/n3 + +@prefix solid: . +@prefix foaf: . +<> a solid:InsertDeletePatch; + solid:inserts { <#me> foaf:name "Alice" . }. +``` + +--- + +## 3. Solid-OIDC + +**What it is**: An authentication layer built on OAuth 2.0 + OpenID Connect that works without pre-established trust between resource servers and identity providers. + +### Flow Overview + +1. App redirects user to their OIDC Identity Provider (IdP) +2. IdP issues a DPoP-bound ID Token containing the `webid` claim +3. App presents the ID Token + DPoP proof to the Resource Server +4. Resource Server validates the token by checking the WebID profile for `solid:oidcIssuer` + +### Authorization Code Flow with PKCE + +This is the **mandatory** flow. Never use Implicit Flow. + +``` +1. App generates code_verifier + code_challenge (PKCE) +2. Redirect → IdP /authorize?response_type=code&code_challenge=... +3. IdP issues auth code +4. App exchanges code for tokens at /token endpoint +5. App receives id_token + access_token (DPoP-bound) +``` + +### DPoP-Bound ID Token Claims + +```json +{ + "webid": "https://alice.solidcommunity.net/profile/card#me", + "iss": "https://solidcommunity.net", + "aud": ["https://myapp.example", "solid"], + "cnf": { "jkt": "" }, + "exp": 1700000000, + "iat": 1699996400 +} +``` + +### DPoP Proof Header + +Every resource request must include a DPoP proof JWT: + +``` +Authorization: DPoP +DPoP: +``` + +### Client Identification + +Two options: +1. **Dereferenceable URI** — client ID is a URL that returns a JSON-LD client registration document +2. **Traditional OIDC registration** — dynamic or static client registration (optional in decentralised context) + +### WebID Issuer Validation + +Resource servers must verify that the `iss` claim in the ID Token matches a `solid:oidcIssuer` triple in the user's WebID Profile. This prevents token misuse. + +### Common Mistakes + +- Using Implicit Flow (not supported) +- Forgetting to include `"solid"` in the `aud` claim +- Not binding tokens with DPoP +- Checking the wrong WebID for issuer validation + +--- + +## 4. Access Control Policy (ACP) + +**What it is**: An RDF-based language for defining who can access what on a Solid pod, using a deny-overrides policy model. + +### Core Objects + +``` +Resource ──has ACR──▶ Access Control Resource (ACR) + │ + Access Control + │ + Policy + ┌────┴────┐ + Allow Deny + │ │ + Matcher Matcher +``` + +### Access Control Resource (ACR) + +Each resource has an associated ACR linked via: + +``` +Link: ; rel="http://www.w3.org/ns/solid/acp#accessControl" +``` + +### Matchers + +Matchers define conditions for a policy. They evaluate against the **request context** (agent, client, issuer, target resource). + +```turtle +@prefix acp: . + +<#matcher-alice> + a acp:Matcher ; + acp:agent . +``` + +Special agents: +- `acp:PublicAgent` — anyone (including unauthenticated) +- `acp:AuthenticatedAgent` — any authenticated user +- `acp:CreatorAgent` — whoever created the resource +- `acp:OwnerAgent` — the pod owner + +### Policies + +```turtle +<#policy-read> + a acp:Policy ; + acp:allow acp:Read ; + acp:anyOf <#matcher-alice> . +``` + +Policy satisfaction logic: + +| Condition | Meaning | +|-----------|---------| +| `acp:allOf` | ALL listed matchers must match | +| `acp:anyOf` | AT LEAST ONE listed matcher must match | +| `acp:noneOf` | NONE of the listed matchers must match | + +### Access Control (connecting ACR to Policy) + +```turtle +<#access-control> + a acp:AccessControl ; + acp:apply <#policy-read> . +``` + +### Grant Logic + +Access mode is granted **if and only if**: +- At least one satisfied policy **allows** it, AND +- No satisfied policy **denies** it + +**Deny overrides allow.** + +### Common Access Patterns + +**Public read access:** +```turtle +<#matcher-public> a acp:Matcher ; acp:agent acp:PublicAgent . +<#policy-public> a acp:Policy ; acp:allow acp:Read ; acp:anyOf <#matcher-public> . +``` + +**Authenticated write access:** +```turtle +<#matcher-auth> a acp:Matcher ; acp:agent acp:AuthenticatedAgent . +<#policy-write> a acp:Policy ; acp:allow acp:Write, acp:Append ; acp:anyOf <#matcher-auth> . +``` + +### ACP vs WAC + +| | ACP | WAC | +|-|-----|-----| +| Model | Policy + Matchers (RDF) | ACL rules (RDF) | +| Deny support | Yes (deny overrides) | Limited | +| Granularity | Fine-grained, composable | Basic | +| Spec status | Active development | Older, widely deployed | + +--- + +## Quick Reference: Spec URLs + +- Solid Protocol: https://solidproject.org/TR/protocol +- WebID Profile: https://solid.github.io/webid-profile/ +- Solid-OIDC: https://solidproject.org/TR/oidc +- ACP: https://solidproject.org/TR/acp +- Solid Notifications: https://solidproject.org/TR/notifications-protocol +- Web Access Control: https://solidproject.org/TR/wac + +--- + +## Other awesome skills + +For more Claude/LLM skills (document processing, dev tools, automation, MCP, etc.), see [Awesome Claude Skills](https://github.com/ComposioHQ/awesome-claude-skills). diff --git a/apps/extension/.skills/style-guide.md b/apps/extension/.skills/style-guide.md new file mode 100644 index 000000000..68796a4ea --- /dev/null +++ b/apps/extension/.skills/style-guide.md @@ -0,0 +1,128 @@ +# Solid Style Guide Skill + +You are an expert on Solid Project brand and visual identity guidelines. Use this knowledge to help teams correctly represent the Solid brand in apps, communications, events, and assets. + +**Official guidelines**: https://solidproject.org/logo_usage_guidelines + +--- + +## Brand Colours + +### Primary Colour + +| Name | Hex | RGB | +|------|-----|-----| +| Royal Lavender | `#7C4DFF` | R124 G77 B255 | + +Royal Lavender is the core brand colour. It is used: +- As a background for the outlined Solid logo variant +- In mastheads and footers +- For primary CTA (call-to-action) buttons + +### Secondary Colours + +| Name | Hex | Notes | +|------|-----|-------| +| Deep Sky Blue | (part of gradient) | Used in the brand gradient | +| Cerulean Blue | (part of gradient) | Used in the brand gradient | + +### The Solid Gradient + +The gradient combines Royal Lavender, Deep Sky Blue, and Cerulean Blue in equal thirds (33/33/33). + +**Allowed uses:** +- As a background +- As an overlay on a greyscale image + +**Not allowed uses:** +- In the logo itself +- In typography +- In icons or other graphics + +--- + +## Logo + +### Two Official Variants + +1. **Standard logo** — works on any flat colour background or overlaid on an image +2. **Outlined logo** — to be used **only** on a Royal Lavender (`#7C4DFF`) background + +### Logo Usage Rules + +**Do:** +- Use the logo on clean, flat-colour backgrounds +- Use the outlined variant on Royal Lavender backgrounds only +- Maintain clear space (exclusion zone) around the logo +- Download official logo files from solidproject.org + +**Do not:** +- Use the logo on complex patterns +- Place the logo in close proximity to other logos, typography, or graphic elements +- Modify, recolour, rotate, or distort the logo +- Use the gradient in or behind the logo +- Use unofficial or recreated versions of the logo + +--- + +## Typography + +Use the typography guidance from the official style guide at https://solidproject.org/logo_usage_guidelines. Do not substitute unapproved fonts in Solid-branded materials. + +--- + +## Iconography and Graphics + +- Do not use the Solid gradient inside icons or graphics +- Keep graphic elements visually distinct from the logo +- Maintain visual hierarchy so the Solid logo is clearly identifiable + +--- + +## Application Contexts + +The brand guidelines apply when representing Solid in: + +| Context | Guidance | +|---------|---------| +| Apps | Use approved logo + colours in UI; do not modify logo | +| Communications (email, docs) | Follow colour and typography rules | +| Events | Use official logo files; follow exclusion zone rules | +| Projects (repos, sites) | Do not use the gradient in logo or wordmark | +| Assets (banners, icons) | Outlined logo on Royal Lavender only | + +--- + +## Quick Colour Reference + +```css +/* Primary brand colour */ +--solid-royal-lavender: #7C4DFF; + +/* Use for: mastheads, footers, primary CTAs, outlined logo background */ +background-color: #7C4DFF; +``` + +--- + +## Getting Official Assets + +All official Solid logos and brand assets are available at: +https://solidproject.org/logo_usage_guidelines + +logo reference + + + + + + + + + + +icons reference +https://heroicons.com/ + + +Always use official assets — do not recreate or approximate the logo. diff --git a/apps/extension/.skills/test-driven-development.md b/apps/extension/.skills/test-driven-development.md new file mode 100644 index 000000000..34a9f2b25 --- /dev/null +++ b/apps/extension/.skills/test-driven-development.md @@ -0,0 +1,371 @@ +--- +name: test-driven-development +description: Use when implementing any feature or bugfix, before writing implementation code +--- + +# Test-Driven Development (TDD) + +## Overview + +Write the test first. Watch it fail. Write minimal code to pass. + +**Core principle:** If you didn't watch the test fail, you don't know if it tests the right thing. + +**Violating the letter of the rules is violating the spirit of the rules.** + +## When to Use + +**Always:** +- New features +- Bug fixes +- Refactoring +- Behavior changes + +**Exceptions (ask your human partner):** +- Throwaway prototypes +- Generated code +- Configuration files + +Thinking "skip TDD just this once"? Stop. That's rationalization. + +## The Iron Law + +``` +NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST +``` + +Write code before the test? Delete it. Start over. + +**No exceptions:** +- Don't keep it as "reference" +- Don't "adapt" it while writing tests +- Don't look at it +- Delete means delete + +Implement fresh from tests. Period. + +## Red-Green-Refactor + +```dot +digraph tdd_cycle { + rankdir=LR; + red [label="RED\nWrite failing test", shape=box, style=filled, fillcolor="#ffcccc"]; + verify_red [label="Verify fails\ncorrectly", shape=diamond]; + green [label="GREEN\nMinimal code", shape=box, style=filled, fillcolor="#ccffcc"]; + verify_green [label="Verify passes\nAll green", shape=diamond]; + refactor [label="REFACTOR\nClean up", shape=box, style=filled, fillcolor="#ccccff"]; + next [label="Next", shape=ellipse]; + + red -> verify_red; + verify_red -> green [label="yes"]; + verify_red -> red [label="wrong\nfailure"]; + green -> verify_green; + verify_green -> refactor [label="yes"]; + verify_green -> green [label="no"]; + refactor -> verify_green [label="stay\ngreen"]; + verify_green -> next; + next -> red; +} +``` + +### RED - Write Failing Test + +Write one minimal test showing what should happen. + + +```typescript +test('retries failed operations 3 times', async () => { + let attempts = 0; + const operation = () => { + attempts++; + if (attempts < 3) throw new Error('fail'); + return 'success'; + }; + + const result = await retryOperation(operation); + + expect(result).toBe('success'); + expect(attempts).toBe(3); +}); +``` +Clear name, tests real behavior, one thing + + + +```typescript +test('retry works', async () => { + const mock = jest.fn() + .mockRejectedValueOnce(new Error()) + .mockRejectedValueOnce(new Error()) + .mockResolvedValueOnce('success'); + await retryOperation(mock); + expect(mock).toHaveBeenCalledTimes(3); +}); +``` +Vague name, tests mock not code + + +**Requirements:** +- One behavior +- Clear name +- Real code (no mocks unless unavoidable) + +### Verify RED - Watch It Fail + +**MANDATORY. Never skip.** + +```bash +npm test path/to/test.test.ts +``` + +Confirm: +- Test fails (not errors) +- Failure message is expected +- Fails because feature missing (not typos) + +**Test passes?** You're testing existing behavior. Fix test. + +**Test errors?** Fix error, re-run until it fails correctly. + +### GREEN - Minimal Code + +Write simplest code to pass the test. + + +```typescript +async function retryOperation(fn: () => Promise): Promise { + for (let i = 0; i < 3; i++) { + try { + return await fn(); + } catch (e) { + if (i === 2) throw e; + } + } + throw new Error('unreachable'); +} +``` +Just enough to pass + + + +```typescript +async function retryOperation( + fn: () => Promise, + options?: { + maxRetries?: number; + backoff?: 'linear' | 'exponential'; + onRetry?: (attempt: number) => void; + } +): Promise { + // YAGNI +} +``` +Over-engineered + + +Don't add features, refactor other code, or "improve" beyond the test. + +### Verify GREEN - Watch It Pass + +**MANDATORY.** + +```bash +npm test path/to/test.test.ts +``` + +Confirm: +- Test passes +- Other tests still pass +- Output pristine (no errors, warnings) + +**Test fails?** Fix code, not test. + +**Other tests fail?** Fix now. + +### REFACTOR - Clean Up + +After green only: +- Remove duplication +- Improve names +- Extract helpers + +Keep tests green. Don't add behavior. + +### Repeat + +Next failing test for next feature. + +## Good Tests + +| Quality | Good | Bad | +|---------|------|-----| +| **Minimal** | One thing. "and" in name? Split it. | `test('validates email and domain and whitespace')` | +| **Clear** | Name describes behavior | `test('test1')` | +| **Shows intent** | Demonstrates desired API | Obscures what code should do | + +## Why Order Matters + +**"I'll write tests after to verify it works"** + +Tests written after code pass immediately. Passing immediately proves nothing: +- Might test wrong thing +- Might test implementation, not behavior +- Might miss edge cases you forgot +- You never saw it catch the bug + +Test-first forces you to see the test fail, proving it actually tests something. + +**"I already manually tested all the edge cases"** + +Manual testing is ad-hoc. You think you tested everything but: +- No record of what you tested +- Can't re-run when code changes +- Easy to forget cases under pressure +- "It worked when I tried it" ≠ comprehensive + +Automated tests are systematic. They run the same way every time. + +**"Deleting X hours of work is wasteful"** + +Sunk cost fallacy. The time is already gone. Your choice now: +- Delete and rewrite with TDD (X more hours, high confidence) +- Keep it and add tests after (30 min, low confidence, likely bugs) + +The "waste" is keeping code you can't trust. Working code without real tests is technical debt. + +**"TDD is dogmatic, being pragmatic means adapting"** + +TDD IS pragmatic: +- Finds bugs before commit (faster than debugging after) +- Prevents regressions (tests catch breaks immediately) +- Documents behavior (tests show how to use code) +- Enables refactoring (change freely, tests catch breaks) + +"Pragmatic" shortcuts = debugging in production = slower. + +**"Tests after achieve the same goals - it's spirit not ritual"** + +No. Tests-after answer "What does this do?" Tests-first answer "What should this do?" + +Tests-after are biased by your implementation. You test what you built, not what's required. You verify remembered edge cases, not discovered ones. + +Tests-first force edge case discovery before implementing. Tests-after verify you remembered everything (you didn't). + +30 minutes of tests after ≠ TDD. You get coverage, lose proof tests work. + +## Common Rationalizations + +| Excuse | Reality | +|--------|---------| +| "Too simple to test" | Simple code breaks. Test takes 30 seconds. | +| "I'll test after" | Tests passing immediately prove nothing. | +| "Tests after achieve same goals" | Tests-after = "what does this do?" Tests-first = "what should this do?" | +| "Already manually tested" | Ad-hoc ≠ systematic. No record, can't re-run. | +| "Deleting X hours is wasteful" | Sunk cost fallacy. Keeping unverified code is technical debt. | +| "Keep as reference, write tests first" | You'll adapt it. That's testing after. Delete means delete. | +| "Need to explore first" | Fine. Throw away exploration, start with TDD. | +| "Test hard = design unclear" | Listen to test. Hard to test = hard to use. | +| "TDD will slow me down" | TDD faster than debugging. Pragmatic = test-first. | +| "Manual test faster" | Manual doesn't prove edge cases. You'll re-test every change. | +| "Existing code has no tests" | You're improving it. Add tests for existing code. | + +## Red Flags - STOP and Start Over + +- Code before test +- Test after implementation +- Test passes immediately +- Can't explain why test failed +- Tests added "later" +- Rationalizing "just this once" +- "I already manually tested it" +- "Tests after achieve the same purpose" +- "It's about spirit not ritual" +- "Keep as reference" or "adapt existing code" +- "Already spent X hours, deleting is wasteful" +- "TDD is dogmatic, I'm being pragmatic" +- "This is different because..." + +**All of these mean: Delete code. Start over with TDD.** + +## Example: Bug Fix + +**Bug:** Empty email accepted + +**RED** +```typescript +test('rejects empty email', async () => { + const result = await submitForm({ email: '' }); + expect(result.error).toBe('Email required'); +}); +``` + +**Verify RED** +```bash +$ npm test +FAIL: expected 'Email required', got undefined +``` + +**GREEN** +```typescript +function submitForm(data: FormData) { + if (!data.email?.trim()) { + return { error: 'Email required' }; + } + // ... +} +``` + +**Verify GREEN** +```bash +$ npm test +PASS +``` + +**REFACTOR** +Extract validation for multiple fields if needed. + +## Verification Checklist + +Before marking work complete: + +- [ ] Every new function/method has a test +- [ ] Watched each test fail before implementing +- [ ] Each test failed for expected reason (feature missing, not typo) +- [ ] Wrote minimal code to pass each test +- [ ] All tests pass +- [ ] Output pristine (no errors, warnings) +- [ ] Tests use real code (mocks only if unavoidable) +- [ ] Edge cases and errors covered + +Can't check all boxes? You skipped TDD. Start over. + +## When Stuck + +| Problem | Solution | +|---------|----------| +| Don't know how to test | Write wished-for API. Write assertion first. Ask your human partner. | +| Test too complicated | Design too complicated. Simplify interface. | +| Must mock everything | Code too coupled. Use dependency injection. | +| Test setup huge | Extract helpers. Still complex? Simplify design. | + +## Debugging Integration + +Bug found? Write failing test reproducing it. Follow TDD cycle. Test proves fix and prevents regression. + +Never fix bugs without a test. + +## Testing Anti-Patterns + +When adding mocks or test utilities, read @testing-anti-patterns.md to avoid common pitfalls: +- Testing mock behavior instead of real behavior +- Adding test-only methods to production classes +- Mocking without understanding dependencies + +## Final Rule + +``` +Production code → test exists and failed first +Otherwise → not TDD +``` + +No exceptions without your human partner's permission. \ No newline at end of file diff --git a/apps/extension/CLAUDE.md b/apps/extension/CLAUDE.md new file mode 100644 index 000000000..ecf3fa311 --- /dev/null +++ b/apps/extension/CLAUDE.md @@ -0,0 +1,64 @@ +# Solid Browser Extension + +## Overview + +A Chrome browser extension that enables Solid authentication at the browser level. Injects `window.solid` into all pages providing `.fetch`, `.webId`, `.setClientId()`, `.login()`, and `.logout()`. + +## Architecture + +``` +Page (MAIN world) <--postMessage--> Content Script (ISOLATED) <--chrome.runtime--> Service Worker + inject.ts content-script.ts service-worker.ts +``` + +- **inject.ts**: MAIN world script defining `window.solid` +- **content-script.ts**: ISOLATED world bridge relaying messages +- **service-worker.ts**: Background session manager, auth fetch proxy, client ID registry +- **auth-flow.ts**: Manual OIDC flow adapted for service worker (no `window`) +- **session-database.ts**: Token/key persistence via `chrome.storage.local` + +## Auth Library + +Uses `@uvdsl/solid-oidc-client-browser/core`. The `login()` method is bypassed (uses `window.location.href`) — we manually construct the auth URL and use `chrome.tabs.create()`. `authFetch()` and `restore()` are used directly. + +## Build & Test + +```bash +npm install # Install dependencies +npm run build # Build extension to dist/ +npm run build:dev # Build in watch mode +npm run test:e2e # Build + run Playwright e2e tests +``` + +### E2E Testing + +- Requires headed Chrome (extensions don't work in headless) +- Uses Community Solid Server on localhost:3000 with in-memory storage +- Test site served on a separate port +- Extension loaded from `dist/` via `--load-extension` + +## Skills + +Solid-specific skill documents are in `.skills/`: +- `spec.md` — Solid Protocol, WebID, Solid-OIDC, ACP +- `servers.md` — Community Solid Server +- `style-guide.md` — Solid brand guidelines (use for popup UI) +- `data-modelling.md` — RDF vocabularies, SHACL +- `integration-guide.md` — Client libraries + +## Documentation Lookups + +Use the Context7 MCP server for up-to-date library documentation. Prefer Context7 over training data for API specifics of: +- `@uvdsl/solid-oidc-client-browser` +- Playwright extension testing +- Community Solid Server configuration +- Chrome Extension MV3 APIs + +## Key Conventions + +- TypeScript throughout, strict mode +- Webpack bundler with 5 entry points +- Manifest V3 with `world: "MAIN"` for injection +- DPoP keys stored as extractable JWK (service worker suspension) +- Per-origin client ID support via `solid.setClientId()` +- Solid branding for popup: primary color #7C4DFF diff --git a/apps/extension/PLAN.md b/apps/extension/PLAN.md new file mode 100644 index 000000000..3d053889a --- /dev/null +++ b/apps/extension/PLAN.md @@ -0,0 +1,393 @@ +# Solid Browser Extension - Implementation Plan + +## Context + +Build a Chrome browser extension that enables Solid authentication at the browser level. The extension injects `window.solid` (with `.fetch`, `.webId`, and `.login()`) into all pages, allowing any website to perform authenticated Solid requests. Websites can optionally declare their own client identifier so the Solid server knows which application is making the request. E2E tests verify the full flow against a local Community Solid Server. + +The repo is a blank slate (only LICENSE exists). Auth uses `@uvdsl/solid-oidc-client-browser/core` with a manually adapted login flow for MV3 service workers. Tests target Chrome only via Playwright. + +--- + +## 1. Project Structure + +``` +solid-browser-extension/ + .github/ + workflows/ + ci.yml # Build + e2e tests with xvfb + .skills/ # LLM skill documents (see section 8) + src/ + manifest.json # MV3 manifest + background/ + service-worker.ts # Session mgmt, message handling, auth fetch proxy + session-database.ts # chrome.storage.local adapter for token/key persistence + auth-flow.ts # Manual OIDC authorization URL construction + redirect handling + popup/ + popup.html # Login UI (Solid branding: #7C4DFF) + popup.ts # Sends login request to background + popup.css # Styled per Solid brand guidelines + content/ + content-script.ts # ISOLATED world: relays messages page <-> background + inject/ + inject.ts # MAIN world: defines window.solid, fetch proxy via postMessage + redirect/ + redirect.html # OAuth redirect landing page + redirect.ts # Sends auth code to background + test-site/ + index.html # Uses window.solid to display WebID + fetch private doc + app.js + e2e/ + playwright.config.ts + fixtures.ts # Extension loading, ID discovery + setup/ + global-setup.ts # Start CSS + test site server + global-teardown.ts # Stop servers + css-server.ts # CSS lifecycle management + seed.json # Pre-registered test user + tests/ + login.spec.ts + authenticated-fetch.spec.ts + session-restore.spec.ts + package.json + tsconfig.json + webpack.config.ts + .gitignore + .claude/ + settings.json # MCP servers (Context7), hooks + CLAUDE.md # Project conventions for AI agents + LICENSE +``` + +--- + +## 2. Extension Architecture (Manifest V3) + +### manifest.json + +- `manifest_version: 3` +- **Background**: Service worker (`background/service-worker.js`, type: module) +- **Popup**: `popup/popup.html` via `action.default_popup` +- **Content scripts** (two entries, both ``, `run_at: document_start`): + - `inject/inject.js` with `world: "MAIN"` — injects `window.solid` + - `content/content-script.js` with `world: "ISOLATED"` — bridges page <-> background +- **Permissions**: `storage`, `activeTab` +- **Host permissions**: `` (needed for authenticated fetches to any pod) +- **Web accessible resources**: `redirect/redirect.html` + +### Message flow + +``` +Page (MAIN world) <--postMessage--> Content Script (ISOLATED) <--chrome.runtime--> Service Worker + inject.ts content-script.ts service-worker.ts +``` + +### Message types + +| Message | Direction | Payload | +|---------|-----------|---------| +| `SOLID_LOGIN` | Popup -> Background | `{ idpUrl }` | +| `SOLID_HANDLE_REDIRECT` | Redirect page -> Background | `{ code, state, iss }` | +| `SOLID_FETCH_REQUEST` | Inject -> Content -> Background | `{ requestId, url, method, headers, body, clientId? }` | +| `SOLID_FETCH_RESPONSE` | Background -> Content -> Inject | `{ requestId, status, statusText, headers, body }` | +| `SOLID_GET_STATE` | Inject -> Content -> Background | (none) | +| `SOLID_STATE_UPDATE` | Content -> Inject | `{ webId }` | +| `SOLID_STATE_CHANGED` | Background -> Content (broadcast) | `{ webId }` | +| `SOLID_SET_CLIENT_ID` | Inject -> Content -> Background | `{ origin, clientId }` | + +--- + +## 3. Injected API (`window.solid`) + +### Core API + +```typescript +interface SolidExtension { + /** The authenticated user's WebID, or null if not logged in */ + readonly webId: string | null; + + /** Perform an authenticated fetch using the extension's Solid session */ + fetch(input: RequestInfo | URL, init?: RequestInit): Promise; + + /** + * Set the client identifier for the current origin. + * This is a dereferenceable URI pointing to a Client ID Document (JSON-LD). + * If not set, the extension uses dynamic client registration. + */ + setClientId(clientId: string): void; + + /** Open the extension login flow (convenience — same as clicking the popup) */ + login(idpUrl: string): Promise; + + /** Log out the current session */ + logout(): Promise; +} + +declare global { + interface Window { + solid: SolidExtension; + } +} +``` + +### Client Identifier Design + +**Problem**: When the extension proxies `solid.fetch` for a website, the Solid server sees the extension as the client — not the website. Websites need a way to declare their own identity. + +**Solution**: Websites call `solid.setClientId()` with a dereferenceable client identifier URI. This URI must resolve to a JSON-LD Client ID Document per the Solid-OIDC spec. + +```javascript +// Website declares its client identity +solid.setClientId("https://myapp.example/.well-known/solid-client"); + +// Now authenticated fetches identify as "myapp.example" to the Solid server +const response = await solid.fetch("https://pod.example/private/data.ttl"); +``` + +**Behavior**: +- `setClientId(uri)` stores the mapping `origin -> clientId` in the background service worker +- The background validates the URI is dereferenceable (fetches the Client ID Document) and caches the result +- Authenticated fetches from that origin use the declared client_id in DPoP proofs +- If no client ID is set for an origin, the extension falls back to dynamic client registration using the extension itself as the client + +**Post-logout redirect URI limitation**: When using dereferenceable client identifiers, the Client ID Document must include `post_logout_redirect_uris`. For the extension flow, the logout redirect goes to the extension's redirect page, not the website. This means: +- IDP logout redirects to `chrome-extension:///redirect/redirect.html` regardless of the website's client ID +- The website's Client ID Document should include the extension's redirect URI in its `post_logout_redirect_uris` if IDP logout is needed (impractical for general use since the extension ID is dynamic) +- **Pragmatic default**: App-only logout (clear local tokens) rather than IDP logout. IDP logout is only feasible when the website controls its own Client ID Document AND knows the extension ID + +**Open question**: Whether a more ergonomic pattern exists for Solid post-logout flows in the extension context. This is a known limitation of the current Solid-OIDC spec when applied to browser extensions. + +--- + +## 4. Authentication Flow + +Using `@uvdsl/solid-oidc-client-browser/core` with manual adaptations for service worker context: + +1. **User opens popup**, enters IDP URL, clicks Login +2. **Background `auth-flow.ts`** (no `window` available): + - Fetches IDP's `.well-known/openid-configuration` + - Dynamically registers client (redirect_uri = `chrome-extension://${chrome.runtime.id}/redirect/redirect.html`) + - Generates PKCE code_verifier + challenge + - Generates CSRF state token + - Stores auth params in `chrome.storage.local` + - Opens IDP auth URL via `chrome.tabs.create()` +3. **IDP authenticates user**, redirects to `redirect.html?code=...&state=...&iss=...` +4. **redirect.ts** extracts params, sends `SOLID_HANDLE_REDIRECT` to background +5. **Background** exchanges code for tokens (with DPoP proof + PKCE verifier), stores tokens + WebID +6. **Broadcasts** `SOLID_STATE_CHANGED` to all tabs + +### Key technical decisions + +- **DPoP keys**: Generated as **extractable**, exported as JWK to `chrome.storage.local`, re-imported on service worker wake-up (non-extractable keys are lost on suspension) +- **SessionCore usage**: Used for `authFetch()` and `restore()` only; `login()` bypassed in favor of manual auth URL construction +- **Response serialization**: Bodies serialized as text over chrome.runtime messaging (Solid resources are predominantly text/RDF); binary support can be added later via base64 +- **Per-origin client IDs**: Background maintains a `Map` persisted in `chrome.storage.local`. When making an authenticated fetch, the DPoP proof includes the appropriate client_id for the requesting origin + +--- + +## 5. Popup UI (Solid Branding) + +The popup follows Solid brand guidelines (from `.skills/style-guide.md`): + +- **Primary color**: `#7C4DFF` (Solid purple) +- **Logo**: Solid logo displayed at the top of the popup +- **Typography**: Clean, modern sans-serif +- **Layout**: Simple form — IDP URL input + Login button. When logged in, show WebID + Logout button +- **States**: Not logged in / Logging in (spinner) / Logged in (shows WebID) + +--- + +## 6. Build Configuration + +- **Bundler**: Webpack with `ts-loader` +- **5 entry points**: service-worker, popup, content-script, inject, redirect +- **CopyWebpackPlugin**: copies manifest.json, HTML, CSS to dist/ +- **TypeScript**: ES2022 target, strict mode, `@types/chrome` + +### Dependencies + +**Runtime**: `@uvdsl/solid-oidc-client-browser`, `jose` +**Dev**: `typescript`, `webpack`, `webpack-cli`, `ts-loader`, `copy-webpack-plugin`, `@types/chrome`, `@playwright/test`, `@solid/community-server`, `http-server`, `ts-node` + +--- + +## 7. E2E Testing + +### Infrastructure +- **Community Solid Server**: Started in `global-setup.ts` with in-memory config + `seed.json` (pre-registered test user at `http://localhost:3000`) +- **Test site**: Served via `http-server` on a separate port +- **Playwright**: Persistent context with `--load-extension`, `headless: false`, chromium channel + +### Fixtures (`e2e/fixtures.ts`) +- Launch persistent Chromium context with extension loaded from `dist/` +- Discover extension ID from service worker URL + +### Test scenarios + +**login.spec.ts** +1. Navigate to `chrome-extension://${extensionId}/popup.html` +2. Enter IDP URL (`http://localhost:3000`), click Login +3. Handle CSS login form (fill email/password, submit) +4. Verify redirect completes and popup shows WebID + +**authenticated-fetch.spec.ts** +1. Complete login +2. Navigate to test site +3. Assert `#webid` displays the authenticated WebID +4. Assert `#fetch-result` contains private document content + +**session-restore.spec.ts** +1. Complete login +2. Navigate away and back to test site +3. Assert session is still active (WebID still displayed) + +### CI +- GitHub Actions with `xvfb-run` for headed Chrome on Linux +- Build extension, install Playwright, run tests + +--- + +## 8. Implementation Order + +1. **Skills & agent config**: CLAUDE.md, .skills/, .claude/settings.json (MCP + hooks), roborev setup +2. **Scaffold**: package.json, tsconfig, webpack config, .gitignore, manifest.json +3. **inject.ts**: Define `window.solid` with fetch proxy via postMessage +4. **content-script.ts**: Message bridge between page and background +5. **session-database.ts**: chrome.storage.local adapter with JWK key serialization +6. **auth-flow.ts**: Manual OIDC flow (discovery, registration, PKCE, auth URL) +7. **service-worker.ts**: Message handler, session management, auth fetch proxy, client ID management +8. **popup**: HTML + TS + CSS for login UI (Solid branded) +9. **redirect**: HTML + TS for OAuth callback +10. **Test site**: Simple HTML/JS using window.solid +11. **E2E setup**: Playwright config, fixtures, CSS server lifecycle +12. **E2E tests**: Login, authenticated fetch, session restore +13. **CI**: GitHub Actions workflow + +--- + +## 9. Key Files to Create/Modify + +| File | Purpose | +|------|---------| +| `src/background/service-worker.ts` | Central session manager, message handler, auth fetch proxy, client ID registry | +| `src/background/session-database.ts` | Token/key persistence via chrome.storage.local | +| `src/background/auth-flow.ts` | OIDC authorization URL construction for service worker context | +| `src/inject/inject.ts` | Defines `window.solid` in MAIN world (fetch, webId, setClientId, login, logout) | +| `src/content/content-script.ts` | Message bridge (ISOLATED world) | +| `src/popup/popup.ts` + `.html` + `.css` | Login UI with Solid branding | +| `src/redirect/redirect.ts` + `.html` | OAuth callback handler | +| `e2e/fixtures.ts` | Playwright extension fixtures | +| `e2e/setup/css-server.ts` | CSS server lifecycle for tests | + +--- + +## 10. Skills, MCP, & Agent Team Setup + +### Skills Documents (`.skills/`) + +**Solid-specific skills** (from https://github.com/solid/solid-llm-skills): +- `spec.md` — Solid Protocol, WebID, Solid-OIDC, ACP specifications +- `servers.md` — Community Solid Server deployment/CLI +- `style-guide.md` — Solid brand guidelines (#7C4DFF, logo, typography) — **used for popup UI** +- `data-modelling.md` — RDF vocabularies, SHACL +- `integration-guide.md` — Client libraries (N3.js, etc.) + +**General engineering skills** (from https://github.com/solid/solid-llm-skills): +- `software-engineer.md` +- `security-engineer.md` (critical for auth extension) +- `qa-engineer.md` (for test strategy) + +**Community skills** (from https://github.com/ComposioHQ/awesome-claude-skills — install relevant ones): +- **Playwright Browser Automation** (https://github.com/lackeyjb/playwright-skill) — e2e test writing guidance +- **test-driven-development** — TDD workflow skill +- **software-architecture** — SOLID principles, clean architecture +- **test-fixing** — detect and fix failing tests +- **using-git-worktrees** — isolated development environments + +### MCP Servers (`.claude/settings.json`) + +**Context7** — Use the Context7 MCP adapter for resolving up-to-date documentation for libraries used in this project. This avoids hallucinating APIs and ensures correct usage of: +- `@uvdsl/solid-oidc-client-browser` API +- Playwright extension testing APIs +- Community Solid Server configuration +- Chrome Extension APIs (MV3) +- Webpack configuration + +Configure in `.claude/settings.json`: +```json +{ + "mcpServers": { + "context7": { + "command": "npx", + "args": ["-y", "@upstash/context7-mcp@latest"] + } + } +} +``` + +### CLAUDE.md + +Project conventions file including: +- Build commands (`npm run build`, `npm run test:e2e`) +- Architecture overview (message flow, auth flow) +- Testing requirements (CSS must be running, headed Chrome) +- Links to skill documents in `.skills/` +- Instruction to use Context7 MCP for library documentation lookups + +### Agent Team Configuration + +**Implementation agent** (Claude Code, primary): +- Handles all code writing, building, debugging +- References `.skills/` for Solid-specific context +- Uses Context7 MCP for library documentation +- Uses CLAUDE.md for project conventions + +**Review agent** (roborev): +- A background daemon that automatically reviews every git commit using AI agents +- Reviews are asynchronous — commits complete instantly, reviews happen in background + +**roborev setup**: +```bash +# Install (choose one) +curl -fsSL https://roborev.io/install.sh | bash +# or: brew install roborev-dev/tap/roborev + +# Initialize in repo (installs post-commit hook, starts daemon) +roborev init --agent claude-code +``` + +**roborev workflow**: +- Every `git commit` automatically triggers an async review via the post-commit hook +- View results: `roborev tui` (interactive terminal UI) or `roborev show` (current commit) +- Check daemon health: `roborev status` +- Auto-fix findings: `roborev fix` (runs agent in isolated worktree, applies patches) +- Iterative refinement: `roborev refine --max-iterations 5` (fix → re-review → repeat until clean) +- Verdicts: Pass (P), Fail (F), Warning (W) + +**Custom review guidelines** in `.roborev.toml`: +- Security review rules (OAuth flows, token handling, message passing) +- Chrome extension best practices (MV3 service worker lifecycle) +- Solid protocol compliance checks + +**Full team workflow**: +1. Claude Code implements features/fixes +2. Developer commits +3. roborev daemon automatically reviews the commit in background +4. Developer views findings in `roborev tui` +5. If issues found: `roborev fix` auto-remediates, or developer directs Claude Code to fix +6. `roborev refine` iterates until review passes + +--- + +## 11. Verification + +To verify the extension works end-to-end: + +1. `npm install && npm run build` — extension builds to `dist/` +2. Load `dist/` as unpacked extension in Chrome +3. Start CSS: `npx @solid/community-server -p 3000` +4. Register a user at `http://localhost:3000` +5. Click extension icon, enter `http://localhost:3000`, login +6. Open test-site, verify WebID displays and private fetch succeeds +7. Test `solid.setClientId()` with a test Client ID Document + +Automated: `npm run test:e2e` runs all of the above via Playwright. diff --git a/apps/extension/e2e/fixtures.ts b/apps/extension/e2e/fixtures.ts new file mode 100644 index 000000000..a9f71b7f6 --- /dev/null +++ b/apps/extension/e2e/fixtures.ts @@ -0,0 +1,33 @@ +import { test as base, chromium, type BrowserContext } from '@playwright/test'; +import path from 'path'; + +export const test = base.extend<{ + context: BrowserContext; + extensionId: string; +}>({ + // eslint-disable-next-line no-empty-pattern + context: async ({}, use) => { + const pathToExtension = path.resolve(__dirname, '../dist'); + const context = await chromium.launchPersistentContext('', { + channel: 'chromium', + headless: false, + args: [ + `--disable-extensions-except=${pathToExtension}`, + `--load-extension=${pathToExtension}`, + ], + }); + await use(context); + await context.close(); + }, + extensionId: async ({ context }, use) => { + // Wait for the service worker to be registered + let sw = context.serviceWorkers()[0]; + if (!sw) { + sw = await context.waitForEvent('serviceworker'); + } + const extensionId = sw.url().split('/')[2]; + await use(extensionId); + }, +}); + +export const expect = test.expect; diff --git a/apps/extension/e2e/playwright.config.ts b/apps/extension/e2e/playwright.config.ts new file mode 100644 index 000000000..da8af28f6 --- /dev/null +++ b/apps/extension/e2e/playwright.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + timeout: 60_000, + fullyParallel: true, + retries: 0, + use: { + headless: false, + }, + globalSetup: require.resolve('./setup/global-setup'), + globalTeardown: require.resolve('./setup/global-teardown'), +}); diff --git a/apps/extension/e2e/setup/css-server.ts b/apps/extension/e2e/setup/css-server.ts new file mode 100644 index 000000000..ce7492c4c --- /dev/null +++ b/apps/extension/e2e/setup/css-server.ts @@ -0,0 +1,67 @@ +import { spawn, type ChildProcess } from 'child_process'; +import path from 'path'; +import http from 'http'; + +let serverProcess: ChildProcess | null = null; + +const CSS_PORT = 3000; +const CSS_BASE_URL = `http://localhost:${CSS_PORT}`; + +export async function startCSSServer(): Promise { + const seedConfig = path.resolve(__dirname, 'seed.json'); + + serverProcess = spawn('npx', [ + '@solid/community-server', + '-p', String(CSS_PORT), + '-c', '@css:config/default.json', + '--seedConfig', seedConfig, + '-l', 'warn', + ], { + stdio: 'pipe', + env: { ...process.env }, + }); + + serverProcess.stderr?.on('data', (data: Buffer) => { + const msg = data.toString(); + if (msg.includes('Error') || msg.includes('error')) { + console.error('[CSS]', msg); + } + }); + + // Wait for the server to be ready + await waitForServer(CSS_BASE_URL, 30_000); +} + +export async function stopCSSServer(): Promise { + if (serverProcess) { + serverProcess.kill('SIGTERM'); + serverProcess = null; + } +} + +async function waitForServer(url: string, timeoutMs: number): Promise { + const start = Date.now(); + + while (Date.now() - start < timeoutMs) { + try { + await new Promise((resolve, reject) => { + const req = http.get(url, (res) => { + res.resume(); + resolve(); + }); + req.on('error', reject); + req.setTimeout(1000, () => { + req.destroy(); + reject(new Error('timeout')); + }); + }); + return; + } catch { + await new Promise((r) => setTimeout(r, 500)); + } + } + + throw new Error(`Server at ${url} did not start within ${timeoutMs}ms`); +} + +export { CSS_PORT, CSS_BASE_URL }; diff --git a/apps/extension/e2e/setup/global-setup.ts b/apps/extension/e2e/setup/global-setup.ts new file mode 100644 index 000000000..3075ed045 --- /dev/null +++ b/apps/extension/e2e/setup/global-setup.ts @@ -0,0 +1,59 @@ +import { startCSSServer } from './css-server'; +import { spawn, type ChildProcess } from 'child_process'; +import path from 'path'; +import http from 'http'; + +let testSiteProcess: ChildProcess | null = null; +const TEST_SITE_PORT = 8080; + +async function globalSetup() { + // Start Community Solid Server + console.log('Starting Community Solid Server...'); + await startCSSServer(); + console.log('CSS server ready on http://localhost:3000'); + + // Start test site server + const testSitePath = path.resolve(__dirname, '../../test-site'); + testSiteProcess = spawn('npx', [ + 'http-server', testSitePath, + '-p', String(TEST_SITE_PORT), + '-s', // Silent + ], { + stdio: 'pipe', + env: { ...process.env }, + }); + + // Wait for test site to be ready + await waitForServer(`http://localhost:${TEST_SITE_PORT}`, 10_000); + console.log(`Test site ready on http://localhost:${TEST_SITE_PORT}`); + + // Store the process for teardown + (globalThis as Record).__TEST_SITE_PROCESS__ = testSiteProcess; +} + +async function waitForServer(url: string, timeoutMs: number): Promise { + const start = Date.now(); + + while (Date.now() - start < timeoutMs) { + try { + await new Promise((resolve, reject) => { + const req = http.get(url, (res) => { + res.resume(); + resolve(); + }); + req.on('error', reject); + req.setTimeout(1000, () => { + req.destroy(); + reject(new Error('timeout')); + }); + }); + return; + } catch { + await new Promise((r) => setTimeout(r, 500)); + } + } + + throw new Error(`Server at ${url} did not start within ${timeoutMs}ms`); +} + +export default globalSetup; diff --git a/apps/extension/e2e/setup/global-teardown.ts b/apps/extension/e2e/setup/global-teardown.ts new file mode 100644 index 000000000..071188942 --- /dev/null +++ b/apps/extension/e2e/setup/global-teardown.ts @@ -0,0 +1,15 @@ +import { stopCSSServer } from './css-server'; +import type { ChildProcess } from 'child_process'; + +async function globalTeardown() { + // Stop CSS server + await stopCSSServer(); + + // Stop test site server + const testSiteProcess = (globalThis as Record).__TEST_SITE_PROCESS__ as ChildProcess | undefined; + if (testSiteProcess) { + testSiteProcess.kill('SIGTERM'); + } +} + +export default globalTeardown; diff --git a/apps/extension/e2e/setup/seed.json b/apps/extension/e2e/setup/seed.json new file mode 100644 index 000000000..4a973b2d7 --- /dev/null +++ b/apps/extension/e2e/setup/seed.json @@ -0,0 +1,11 @@ +[ + { + "email": "test@example.com", + "password": "test-password-123", + "pods": [ + { + "name": "test-pod" + } + ] + } +] diff --git a/apps/extension/e2e/tests/authenticated-fetch.spec.ts b/apps/extension/e2e/tests/authenticated-fetch.spec.ts new file mode 100644 index 000000000..e04410e51 --- /dev/null +++ b/apps/extension/e2e/tests/authenticated-fetch.spec.ts @@ -0,0 +1,21 @@ +import { test, expect } from '../fixtures'; +import { performLogin } from './helpers'; + +const TEST_SITE = 'http://localhost:8080'; + +test('authenticated fetch displays WebID and profile data', async ({ context, extensionId }) => { + await performLogin(context, extensionId); + + // Navigate to the test site + const page = await context.newPage(); + await page.goto(TEST_SITE); + + // Wait for the WebID to be displayed + await expect(page.locator('#webid')).toContainText('test-pod', { timeout: 15_000 }); + + // Wait for the fetch result to contain profile data + await expect(page.locator('#fetch-result')).not.toContainText('Waiting', { timeout: 15_000 }); + + // The status should indicate success + await expect(page.locator('#status')).toContainText('successfully'); +}); diff --git a/apps/extension/e2e/tests/helpers.ts b/apps/extension/e2e/tests/helpers.ts new file mode 100644 index 000000000..ad59a37df --- /dev/null +++ b/apps/extension/e2e/tests/helpers.ts @@ -0,0 +1,41 @@ +import type { BrowserContext } from '@playwright/test'; + +const CSS_BASE = 'http://localhost:3000'; + +export async function performLogin(context: BrowserContext, extensionId: string) { + // Open popup and initiate login + const popupPage = await context.newPage(); + await popupPage.goto(`chrome-extension://${extensionId}/popup/popup.html`); + await popupPage.fill('#idp-url', CSS_BASE); + + // Listen for the CSS login page to open before clicking + const loginPagePromise = context.waitForEvent('page'); + await popupPage.click('#login-btn'); + + // Get the CSS login page (opened by chrome.identity.launchWebAuthFlow) + const loginPage = await loginPagePromise; + await loginPage.waitForLoadState('networkidle'); + + // CSS auto-redirects to password login page + // Wait for the submit button to be enabled (CSS uses JS to enable it) + await loginPage.waitForSelector('button[type="submit"]:not([disabled])', { timeout: 10_000 }); + + // Fill in credentials + await loginPage.fill('#email', 'test@example.com'); + await loginPage.fill('#password', 'test-password-123'); + await loginPage.click('button[type="submit"]'); + + // Wait for consent page to load + await loginPage.waitForURL('**/.account/oidc/consent/**', { timeout: 10_000 }); + await loginPage.waitForLoadState('networkidle'); + + // Wait for the Authorize button to be enabled (JS populates WebIDs then enables it) + await loginPage.waitForSelector('#authorize:not([disabled])', { timeout: 10_000 }); + await loginPage.click('#authorize'); + + // Wait for the auth flow to complete by polling the popup for logged-in state + await popupPage.goto(`chrome-extension://${extensionId}/popup/popup.html`); + await popupPage.locator('#logged-in-view').waitFor({ state: 'visible', timeout: 15_000 }); + + await popupPage.close(); +} diff --git a/apps/extension/e2e/tests/login.spec.ts b/apps/extension/e2e/tests/login.spec.ts new file mode 100644 index 000000000..59ad98e94 --- /dev/null +++ b/apps/extension/e2e/tests/login.spec.ts @@ -0,0 +1,12 @@ +import { test, expect } from '../fixtures'; +import { performLogin } from './helpers'; + +test('login via popup with Community Solid Server', async ({ context, extensionId }) => { + await performLogin(context, extensionId); + + // Verify we're now logged in by reopening popup + const popupPage = await context.newPage(); + await popupPage.goto(`chrome-extension://${extensionId}/popup/popup.html`); + await expect(popupPage.locator('#logged-in-view')).toBeVisible({ timeout: 10_000 }); + await expect(popupPage.locator('#webid-display')).toContainText('test-pod'); +}); diff --git a/apps/extension/e2e/tests/session-restore.spec.ts b/apps/extension/e2e/tests/session-restore.spec.ts new file mode 100644 index 000000000..66bdbc356 --- /dev/null +++ b/apps/extension/e2e/tests/session-restore.spec.ts @@ -0,0 +1,22 @@ +import { test, expect } from '../fixtures'; +import { performLogin } from './helpers'; + +const TEST_SITE = 'http://localhost:8080'; + +test('session persists after navigation', async ({ context, extensionId }) => { + await performLogin(context, extensionId); + + // Navigate to test site and verify + const page = await context.newPage(); + await page.goto(TEST_SITE); + await expect(page.locator('#webid')).toContainText('test-pod', { timeout: 15_000 }); + + // Navigate away + await page.goto('about:blank'); + + // Navigate back to test site + await page.goto(TEST_SITE); + + // WebID should still be displayed (session persisted) + await expect(page.locator('#webid')).toContainText('test-pod', { timeout: 15_000 }); +}); diff --git a/apps/extension/e2e/tsconfig.json b/apps/extension/e2e/tsconfig.json new file mode 100644 index 000000000..6c0cad067 --- /dev/null +++ b/apps/extension/e2e/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "moduleResolution": "node", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["./**/*.ts"] +} diff --git a/apps/extension/package-lock.json b/apps/extension/package-lock.json new file mode 100644 index 000000000..0a6b73e4b --- /dev/null +++ b/apps/extension/package-lock.json @@ -0,0 +1,10410 @@ +{ + "name": "solid-browser-extension", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "solid-browser-extension", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@uvdsl/solid-oidc-client-browser": "^0.2.2", + "jose": "^5.9.6" + }, + "devDependencies": { + "@playwright/test": "^1.49.0", + "@solid/community-server": "^7.1.3", + "@types/chrome": "^0.0.287", + "copy-webpack-plugin": "^12.0.2", + "http-server": "^14.1.1", + "ts-loader": "^9.5.1", + "ts-node": "^10.9.2", + "typescript": "^5.7.2", + "webpack": "^5.97.1", + "webpack-cli": "^6.0.1" + } + }, + "node_modules/@bergos/jsonparse": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@bergos/jsonparse/-/jsonparse-1.4.2.tgz", + "integrity": "sha512-qUt0QNJjvg4s1zk+AuLM6s/zcsQ8MvGn7+1f0vPuxvpCYa08YtTryuDInngbEyW5fNGGYe2znKt61RMGd5HnXg==", + "dev": true, + "engines": [ + "node >= 0.2.0" + ], + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3" + } + }, + "node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@comunica/actor-abstract-mediatyped": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-abstract-mediatyped/-/actor-abstract-mediatyped-2.10.0.tgz", + "integrity": "sha512-0o6WBujsMnIVcwvRJv6Nj+kKPLZzqBS3On48rm01Rh9T1/My0E/buJMXwgcARKCfMonc2mJ9zxpPCh5ilGEU2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0" + } + }, + "node_modules/@comunica/actor-abstract-parse": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-abstract-parse/-/actor-abstract-parse-2.10.0.tgz", + "integrity": "sha512-0puCWF+y24EDOOAUUVVbC+tOf4UV+LzEbqi8T5v25jcVGCXyTqfra+bDywfrcv3adrVp18jLCJ46ycaH5xhy9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/core": "^2.10.0", + "readable-stream": "^4.4.2" + } + }, + "node_modules/@comunica/actor-abstract-path": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-abstract-path/-/actor-abstract-path-2.10.1.tgz", + "integrity": "sha512-+k1ltuUuIyn4iUm5oRMObyt2zhu68h7ymzxuKU4ezATlgwfwj6EM7/3W2n2/gxjg9tcFMr5GC6aNnFQmq3Iuig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bindings-factory": "^2.10.1", + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/context-entries": "^2.10.0", + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0", + "@rdfjs/types": "*", + "asynciterator": "^3.8.1", + "rdf-data-factory": "^1.1.1", + "rdf-string": "^1.6.1", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-context-preprocess-source-to-destination": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-context-preprocess-source-to-destination/-/actor-context-preprocess-source-to-destination-2.10.0.tgz", + "integrity": "sha512-sQc42Sd4cuVumZ9+PDnWBTBYneqCFShFliK8Et83GR3wBGzu9x0tS/M2o3e63sBbb6ZkWHyO5jl/O8AbrjhcTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-context-preprocess": "^2.10.0", + "@comunica/context-entries": "^2.10.0", + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0" + } + }, + "node_modules/@comunica/actor-dereference-fallback": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-dereference-fallback/-/actor-dereference-fallback-2.10.0.tgz", + "integrity": "sha512-RSc/ScPdC7l13aZjz/6r4niWA8WDETbzuESQKKSWXi/HAlFOyOxdrDADdayVY2oyeZHIQibeNRtSi2ItzU7OPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-dereference": "^2.10.0", + "@comunica/core": "^2.10.0" + } + }, + "node_modules/@comunica/actor-dereference-file": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-dereference-file/-/actor-dereference-file-2.10.0.tgz", + "integrity": "sha512-WXfAyHm0M3+YbYEtLtasT6YHsrzTAevmH27ex8r51qKNj2LK74llpw4mSeea3xyjQR30jVnKBIJSxuSbN64Now==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-dereference": "^2.10.0", + "@comunica/core": "^2.10.0" + } + }, + "node_modules/@comunica/actor-dereference-http": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/@comunica/actor-dereference-http/-/actor-dereference-http-2.10.2.tgz", + "integrity": "sha512-gdDo83W1TAgD2jx0kVbzZKzzt++L4Y4fbyTOH3duy6vx1EMGGZlNCp6I1uguepKEjNX4N0zhAcZzdJcv8A3XMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-dereference": "^2.10.0", + "@comunica/bus-http": "^2.10.2", + "@comunica/core": "^2.10.0", + "cross-fetch": "^4.0.0", + "relative-to-absolute-iri": "^1.0.7", + "stream-to-string": "^1.2.0" + } + }, + "node_modules/@comunica/actor-dereference-rdf-parse": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-dereference-rdf-parse/-/actor-dereference-rdf-parse-2.10.0.tgz", + "integrity": "sha512-ANWL6Bv+2WHUjVRS7hfkOfVBNJs8xYZ9KHlgBOQ94CKtQZB9uSMjdb1hLp/cQjiDmFIWLn0+GM5Xi0KFwBkVAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-dereference": "^2.10.0", + "@comunica/bus-dereference-rdf": "^2.10.0", + "@comunica/bus-rdf-parse": "^2.10.0" + } + }, + "node_modules/@comunica/actor-hash-bindings-sha1": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-hash-bindings-sha1/-/actor-hash-bindings-sha1-2.10.0.tgz", + "integrity": "sha512-f981PcCiDWbdZfM1ct1v1q/VII14y18lo1enEdHB25SF0hCkzIDwh9IrfDfJDju5I6luSWNE/MYMMeAAmF9e3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-hash-bindings": "^2.10.0", + "@comunica/core": "^2.10.0", + "canonicalize": "^2.0.0", + "hash.js": "^1.1.7", + "rdf-string": "^1.6.1" + } + }, + "node_modules/@comunica/actor-http-fetch": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/@comunica/actor-http-fetch/-/actor-http-fetch-2.10.2.tgz", + "integrity": "sha512-siHGx0TMVNb2gXvOroq0B3JE6uuS+4s+MsDkntqdBNVigwVYqLpNSKEaL5is8pputFfohJfDQY06lAHbfDNEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-http": "^2.10.2", + "@comunica/context-entries": "^2.10.0", + "@comunica/mediatortype-time": "^2.10.0", + "abort-controller": "^3.0.0", + "cross-fetch": "^4.0.0" + } + }, + "node_modules/@comunica/actor-http-proxy": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/@comunica/actor-http-proxy/-/actor-http-proxy-2.10.2.tgz", + "integrity": "sha512-3yUF8BCh4nwq8J6NRILEsyNrQNStkE9ggJ7hYwRfA1XcMgz1pANNaWJ2P2TEKH1jNinr23bL3JeuUZCm9Kz9dA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-http": "^2.10.2", + "@comunica/context-entries": "^2.10.0", + "@comunica/mediatortype-time": "^2.10.0", + "@comunica/types": "^2.10.0" + } + }, + "node_modules/@comunica/actor-http-wayback": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/@comunica/actor-http-wayback/-/actor-http-wayback-2.10.2.tgz", + "integrity": "sha512-wjYNXRrJvMqt9paO3HawyM+O5/14ofSHFuMAwGr/UyZQ5pCSFkY0YPd+qp9y8C4xvypPgsvT3PtiRyKgjD4FWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-http": "^2.10.2", + "@comunica/context-entries": "^2.10.0", + "@comunica/core": "^2.10.0", + "cross-fetch": "^4.0.0", + "stream-to-string": "^1.2.0" + } + }, + "node_modules/@comunica/actor-init-query": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/@comunica/actor-init-query/-/actor-init-query-2.10.2.tgz", + "integrity": "sha512-7A4bXdKCjXRdUThvMOOyg+U17DPeBAsyDYz1SA8F4lPUR06NapcG5TmZF+YWUTN/2EG5fZPUnD3etKuPXreGUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/actor-http-proxy": "^2.10.2", + "@comunica/bus-context-preprocess": "^2.10.0", + "@comunica/bus-http-invalidate": "^2.10.0", + "@comunica/bus-init": "^2.10.0", + "@comunica/bus-optimize-query-operation": "^2.10.0", + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/bus-query-parse": "^2.10.0", + "@comunica/bus-query-result-serialize": "^2.10.0", + "@comunica/context-entries": "^2.10.0", + "@comunica/core": "^2.10.0", + "@comunica/logger-pretty": "^2.10.0", + "@comunica/runner": "^2.10.0", + "@comunica/types": "^2.10.0", + "@rdfjs/types": "*", + "@types/yargs": "^17.0.24", + "asynciterator": "^3.8.1", + "negotiate": "^1.0.1", + "rdf-quad": "^1.5.0", + "rdf-string": "^1.6.1", + "sparqlalgebrajs": "^4.2.0", + "streamify-string": "^1.0.1", + "yargs": "^17.7.2" + }, + "optionalDependencies": { + "process": "^0.11.10" + } + }, + "node_modules/@comunica/actor-optimize-query-operation-bgp-to-join": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-optimize-query-operation-bgp-to-join/-/actor-optimize-query-operation-bgp-to-join-2.10.0.tgz", + "integrity": "sha512-M9vwM4a3VQA/ir8Q7eGRNzzx52u6RJFIXBW8p+Zkn+zv+4fsket3zLYJGhJU7dcvaSXcOi68rDP/r8KfgNXr4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-optimize-query-operation": "^2.10.0", + "@comunica/core": "^2.10.0", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-optimize-query-operation-join-bgp": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-optimize-query-operation-join-bgp/-/actor-optimize-query-operation-join-bgp-2.10.0.tgz", + "integrity": "sha512-tzZojWPbWn/S0DZGjGfV90ZRJVWT/yX3DKGgZ1ur33U5TW8n/fBQxHNMPCLu0GkMQ1dyx6bU+ekILTqm+21Jyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-optimize-query-operation": "^2.10.0", + "@comunica/core": "^2.10.0", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-optimize-query-operation-join-connected": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-optimize-query-operation-join-connected/-/actor-optimize-query-operation-join-connected-2.10.0.tgz", + "integrity": "sha512-RsbKIAxX1HyoR/AUzqIV++dTcLiEElRIVDHYTaXVVvGgHECYdh9s+oc8cvv/lDbLVpfnc6P9C9BTAfrqOjKkhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-optimize-query-operation": "^2.10.0", + "@comunica/core": "^2.10.0", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-query-operation-ask": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-operation-ask/-/actor-query-operation-ask-2.10.1.tgz", + "integrity": "sha512-7oktqE4fkMhi6Hs9XCcwwoZRsEismVqJZ5wp9lXXOPaxnHEiFyj5gb/B6baCstoCvCt6LcU8fVvfHSitbFCpeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-query-operation-bgp-join": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-operation-bgp-join/-/actor-query-operation-bgp-join-2.10.1.tgz", + "integrity": "sha512-eNpnvgFyKlZEHkMzubYL8ndADSsAQH4rwXvh22CGnf0FwyndHr6TEpmE6j77m9vXiSJ/lda0U3Zv4vIXvtREOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-query-operation-construct": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-operation-construct/-/actor-query-operation-construct-2.10.1.tgz", + "integrity": "sha512-S+Nt1+1psv01QRnfytZjiog2NBNHIbjr7XIv+MO3p6aVmLCoZ6lmjxSGNdbX+EmcGr7tbbafXK5z3zRM+ke8Mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0", + "@rdfjs/types": "*", + "asynciterator": "^3.8.1", + "rdf-data-factory": "^1.1.1", + "rdf-terms": "^1.11.0", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-query-operation-describe-subject": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-operation-describe-subject/-/actor-query-operation-describe-subject-2.10.1.tgz", + "integrity": "sha512-E8i0M6haJ5iZVeHMn5PbvA4G+l87mcZKqIxVpYAnJVpD667F74Dkx3IMbk+ohRmyRmnkOEmztUrjeyixHHzUEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/actor-query-operation-union": "^2.10.1", + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0", + "asynciterator": "^3.8.1", + "rdf-data-factory": "^1.1.1", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-query-operation-distinct-hash": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-operation-distinct-hash/-/actor-query-operation-distinct-hash-2.10.1.tgz", + "integrity": "sha512-exvJbgcJ0Pe4EGbLJD5LuGpvaGcFeckCxwB5pyd9OewNke+tLLP7nbEjB8KFEPpCO9LE7zt4faB1HvpJdEHQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-hash-bindings": "^2.10.0", + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-query-operation-extend": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-operation-extend/-/actor-query-operation-extend-2.10.1.tgz", + "integrity": "sha512-wkZxUfDu8T5lXD+OFLItmjjbnEBqtv0z8pxVKgI/gX8mOeu5KcPWLH0dJODTWoIzIYrJhV25FmCgBks1rt6K8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bindings-factory": "^2.10.1", + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/core": "^2.10.0", + "@comunica/expression-evaluator": "^2.10.0", + "@comunica/types": "^2.10.0", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-query-operation-filter-sparqlee": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-operation-filter-sparqlee/-/actor-query-operation-filter-sparqlee-2.10.1.tgz", + "integrity": "sha512-w2PnDNnlf+9B947ZdeSs7NpW9qGJjRiuODZYwhh0e6cx89GPDhEDVuJwawF6VP3m/oLcgXOAdif0Wwo3d8KNAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bindings-factory": "^2.10.1", + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/core": "^2.10.0", + "@comunica/expression-evaluator": "^2.10.0", + "@comunica/types": "^2.10.0", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-query-operation-from-quad": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-operation-from-quad/-/actor-query-operation-from-quad-2.10.1.tgz", + "integrity": "sha512-7D4R8ONNJJPzoRu96dwIToOEk6/3O/T26FRzCqQKrbjFHNkX2v92KA/SiDzNz59VmDNWjYF1rsV31Ade6J89MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0", + "@rdfjs/types": "*", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-query-operation-group": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-operation-group/-/actor-query-operation-group-2.10.1.tgz", + "integrity": "sha512-Od5s9Vb6uDPzXa6OAUC1WSMF96spNPJI2Zqf0Ixejw4zCNevOK/VwHivYfF0vHIUZxjRrOl3Al1ZU9L8n5Wxlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bindings-factory": "^2.10.1", + "@comunica/bus-hash-bindings": "^2.10.0", + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/core": "^2.10.0", + "@comunica/expression-evaluator": "^2.10.0", + "@comunica/types": "^2.10.0", + "@rdfjs/types": "*", + "asynciterator": "^3.8.1", + "rdf-data-factory": "^1.1.1", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-query-operation-join": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-operation-join/-/actor-query-operation-join-2.10.1.tgz", + "integrity": "sha512-CGed1nSPvKsM8rvj/4KFME0lLnzlDMMEU+xGczu+BZW4FK+Z6RyBtHIUmy8SgFvNP1GXz83q8KnoecF5z8IpjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/bus-rdf-join": "^2.10.1", + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-query-operation-leftjoin": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-operation-leftjoin/-/actor-query-operation-leftjoin-2.10.1.tgz", + "integrity": "sha512-j0RwdoiV2WsCQnxcSa//m5FZ+ZHDRBm6ObsgpqS44WxzpV8rIB6Dq/3UxGgE7D2vK400JaiiHa3dFiHTwDF18w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/bus-rdf-join": "^2.10.1", + "@comunica/core": "^2.10.0", + "@comunica/expression-evaluator": "^2.10.0", + "@comunica/types": "^2.10.0", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-query-operation-minus": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-operation-minus/-/actor-query-operation-minus-2.10.1.tgz", + "integrity": "sha512-rUvHbc5/EUWMSJUgOEtxabCJ9IT9YThuG0FhcQk+BGRPGmsv2oz8uri5urKgCjfVXMH/09hRZksiDMqrmkQmZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/bus-rdf-join": "^2.10.1", + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-query-operation-nop": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-operation-nop/-/actor-query-operation-nop-2.10.1.tgz", + "integrity": "sha512-l/Z8Uuoq3AlSoxkgYjrP7O7Xc9h8Y3ZOh0f7UKCuAST3U5vPQ3k1YJckrRtdli8s0NHptN9TfZjwviEHuYbDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bindings-factory": "^2.10.1", + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/core": "^2.10.0", + "@comunica/metadata": "^2.10.0", + "@comunica/types": "^2.10.0", + "asynciterator": "^3.8.1", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-query-operation-orderby-sparqlee": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-operation-orderby-sparqlee/-/actor-query-operation-orderby-sparqlee-2.10.1.tgz", + "integrity": "sha512-8D2JmCsBtqJC29zfiaAXNzZdsKybhDFo2F8iTHul3nQHxBC2CeKDrBnY70B/HpbWxkDE+pwMfSTEFc/CvNZN6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/core": "^2.10.0", + "@comunica/expression-evaluator": "^2.10.0", + "@comunica/types": "^2.10.0", + "asynciterator": "^3.8.1", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-query-operation-path-alt": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-operation-path-alt/-/actor-query-operation-path-alt-2.10.1.tgz", + "integrity": "sha512-y1AHtkibThqHve79wAriXqrZ6hdLBhcdwyOpVqqEhY19a32P97Xv58bOwOkNeLguYdn/5CFlCTHz6dnzxUIoXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/actor-abstract-path": "^2.10.1", + "@comunica/actor-query-operation-union": "^2.10.1", + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/types": "^2.10.0", + "asynciterator": "^3.8.1", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-query-operation-path-inv": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-operation-path-inv/-/actor-query-operation-path-inv-2.10.1.tgz", + "integrity": "sha512-pd30Ug7bOAZ5amfA3I6v+cpitlDn2i5fE1BA006LYJISCAHSfKEgLmU2Q4ZPbwi4s1A8WKKLV7Q389Ru3Xtziw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/actor-abstract-path": "^2.10.1", + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/types": "^2.10.0", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-query-operation-path-link": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-operation-path-link/-/actor-query-operation-path-link-2.10.1.tgz", + "integrity": "sha512-akujCHvCLmxaZ3gw9b1odDcqqAQnbbr9E8dTWLZyMJ4Mei8q/FmfWTF5MjGuQOas4UmQ3mm6gcqAKRZnJqlXNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/actor-abstract-path": "^2.10.1", + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/types": "^2.10.0", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-query-operation-path-nps": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-operation-path-nps/-/actor-query-operation-path-nps-2.10.1.tgz", + "integrity": "sha512-5X3EUzn6Cygz94gNn1XWQQUZVp+de59sw8/rxPQqgwzdi1Y1O9zrLv+/7GqMJoLz6MHmDSgsceTIY4eC1qmmOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/actor-abstract-path": "^2.10.1", + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/types": "^2.10.0", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-query-operation-path-one-or-more": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-operation-path-one-or-more/-/actor-query-operation-path-one-or-more-2.10.1.tgz", + "integrity": "sha512-SkQeKESQqZOlzuMIsipcZ3ni7YfeyYMZCOtxC01HFbeyq+SDVbyfYUZ4Dd9uAi/g3InyzJRfou4csxHS8g7sHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/actor-abstract-path": "^2.10.1", + "@comunica/bindings-factory": "^2.10.1", + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/types": "^2.10.0", + "asynciterator": "^3.8.1", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-query-operation-path-seq": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-operation-path-seq/-/actor-query-operation-path-seq-2.10.1.tgz", + "integrity": "sha512-8TYLdVYaq9oMd9cuLFay78103bOfvygQU/C8NtPdLI9kkRWFsBatvaKmykHOHQAvaLgNhniOlrIJNEpepZGnAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/actor-abstract-path": "^2.10.1", + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/bus-rdf-join": "^2.10.1", + "@comunica/types": "^2.10.0", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-query-operation-path-zero-or-more": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-operation-path-zero-or-more/-/actor-query-operation-path-zero-or-more-2.10.1.tgz", + "integrity": "sha512-DtqBSw4LV1KI3q1YYAwgXlWrz1PO4EUpe/bVri0UB3JSQnxjBMHuJlHn2crC9Z93tmizneXxfvtWlLSXRrehsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/actor-abstract-path": "^2.10.1", + "@comunica/bindings-factory": "^2.10.1", + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/types": "^2.10.0", + "asynciterator": "^3.8.1", + "rdf-string": "^1.6.1", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-query-operation-path-zero-or-one": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-operation-path-zero-or-one/-/actor-query-operation-path-zero-or-one-2.10.1.tgz", + "integrity": "sha512-qePX+7iW5DXDwaYO210y7jhSU32Zk82S5UHuLLvd4q4HS1Z7j8e4KhukbeZKzQmOsO8S5JOHHM9vwvsOc3GPlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/actor-abstract-path": "^2.10.1", + "@comunica/bindings-factory": "^2.10.1", + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/metadata": "^2.10.0", + "@comunica/types": "^2.10.0", + "asynciterator": "^3.8.1", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-query-operation-project": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-operation-project/-/actor-query-operation-project-2.10.1.tgz", + "integrity": "sha512-KAaPl4GFIQMWR8I8OoJroktGssPKGbEEJHyGzTuYXrmJrcXgknOxf5IUSVJNpaFfS6dshT6nqW+ciT+wRzz0Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/core": "^2.10.0", + "@comunica/data-factory": "^2.7.0", + "@comunica/types": "^2.10.0", + "@rdfjs/types": "*", + "rdf-data-factory": "^1.1.1", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-query-operation-quadpattern": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-operation-quadpattern/-/actor-query-operation-quadpattern-2.10.1.tgz", + "integrity": "sha512-RZj1TXW+VDU4aYJVnSzgs8q0340e+YUeGLtoY9sl0Xzc8YNaIus4nXRUz/KfOXDknxm1q+a4Bof4yHNgXtb1Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bindings-factory": "^2.10.1", + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/bus-rdf-resolve-quad-pattern": "^2.10.0", + "@comunica/context-entries": "^2.10.0", + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0", + "@rdfjs/types": "*", + "asynciterator": "^3.8.1", + "rdf-data-factory": "^1.1.1", + "rdf-string": "^1.6.3", + "rdf-terms": "^1.11.0", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-query-operation-reduced-hash": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-operation-reduced-hash/-/actor-query-operation-reduced-hash-2.10.1.tgz", + "integrity": "sha512-9hX25ztkbNxnaUd7Gtilok+9WJkr/s3a3y4axLoYX4/nOogYN+nZRKChvNSn4qn/lWvpG5VWv4+q0en1fP+AGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-hash-bindings": "^2.10.0", + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0", + "lru-cache": "^10.0.0", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-query-operation-service": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-operation-service/-/actor-query-operation-service-2.10.1.tgz", + "integrity": "sha512-GvpvhUmhkVFOCLrmcblgIPqi91XPRog5WkC9NFMRCToaSNAMQq82DX2dvwzn3IFItcmyZrmy+GYoaQ9miK2uVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bindings-factory": "^2.10.1", + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/context-entries": "^2.10.0", + "@comunica/core": "^2.10.0", + "@comunica/metadata": "^2.10.0", + "@comunica/types": "^2.10.0", + "asynciterator": "^3.8.1", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-query-operation-slice": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-operation-slice/-/actor-query-operation-slice-2.10.1.tgz", + "integrity": "sha512-KOBnTIUvwf28WB7oHevUC/xciEdH5gLg7MN8DvamkAkUiUjviEsRpkswUiD8lFe1dAs0ekA4pC0NoZ8BWp3uqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/context-entries": "^2.10.0", + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-query-operation-sparql-endpoint": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-operation-sparql-endpoint/-/actor-query-operation-sparql-endpoint-2.10.2.tgz", + "integrity": "sha512-nbBzVHhYHUu/9qg9ZzTw7rKvsRb3ViBvM+Fye0oMXojZUbyu2WI6eLFUc2Ze1/LYDNf/1KHNpkg6OdsiEi8HFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bindings-factory": "^2.10.1", + "@comunica/bus-http": "^2.10.2", + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/bus-rdf-resolve-quad-pattern": "^2.10.0", + "@comunica/bus-rdf-update-quads": "^2.10.2", + "@comunica/context-entries": "^2.10.0", + "@comunica/core": "^2.10.0", + "@comunica/mediatortype-httprequests": "^2.10.0", + "@comunica/metadata": "^2.10.0", + "@comunica/types": "^2.10.0", + "@rdfjs/types": "*", + "asynciterator": "^3.8.1", + "fetch-sparql-endpoint": "^4.1.0", + "rdf-data-factory": "^1.1.1", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-query-operation-sparql-endpoint/node_modules/@types/readable-stream": { + "version": "2.3.15", + "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-2.3.15.tgz", + "integrity": "sha512-oM5JSKQCcICF1wvGgmecmHldZ48OZamtMxcGGVICOJA8o8cahXC1zEVAif8iwoc5j8etxFaRFnf095+CDsuoFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "safe-buffer": "~5.1.1" + } + }, + "node_modules/@comunica/actor-query-operation-sparql-endpoint/node_modules/cross-fetch": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz", + "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, + "node_modules/@comunica/actor-query-operation-sparql-endpoint/node_modules/fetch-sparql-endpoint": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/fetch-sparql-endpoint/-/fetch-sparql-endpoint-4.2.1.tgz", + "integrity": "sha512-nRaexc3QCO95bjESf4ngNQ1J+qNtVzxFGlPUopqOIVHm/j6IDhWg996kk7fBM98Mmo0uM9b6uiTbXmJHOrnqYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rdfjs/types": "*", + "@smessie/readable-web-to-node-stream": "^3.0.3", + "@types/readable-stream": "^2.3.11", + "@types/sparqljs": "^3.1.3", + "abort-controller": "^3.0.0", + "cross-fetch": "^3.0.6", + "is-stream": "^2.0.0", + "minimist": "^1.2.0", + "n3": "^1.6.3", + "rdf-string": "^1.6.0", + "sparqljs": "^3.1.2", + "sparqljson-parse": "^2.2.0", + "sparqlxml-parse": "^2.1.1", + "stream-to-string": "^1.1.0" + }, + "bin": { + "fetch-sparql-endpoint": "bin/fetch-sparql-endpoint.js" + } + }, + "node_modules/@comunica/actor-query-operation-sparql-endpoint/node_modules/sparqljson-parse": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/sparqljson-parse/-/sparqljson-parse-2.2.0.tgz", + "integrity": "sha512-2TfvNvUsaJyWfCrq3ExdDdbF9LBLzIUCricg+D1YCYbbmyTzscgCtRk4KcIyJF178DtfCt4BkKzbKl8IXMHp8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bergos/jsonparse": "^1.4.1", + "@rdfjs/types": "*", + "@types/readable-stream": "^2.3.13", + "rdf-data-factory": "^1.1.0", + "readable-stream": "^4.0.0" + } + }, + "node_modules/@comunica/actor-query-operation-sparql-endpoint/node_modules/sparqlxml-parse": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/sparqlxml-parse/-/sparqlxml-parse-2.1.1.tgz", + "integrity": "sha512-71sltShF6gDAzuKWEHNeij7r0Mv5VqRrvJing6W4WHJ12GRe6+t1IRTv6MeqxYN3XJmKevs7B3HCBUo7wceeJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rdfjs/types": "*", + "@rubensworks/saxes": "^6.0.1", + "@types/readable-stream": "^2.3.13", + "buffer": "^6.0.3", + "rdf-data-factory": "^1.1.0", + "readable-stream": "^4.0.0" + } + }, + "node_modules/@comunica/actor-query-operation-union": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-operation-union/-/actor-query-operation-union-2.10.1.tgz", + "integrity": "sha512-Ezi2bAa9r6yyffXDDUPLlKoszsXnuhDUeQSQuU3c7JEAcwip3wC3zMNkavowwfRZ/1D5doitmUEdw2lAd+xloA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/core": "^2.10.0", + "@comunica/metadata": "^2.10.0", + "@comunica/types": "^2.10.0", + "@rdfjs/types": "*", + "asynciterator": "^3.8.1", + "rdf-terms": "^1.11.0", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-query-operation-update-add-rewrite": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-operation-update-add-rewrite/-/actor-query-operation-update-add-rewrite-2.10.1.tgz", + "integrity": "sha512-is3mrCPciExrlny5JbCvB011kUNYE9/fzQc/zmA3h24S5hHZbygA9mSS+dI85IwwqdKPYlrEqfn8c0kCVWMKyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0", + "rdf-data-factory": "^1.1.1", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-query-operation-update-clear": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-operation-update-clear/-/actor-query-operation-update-clear-2.10.2.tgz", + "integrity": "sha512-+sf6+LvXdKBv2pCuBH/ad5QdpheZSPEvw19UoaPQRQyQVBzIskOtfs4rwJHSn/YmoqhbstKZszakad3oxWwTTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/bus-rdf-update-quads": "^2.10.2", + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0", + "@rdfjs/types": "*", + "rdf-data-factory": "^1.1.1", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-query-operation-update-compositeupdate": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-operation-update-compositeupdate/-/actor-query-operation-update-compositeupdate-2.10.1.tgz", + "integrity": "sha512-IVNouBPFQLOczhW3qHyEoyxWrc7wnVT2vPwRHEaGlfnSiYAX42XSNLb9jR0XjB70wh3Civue4Ovs3upOXdrN3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-query-operation-update-copy-rewrite": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-operation-update-copy-rewrite/-/actor-query-operation-update-copy-rewrite-2.10.1.tgz", + "integrity": "sha512-l/3AM35hjahyHmiLoB3FPm0Jlhdmd/vqgOGj7V3Ra+TfHo5h8XOB3uzG78Q06HQNw4iyONBZc5lLlYXkzRd5lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-query-operation-update-create": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-operation-update-create/-/actor-query-operation-update-create-2.10.2.tgz", + "integrity": "sha512-g3DwLkYFTU8uZoIOV7oNPWStBmqvnBBPvLngG19MQQezuVoh8w88efxhbN0B/khi5/v4qcLsr7C0ffAaPF8Fbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/bus-rdf-update-quads": "^2.10.2", + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-query-operation-update-deleteinsert": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-operation-update-deleteinsert/-/actor-query-operation-update-deleteinsert-2.10.2.tgz", + "integrity": "sha512-FiRCLUAxkDoFpOe9jKC5llI7njbFdb1N8McRvZjBazUS4XDutjTZEkcKLs6AcRyG3esfHt6gNm6PqCuZ+aP8TA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/actor-query-operation-construct": "^2.10.1", + "@comunica/bindings-factory": "^2.10.1", + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/bus-rdf-update-quads": "^2.10.2", + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0", + "@rdfjs/types": "*", + "asynciterator": "^3.8.1", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-query-operation-update-drop": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-operation-update-drop/-/actor-query-operation-update-drop-2.10.2.tgz", + "integrity": "sha512-N/878InwoyQfysjCyo9r+H82eUlNeEGODJ95gCvzF/QGRc11N3dfcd3XijyHQ9OKAoQ9oR5gcS829LB3BDtKHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/bus-rdf-update-quads": "^2.10.2", + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0", + "@rdfjs/types": "*", + "rdf-data-factory": "^1.1.1", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-query-operation-update-load": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-operation-update-load/-/actor-query-operation-update-load-2.10.2.tgz", + "integrity": "sha512-lQb5fxb1+ZFbQkylmepze+e+LtVmVNvAvFBvjxUSfCT62uIKKHMeh1So5kTrGD0Co4ABCs1h6o9WB+8yQzFtQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/bus-rdf-update-quads": "^2.10.2", + "@comunica/context-entries": "^2.10.0", + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0", + "rdf-data-factory": "^1.1.1", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-query-operation-update-move-rewrite": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-operation-update-move-rewrite/-/actor-query-operation-update-move-rewrite-2.10.1.tgz", + "integrity": "sha512-GDLSHG2++EAAyUKhDu+mM6QfMTuzM8dS24HqeQL5Wzbkdc2KTmNKyJuhJw6SfXr6EiF/kxf1GPY6zwjcwACx/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-query-operation-values": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-operation-values/-/actor-query-operation-values-2.10.1.tgz", + "integrity": "sha512-++9IgCVCQPIF8fzZLmrVpxPj8eI9TvkLshHAugQQBnhSijrDMUudW9eoA+eFmCaD/Ru7YtlKe3OJzRGV8FCG+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bindings-factory": "^2.10.1", + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/core": "^2.10.0", + "@comunica/metadata": "^2.10.0", + "@comunica/types": "^2.10.0", + "asynciterator": "^3.8.1", + "rdf-data-factory": "^1.1.1", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-query-parse-graphql": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-parse-graphql/-/actor-query-parse-graphql-2.10.0.tgz", + "integrity": "sha512-l3RrkxElDYV4weXt3vpC0Q0She4AhbvPbPDronQulgN9nFAZhz4z9k8800T5uWMsL98wHNNXDFlnFk5S38lsow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-query-parse": "^2.10.0", + "@comunica/context-entries": "^2.10.0", + "@comunica/core": "^2.10.0", + "graphql-to-sparql": "^3.0.1" + } + }, + "node_modules/@comunica/actor-query-parse-sparql": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-parse-sparql/-/actor-query-parse-sparql-2.10.0.tgz", + "integrity": "sha512-DUVAuSSNn0AyvLruOpRpLZBsr96Q4LuV1gcO+alKZALtfOZikRKY/3sXz1NUkaRQc7qDH9xFFTFrfJd0jLvlDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-query-parse": "^2.10.0", + "@comunica/core": "^2.10.0", + "@types/sparqljs": "^3.1.3", + "sparqlalgebrajs": "^4.2.0", + "sparqljs": "^3.7.1" + } + }, + "node_modules/@comunica/actor-query-result-serialize-json": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-result-serialize-json/-/actor-query-result-serialize-json-2.10.0.tgz", + "integrity": "sha512-GuVcsOEhKgnVPT0AaCn8sJl/Uj5UUjUktEJpuMx1UAYt0//jcQsezJslYWmJrfXE/WJYidynyDxm8z3+jwLF7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-query-result-serialize": "^2.10.0", + "@comunica/types": "^2.10.0", + "rdf-string": "^1.6.1", + "readable-stream": "^4.4.2" + } + }, + "node_modules/@comunica/actor-query-result-serialize-rdf": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-result-serialize-rdf/-/actor-query-result-serialize-rdf-2.10.0.tgz", + "integrity": "sha512-TBXJrDs5brRMFg8UisXS/F1vJw8nUtLhjugNZcd4ST8J965Ho1aNopydp4PMmwINMRxHhHtWJGwIB2Z5xD2lDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-query-result-serialize": "^2.10.0", + "@comunica/bus-rdf-serialize": "^2.10.0", + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0" + } + }, + "node_modules/@comunica/actor-query-result-serialize-simple": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-result-serialize-simple/-/actor-query-result-serialize-simple-2.10.0.tgz", + "integrity": "sha512-pS7+aB9Rym1B5oi+O68NFjEq+EwpCRYtTIxGBp39CTQ0F7m4edt9QwqmARqveJPryK5X66ACvjxvutEaTgWI8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-query-result-serialize": "^2.10.0", + "@comunica/types": "^2.10.0", + "@rdfjs/types": "*", + "rdf-string": "^1.6.3", + "readable-stream": "^4.4.2" + } + }, + "node_modules/@comunica/actor-query-result-serialize-sparql-csv": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-result-serialize-sparql-csv/-/actor-query-result-serialize-sparql-csv-2.10.0.tgz", + "integrity": "sha512-Vk+7oTIPigDENK3CnV56vLfvMZVjHc3p2F4a49WDHfMgRrfQKJSQkx603vjW35n3tmUB8JSgRXr/+v7LK83KYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-query-result-serialize": "^2.10.0", + "@comunica/types": "^2.10.0", + "@rdfjs/types": "*", + "readable-stream": "^4.4.2" + } + }, + "node_modules/@comunica/actor-query-result-serialize-sparql-json": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-result-serialize-sparql-json/-/actor-query-result-serialize-sparql-json-2.10.2.tgz", + "integrity": "sha512-+J7SWXc4nXHzmQMk6q8MScrLNKdqX+/xQe6XCk0zDbDAt3/8EJh/2ROYFp4fEQyPDFWOwN4xpALgHRIh8PQRAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-http": "^2.10.2", + "@comunica/bus-http-invalidate": "^2.10.0", + "@comunica/bus-query-result-serialize": "^2.10.0", + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0", + "@rdfjs/types": "*", + "readable-stream": "^4.4.2" + } + }, + "node_modules/@comunica/actor-query-result-serialize-sparql-tsv": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-result-serialize-sparql-tsv/-/actor-query-result-serialize-sparql-tsv-2.10.0.tgz", + "integrity": "sha512-TgA2WIXKdu/SrbHEP8HvGoLjhDOZnBoHsGsLFSHpxY/Uwk21rZqJLBEkhuhkUtGYzQPJ1n6Wmpjz9lBrUHGJPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-query-result-serialize": "^2.10.0", + "@comunica/types": "^2.10.0", + "@rdfjs/types": "*", + "rdf-string-ttl": "^1.3.2", + "readable-stream": "^4.4.2" + } + }, + "node_modules/@comunica/actor-query-result-serialize-sparql-xml": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-result-serialize-sparql-xml/-/actor-query-result-serialize-sparql-xml-2.10.0.tgz", + "integrity": "sha512-8RDj5ZN23HnIc6zI5pD5XKi2pyg2cx6DhI7VDRcboi7v0DxfROuQqSEtbQ8m/W6Pngdz01ySogRcIVJCzRzBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-query-result-serialize": "^2.10.0", + "@comunica/types": "^2.10.0", + "@rdfjs/types": "*", + "readable-stream": "^4.4.2" + } + }, + "node_modules/@comunica/actor-query-result-serialize-stats": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-result-serialize-stats/-/actor-query-result-serialize-stats-2.10.2.tgz", + "integrity": "sha512-jhj/vLDRxLuRMonBaqICt4saM9/UO9wJBT3Jxk7Rp73aQWLo+lILXKzcWpuxkh/EFx8raLUBmbjWCduamU1DzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-http": "^2.10.2", + "@comunica/bus-http-invalidate": "^2.10.0", + "@comunica/bus-query-result-serialize": "^2.10.0", + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0", + "process": "^0.11.10", + "readable-stream": "^4.4.2" + } + }, + "node_modules/@comunica/actor-query-result-serialize-table": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-result-serialize-table/-/actor-query-result-serialize-table-2.10.0.tgz", + "integrity": "sha512-AAPrgM/rbsSThRu9jkfJhBUeTUwQTLHNVbIn8El+Akvz+Fueoi6oSi3SslpPMHOvIUiOAgCZ05f2RbBLlhP03g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-query-result-serialize": "^2.10.0", + "@comunica/types": "^2.10.0", + "@rdfjs/types": "*", + "rdf-data-factory": "^1.1.1", + "rdf-string": "^1.6.3", + "rdf-terms": "^1.11.0", + "readable-stream": "^4.4.2" + } + }, + "node_modules/@comunica/actor-query-result-serialize-tree": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-query-result-serialize-tree/-/actor-query-result-serialize-tree-2.10.0.tgz", + "integrity": "sha512-sEyIzoSTV11YPY6r4fn6fwrf3WjLD6GrwXMTuevsDAKDYaMYxyriH3T/LMLLBEURy8SLD1I1Fpw/qaZisRmLTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-query-result-serialize": "^2.10.0", + "@comunica/context-entries": "^2.10.0", + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0", + "readable-stream": "^4.4.2", + "sparqljson-to-tree": "^3.0.1" + } + }, + "node_modules/@comunica/actor-rdf-join-entries-sort-cardinality": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-join-entries-sort-cardinality/-/actor-rdf-join-entries-sort-cardinality-2.10.0.tgz", + "integrity": "sha512-6dd/29q6QuQN2Ap090VA0KUFmmnHalPxFJb4MGh5nIbWZH0F/EvI+uK5vPx29cttr1yXL5u+MbJWaLb3IxwILg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-join-entries-sort": "^2.10.0", + "@comunica/core": "^2.10.0" + } + }, + "node_modules/@comunica/actor-rdf-join-inner-hash": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-join-inner-hash/-/actor-rdf-join-inner-hash-2.10.1.tgz", + "integrity": "sha512-nUtdS3NJGKSJQC8KjDVz4TEDmkXHBYQi0/bwnAXCDl1phhq8lgv+YEmRDNe/kuCze7HyqEt98rlSJ+ZhvcHXVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-join": "^2.10.1", + "@comunica/mediatortype-join-coefficients": "^2.10.0", + "@comunica/types": "^2.10.0", + "asyncjoin": "^1.1.1" + } + }, + "node_modules/@comunica/actor-rdf-join-inner-multi-bind": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-join-inner-multi-bind/-/actor-rdf-join-inner-multi-bind-2.10.1.tgz", + "integrity": "sha512-tNZ2Q7z44Yr0iIFkvtTVAsts4v0IoC4b0FYaIUeYav4y5JOlR74hWWijTAzVfb31dTMsAp3r+y0xGIdd75LRHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/bus-rdf-join": "^2.10.1", + "@comunica/bus-rdf-join-entries-sort": "^2.10.0", + "@comunica/context-entries": "^2.10.0", + "@comunica/mediatortype-join-coefficients": "^2.10.0", + "@comunica/types": "^2.10.0", + "asynciterator": "^3.8.1", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-rdf-join-inner-multi-empty": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-join-inner-multi-empty/-/actor-rdf-join-inner-multi-empty-2.10.1.tgz", + "integrity": "sha512-z6a3qENwuvSU0PvqOySrsHsWSUvzfWd1xIYwEvKuEIJ9vYPoefIUgggx08E95ZF/k+PxZ0vKEywFpBSUKUzGYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-join": "^2.10.1", + "@comunica/mediatortype-join-coefficients": "^2.10.0", + "@comunica/metadata": "^2.10.0", + "@comunica/types": "^2.10.0", + "asynciterator": "^3.8.1" + } + }, + "node_modules/@comunica/actor-rdf-join-inner-multi-smallest": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-join-inner-multi-smallest/-/actor-rdf-join-inner-multi-smallest-2.10.1.tgz", + "integrity": "sha512-MXwIvq+viDCmsxJwD4+fwMhwZINWva3jtQ3j5ne6DXgZYUJUFOw3VujvCP4/cl075RuSxYlXgy6ETHLa1TNr7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/bus-rdf-join": "^2.10.1", + "@comunica/bus-rdf-join-entries-sort": "^2.10.0", + "@comunica/mediatortype-join-coefficients": "^2.10.0", + "@comunica/types": "^2.10.0", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-rdf-join-inner-nestedloop": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-join-inner-nestedloop/-/actor-rdf-join-inner-nestedloop-2.10.1.tgz", + "integrity": "sha512-nFjGMrAIrRjRcsaU8UQXLbsDODVdf4LDpVNVQIrjfoWzhOIy13ApDQrqtuObaGVfryiFgt34zVEOwMWezWzl0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-join": "^2.10.1", + "@comunica/mediatortype-join-coefficients": "^2.10.0", + "@comunica/types": "^2.10.0", + "asyncjoin": "^1.1.1" + } + }, + "node_modules/@comunica/actor-rdf-join-inner-none": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-join-inner-none/-/actor-rdf-join-inner-none-2.10.1.tgz", + "integrity": "sha512-4mqsuqvLSuXMbgY0PghqK5hmBGH5YkRTwUOpGpBE0EVQaiAoQOME0uVslkt2TBzUx5IQJC+trr/80sbA9mAhMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bindings-factory": "^2.10.1", + "@comunica/bus-rdf-join": "^2.10.1", + "@comunica/mediatortype-join-coefficients": "^2.10.0", + "@comunica/metadata": "^2.10.0", + "asynciterator": "^3.8.1" + } + }, + "node_modules/@comunica/actor-rdf-join-inner-single": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-join-inner-single/-/actor-rdf-join-inner-single-2.10.1.tgz", + "integrity": "sha512-RfnwTEsuXNdR0cNRWaCvNPlfD5KyuScsc/55j/9mr8yqGUTE9h9Om1Is5u7xnpRMxGOEqwVP6apK3ZxsZqlL/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-join": "^2.10.1", + "@comunica/mediatortype-join-coefficients": "^2.10.0" + } + }, + "node_modules/@comunica/actor-rdf-join-inner-symmetrichash": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-join-inner-symmetrichash/-/actor-rdf-join-inner-symmetrichash-2.10.1.tgz", + "integrity": "sha512-beFGkMUe3pTADtMXXPU8ab/IMULj+Hkg3Iah0zgrVZgwWH1Kgfkj/2qp32Ll5y9qcRbio4ruruKlHNXJJUU46Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-join": "^2.10.1", + "@comunica/mediatortype-join-coefficients": "^2.10.0", + "@comunica/types": "^2.10.0", + "asyncjoin": "^1.1.1" + } + }, + "node_modules/@comunica/actor-rdf-join-minus-hash": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-join-minus-hash/-/actor-rdf-join-minus-hash-2.10.1.tgz", + "integrity": "sha512-wIaB/EpuySaARhimoLzrE0cTH0TgVkL43IAtYX7ECwH9Qcv8blO4zbL4q2KUkY7OKZRM892aqMfo3kO1vMIK7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/bus-rdf-join": "^2.10.1", + "@comunica/mediatortype-join-coefficients": "^2.10.0", + "@comunica/types": "^2.10.0", + "@rdfjs/types": "*" + } + }, + "node_modules/@comunica/actor-rdf-join-minus-hash-undef": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-join-minus-hash-undef/-/actor-rdf-join-minus-hash-undef-2.10.1.tgz", + "integrity": "sha512-tz5LdeAHnylEQIq4bRfFqaH89WZXkkdFxEshqxWijFBp5wprUYiotMDrBo9zDFaPquhs42fILtTzLY9yaalc9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/bus-rdf-join": "^2.10.1", + "@comunica/mediatortype-join-coefficients": "^2.10.0", + "@comunica/types": "^2.10.0", + "@rdfjs/types": "*", + "rdf-string": "^1.6.1" + } + }, + "node_modules/@comunica/actor-rdf-join-optional-bind": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-join-optional-bind/-/actor-rdf-join-optional-bind-2.10.1.tgz", + "integrity": "sha512-6dOoI/rzRZ0RUyv2WlToClE42Z2YJE5xcSrot7haT2eMdxbzr1KjyasHBcIIkSK+WViDO006lXZ1Hi4tJm9uuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/actor-rdf-join-inner-multi-bind": "^2.10.1", + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/bus-rdf-join": "^2.10.1", + "@comunica/context-entries": "^2.10.0", + "@comunica/mediatortype-join-coefficients": "^2.10.0", + "@comunica/types": "^2.10.0", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-rdf-join-optional-nestedloop": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-join-optional-nestedloop/-/actor-rdf-join-optional-nestedloop-2.10.1.tgz", + "integrity": "sha512-d7KUDjEKZszizd4SBvYkK2A6lScrq9ciEgzdrrp6IYZhIGAhJLTgPNg3Js3NEjpE7oj4KWl2WwKJe2sWcJbKJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-join": "^2.10.1", + "@comunica/mediatortype-join-coefficients": "^2.10.0", + "@comunica/types": "^2.10.0", + "asyncjoin": "^1.1.1" + } + }, + "node_modules/@comunica/actor-rdf-join-selectivity-variable-counting": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-join-selectivity-variable-counting/-/actor-rdf-join-selectivity-variable-counting-2.10.0.tgz", + "integrity": "sha512-D7tdzxA93bpZGXI5emJyvzk6LabeAnzcQMU/V5x2QwJxyoNr+LFbesBHDDP3/u4UJwmeP0a+dU0e5mbpJujSXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-join-selectivity": "^2.10.0", + "@comunica/core": "^2.10.0", + "@comunica/mediatortype-accuracy": "^2.10.0", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-rdf-metadata-accumulate-cancontainundefs": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-metadata-accumulate-cancontainundefs/-/actor-rdf-metadata-accumulate-cancontainundefs-2.10.0.tgz", + "integrity": "sha512-N3rwX4kT9rkW+89q4xCjO3KKG0DbeNIyeMWDzeh2vTw8nAXYyTiPjHYvx/6VUMzhFUWF+50VtVv8ZJPO6nEapw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-metadata-accumulate": "^2.10.0", + "@comunica/core": "^2.10.0" + } + }, + "node_modules/@comunica/actor-rdf-metadata-accumulate-cardinality": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-metadata-accumulate-cardinality/-/actor-rdf-metadata-accumulate-cardinality-2.10.0.tgz", + "integrity": "sha512-UpC5PbhzEDCAxTUqETH89uRaFRqmP6YuWt67OAPo5wocv2tQDs6/SdLwS695XnfeMJdfDHsXyoUzQg3r8dwydw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-metadata-accumulate": "^2.10.0", + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0" + } + }, + "node_modules/@comunica/actor-rdf-metadata-accumulate-pagesize": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-metadata-accumulate-pagesize/-/actor-rdf-metadata-accumulate-pagesize-2.10.0.tgz", + "integrity": "sha512-r364CWGr5rMpV2ec3TsD+9Yhvi1JUuRXLBQqtgzjAPbpWjfDSM1Q4h0P1z9h3D+sdUMEX/0iGAY3AH2FjJAxwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-metadata-accumulate": "^2.10.0", + "@comunica/core": "^2.10.0" + } + }, + "node_modules/@comunica/actor-rdf-metadata-accumulate-requesttime": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-metadata-accumulate-requesttime/-/actor-rdf-metadata-accumulate-requesttime-2.10.0.tgz", + "integrity": "sha512-SpG7gxxAPoW2NbgyZ2UNpwluJ+IvCOYIRDTXmVTAK8bntav+/ZG30yfESFBjB3LmJEwAnktAsTgM6OhldohPKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-metadata-accumulate": "^2.10.0", + "@comunica/core": "^2.10.0" + } + }, + "node_modules/@comunica/actor-rdf-metadata-all": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-metadata-all/-/actor-rdf-metadata-all-2.10.0.tgz", + "integrity": "sha512-dHaSxHTdneWVBMAF6WqZrGD+u4TPpHQaJ2WutK1NvQNPIiF0N7249aGTvXBIXZfsKYyQ73PUORDeLEOjX+tT7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-metadata": "^2.10.0", + "@comunica/core": "^2.10.0", + "readable-stream": "^4.4.2" + } + }, + "node_modules/@comunica/actor-rdf-metadata-extract-allow-http-methods": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-metadata-extract-allow-http-methods/-/actor-rdf-metadata-extract-allow-http-methods-2.10.0.tgz", + "integrity": "sha512-aCSX+lWcmz5Q/g34VJEblczqDS6N+gJ3AlcOcGuqhd6qHRU17dMeCIZCk8p6p+AhbJ30w4BTsrZRY2sF0MGCVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-metadata-extract": "^2.10.0", + "@comunica/core": "^2.10.0" + } + }, + "node_modules/@comunica/actor-rdf-metadata-extract-hydra-controls": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-metadata-extract-hydra-controls/-/actor-rdf-metadata-extract-hydra-controls-2.10.0.tgz", + "integrity": "sha512-T6F5OaQNqrHVIwSGNRX6YPDBoAOYBQj3NTPID7vQae7J80oEX+CLoTkeJJwfHpoUWx0ihs8J0UkABgK3AWeylA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-metadata-extract": "^2.10.0", + "@comunica/core": "^2.10.0", + "@rdfjs/types": "*", + "@types/uritemplate": "^0.3.4", + "uritemplate": "0.3.4" + } + }, + "node_modules/@comunica/actor-rdf-metadata-extract-hydra-count": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-metadata-extract-hydra-count/-/actor-rdf-metadata-extract-hydra-count-2.10.0.tgz", + "integrity": "sha512-nOMLN+9OSLFOVz6jc9pcyDizhcBBVT2azn7StTMK5ukFCcPCENS4y6lYhC5cijKZY7vUa7U6VzhX2vvw20MKDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-metadata-extract": "^2.10.0", + "@comunica/core": "^2.10.0" + } + }, + "node_modules/@comunica/actor-rdf-metadata-extract-hydra-pagesize": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-metadata-extract-hydra-pagesize/-/actor-rdf-metadata-extract-hydra-pagesize-2.10.0.tgz", + "integrity": "sha512-mD8KS2ENr2rbfBWxtVpxkB/Y2LyyAnwQU5UYKkpet8ELhlostdGROzYCNIAgfOgirOAsLgVkbmrX0XBGouI7rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-metadata-extract": "^2.10.0", + "@comunica/core": "^2.10.0" + } + }, + "node_modules/@comunica/actor-rdf-metadata-extract-patch-sparql-update": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-metadata-extract-patch-sparql-update/-/actor-rdf-metadata-extract-patch-sparql-update-2.10.0.tgz", + "integrity": "sha512-U5ARpeWKShbbSfdtJeb6nyPcsdtMwEo2dp56T4aSTNSBKtAhQ78DjOxb23WIU/VR/qpw2yWcsbPnNJvSaLpRVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-metadata-extract": "^2.10.0", + "@comunica/core": "^2.10.0" + } + }, + "node_modules/@comunica/actor-rdf-metadata-extract-put-accepted": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-metadata-extract-put-accepted/-/actor-rdf-metadata-extract-put-accepted-2.10.0.tgz", + "integrity": "sha512-cGJg6tMMCOSGcitkUBN7b9/Sg5zgwWQC52g+Zk22o4i+Zgt24WLjfXXbnGWGoV+h9YZo8pkg7v1cpE5GpapNCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-metadata-extract": "^2.10.0", + "@comunica/core": "^2.10.0" + } + }, + "node_modules/@comunica/actor-rdf-metadata-extract-request-time": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-metadata-extract-request-time/-/actor-rdf-metadata-extract-request-time-2.10.0.tgz", + "integrity": "sha512-zh3coTPZMbgF4mXKCO3bzn99INt9HFraKMZWc9s/kwBE6vhNZ5246Ql/6z1v7mccoIbanhI72gtjFTGGHru80Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-metadata-extract": "^2.10.0", + "@comunica/core": "^2.10.0" + } + }, + "node_modules/@comunica/actor-rdf-metadata-extract-sparql-service": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-metadata-extract-sparql-service/-/actor-rdf-metadata-extract-sparql-service-2.10.0.tgz", + "integrity": "sha512-Xc+id8FURTmY3ccb4hcVuAaOou5UqD+1YkTnGfMWQxVgMlFC1eeBvwWVzvedj0sHhnfbLgDwbCVYLCK1lNndSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-metadata-extract": "^2.10.0", + "@comunica/core": "^2.10.0", + "relative-to-absolute-iri": "^1.0.7" + } + }, + "node_modules/@comunica/actor-rdf-metadata-primary-topic": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-metadata-primary-topic/-/actor-rdf-metadata-primary-topic-2.10.0.tgz", + "integrity": "sha512-nabxkiYSPGPRylhYjGxF0KiJ/K8QiG1N/am/t8eaqwyjn/fo2/tHl0yXUaLLx0E8fChfbBv10sVlmLhsLrg8DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-metadata": "^2.10.0", + "@comunica/core": "^2.10.0", + "@rdfjs/types": "*", + "readable-stream": "^4.4.2" + } + }, + "node_modules/@comunica/actor-rdf-parse-html": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-parse-html/-/actor-rdf-parse-html-2.10.0.tgz", + "integrity": "sha512-zgImXKpc+BN1i6lQiN1Qhlb1HbKdMIeJMOys6qbzRIijdK8GkGGChwhQp7Cso3lY1Nf4K7M3jPLZeQXeED2w7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-parse": "^2.10.0", + "@comunica/bus-rdf-parse-html": "^2.10.0", + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0", + "@rdfjs/types": "*", + "htmlparser2": "^9.0.0", + "readable-stream": "^4.4.2" + } + }, + "node_modules/@comunica/actor-rdf-parse-html-microdata": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-parse-html-microdata/-/actor-rdf-parse-html-microdata-2.10.0.tgz", + "integrity": "sha512-JLfiDauq4SmpI6TDS4HaHzI6iJe1j8lSk5FRRYK6YVEu8eO28jPmxQJiOiwbQiYqsjsV7kON/WIZSoUELoI4Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-parse-html": "^2.10.0", + "@comunica/core": "^2.10.0", + "microdata-rdf-streaming-parser": "^2.0.1" + } + }, + "node_modules/@comunica/actor-rdf-parse-html-rdfa": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-parse-html-rdfa/-/actor-rdf-parse-html-rdfa-2.10.0.tgz", + "integrity": "sha512-9K3iaws9+FGl50oZi53hqyzhwjNKZ3mIr2zg/TAJZoapKvc14cthH17zKSSJrqI/NgBStRmZhBBkXcwfu1CANw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-parse-html": "^2.10.0", + "@comunica/core": "^2.10.0", + "rdfa-streaming-parser": "^2.0.1" + } + }, + "node_modules/@comunica/actor-rdf-parse-html-script": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-parse-html-script/-/actor-rdf-parse-html-script-2.10.0.tgz", + "integrity": "sha512-7XYqWchDquWnBLjG7rmmY+tdE81UZ8fPCU0Hn+vI39/MikNOpaiyr/ZYFqhogWFa9SkjmH0a7idVUzmjiwKRZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-parse": "^2.10.0", + "@comunica/bus-rdf-parse-html": "^2.10.0", + "@comunica/context-entries": "^2.10.0", + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0", + "@rdfjs/types": "*", + "readable-stream": "^4.4.2", + "relative-to-absolute-iri": "^1.0.7" + } + }, + "node_modules/@comunica/actor-rdf-parse-jsonld": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-parse-jsonld/-/actor-rdf-parse-jsonld-2.10.2.tgz", + "integrity": "sha512-K4fvD0zMU22KkQCqIFVT5Oy2FREEZ9CAo9u6kOcsMxEvg9aHGIM6hkaXR8I+1JCx1mDuEj3zQ8joR4tQh8fYCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-http": "^2.10.2", + "@comunica/bus-rdf-parse": "^2.10.0", + "@comunica/context-entries": "^2.10.0", + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0", + "jsonld-context-parser": "^2.2.2", + "jsonld-streaming-parser": "^3.0.1", + "stream-to-string": "^1.2.0" + } + }, + "node_modules/@comunica/actor-rdf-parse-n3": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-parse-n3/-/actor-rdf-parse-n3-2.10.0.tgz", + "integrity": "sha512-o1MAbwJxW4Br2WCZdhFoRmAiOP4mfogeQqJ4nqlsOkoMtQ45EvLHsotb3Kqhuk5V+vsTxyK5v/a4zylGtcU7VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-parse": "^2.10.0", + "@comunica/types": "^2.10.0", + "n3": "^1.17.0" + } + }, + "node_modules/@comunica/actor-rdf-parse-rdfxml": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-parse-rdfxml/-/actor-rdf-parse-rdfxml-2.10.0.tgz", + "integrity": "sha512-HoJN52shXY3cvYtsS0cpin9KXpW3L7g1leebyCRSqnlnHdJv5D6G0Ep8vyt2xhquKNbOQ7LnP5VhiDiqz73XDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-parse": "^2.10.0", + "@comunica/types": "^2.10.0", + "rdfxml-streaming-parser": "^2.2.3" + } + }, + "node_modules/@comunica/actor-rdf-parse-shaclc": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-parse-shaclc/-/actor-rdf-parse-shaclc-2.10.0.tgz", + "integrity": "sha512-i6tmuZuS+RtDiSXpQc3s/PxtCqwIguo4ANmVB20PK4VWgQgBwoPG7LlNcJ0xmuH/3Bv6C2Agn18PLF6dZX+fKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-parse": "^2.10.0", + "@comunica/types": "^2.10.0", + "@rdfjs/types": "*", + "asynciterator": "^3.8.1", + "readable-stream": "^4.4.2", + "shaclc-parse": "^1.4.0", + "stream-to-string": "^1.2.0" + } + }, + "node_modules/@comunica/actor-rdf-parse-xml-rdfa": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-parse-xml-rdfa/-/actor-rdf-parse-xml-rdfa-2.10.0.tgz", + "integrity": "sha512-68r/6B/fEyA1/OYleVuaPq47J+g4xJcJijpdL1wEj7CqjV+Xa+sDWRpNCyLcD/e1Y/g9UQmLz0ZnSpR00PFddA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-parse": "^2.10.0", + "@comunica/types": "^2.10.0", + "rdfa-streaming-parser": "^2.0.1" + } + }, + "node_modules/@comunica/actor-rdf-resolve-hypermedia-links-next": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-resolve-hypermedia-links-next/-/actor-rdf-resolve-hypermedia-links-next-2.10.0.tgz", + "integrity": "sha512-SpW46Tx8ksAxotGK2UEpvGcYjKwxB0x2KnbGmKHvo59embRjcUL/bmq3uHqZe7UwfynR2wDaRzMdVVSQccWSyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-resolve-hypermedia-links": "^2.10.0", + "@comunica/core": "^2.10.0" + } + }, + "node_modules/@comunica/actor-rdf-resolve-hypermedia-links-queue-fifo": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-resolve-hypermedia-links-queue-fifo/-/actor-rdf-resolve-hypermedia-links-queue-fifo-2.10.0.tgz", + "integrity": "sha512-Hh53Ts6z6MxKXhZZxgpXfc1hgNzIX/xbA9mD2Au7ZfAa5V5j8zPaVVKe06sxILQBTPMsFh1idP3vIqRwRXpsvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-resolve-hypermedia-links": "^2.10.0", + "@comunica/bus-rdf-resolve-hypermedia-links-queue": "^2.10.0", + "@comunica/core": "^2.10.0" + } + }, + "node_modules/@comunica/actor-rdf-resolve-hypermedia-none": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-resolve-hypermedia-none/-/actor-rdf-resolve-hypermedia-none-2.10.0.tgz", + "integrity": "sha512-C4sJ0QJetq3QxsRkYstK5YXRYDGkcVTfyBOFUMYj7PbVakapnl8qPZkVL7VPMLVLVOfyBQHTT43Yp6Nl8VvmSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/actor-rdf-resolve-quad-pattern-rdfjs-source": "^2.10.0", + "@comunica/bus-rdf-resolve-hypermedia": "^2.10.0", + "rdf-store-stream": "^2.0.0" + } + }, + "node_modules/@comunica/actor-rdf-resolve-hypermedia-qpf": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-resolve-hypermedia-qpf/-/actor-rdf-resolve-hypermedia-qpf-2.10.0.tgz", + "integrity": "sha512-1iP9xD72bxFBLpbfC7Ev0Xoc+0rwusPFdnoYbEtqMHRfiM0h3nNrsSxyzdGJMAZaJeQzmBZIEiwR5pbo9qpmaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/actor-rdf-metadata-extract-hydra-controls": "^2.10.0", + "@comunica/bus-dereference-rdf": "^2.10.0", + "@comunica/bus-rdf-metadata": "^2.10.0", + "@comunica/bus-rdf-metadata-extract": "^2.10.0", + "@comunica/bus-rdf-resolve-hypermedia": "^2.10.0", + "@comunica/bus-rdf-resolve-quad-pattern": "^2.10.0", + "@comunica/types": "^2.10.0", + "@rdfjs/types": "*", + "asynciterator": "^3.8.1", + "rdf-data-factory": "^1.1.1", + "rdf-string": "^1.6.1", + "rdf-terms": "^1.11.0" + } + }, + "node_modules/@comunica/actor-rdf-resolve-hypermedia-sparql": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-resolve-hypermedia-sparql/-/actor-rdf-resolve-hypermedia-sparql-2.10.2.tgz", + "integrity": "sha512-UFsTuzHvjK/XhRGqfHr3WAVr+iBv6XTuU1fV9EuOaB+odclQ+H6TGtmW6/38CSufj86Y691VBXMk29zdWfrmGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bindings-factory": "^2.10.1", + "@comunica/bus-http": "^2.10.2", + "@comunica/bus-rdf-resolve-hypermedia": "^2.10.0", + "@comunica/bus-rdf-resolve-quad-pattern": "^2.10.0", + "@comunica/types": "^2.10.0", + "@rdfjs/types": "*", + "asynciterator": "^3.8.1", + "fetch-sparql-endpoint": "^4.0.0", + "lru-cache": "^10.0.0", + "rdf-data-factory": "^1.1.1", + "rdf-terms": "^1.11.0", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-rdf-resolve-hypermedia-sparql/node_modules/@types/readable-stream": { + "version": "2.3.15", + "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-2.3.15.tgz", + "integrity": "sha512-oM5JSKQCcICF1wvGgmecmHldZ48OZamtMxcGGVICOJA8o8cahXC1zEVAif8iwoc5j8etxFaRFnf095+CDsuoFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "safe-buffer": "~5.1.1" + } + }, + "node_modules/@comunica/actor-rdf-resolve-hypermedia-sparql/node_modules/cross-fetch": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz", + "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, + "node_modules/@comunica/actor-rdf-resolve-hypermedia-sparql/node_modules/fetch-sparql-endpoint": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/fetch-sparql-endpoint/-/fetch-sparql-endpoint-4.2.1.tgz", + "integrity": "sha512-nRaexc3QCO95bjESf4ngNQ1J+qNtVzxFGlPUopqOIVHm/j6IDhWg996kk7fBM98Mmo0uM9b6uiTbXmJHOrnqYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rdfjs/types": "*", + "@smessie/readable-web-to-node-stream": "^3.0.3", + "@types/readable-stream": "^2.3.11", + "@types/sparqljs": "^3.1.3", + "abort-controller": "^3.0.0", + "cross-fetch": "^3.0.6", + "is-stream": "^2.0.0", + "minimist": "^1.2.0", + "n3": "^1.6.3", + "rdf-string": "^1.6.0", + "sparqljs": "^3.1.2", + "sparqljson-parse": "^2.2.0", + "sparqlxml-parse": "^2.1.1", + "stream-to-string": "^1.1.0" + }, + "bin": { + "fetch-sparql-endpoint": "bin/fetch-sparql-endpoint.js" + } + }, + "node_modules/@comunica/actor-rdf-resolve-hypermedia-sparql/node_modules/sparqljson-parse": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/sparqljson-parse/-/sparqljson-parse-2.2.0.tgz", + "integrity": "sha512-2TfvNvUsaJyWfCrq3ExdDdbF9LBLzIUCricg+D1YCYbbmyTzscgCtRk4KcIyJF178DtfCt4BkKzbKl8IXMHp8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bergos/jsonparse": "^1.4.1", + "@rdfjs/types": "*", + "@types/readable-stream": "^2.3.13", + "rdf-data-factory": "^1.1.0", + "readable-stream": "^4.0.0" + } + }, + "node_modules/@comunica/actor-rdf-resolve-hypermedia-sparql/node_modules/sparqlxml-parse": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/sparqlxml-parse/-/sparqlxml-parse-2.1.1.tgz", + "integrity": "sha512-71sltShF6gDAzuKWEHNeij7r0Mv5VqRrvJing6W4WHJ12GRe6+t1IRTv6MeqxYN3XJmKevs7B3HCBUo7wceeJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rdfjs/types": "*", + "@rubensworks/saxes": "^6.0.1", + "@types/readable-stream": "^2.3.13", + "buffer": "^6.0.3", + "rdf-data-factory": "^1.1.0", + "readable-stream": "^4.0.0" + } + }, + "node_modules/@comunica/actor-rdf-resolve-quad-pattern-federated": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-resolve-quad-pattern-federated/-/actor-rdf-resolve-quad-pattern-federated-2.10.1.tgz", + "integrity": "sha512-OBRTTUWkXKa0ibDzcYLG7aKf3BfQp2j75xm65brRvwstNLmye9ZEq1PrNhbP5UDqQQeCgzPBrb0eGC8Vxek2RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/bus-rdf-metadata-accumulate": "^2.10.0", + "@comunica/bus-rdf-resolve-quad-pattern": "^2.10.0", + "@comunica/context-entries": "^2.10.0", + "@comunica/core": "^2.10.0", + "@comunica/data-factory": "^2.7.0", + "@comunica/metadata": "^2.10.0", + "@comunica/types": "^2.10.0", + "@rdfjs/types": "*", + "asynciterator": "^3.8.1", + "rdf-data-factory": "^1.1.1", + "rdf-terms": "^1.11.0", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-rdf-resolve-quad-pattern-hypermedia": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-resolve-quad-pattern-hypermedia/-/actor-rdf-resolve-quad-pattern-hypermedia-2.10.1.tgz", + "integrity": "sha512-XkJOYu0bizWHsvgiaGyNAnRZsqv2risREK5SY14VCMXDYqmOWJLDppveGEUZAoEKEJuo4ZLDlP2gLDGzc0krxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-dereference-rdf": "^2.10.0", + "@comunica/bus-http-invalidate": "^2.10.0", + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/bus-rdf-metadata": "^2.10.0", + "@comunica/bus-rdf-metadata-accumulate": "^2.10.0", + "@comunica/bus-rdf-metadata-extract": "^2.10.0", + "@comunica/bus-rdf-resolve-hypermedia": "^2.10.0", + "@comunica/bus-rdf-resolve-hypermedia-links": "^2.10.0", + "@comunica/bus-rdf-resolve-hypermedia-links-queue": "^2.10.0", + "@comunica/bus-rdf-resolve-quad-pattern": "^2.10.0", + "@comunica/context-entries": "^2.10.0", + "@comunica/core": "^2.10.0", + "@comunica/metadata": "^2.10.0", + "@comunica/types": "^2.10.0", + "@rdfjs/types": "*", + "asynciterator": "^3.8.1", + "lru-cache": "^10.0.0", + "rdf-data-factory": "^1.1.2", + "rdf-streaming-store": "^1.1.0", + "readable-stream": "^4.4.2", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/actor-rdf-resolve-quad-pattern-rdfjs-source": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-resolve-quad-pattern-rdfjs-source/-/actor-rdf-resolve-quad-pattern-rdfjs-source-2.10.0.tgz", + "integrity": "sha512-d6AlrngvZaVgoiiyMhkf6uiYaFZZdn/UZLo0FhZ++or1NZXo5KxK4UMgdiIygvPEiuuVzy0W1djHgOQ1rgh50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-resolve-quad-pattern": "^2.10.0", + "@comunica/core": "^2.10.0", + "@comunica/metadata": "^2.10.0", + "@comunica/types": "^2.10.0", + "@rdfjs/types": "*", + "asynciterator": "^3.8.1", + "rdf-data-factory": "^1.1.2", + "rdf-terms": "^1.11.0" + } + }, + "node_modules/@comunica/actor-rdf-resolve-quad-pattern-string-source": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-resolve-quad-pattern-string-source/-/actor-rdf-resolve-quad-pattern-string-source-2.10.0.tgz", + "integrity": "sha512-v6QOBtXTXrDUZRHocrm2OYCsxGpyTScka/n85cewCcInqVGJP9J6zpdwetzvIy7wVJkac7JQabd96OEyDMK3sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-parse": "^2.10.0", + "@comunica/bus-rdf-resolve-quad-pattern": "^2.10.0", + "@comunica/context-entries": "^2.10.0", + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0", + "@rdfjs/types": "*", + "lru-cache": "^10.0.0", + "rdf-store-stream": "^2.0.0", + "readable-stream": "^4.4.2" + } + }, + "node_modules/@comunica/actor-rdf-serialize-jsonld": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-serialize-jsonld/-/actor-rdf-serialize-jsonld-2.10.0.tgz", + "integrity": "sha512-u1M5N7BSrkhS461fV6QXKMh6TnvpoEiSHPru7wJg1kGqR9q3reuQeKLf/U23JDYb1kom8uU3R7aBpDIjgVc49Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-serialize": "^2.10.0", + "@comunica/types": "^2.10.0", + "jsonld-streaming-serializer": "^2.1.0" + } + }, + "node_modules/@comunica/actor-rdf-serialize-n3": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-serialize-n3/-/actor-rdf-serialize-n3-2.10.0.tgz", + "integrity": "sha512-CoDktUI3YQuI7UBV+fQOdKl+5XjBx0XTOF9XxEDiNg5nwndEmDvq6C23fSHfkqX3/xDlnsuS/YysHAqXCrYoiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-serialize": "^2.10.0", + "@comunica/types": "^2.10.0", + "n3": "^1.17.0" + } + }, + "node_modules/@comunica/actor-rdf-serialize-shaclc": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-serialize-shaclc/-/actor-rdf-serialize-shaclc-2.10.0.tgz", + "integrity": "sha512-gp4bu4+aPtMk4bavXP27uD9X9bpa2F5u6/JtsaX2qwcqVI0x1tkVQOkm2RkUhafcHNj0Fz6lQ3aXmRIAQvaefg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-serialize": "^2.10.0", + "@comunica/types": "^2.10.0", + "arrayify-stream": "^2.0.1", + "readable-stream": "^4.4.2", + "shaclc-write": "^1.4.2" + } + }, + "node_modules/@comunica/actor-rdf-update-hypermedia-patch-sparql-update": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-update-hypermedia-patch-sparql-update/-/actor-rdf-update-hypermedia-patch-sparql-update-2.10.2.tgz", + "integrity": "sha512-z/fOzYlA5fPtauTUISYhCWMKtEpkvKkSZIdvcgeGvetLnvw4fytfVHdtPhirZYmPya10GCeTG7m2iHvK53lOsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-http": "^2.10.2", + "@comunica/bus-rdf-update-hypermedia": "^2.10.2", + "@comunica/bus-rdf-update-quads": "^2.10.2", + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0", + "@rdfjs/types": "*", + "asynciterator": "^3.8.1", + "cross-fetch": "^4.0.0", + "rdf-string-ttl": "^1.3.2", + "readable-stream": "^4.4.2" + } + }, + "node_modules/@comunica/actor-rdf-update-hypermedia-put-ldp": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-update-hypermedia-put-ldp/-/actor-rdf-update-hypermedia-put-ldp-2.10.2.tgz", + "integrity": "sha512-Tof/mU0Lkt7HP3SwHXODczxvAFelWzAHdP+ap4Upr47K6Zg5GRPwJv//2AcPvT3p42Li6wuMz/4nh/A3pcnCKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-http": "^2.10.2", + "@comunica/bus-rdf-serialize": "^2.10.0", + "@comunica/bus-rdf-update-hypermedia": "^2.10.2", + "@comunica/bus-rdf-update-quads": "^2.10.2", + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0", + "@rdfjs/types": "*", + "asynciterator": "^3.8.1", + "cross-fetch": "^4.0.0" + } + }, + "node_modules/@comunica/actor-rdf-update-hypermedia-sparql": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-update-hypermedia-sparql/-/actor-rdf-update-hypermedia-sparql-2.10.2.tgz", + "integrity": "sha512-uw1NIAoxuAechsjTQ6b53XpGOMx3Mp5uEL5LtUwNC6COJE6tzWH8wG54Dwj+0VNxsgqsSircKu2xwGl1uOsOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-http": "^2.10.2", + "@comunica/bus-rdf-update-hypermedia": "^2.10.2", + "@comunica/bus-rdf-update-quads": "^2.10.2", + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0", + "@rdfjs/types": "*", + "asynciterator": "^3.8.1", + "fetch-sparql-endpoint": "^4.0.0", + "rdf-string-ttl": "^1.3.2", + "stream-to-string": "^1.2.0" + } + }, + "node_modules/@comunica/actor-rdf-update-hypermedia-sparql/node_modules/@types/readable-stream": { + "version": "2.3.15", + "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-2.3.15.tgz", + "integrity": "sha512-oM5JSKQCcICF1wvGgmecmHldZ48OZamtMxcGGVICOJA8o8cahXC1zEVAif8iwoc5j8etxFaRFnf095+CDsuoFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "safe-buffer": "~5.1.1" + } + }, + "node_modules/@comunica/actor-rdf-update-hypermedia-sparql/node_modules/cross-fetch": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz", + "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, + "node_modules/@comunica/actor-rdf-update-hypermedia-sparql/node_modules/fetch-sparql-endpoint": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/fetch-sparql-endpoint/-/fetch-sparql-endpoint-4.2.1.tgz", + "integrity": "sha512-nRaexc3QCO95bjESf4ngNQ1J+qNtVzxFGlPUopqOIVHm/j6IDhWg996kk7fBM98Mmo0uM9b6uiTbXmJHOrnqYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rdfjs/types": "*", + "@smessie/readable-web-to-node-stream": "^3.0.3", + "@types/readable-stream": "^2.3.11", + "@types/sparqljs": "^3.1.3", + "abort-controller": "^3.0.0", + "cross-fetch": "^3.0.6", + "is-stream": "^2.0.0", + "minimist": "^1.2.0", + "n3": "^1.6.3", + "rdf-string": "^1.6.0", + "sparqljs": "^3.1.2", + "sparqljson-parse": "^2.2.0", + "sparqlxml-parse": "^2.1.1", + "stream-to-string": "^1.1.0" + }, + "bin": { + "fetch-sparql-endpoint": "bin/fetch-sparql-endpoint.js" + } + }, + "node_modules/@comunica/actor-rdf-update-hypermedia-sparql/node_modules/sparqljson-parse": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/sparqljson-parse/-/sparqljson-parse-2.2.0.tgz", + "integrity": "sha512-2TfvNvUsaJyWfCrq3ExdDdbF9LBLzIUCricg+D1YCYbbmyTzscgCtRk4KcIyJF178DtfCt4BkKzbKl8IXMHp8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bergos/jsonparse": "^1.4.1", + "@rdfjs/types": "*", + "@types/readable-stream": "^2.3.13", + "rdf-data-factory": "^1.1.0", + "readable-stream": "^4.0.0" + } + }, + "node_modules/@comunica/actor-rdf-update-hypermedia-sparql/node_modules/sparqlxml-parse": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/sparqlxml-parse/-/sparqlxml-parse-2.1.1.tgz", + "integrity": "sha512-71sltShF6gDAzuKWEHNeij7r0Mv5VqRrvJing6W4WHJ12GRe6+t1IRTv6MeqxYN3XJmKevs7B3HCBUo7wceeJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rdfjs/types": "*", + "@rubensworks/saxes": "^6.0.1", + "@types/readable-stream": "^2.3.13", + "buffer": "^6.0.3", + "rdf-data-factory": "^1.1.0", + "readable-stream": "^4.0.0" + } + }, + "node_modules/@comunica/actor-rdf-update-quads-hypermedia": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-update-quads-hypermedia/-/actor-rdf-update-quads-hypermedia-2.10.2.tgz", + "integrity": "sha512-kzGfDv0PqcOIIULJLG8jtA/dOcrNUodu98J08ruSuYQBbnFgAZ07MG1TkWhEI/AM6D0w7hXkgQaC1sGWn4gVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-dereference-rdf": "^2.10.0", + "@comunica/bus-http-invalidate": "^2.10.0", + "@comunica/bus-rdf-metadata": "^2.10.0", + "@comunica/bus-rdf-metadata-extract": "^2.10.0", + "@comunica/bus-rdf-update-hypermedia": "^2.10.2", + "@comunica/bus-rdf-update-quads": "^2.10.2", + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0", + "lru-cache": "^10.0.0" + } + }, + "node_modules/@comunica/actor-rdf-update-quads-rdfjs-store": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-update-quads-rdfjs-store/-/actor-rdf-update-quads-rdfjs-store-2.10.2.tgz", + "integrity": "sha512-anX3SovvY2H8KwuWu8G9EqtITmCsz12jfqunNn5Efcch/bm4HyHTC1GThx77m6qpCdg4OMx8TLhNrH1II1UM1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-update-quads": "^2.10.2", + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0", + "@rdfjs/types": "*", + "asynciterator": "^3.8.1", + "rdf-data-factory": "^1.1.1", + "rdf-string": "^1.6.1" + } + }, + "node_modules/@comunica/bindings-factory": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/bindings-factory/-/bindings-factory-2.10.1.tgz", + "integrity": "sha512-AUD3VWlCYljgk5jfaMejSIL9CiX3aV/cAn314e/dYP/rrnVgachcCwyaD8hKHWTBHDs5rcGxr/iwruBOfsERvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rdfjs/types": "*", + "immutable": "^4.1.0", + "rdf-data-factory": "^1.1.1", + "rdf-string": "^1.6.1" + } + }, + "node_modules/@comunica/bus-context-preprocess": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/bus-context-preprocess/-/bus-context-preprocess-2.10.0.tgz", + "integrity": "sha512-eJ5CkzbnmxB9fkr2F05jnnjcaowp+yxd0+pAtvx5MLl2Kpx3nWLqHPcl4/EVVDPD+i0TEkq4AXQ1BD9BMuXK0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0" + } + }, + "node_modules/@comunica/bus-dereference": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/bus-dereference/-/bus-dereference-2.10.0.tgz", + "integrity": "sha512-nWyQXiH7zbiPTVttWVKJHykhV4IuahfhfUwPx3Op+cVsK489Su84dnGeSmPkxTAFFuxe6wU6ZEH4i7PDu48YvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/actor-abstract-mediatyped": "^2.10.0", + "@comunica/actor-abstract-parse": "^2.10.0", + "@comunica/context-entries": "^2.10.0", + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0", + "readable-stream": "^4.4.2" + } + }, + "node_modules/@comunica/bus-dereference-rdf": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/bus-dereference-rdf/-/bus-dereference-rdf-2.10.0.tgz", + "integrity": "sha512-WY/wPmFpO76wwJ2D5Aus43ZbYnBRLvQ0EOp4yywO0lBiq6F0JisjCVCM4EtWouOEAAfqEoIjHXGyC3gPWqm+SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-dereference": "^2.10.0", + "@comunica/bus-rdf-parse": "^2.10.0", + "@comunica/core": "^2.10.0", + "@rdfjs/types": "*" + } + }, + "node_modules/@comunica/bus-hash-bindings": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/bus-hash-bindings/-/bus-hash-bindings-2.10.0.tgz", + "integrity": "sha512-EdzIUgpSWMtFVxEJSesuQpMkfgznDap+U0F9epotxXc20Gg/qjTzs1gF6NkpDpaidQ7cFlV16vdbdfi8uiZ+mQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0" + } + }, + "node_modules/@comunica/bus-http": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/@comunica/bus-http/-/bus-http-2.10.2.tgz", + "integrity": "sha512-MAYRF6uEBAuJ9dCPW2Uyne7w3lNwXFXKfa14XuPG5DFTDpgo/Z2pWupPrBsA1eIWMNJ6WOG6QyEv4rllSIBqlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/core": "^2.10.0", + "@smessie/readable-web-to-node-stream": "^3.0.3", + "is-stream": "^2.0.1", + "readable-stream-node-to-web": "^1.0.1", + "web-streams-ponyfill": "^1.4.2" + } + }, + "node_modules/@comunica/bus-http-invalidate": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/bus-http-invalidate/-/bus-http-invalidate-2.10.0.tgz", + "integrity": "sha512-9DevRUzuCOfHFtsryIvTU6rOz6vMbnuDzerloBoNsLFVzQCU4wPNZbxiOn0+GMDXxw7M3KgYd+KFxI2kGObVWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/core": "^2.10.0" + } + }, + "node_modules/@comunica/bus-init": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/bus-init/-/bus-init-2.10.0.tgz", + "integrity": "sha512-hJejHa8sLVhQLFlduCVnhOd5aW3FCEz8wmWjyeLI3kiHFaQibnGVMhUuuNRX5f8bnnPuTdEiHc1nnYHuSi+j8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/core": "^2.10.0", + "readable-stream": "^4.4.2" + } + }, + "node_modules/@comunica/bus-optimize-query-operation": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/bus-optimize-query-operation/-/bus-optimize-query-operation-2.10.0.tgz", + "integrity": "sha512-qawKJprbVc+dfjBgVzV45UEo+jZBzY3dRo0a8UkXSvgSWPcX18SGrURl2VL4sZZSAyXQBMrGUwH2eUD8l26ZJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/bus-query-operation": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/bus-query-operation/-/bus-query-operation-2.10.1.tgz", + "integrity": "sha512-PoUSJeKaMZtZu+ZtB+5ABjPOiW1YjxOdLE1N5znxX2oiDKCQHmAXVaVkbVx1jPDLGYFNcOlOSzpRMqLQ/L4JIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bindings-factory": "^2.10.1", + "@comunica/context-entries": "^2.10.0", + "@comunica/core": "^2.10.0", + "@comunica/data-factory": "^2.7.0", + "@comunica/types": "^2.10.0", + "@rdfjs/types": "*", + "asynciterator": "^3.8.1", + "rdf-string": "^1.6.1", + "rdf-terms": "^1.11.0", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/bus-query-parse": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/bus-query-parse/-/bus-query-parse-2.10.0.tgz", + "integrity": "sha512-1LynxACgCYTuBH/JMRG/IGaWtTVwr2O8wxOosCId2W3BDW9nf2DSCyOdnxnCSMSKfnLFWiaVuKybn24OLXW2dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/core": "^2.10.0", + "@rdfjs/types": "*", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/bus-query-result-serialize": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/bus-query-result-serialize/-/bus-query-result-serialize-2.10.0.tgz", + "integrity": "sha512-9P5KUzmXvjtLbd44UVxYNB0yqAHx7molBUc7aysUQ3pbIcP/A57GXzAfiKueeiZ9cVRRG/BGsVoDGVj59tGWNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/actor-abstract-mediatyped": "^2.10.0", + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0" + } + }, + "node_modules/@comunica/bus-rdf-join": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/bus-rdf-join/-/bus-rdf-join-2.10.1.tgz", + "integrity": "sha512-pPFoJVHY5p931jIKt+9sqRCGiuuf8yFqrlOOAd3un72cwuyhwNHvn52xwvcPlNUAySz/kDmW+U0syflqI6VdAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/bus-rdf-join-selectivity": "^2.10.0", + "@comunica/context-entries": "^2.10.0", + "@comunica/core": "^2.10.0", + "@comunica/mediatortype-join-coefficients": "^2.10.0", + "@comunica/metadata": "^2.10.0", + "@comunica/types": "^2.10.0", + "@rdfjs/types": "*", + "rdf-data-factory": "^1.1.1", + "rdf-string": "^1.6.1" + } + }, + "node_modules/@comunica/bus-rdf-join-entries-sort": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/bus-rdf-join-entries-sort/-/bus-rdf-join-entries-sort-2.10.0.tgz", + "integrity": "sha512-17FQrdYtzjY84OI/ZvipJKD0ei3IySmsWwaGC9sIJn+1W4LBVKudTu5S0tzGTKTb0URhS4mrCliUBzyINtIZMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0" + } + }, + "node_modules/@comunica/bus-rdf-join-selectivity": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/bus-rdf-join-selectivity/-/bus-rdf-join-selectivity-2.10.0.tgz", + "integrity": "sha512-YjoygSiH6r4SAYqz6gpvUql2vnznPVE62IsWqYnjFWeH1kBsxO5yEOO01s2FfN3jLcfsytTyG7VNTCN788YbaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/core": "^2.10.0", + "@comunica/mediatortype-accuracy": "^2.10.0", + "@comunica/types": "^2.10.0" + } + }, + "node_modules/@comunica/bus-rdf-metadata": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/bus-rdf-metadata/-/bus-rdf-metadata-2.10.0.tgz", + "integrity": "sha512-LRUnHVqIzyUlmPKPNAYOusCF53iN8KEX7l/VinlA7NH3XBLhTkFoth26MVqIVtjtdH0hVfUVpkwy2kFEJpGldw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/core": "^2.10.0", + "@rdfjs/types": "*" + } + }, + "node_modules/@comunica/bus-rdf-metadata-accumulate": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/bus-rdf-metadata-accumulate/-/bus-rdf-metadata-accumulate-2.10.0.tgz", + "integrity": "sha512-XG/3s4a3yGpYt4H+sn9T2zTaUxLG+37dmhRhXv2cBmR4gaCXkglERPaOrQygHldEF+4ITF3RmXHCgANsQ1AwQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0" + } + }, + "node_modules/@comunica/bus-rdf-metadata-extract": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/bus-rdf-metadata-extract/-/bus-rdf-metadata-extract-2.10.0.tgz", + "integrity": "sha512-KcMZh+7kHjdCIMkLFki99tQH1arVp/evVnk0BGXfWd+ca3eCLrr42tb1tGfN2JkaCSxgtzWO4DRZcSzJ4sI2dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/core": "^2.10.0", + "@rdfjs/types": "*" + } + }, + "node_modules/@comunica/bus-rdf-parse": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/bus-rdf-parse/-/bus-rdf-parse-2.10.0.tgz", + "integrity": "sha512-EgCMZACfTG/+mayQpExWt0HoBT32BBVC1aS1lC43fXKBTxJ8kYrSrorVUuMACoh4dQVGTb+7j1j4K0hGNVzXGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/actor-abstract-mediatyped": "^2.10.0", + "@comunica/actor-abstract-parse": "^2.10.0", + "@comunica/core": "^2.10.0", + "@rdfjs/types": "*" + } + }, + "node_modules/@comunica/bus-rdf-parse-html": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/bus-rdf-parse-html/-/bus-rdf-parse-html-2.10.0.tgz", + "integrity": "sha512-RZliz4TtKP63QggoohGuIkGb6lq0BoYJ4aztKtGldWtPAVP/pdEvlDpiZWLB/j19g7S2aDLNY/lJtZ5efM1tHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/core": "^2.10.0", + "@rdfjs/types": "*" + } + }, + "node_modules/@comunica/bus-rdf-resolve-hypermedia": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/bus-rdf-resolve-hypermedia/-/bus-rdf-resolve-hypermedia-2.10.0.tgz", + "integrity": "sha512-DjCoAg62pPzEOH5gKM9gaL4CVUmhBsmyOzao0tRu20G7L6RnTIFtRaOwMN2z+2uC7AkJRHZY12bPUb+yM8V0UQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-resolve-quad-pattern": "^2.10.0", + "@comunica/core": "^2.10.0", + "@rdfjs/types": "*" + } + }, + "node_modules/@comunica/bus-rdf-resolve-hypermedia-links": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/bus-rdf-resolve-hypermedia-links/-/bus-rdf-resolve-hypermedia-links-2.10.0.tgz", + "integrity": "sha512-Mcz6bUdZySLK2om0cMt86n5TOThZOTpEFq2M42n7YAE3LL2KMnMDdhkaOC6SyY4tS0HGAuhce21Uq+Gz8Veq2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0", + "@rdfjs/types": "*" + } + }, + "node_modules/@comunica/bus-rdf-resolve-hypermedia-links-queue": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/bus-rdf-resolve-hypermedia-links-queue/-/bus-rdf-resolve-hypermedia-links-queue-2.10.0.tgz", + "integrity": "sha512-f9amJk7ikktRfOoRnwag1KMTuo9v+PiDEVQA0dijl+jhcispKdjG6XK0MdZ1KSEmtUWejjS6nMRGvfJdM37eog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-resolve-hypermedia-links": "^2.10.0", + "@comunica/core": "^2.10.0" + } + }, + "node_modules/@comunica/bus-rdf-resolve-quad-pattern": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/bus-rdf-resolve-quad-pattern/-/bus-rdf-resolve-quad-pattern-2.10.0.tgz", + "integrity": "sha512-JEI4DqSprGmrbfmiIwc8PbS+HCoxXwmMtp7gDpoB1HyYKIHzzu9DOIiwmYEDRO5dwV+uTwaYKZz/mUPm2U6EEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/context-entries": "^2.10.0", + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0", + "@rdfjs/types": "*", + "asynciterator": "^3.8.1", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/bus-rdf-serialize": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/bus-rdf-serialize/-/bus-rdf-serialize-2.10.0.tgz", + "integrity": "sha512-AmbN9MUgw6B6AfrIqR1u7PWHZFgbJz+j1SFJVtnHQ51hEpG+Ig9nNG2IWjHOsFK0xBBQ/wXgNmt/cufEMRM1SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/actor-abstract-mediatyped": "^2.10.0", + "@comunica/core": "^2.10.0", + "@rdfjs/types": "*" + } + }, + "node_modules/@comunica/bus-rdf-update-hypermedia": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/@comunica/bus-rdf-update-hypermedia/-/bus-rdf-update-hypermedia-2.10.2.tgz", + "integrity": "sha512-GbRMxXN4kx+4UPsnGxWjyn770m675yy2gWK/xy/5qQIxxRTcuGk4wm/994FZQXpwLX1E0xJ+YKxMgXTIlEWmQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-update-quads": "^2.10.2", + "@comunica/core": "^2.10.0" + } + }, + "node_modules/@comunica/bus-rdf-update-quads": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/@comunica/bus-rdf-update-quads/-/bus-rdf-update-quads-2.10.2.tgz", + "integrity": "sha512-+iVpAHps8ytGq8AZF4xTZbLyskS40JPn64MO+OAuYovqXLlezp6vh9eJ5qETuP9NP+BpZDk3nOU3Ky3fb0QCUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/actor-rdf-resolve-quad-pattern-federated": "^2.10.1", + "@comunica/bus-http": "^2.10.2", + "@comunica/context-entries": "^2.10.0", + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0", + "@rdfjs/types": "*", + "asynciterator": "^3.8.1", + "stream-to-string": "^1.2.0" + } + }, + "node_modules/@comunica/config-query-sparql": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@comunica/config-query-sparql/-/config-query-sparql-2.7.0.tgz", + "integrity": "sha512-rMnFgT7cz9+0z7wV4OzIMY5qM9/Z0mTGrR8y2JokoHyyTcBGOSajFmy61XCSLMCsLLG8qDXsJ4ClCCky3TGfqA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@comunica/context-entries": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/context-entries/-/context-entries-2.10.0.tgz", + "integrity": "sha512-lmCYCcXxW8C6ecFH2whZCt31NT1ejb0P/sbytK7f4ctyA06Q8iYFEcYE4eWOXMdpfkwkcnz31x9XL77OGeSC2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0", + "@rdfjs/types": "*", + "jsonld-context-parser": "^2.2.2", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@comunica/core": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/core/-/core-2.10.0.tgz", + "integrity": "sha512-onsGs2iKHUPRxxMOdx42vdxslk8q9FQZdRjQtHJ6SGiCpJwIL9ciBgPIOl2RL2YfzXHemr/0umeNOppRDcWhJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/types": "^2.10.0", + "immutable": "^4.1.0" + }, + "engines": { + "node": ">=14.0" + } + }, + "node_modules/@comunica/data-factory": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@comunica/data-factory/-/data-factory-2.7.0.tgz", + "integrity": "sha512-dSTzrR1w9SzAWx70ZXKXHUC8f0leUolLZ9TOhGjFhhsBMJ9Pbo0g6vHV8txX5FViShngrg9QNKhsHeQnMk5z6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rdfjs/types": "*" + } + }, + "node_modules/@comunica/expression-evaluator": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/expression-evaluator/-/expression-evaluator-2.10.0.tgz", + "integrity": "sha512-gSfiVSAE+SaxpXq3jT5OnyZd+sD9KFaWtTiKT1tDDs8lD7Jj68aRP7VoEhvKwPwRlUx0aoaXUL2MYtV6JsXRbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rdfjs/types": "*", + "@types/spark-md5": "^3.0.2", + "@types/uuid": "^9.0.0", + "bignumber.js": "^9.0.1", + "hash.js": "^1.1.7", + "lru-cache": "^10.0.0", + "rdf-data-factory": "^1.1.2", + "rdf-string": "^1.6.3", + "relative-to-absolute-iri": "^1.0.6", + "spark-md5": "^3.0.1", + "sparqlalgebrajs": "^4.2.0", + "uuid": "^9.0.0" + } + }, + "node_modules/@comunica/logger-pretty": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/logger-pretty/-/logger-pretty-2.10.0.tgz", + "integrity": "sha512-JXkeM5HnbyTPnQTf5/ugRPL9R+vXT7b/hRVYzYmhAGCjkCNL7NJPTBbIgxmZHqZ+UGxprotrvmDQtwHmVA+Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/types": "^2.10.0", + "object-inspect": "^1.12.2", + "process": "^0.11.10" + } + }, + "node_modules/@comunica/logger-void": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/logger-void/-/logger-void-2.10.0.tgz", + "integrity": "sha512-GFJh9hV8rIC9yXAuLGGKjQRVs8IOQOINBbaTNO+FJUWWWHlo5pDEKAoGYuysz5TBGoT3Lexz8bMfdkuHMa3uIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/types": "^2.10.0" + } + }, + "node_modules/@comunica/mediator-all": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/mediator-all/-/mediator-all-2.10.0.tgz", + "integrity": "sha512-y1+A+sIW462G8iPzi6BSPIb4I9iy08ZruM2Thf1or6sytwLKro7E2RYjS6IdupwfFYafXXCeT85+lrJgTKERhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/core": "^2.10.0" + } + }, + "node_modules/@comunica/mediator-combine-pipeline": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/mediator-combine-pipeline/-/mediator-combine-pipeline-2.10.0.tgz", + "integrity": "sha512-j7+/oUlbhKB4Rq6g9oNKU+e9cQL8U9z8tAUNhoXUSHajcr4huj0t1+riaOD109/DRWhV793ILhBDzgiZbHd7DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/core": "^2.10.0", + "@comunica/types": "^2.10.0" + } + }, + "node_modules/@comunica/mediator-combine-union": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/mediator-combine-union/-/mediator-combine-union-2.10.0.tgz", + "integrity": "sha512-QbP4zP1i6nMDZ8teC0RoTz5E8pOpxDhWPBr1ylb2jzPUjPpMgrnbHYTondlN0Oau3SMEehItojg/LYDtPOP/GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/core": "^2.10.0" + } + }, + "node_modules/@comunica/mediator-join-coefficients-fixed": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@comunica/mediator-join-coefficients-fixed/-/mediator-join-coefficients-fixed-2.10.1.tgz", + "integrity": "sha512-HRvc0e8QDnR3sbRMMCyx9ILFA6KiUxHEqDOpt7BV3kFMWWIpBavFDwPUjLBG6sRA8o0CFu1+oVVh5fAFYZIxzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-rdf-join": "^2.10.1", + "@comunica/context-entries": "^2.10.0", + "@comunica/core": "^2.10.0", + "@comunica/mediatortype-join-coefficients": "^2.10.0", + "@comunica/types": "^2.10.0" + } + }, + "node_modules/@comunica/mediator-number": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/mediator-number/-/mediator-number-2.10.0.tgz", + "integrity": "sha512-0T8D1HGTu5Sd8iKb2dBjc6VRc/U4A15TAN6m561ra9pFlP+w31kby0ZYP6WWBHBobbUsX1LCvnbRQaAC4uWwVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/core": "^2.10.0" + } + }, + "node_modules/@comunica/mediator-race": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/mediator-race/-/mediator-race-2.10.0.tgz", + "integrity": "sha512-JiEtOLMkPnbjSLabVpE4VqDbu2ZKKnkUdATGBeWX+o+MjPw6c0hhw01RG4WY2rQhDyNl++nLQe3EowQh8xW9TA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/core": "^2.10.0" + } + }, + "node_modules/@comunica/mediatortype-accuracy": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/mediatortype-accuracy/-/mediatortype-accuracy-2.10.0.tgz", + "integrity": "sha512-u9Noai4yGACaBRGOoRZ65XoQhazKNx5QaFOX5nJ/p84Qq4g50woC2rpsncuyrXhW1j/rIc2WvIUGUfy/g6CDiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/core": "^2.10.0" + } + }, + "node_modules/@comunica/mediatortype-httprequests": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/mediatortype-httprequests/-/mediatortype-httprequests-2.10.0.tgz", + "integrity": "sha512-uPjs/NdngHZZWomjZor6W29UeOlxganupIOa3Z6H3qdUnsSpxeoS9URXy7BICAX+4PmgebperSn18BRA+PWiSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/core": "^2.10.0" + } + }, + "node_modules/@comunica/mediatortype-join-coefficients": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/mediatortype-join-coefficients/-/mediatortype-join-coefficients-2.10.0.tgz", + "integrity": "sha512-EPipAV5PDNeEVXbsd+8NsqNKu5ztCAoEJ3azcFAmD9di9ppArNJWU/mxy5yUzcBgMUX4wRp6jCa5rIF5sRHG7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/core": "^2.10.0", + "@rdfjs/types": "*" + } + }, + "node_modules/@comunica/mediatortype-time": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/mediatortype-time/-/mediatortype-time-2.10.0.tgz", + "integrity": "sha512-nBz1exxrja1Tj8KSlSevG4Hw2u09tTh6gtNfVjI76i/e7muu4RUWVhi9b8PcwBNAfuUqRl+5OgOSa2X4W+6QlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/core": "^2.10.0" + } + }, + "node_modules/@comunica/metadata": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/metadata/-/metadata-2.10.0.tgz", + "integrity": "sha512-PF7TKhuDIO4GE9tzuAkTxarQV5cmwXZ64hp0qm8Ql/V+dVHu/3xLL9v/Q67ZX26GF9hOyr7cdpNI08M7DHc86g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/types": "^2.10.0" + } + }, + "node_modules/@comunica/query-sparql": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/@comunica/query-sparql/-/query-sparql-2.10.2.tgz", + "integrity": "sha512-bgjQ8N5/vP3Iy71AgDKQc06mXmEBvh7dsenw2VPbvk11iXywec4XCq8TzX+GozL+Zxxl5XyYlBw+nRjvORTGHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/actor-context-preprocess-source-to-destination": "^2.10.0", + "@comunica/actor-dereference-fallback": "^2.10.0", + "@comunica/actor-dereference-http": "^2.10.2", + "@comunica/actor-dereference-rdf-parse": "^2.10.0", + "@comunica/actor-hash-bindings-sha1": "^2.10.0", + "@comunica/actor-http-fetch": "^2.10.2", + "@comunica/actor-http-proxy": "^2.10.2", + "@comunica/actor-http-wayback": "^2.10.2", + "@comunica/actor-init-query": "^2.10.2", + "@comunica/actor-optimize-query-operation-bgp-to-join": "^2.10.0", + "@comunica/actor-optimize-query-operation-join-bgp": "^2.10.0", + "@comunica/actor-optimize-query-operation-join-connected": "^2.10.0", + "@comunica/actor-query-operation-ask": "^2.10.1", + "@comunica/actor-query-operation-bgp-join": "^2.10.1", + "@comunica/actor-query-operation-construct": "^2.10.1", + "@comunica/actor-query-operation-describe-subject": "^2.10.1", + "@comunica/actor-query-operation-distinct-hash": "^2.10.1", + "@comunica/actor-query-operation-extend": "^2.10.1", + "@comunica/actor-query-operation-filter-sparqlee": "^2.10.1", + "@comunica/actor-query-operation-from-quad": "^2.10.1", + "@comunica/actor-query-operation-group": "^2.10.1", + "@comunica/actor-query-operation-join": "^2.10.1", + "@comunica/actor-query-operation-leftjoin": "^2.10.1", + "@comunica/actor-query-operation-minus": "^2.10.1", + "@comunica/actor-query-operation-nop": "^2.10.1", + "@comunica/actor-query-operation-orderby-sparqlee": "^2.10.1", + "@comunica/actor-query-operation-path-alt": "^2.10.1", + "@comunica/actor-query-operation-path-inv": "^2.10.1", + "@comunica/actor-query-operation-path-link": "^2.10.1", + "@comunica/actor-query-operation-path-nps": "^2.10.1", + "@comunica/actor-query-operation-path-one-or-more": "^2.10.1", + "@comunica/actor-query-operation-path-seq": "^2.10.1", + "@comunica/actor-query-operation-path-zero-or-more": "^2.10.1", + "@comunica/actor-query-operation-path-zero-or-one": "^2.10.1", + "@comunica/actor-query-operation-project": "^2.10.1", + "@comunica/actor-query-operation-quadpattern": "^2.10.1", + "@comunica/actor-query-operation-reduced-hash": "^2.10.1", + "@comunica/actor-query-operation-service": "^2.10.1", + "@comunica/actor-query-operation-slice": "^2.10.1", + "@comunica/actor-query-operation-sparql-endpoint": "^2.10.2", + "@comunica/actor-query-operation-union": "^2.10.1", + "@comunica/actor-query-operation-update-add-rewrite": "^2.10.1", + "@comunica/actor-query-operation-update-clear": "^2.10.2", + "@comunica/actor-query-operation-update-compositeupdate": "^2.10.1", + "@comunica/actor-query-operation-update-copy-rewrite": "^2.10.1", + "@comunica/actor-query-operation-update-create": "^2.10.2", + "@comunica/actor-query-operation-update-deleteinsert": "^2.10.2", + "@comunica/actor-query-operation-update-drop": "^2.10.2", + "@comunica/actor-query-operation-update-load": "^2.10.2", + "@comunica/actor-query-operation-update-move-rewrite": "^2.10.1", + "@comunica/actor-query-operation-values": "^2.10.1", + "@comunica/actor-query-parse-graphql": "^2.10.0", + "@comunica/actor-query-parse-sparql": "^2.10.0", + "@comunica/actor-query-result-serialize-json": "^2.10.0", + "@comunica/actor-query-result-serialize-rdf": "^2.10.0", + "@comunica/actor-query-result-serialize-simple": "^2.10.0", + "@comunica/actor-query-result-serialize-sparql-csv": "^2.10.0", + "@comunica/actor-query-result-serialize-sparql-json": "^2.10.2", + "@comunica/actor-query-result-serialize-sparql-tsv": "^2.10.0", + "@comunica/actor-query-result-serialize-sparql-xml": "^2.10.0", + "@comunica/actor-query-result-serialize-stats": "^2.10.2", + "@comunica/actor-query-result-serialize-table": "^2.10.0", + "@comunica/actor-query-result-serialize-tree": "^2.10.0", + "@comunica/actor-rdf-join-entries-sort-cardinality": "^2.10.0", + "@comunica/actor-rdf-join-inner-hash": "^2.10.1", + "@comunica/actor-rdf-join-inner-multi-bind": "^2.10.1", + "@comunica/actor-rdf-join-inner-multi-empty": "^2.10.1", + "@comunica/actor-rdf-join-inner-multi-smallest": "^2.10.1", + "@comunica/actor-rdf-join-inner-nestedloop": "^2.10.1", + "@comunica/actor-rdf-join-inner-none": "^2.10.1", + "@comunica/actor-rdf-join-inner-single": "^2.10.1", + "@comunica/actor-rdf-join-inner-symmetrichash": "^2.10.1", + "@comunica/actor-rdf-join-minus-hash": "^2.10.1", + "@comunica/actor-rdf-join-minus-hash-undef": "^2.10.1", + "@comunica/actor-rdf-join-optional-bind": "^2.10.1", + "@comunica/actor-rdf-join-optional-nestedloop": "^2.10.1", + "@comunica/actor-rdf-join-selectivity-variable-counting": "^2.10.0", + "@comunica/actor-rdf-metadata-accumulate-cancontainundefs": "^2.10.0", + "@comunica/actor-rdf-metadata-accumulate-cardinality": "^2.10.0", + "@comunica/actor-rdf-metadata-accumulate-pagesize": "^2.10.0", + "@comunica/actor-rdf-metadata-accumulate-requesttime": "^2.10.0", + "@comunica/actor-rdf-metadata-all": "^2.10.0", + "@comunica/actor-rdf-metadata-extract-allow-http-methods": "^2.10.0", + "@comunica/actor-rdf-metadata-extract-hydra-controls": "^2.10.0", + "@comunica/actor-rdf-metadata-extract-hydra-count": "^2.10.0", + "@comunica/actor-rdf-metadata-extract-hydra-pagesize": "^2.10.0", + "@comunica/actor-rdf-metadata-extract-patch-sparql-update": "^2.10.0", + "@comunica/actor-rdf-metadata-extract-put-accepted": "^2.10.0", + "@comunica/actor-rdf-metadata-extract-request-time": "^2.10.0", + "@comunica/actor-rdf-metadata-extract-sparql-service": "^2.10.0", + "@comunica/actor-rdf-metadata-primary-topic": "^2.10.0", + "@comunica/actor-rdf-parse-html": "^2.10.0", + "@comunica/actor-rdf-parse-html-microdata": "^2.10.0", + "@comunica/actor-rdf-parse-html-rdfa": "^2.10.0", + "@comunica/actor-rdf-parse-html-script": "^2.10.0", + "@comunica/actor-rdf-parse-jsonld": "^2.10.2", + "@comunica/actor-rdf-parse-n3": "^2.10.0", + "@comunica/actor-rdf-parse-rdfxml": "^2.10.0", + "@comunica/actor-rdf-parse-shaclc": "^2.10.0", + "@comunica/actor-rdf-parse-xml-rdfa": "^2.10.0", + "@comunica/actor-rdf-resolve-hypermedia-links-next": "^2.10.0", + "@comunica/actor-rdf-resolve-hypermedia-links-queue-fifo": "^2.10.0", + "@comunica/actor-rdf-resolve-hypermedia-none": "^2.10.0", + "@comunica/actor-rdf-resolve-hypermedia-qpf": "^2.10.0", + "@comunica/actor-rdf-resolve-hypermedia-sparql": "^2.10.2", + "@comunica/actor-rdf-resolve-quad-pattern-federated": "^2.10.1", + "@comunica/actor-rdf-resolve-quad-pattern-hypermedia": "^2.10.1", + "@comunica/actor-rdf-resolve-quad-pattern-rdfjs-source": "^2.10.0", + "@comunica/actor-rdf-resolve-quad-pattern-string-source": "^2.10.0", + "@comunica/actor-rdf-serialize-jsonld": "^2.10.0", + "@comunica/actor-rdf-serialize-n3": "^2.10.0", + "@comunica/actor-rdf-serialize-shaclc": "^2.10.0", + "@comunica/actor-rdf-update-hypermedia-patch-sparql-update": "^2.10.2", + "@comunica/actor-rdf-update-hypermedia-put-ldp": "^2.10.2", + "@comunica/actor-rdf-update-hypermedia-sparql": "^2.10.2", + "@comunica/actor-rdf-update-quads-hypermedia": "^2.10.2", + "@comunica/actor-rdf-update-quads-rdfjs-store": "^2.10.2", + "@comunica/bus-http-invalidate": "^2.10.0", + "@comunica/bus-query-operation": "^2.10.1", + "@comunica/config-query-sparql": "^2.7.0", + "@comunica/core": "^2.10.0", + "@comunica/logger-void": "^2.10.0", + "@comunica/mediator-all": "^2.10.0", + "@comunica/mediator-combine-pipeline": "^2.10.0", + "@comunica/mediator-combine-union": "^2.10.0", + "@comunica/mediator-join-coefficients-fixed": "^2.10.1", + "@comunica/mediator-number": "^2.10.0", + "@comunica/mediator-race": "^2.10.0", + "@comunica/runner": "^2.10.0", + "@comunica/runner-cli": "^2.10.0", + "@comunica/types": "^2.10.0", + "process": "^0.11.10" + }, + "bin": { + "comunica-dynamic-sparql": "bin/query-dynamic.js", + "comunica-sparql": "bin/query.js", + "comunica-sparql-http": "bin/http.js" + } + }, + "node_modules/@comunica/runner": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/runner/-/runner-2.10.0.tgz", + "integrity": "sha512-v/oEKT+IwjO6Y74bCCzlR+ZMI6oykpfz7GQrQbl1oTWQsvBbTdf0omPkoYnk1esEAsFnsJD+NGwAiRiFKeBo0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/bus-init": "^2.10.0", + "@comunica/core": "^2.10.0", + "componentsjs": "^5.3.2", + "process": "^0.11.10" + }, + "bin": { + "comunica-compile-config": "bin/compile-config" + } + }, + "node_modules/@comunica/runner-cli": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/runner-cli/-/runner-cli-2.10.0.tgz", + "integrity": "sha512-16QI0rWFHURCy5waVFcZ/fhKI/hyzNx5YyCGPaEaUX8MKyamvCCXHSWvPLLbjJbsjGZ9wXrC9dwwhRmbfmidpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/core": "^2.10.0", + "@comunica/runner": "^2.10.0", + "@comunica/types": "^2.10.0", + "process": "^0.11.10" + }, + "bin": { + "comunica-run": "bin/run.js" + } + }, + "node_modules/@comunica/types": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@comunica/types/-/types-2.10.0.tgz", + "integrity": "sha512-1UjPGbZcYrapBjMGUZedrIGcn9rOLpEOlJo1ZkWddFUGTwndVg9d4BZnQw+UnQzXMcLJcdKt94Zns8iEmBqARw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rdfjs/types": "*", + "@types/yargs": "^17.0.24", + "asynciterator": "^3.8.1", + "sparqlalgebrajs": "^4.2.0" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", + "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@so-ric/colorspace": "^1.1.6", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, + "node_modules/@discoveryjs/json-ext": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.6.3.tgz", + "integrity": "sha512-4B4OijXeVNOPZlYA2oEwWOTkzyltLao+xbotHQeqN++Rv27Y6s818+n2Qkp8q+Fxhn0t/5lA5X1Mxktud8eayQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.17.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@ioredis/commands": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", + "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/ttlcache": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz", + "integrity": "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/@jeswr/prefixcc": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jeswr/prefixcc/-/prefixcc-1.2.1.tgz", + "integrity": "sha512-kBBXbqsaeh3Irp416h/RbelqJgIOp6X/OJJlYmLyr/9qlBYKTKSCuEv5/xjZ0Yf8Yec+QFRYBaOQ2JkMBSH7KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-fetch": "^3.1.5" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/@jeswr/prefixcc/node_modules/cross-fetch": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz", + "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/gen-mapping/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/source-map/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@koa/cors": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@koa/cors/-/cors-5.0.0.tgz", + "integrity": "sha512-x/iUDjcS90W69PryLDIMgFyV21YLTnG9zOpPXS7Bkt2b8AsY3zZsIpOLBkYr9fBcF3HbkKaER5hOBZLfpLgYNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@koa/router": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/@koa/router/-/router-13.1.1.tgz", + "integrity": "sha512-JQEuMANYRVHs7lm7KY9PCIjkgJk73h4m4J+g2mkw2Vo1ugPZ17UJVqEH8F+HeAdjKz5do1OaLe7ArDz+z308gw==", + "deprecated": "Please upgrade to v15 or higher. All reported bugs in this version are fixed in newer releases, dependencies have been updated, and security has been improved.", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.1", + "http-errors": "^2.0.0", + "koa-compose": "^4.1.0", + "path-to-regexp": "^6.3.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@rdfjs/data-model": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@rdfjs/data-model/-/data-model-1.3.4.tgz", + "integrity": "sha512-iKzNcKvJotgbFDdti7GTQDCYmL7GsGldkYStiP0K8EYtN7deJu5t7U11rKTz+nR7RtesUggT+lriZ7BakFv8QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rdfjs/types": ">=1.0.1" + }, + "bin": { + "rdfjs-data-model-test": "bin/test.js" + } + }, + "node_modules/@rdfjs/dataset": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@rdfjs/dataset/-/dataset-1.1.1.tgz", + "integrity": "sha512-BNwCSvG0cz0srsG5esq6CQKJc1m8g/M0DZpLuiEp0MMpfwguXX7VeS8TCg4UUG3DV/DqEvhy83ZKSEjdsYseeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rdfjs/data-model": "^1.2.0" + }, + "bin": { + "rdfjs-dataset-test": "bin/test.js" + } + }, + "node_modules/@rdfjs/namespace": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rdfjs/namespace/-/namespace-1.1.0.tgz", + "integrity": "sha512-utO5rtaOKxk8B90qzaQ0N+J5WrCI28DtfAY/zExCmXE7cOfC5uRI/oMKbLaVEPj2P7uArekt/T4IPATtj7Tjug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rdfjs/data-model": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@rdfjs/term-set": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rdfjs/term-set/-/term-set-1.1.0.tgz", + "integrity": "sha512-QQ4yzVe1Rvae/GN9SnOhweHNpaxQtnAjeOVciP/yJ0Gfxtbphy2tM56ZsRLV04Qq5qMcSclZIe6irYyEzx/UwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rdfjs/to-ntriples": "^2.0.0" + } + }, + "node_modules/@rdfjs/to-ntriples": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@rdfjs/to-ntriples/-/to-ntriples-2.0.0.tgz", + "integrity": "sha512-nDhpfhx6W6HKsy4HjyLp3H1nbrX1CiUCWhWQwKcYZX1s9GOjcoQTwY7GUUbVec0hzdJDQBR6gnjxtENBDt482Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rdfjs/types": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rdfjs/types/-/types-1.1.2.tgz", + "integrity": "sha512-wqpOJK1QCbmsGNtyzYnojPU8gRDPid2JO0Q0kMtb4j65xhCK880cnKAfEOwC+dX85VJcCByQx5zOwyyfCjDJsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@rubensworks/saxes": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@rubensworks/saxes/-/saxes-6.0.1.tgz", + "integrity": "sha512-UW4OTIsOtJ5KSXo2Tchi4lhZqu+tlHrOAs4nNti7CrtB53kAZl3/hyrTi6HkMihxdbDM6m2Zc3swc/ZewEe1xw==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.12" + } + }, + "node_modules/@sindresorhus/is": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-5.6.0.tgz", + "integrity": "sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", + "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@smessie/readable-web-to-node-stream": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@smessie/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.3.tgz", + "integrity": "sha512-8FFE7psRtRWQT31/duqbmgnSf2++QLR2YH9kj5iwsHhnoqSvHdOY3SAN5e7dhc+60p2cNk7rv3HYOiXOapTEXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "process": "^0.11.10", + "readable-stream": "^4.5.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@so-ric/colorspace": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", + "integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "color": "^5.0.2", + "text-hex": "1.0.x" + } + }, + "node_modules/@solid/access-control-policy": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@solid/access-control-policy/-/access-control-policy-0.1.3.tgz", + "integrity": "sha512-LTxfN8N5hNBNYfuwJr0nyfxlp2P0+GeK+biCa1FQgIqska3wXpTgYaxjVgsw27mKx4N1FOlaGwG+nXdLnl9ykg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@solid/access-token-verifier": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@solid/access-token-verifier/-/access-token-verifier-2.1.1.tgz", + "integrity": "sha512-dGvu4xk3P74xl6XYsULWBY8F/fNCoi/EK07Iv3RT+0vDA21+3VEKk72sWmki0W59qJRTRDQZXkCOyo4b5Hd7zA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jose": "^5.1.3", + "lru-cache": "^6.0.0", + "n3": "^1.17.1", + "node-fetch": "^2.7.0", + "ts-guards": "^0.5.1" + } + }, + "node_modules/@solid/access-token-verifier/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@solid/community-server": { + "version": "7.1.9", + "resolved": "https://registry.npmjs.org/@solid/community-server/-/community-server-7.1.9.tgz", + "integrity": "sha512-q3DmqoOyhMw0AGOMBDZCFTd4TjOltqQ6WFPa++aEqzgAdzeKvX/WiD317Y/OlNHAc146ieRAxx1Vd0PHDbF3oQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/context-entries": "^2.8.2", + "@comunica/query-sparql": "^2.9.0", + "@isaacs/ttlcache": "^1.4.1", + "@rdfjs/types": "^1.1.0", + "@solid/access-control-policy": "^0.1.3", + "@solid/access-token-verifier": "^2.1.1", + "@types/async-lock": "^1.4.0", + "@types/bcryptjs": "^2.4.4", + "@types/cookie": "^0.5.2", + "@types/cors": "^2.8.14", + "@types/ejs": "^3.1.3", + "@types/end-of-stream": "^1.4.2", + "@types/fs-extra": "^11.0.2", + "@types/lodash.orderby": "^4.6.7", + "@types/mime-types": "^2.1.2", + "@types/n3": "^1.16.3", + "@types/node": "^18.18.4", + "@types/nodemailer": "^6.4.11", + "@types/oidc-provider": "^8.4.0", + "@types/proper-lockfile": "^4.1.2", + "@types/pump": "^1.1.1", + "@types/punycode": "^2.1.0", + "@types/rdf-validate-shacl": "^0.4.4", + "@types/sparqljs": "^3.1.6", + "@types/url-join": "^4.0.1", + "@types/uuid": "^9.0.5", + "@types/ws": "^8.5.7", + "@types/yargs": "^17.0.28", + "arrayify-stream": "^2.0.1", + "async-lock": "^1.4.0", + "bcryptjs": "^2.4.3", + "componentsjs": "^5.5.1", + "cookie": "^0.7.0", + "cors": "^2.8.5", + "cross-fetch": "^4.0.0", + "ejs": "^3.1.9", + "end-of-stream": "^1.4.4", + "escape-string-regexp": "^4.0.0", + "eslint": "^9.35.0", + "fetch-sparql-endpoint": "^6.0.0", + "fs-extra": "^11.1.1", + "handlebars": "^4.7.8", + "ioredis": "^5.3.2", + "iso8601-duration": "^2.1.1", + "jose": "^4.15.2", + "jsonld-context-parser": "^2.3.2", + "lodash.orderby": "^4.6.0", + "marked": "^9.1.0", + "mime-types": "^2.1.35", + "n3": "^1.17.1", + "nodemailer": "^7.0.7", + "oidc-provider": "^8.4.0", + "proper-lockfile": "^4.1.2", + "pump": "^3.0.0", + "punycode": "^2.3.0", + "rdf-dereference": "^2.2.0", + "rdf-parse": "^2.3.2", + "rdf-serialize": "^2.2.2", + "rdf-string": "^1.6.3", + "rdf-terms": "^1.11.0", + "rdf-validate-shacl": "^0.4.5", + "sparqlalgebrajs": "^4.3.0", + "sparqljs": "^3.7.1", + "url-join": "^4.0.1", + "uuid": "^9.0.1", + "winston": "^3.11.0", + "winston-transport": "^4.5.0", + "ws": "^8.14.2", + "yargs": "^17.7.2", + "yup": "^1.3.2" + }, + "bin": { + "community-solid-server": "bin/server.js" + }, + "engines": { + "node": ">=18.0" + } + }, + "node_modules/@solid/community-server/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@solid/community-server/node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@solid/community-server/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@szmarczak/http-timer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz", + "integrity": "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.1" + }, + "engines": { + "node": ">=14.16" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/accepts": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@types/accepts/-/accepts-1.3.7.tgz", + "integrity": "sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/async-lock": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@types/async-lock/-/async-lock-1.4.2.tgz", + "integrity": "sha512-HlZ6Dcr205BmNhwkdXqrg2vkFMN2PluI7Lgr8In3B3wE5PiQHhjRqtW/lGdVU9gw+sM0JcIDx2AN+cW8oSWIcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/chrome": { + "version": "0.0.287", + "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.287.tgz", + "integrity": "sha512-wWhBNPNXZHwycHKNYnexUcpSbrihVZu++0rdp6GEk5ZgAglenLx+RwdEouh6FrHS0XQiOxSd62yaujM1OoQlZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/filesystem": "*", + "@types/har-format": "*" + } + }, + "node_modules/@types/clownface": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@types/clownface/-/clownface-2.0.10.tgz", + "integrity": "sha512-Vz48oQux0YArQ66wfRp54NlxvEmpyTqbFIH435AsgN7C+p4MXao/rjXUisULL6436bxjFk4VluZr7J2HQkBHmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rdfjs/types": ">=1", + "@types/rdfjs__environment": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/content-disposition": { + "version": "0.5.9", + "resolved": "https://registry.npmjs.org/@types/content-disposition/-/content-disposition-0.5.9.tgz", + "integrity": "sha512-8uYXI3Gw35MhiVYhG3s295oihrxRyytcRHjSjqnqZVDDy/xcGBRny7+Xj1Wgfhv5QzRtN2hB2dVRBUX9XW3UcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/cookie": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.5.4.tgz", + "integrity": "sha512-7z/eR6O859gyWIAjuvBWFzNURmf2oPBmJlfVWkwehU5nzIyjwBsTh7WMmEEV4JFnHuQ3ex4oyTvfKzcyJVDBNA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/cookies": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@types/cookies/-/cookies-0.9.2.tgz", + "integrity": "sha512-1AvkDdZM2dbyFybL4fxpuNCaWyv//0AwsuUk2DWeXyM1/5ZKm6W3z6mQi24RZ4l2ucY+bkSHzbDVpySqPGuV8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/express": "*", + "@types/keygrip": "*", + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ejs": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@types/ejs/-/ejs-3.1.5.tgz", + "integrity": "sha512-nv+GSx77ZtXiJzwKdsASqi+YQ5Z7vwHsTP0JY2SiQgjGckkBRKZnk8nIM+7oUZ1VCtuTz0+By4qVR7fqzp/Dfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/@types/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-StWAwZWMI5cK5wBKJHK/0MBJaZKMlN78EeDhBhBz6eEK51StnQzwERHG438/ToRJ/2CGaBW8TpyYxjkB1v9whA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/filesystem": { + "version": "0.0.36", + "resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz", + "integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/filewriter": "*" + } + }, + "node_modules/@types/filewriter": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz", + "integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/fs-extra": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-11.0.4.tgz", + "integrity": "sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/jsonfile": "*", + "@types/node": "*" + } + }, + "node_modules/@types/har-format": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz", + "integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/http-assert": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.6.tgz", + "integrity": "sha512-TTEwmtjgVbYAzZYWyeHPrrtWnfVkm8tQkP8P21uQifPgMRgjrow3XDEYqucuC8SKZJT7pUnhU/JymvjggxO9vw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/http-link-header": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/http-link-header/-/http-link-header-1.0.7.tgz", + "integrity": "sha512-snm5oLckop0K3cTDAiBnZDy6ncx9DJ3mCRDvs42C884MbVYPP74Tiq2hFsSDRTyjK6RyDYDIulPiW23ge+g5Lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsonfile": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/@types/jsonfile/-/jsonfile-6.1.4.tgz", + "integrity": "sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/keygrip": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.6.tgz", + "integrity": "sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/koa": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/koa/-/koa-3.0.2.tgz", + "integrity": "sha512-7TRzVOBcH/q8CfPh9AmHBQ8TZtimT4Sn+rw8//hXveI6+F41z93W8a+0B0O8L7apKQv+vKBIEZSECiL0Oo1JFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/accepts": "*", + "@types/content-disposition": "*", + "@types/cookies": "*", + "@types/http-assert": "*", + "@types/http-errors": "^2", + "@types/keygrip": "*", + "@types/koa-compose": "*", + "@types/node": "*" + } + }, + "node_modules/@types/koa-compose": { + "version": "3.2.9", + "resolved": "https://registry.npmjs.org/@types/koa-compose/-/koa-compose-3.2.9.tgz", + "integrity": "sha512-BroAZ9FTvPiCy0Pi8tjD1OfJ7bgU1gQf0eR6e1Vm+JJATy9eKOG3hQMFtMciMawiSOVnLMdmUOC46s7HBhSTsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/koa": "*" + } + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash.orderby": { + "version": "4.6.9", + "resolved": "https://registry.npmjs.org/@types/lodash.orderby/-/lodash.orderby-4.6.9.tgz", + "integrity": "sha512-T9o2wkIJOmxXwVTPTmwJ59W6eTi2FseiLR369fxszG649Po/xe9vqFNhf/MtnvT5jrbDiyWKxPFPZbpSVK0SVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/mime-types": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.4.tgz", + "integrity": "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/n3": { + "version": "1.26.1", + "resolved": "https://registry.npmjs.org/@types/n3/-/n3-1.26.1.tgz", + "integrity": "sha512-TilYHzpU6ecXVJAbV+6o17Z8ZkWLWx6ZJD3IluaU4RiGHxqjU2or9fopxFHS6iXS6qcl5Mg1K3wSx9L8xxJaJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rdfjs/types": "*", + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "25.5.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", + "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/nodemailer": { + "version": "6.4.23", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.23.tgz", + "integrity": "sha512-aFV3/NsYFLSx9mbb5gtirBSXJnAlrusoKNuPbxsASWc7vrKLmIrTQRpdcxNcSFL3VW2A2XpeLEavwb2qMi6nlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/oidc-provider": { + "version": "8.8.1", + "resolved": "https://registry.npmjs.org/@types/oidc-provider/-/oidc-provider-8.8.1.tgz", + "integrity": "sha512-Yi/OJ7s0CFJ1AWAQrY2EO/zkV9uppLtiGAzrA07lBDveUOvxtYh7GflnHFXcgufVaPxVAjdykizjTYTMNVhdJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/keygrip": "*", + "@types/koa": "*", + "@types/node": "*" + } + }, + "node_modules/@types/proper-lockfile": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@types/proper-lockfile/-/proper-lockfile-4.1.4.tgz", + "integrity": "sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/retry": "*" + } + }, + "node_modules/@types/pump": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@types/pump/-/pump-1.1.3.tgz", + "integrity": "sha512-ZyooTTivmOwPfOwLVaszkF8Zq6mvavgjuHYitZhrIjfQAJDH+kIP3N+MzpG1zDAslsHvVz6Q8ECfivix3qLJaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/punycode": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@types/punycode/-/punycode-2.1.4.tgz", + "integrity": "sha512-trzh6NzBnq8yw5e35f8xe8VTYjqM3NE7bohBtvDVf/dtUer3zYTLK1Ka3DG3p7bdtoaOHZucma6FfVKlQ134pQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/rdf-validate-shacl": { + "version": "0.4.9", + "resolved": "https://registry.npmjs.org/@types/rdf-validate-shacl/-/rdf-validate-shacl-0.4.9.tgz", + "integrity": "sha512-mjWwr+/7p2NPmJThB0nS1N7HWTrPAP5MFOVjEChiy2/e9mNH7WxtkMAEro00Ew/prcf6pw5ke1dzq/vkhBh7+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rdfjs/types": "*", + "@types/clownface": "*", + "@types/node": "*" + } + }, + "node_modules/@types/rdfjs__environment": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@types/rdfjs__environment/-/rdfjs__environment-1.0.0.tgz", + "integrity": "sha512-MDcnv3qfJvbHoEpUQXj5muT8g3e+xz1D8sGevrq3+Q4TzeEvQf5ijGX5l8485XFYrN/OBApgzXkHMZC04/kd5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rdfjs/types": "*", + "@types/node": "*" + } + }, + "node_modules/@types/readable-stream": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.23.tgz", + "integrity": "sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/retry": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.5.tgz", + "integrity": "sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/spark-md5": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/spark-md5/-/spark-md5-3.0.5.tgz", + "integrity": "sha512-lWf05dnD42DLVKQJZrDHtWFidcLrHuip01CtnC2/S6AMhX4t9ZlEUj4iuRlAnts0PQk7KESOqKxeGE/b6sIPGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/sparqljs": { + "version": "3.1.12", + "resolved": "https://registry.npmjs.org/@types/sparqljs/-/sparqljs-3.1.12.tgz", + "integrity": "sha512-zg/sdKKtYI0845wKPSuSgunyU1o/+7tRzMw85lHsf4p/0UbA6+65MXAyEtv1nkaqSqrq/bXm7+bqXas+Xo5dpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rdfjs/types": ">=1.0.0" + } + }, + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/uritemplate": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@types/uritemplate/-/uritemplate-0.3.6.tgz", + "integrity": "sha512-31BMGZ8GgLxgXxLnqg4KbbyYJjU1flhTTD2+PVQStVUPXSk0IIpK0zt+tH3eLT7ZRwLnzQw6JhYx69qza3U0wg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/url-join": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/url-join/-/url-join-4.0.3.tgz", + "integrity": "sha512-3l1qMm3wqO0iyC5gkADzT95UVW7C/XXcdvUcShOideKF0ddgVRErEQQJXBd2kvQm+aSgqhBGHGB38TgMeT57Ww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/uuid": { + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", + "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@uvdsl/solid-oidc-client-browser": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@uvdsl/solid-oidc-client-browser/-/solid-oidc-client-browser-0.2.2.tgz", + "integrity": "sha512-JhcfSPu+eVyPMl2Dz46jq9ZHZwfZSqzCrQiHkvFZyam9ZEGXmLF1QJs4O+MddiEJaF5rVeEPd20YWprp5drLKw==", + "license": "MIT", + "dependencies": { + "jose": "^5.9.6" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webpack-cli/configtest": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-3.0.1.tgz", + "integrity": "sha512-u8d0pJ5YFgneF/GuvEiDA61Tf1VDomHHYMjv/wc9XzYj7nopltpG96nXN5dJRstxZhcNpV1g+nT6CydO7pHbjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "webpack": "^5.82.0", + "webpack-cli": "6.x.x" + } + }, + "node_modules/@webpack-cli/info": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-3.0.1.tgz", + "integrity": "sha512-coEmDzc2u/ffMvuW9aCjoRzNSPDl/XLuhPdlFRpT9tZHmJ/039az33CE7uH+8s0uL1j5ZNtfdv0HkfaKRBGJsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "webpack": "^5.82.0", + "webpack-cli": "6.x.x" + } + }, + "node_modules/@webpack-cli/serve": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-3.0.1.tgz", + "integrity": "sha512-sbgw03xQaCLiT6gcY/6u3qBDn01CWw/nbaXl3gTdTFuJJ75Gffv3E3DBpgvY2fkkrdS1fpjaXNOmJlnbtKauKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "webpack": "^5.82.0", + "webpack-cli": "6.x.x" + }, + "peerDependenciesMeta": { + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dev": true, + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/arrayify-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrayify-stream/-/arrayify-stream-2.0.1.tgz", + "integrity": "sha512-z8fB6PtmnewQpFB53piS2d1KlUi3BPMICH2h7leCOUXpQcwvZ4GbHHSpdKoUrgLMR6b4Qan/uDe1St3Ao3yIHg==", + "dev": true, + "license": "MIT" + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-lock": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz", + "integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynciterator": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/asynciterator/-/asynciterator-3.10.0.tgz", + "integrity": "sha512-eDOBoUf2m+4ht0ETVn2SCfuBIZZ6UWyyQbP++LRPKoK7PmrCQq37pJ6vRvyef4o1Pn+CwWnzMlkXxGdh/krVIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tiny-set-immediate": "^1.0.2" + } + }, + "node_modules/asyncjoin": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/asyncjoin/-/asyncjoin-1.2.4.tgz", + "integrity": "sha512-7/1g5uV2/iTDQteJ/pxqZq6qkO5406V+vNyOCYtHJ+mo6bmvvQHHrZgd7AtU/rx+cnz08NPWlwk8daW61thnlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynciterator": "^3.9.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.14", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.14.tgz", + "integrity": "sha512-fOVLPAsFTsQfuCkvahZkzq6nf8KvGWanlYoTh0SVA0A/PIUxQGU2AOZAoD95n2gFLVDW/jP6sbGLny95nmEuHA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cache-content-type": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cache-content-type/-/cache-content-type-1.0.1.tgz", + "integrity": "sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^2.1.18", + "ylru": "^1.2.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/cacheable-lookup": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz", + "integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + } + }, + "node_modules/cacheable-request": { + "version": "10.2.14", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-10.2.14.tgz", + "integrity": "sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "^4.0.2", + "get-stream": "^6.0.1", + "http-cache-semantics": "^4.1.1", + "keyv": "^4.5.3", + "mimic-response": "^4.0.0", + "normalize-url": "^8.0.0", + "responselike": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001784", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001784.tgz", + "integrity": "sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/canonicalize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/canonicalize/-/canonicalize-2.1.0.tgz", + "integrity": "sha512-F705O3xrsUtgt98j7leetNhTWPe+5S72rlL5O4jA1pKqBVQ/dT1O1D6PFxmSXvc0SUOinWS57DKx0I3CHrXJHQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "canonicalize": "bin/canonicalize.js" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/clownface": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/clownface/-/clownface-1.5.1.tgz", + "integrity": "sha512-Ko8N/UFsnhEGmPlyE1bUFhbRhVgDbxqlIjcqxtLysc4dWaY0A7iCdg3savhAxs7Lheb7FCygIyRh7ADYZWVIng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rdfjs/data-model": "^1.1.0", + "@rdfjs/namespace": "^1.0.0" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/color": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", + "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^3.1.3", + "color-string": "^2.1.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-string": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", + "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/color-string/node_modules/color-name": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/color/node_modules/color-convert": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", + "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=14.6" + } + }, + "node_modules/color/node_modules/color-name": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/componentsjs": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/componentsjs/-/componentsjs-5.5.1.tgz", + "integrity": "sha512-hmqq+ZUa98t9CoeWPGwE14I18aXQFAt66HRd8DaZCNggcSr82vhlyrjeXX0JAUMgr2MyQzwKstkv4INRAREguA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rdfjs/types": "*", + "@types/minimist": "^1.2.0", + "@types/node": "^18.0.0", + "@types/semver": "^7.3.4", + "jsonld-context-parser": "^2.1.1", + "minimist": "^1.2.0", + "rdf-data-factory": "^1.1.0", + "rdf-object": "^1.14.0", + "rdf-parse": "^2.0.0", + "rdf-quad": "^1.5.0", + "rdf-string": "^1.6.0", + "rdf-terms": "^1.7.0", + "semver": "^7.3.2", + "winston": "^3.3.3" + }, + "bin": { + "componentsjs-compile-config": "bin/compile-config.js" + } + }, + "node_modules/componentsjs/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/componentsjs/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-disposition/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookies": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz", + "integrity": "sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "keygrip": "~1.1.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/copy-webpack-plugin": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-12.0.2.tgz", + "integrity": "sha512-SNwdBeHyII+rWvee/bTnAYyO8vfVdcSTud4EIb6jcZ8inLeWucJE0DnxXQBjlQ5zlteuuvooGQy3LIyGxhvlOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.1", + "globby": "^14.0.0", + "normalize-path": "^3.0.0", + "schema-utils": "^4.2.0", + "serialize-javascript": "^6.0.2" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/corser": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/corser/-/corser-2.0.1.tgz", + "integrity": "sha512-utCYNzRSQIZNPIcGZdQc92UVJYAhtGAteCFg0yRaFm8f0P+CPtyGyHXJcGXnffjCybUCEx3FQ2G7U3/o9eIkVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-fetch": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", + "integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", + "integrity": "sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.331", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", + "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/envinfo": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.21.0.tgz", + "integrity": "sha512-Lw7I8Zp5YKHFCXL7+Dz95g4CcbMEpgvqZNNq3AmlT5XAV6CgAAk6gyAMqn2zjw08K9BHfcNuKrMiCPLByGafow==", + "dev": true, + "license": "MIT", + "bin": { + "envinfo": "dist/cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eta": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/eta/-/eta-3.5.0.tgz", + "integrity": "sha512-e3x3FBvGzeCIHhF+zhK8FZA2vC5uFn6b4HJjegUbIWrDb4mJ7JjTGMJY9VGIbRVpmSwHopNiaJibhjIr+HfLug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + }, + "funding": { + "url": "https://github.com/eta-dev/eta?sponsor=1" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fetch-sparql-endpoint": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/fetch-sparql-endpoint/-/fetch-sparql-endpoint-6.2.0.tgz", + "integrity": "sha512-NDbTgK2dPcNA4P2mXVZDbwIA3DY2k2TpEF5+GmCsCNrIbAnAl938JU41SZ2sCSkBXvBoVvb2q5eJ0u7W4K5aog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/n3": "^1.0.0", + "@types/readable-stream": "^4.0.0", + "@types/sparqljs": "^3.0.0", + "is-stream": "^2.0.0", + "n3": "^1.0.0", + "rdf-string": "^2.0.0", + "readable-from-web": "^1.0.0", + "sparqljs": "^3.0.0", + "sparqljson-parse": "^3.0.0", + "sparqlxml-parse": "^3.0.0", + "stream-to-string": "^1.0.0", + "yargs": "^17.0.0" + }, + "bin": { + "fetch-sparql-endpoint": "bin/fetch-sparql-endpoint.js" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/rubensworks/" + } + }, + "node_modules/fetch-sparql-endpoint/node_modules/@rdfjs/types": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@rdfjs/types/-/types-2.0.1.tgz", + "integrity": "sha512-uyAzpugX7KekAXAHq26m3JlUIZJOC0uSBhpnefGV5i15bevDyyejoB7I+9MKeUrzXD8OOUI3+4FeV1wwQr5ihA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/fetch-sparql-endpoint/node_modules/rdf-data-factory": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/rdf-data-factory/-/rdf-data-factory-2.0.2.tgz", + "integrity": "sha512-WzPoYHwQYWvIP9k+7IBLY1b4nIDitzAK4mA37WumAF/Cjvu/KOtYJH9IPZnUTWNSd5K2+pq4vrcE9WZC4sRHhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rdfjs/types": "^2.0.0" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/rubensworks/" + } + }, + "node_modules/fetch-sparql-endpoint/node_modules/rdf-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/rdf-string/-/rdf-string-2.0.1.tgz", + "integrity": "sha512-SMW4ponnKNrsP9kYpOLyICeM4UJmEXIeS3zri7kPK9gzLFsHD88oiza8LnokNYxd76zW4JoYWD+v4x0g8rJBjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "rdf-data-factory": "^2.0.0" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/rubensworks/" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data-encoder": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-2.1.4.tgz", + "integrity": "sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.17" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-extra": { + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", + "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz", + "integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^2.1.0", + "fast-glob": "^3.3.3", + "ignore": "^7.0.3", + "path-type": "^6.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/got/-/got-13.0.0.tgz", + "integrity": "sha512-XfBk1CxOOScDcMr9O1yKkNaQyy865NbYs+F7dr4H0LZMVgCj2Le59k6PqbNHoL5ToeaEQUYh6c6yMfVcc6SJxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^5.2.0", + "@szmarczak/http-timer": "^5.0.1", + "cacheable-lookup": "^7.0.0", + "cacheable-request": "^10.2.8", + "decompress-response": "^6.0.0", + "form-data-encoder": "^2.1.2", + "get-stream": "^6.0.1", + "http2-wrapper": "^2.1.10", + "lowercase-keys": "^3.0.0", + "p-cancelable": "^3.0.0", + "responselike": "^3.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/graphql": { + "version": "15.10.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-15.10.2.tgz", + "integrity": "sha512-1PRqdDPAmViWr4h1GVBT8RoPZfWSGZa7kDzleTilOfVIslsgf+cia3Nl95v1KDmR4iERPaT7WzQ+tN4MJmbg3w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/graphql-to-sparql": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/graphql-to-sparql/-/graphql-to-sparql-3.0.1.tgz", + "integrity": "sha512-A+RwB99o66CUj+XuqtP/u3P7fGS/qF6P+/jhNl1BE/JZ2SCnkrODvV0LADuJeCDmPh45fDhq+GTDVoN1ZQHYFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rdfjs/types": "*", + "graphql": "^15.5.2", + "jsonld-context-parser": "^2.0.2", + "minimist": "^1.2.0", + "rdf-data-factory": "^1.1.0", + "sparqlalgebrajs": "^4.0.0" + }, + "bin": { + "graphql-to-sparql": "bin/graphql-to-sparql.js" + } + }, + "node_modules/handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hash.js": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", + "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, + "node_modules/http-assert": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/http-assert/-/http-assert-1.5.0.tgz", + "integrity": "sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-equal": "~1.0.1", + "http-errors": "~1.8.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-assert/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-assert/node_modules/http-errors": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-assert/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-link-header": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/http-link-header/-/http-link-header-1.1.3.tgz", + "integrity": "sha512-3cZ0SRL8fb9MUlU3mKM61FcQvPfXx2dBrZW3Vbg5CXa8jFlK8OaEpePenLe1oEXQduhz8b0QjsqfS59QP4AJDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-server": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/http-server/-/http-server-14.1.1.tgz", + "integrity": "sha512-+cbxadF40UXd9T01zUHgA+rlo2Bg1Srer4+B4NwIHdaGxAGGv59nYRnGGDJ9LBk7alpS0US+J+bLLdQOOkJq4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "basic-auth": "^2.0.1", + "chalk": "^4.1.2", + "corser": "^2.0.1", + "he": "^1.2.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy": "^1.18.1", + "mime": "^1.6.0", + "minimist": "^1.2.6", + "opener": "^1.5.1", + "portfinder": "^1.0.28", + "secure-compare": "3.0.1", + "union": "~0.5.0", + "url-join": "^4.0.1" + }, + "bin": { + "http-server": "bin/http-server" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/http2-wrapper": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz", + "integrity": "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.2.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/http2-wrapper/node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immutable": { + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.8.tgz", + "integrity": "sha512-d/Ld9aLbKpNwyl0KiM2CT1WYvkitQ1TSvmRtkcV8FKStiDoA7Slzgjmb/1G2yhKM1p0XeNOieaTbFZmU1d3Xuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/interpret": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", + "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/ioredis": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", + "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/iso8601-duration": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/iso8601-duration/-/iso8601-duration-2.1.3.tgz", + "integrity": "sha512-OwkROKDXYhqKTl9uyB+/+lQ/Tx+L9LVb9tNRcsO4LtCSBDrmYbzyJLg9rGjYKAPDabD6IVdjMyUnnULHpejrCg==", + "dev": true, + "license": "MIT" + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonld-context-parser": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/jsonld-context-parser/-/jsonld-context-parser-2.4.0.tgz", + "integrity": "sha512-ZYOfvh525SdPd9ReYY58dxB3E2RUEU4DJ6ZibO8AitcowPeBH4L5rCAitE2om5G1P+HMEgYEYEr4EZKbVN4tpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-link-header": "^1.0.1", + "@types/node": "^18.0.0", + "cross-fetch": "^3.0.6", + "http-link-header": "^1.0.2", + "relative-to-absolute-iri": "^1.0.5" + }, + "bin": { + "jsonld-context-parse": "bin/jsonld-context-parse.js" + } + }, + "node_modules/jsonld-context-parser/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/jsonld-context-parser/node_modules/cross-fetch": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz", + "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, + "node_modules/jsonld-context-parser/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonld-streaming-parser": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/jsonld-streaming-parser/-/jsonld-streaming-parser-3.4.0.tgz", + "integrity": "sha512-897CloyQgQidfkB04dLM5XaAXVX/cN9A2hvgHJo4y4jRhIpvg3KLMBBfcrswepV2N3T8c/Rp2JeFdWfVsbVZ7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bergos/jsonparse": "^1.4.0", + "@rdfjs/types": "*", + "@types/http-link-header": "^1.0.1", + "@types/readable-stream": "^2.3.13", + "buffer": "^6.0.3", + "canonicalize": "^1.0.1", + "http-link-header": "^1.0.2", + "jsonld-context-parser": "^2.4.0", + "rdf-data-factory": "^1.1.0", + "readable-stream": "^4.0.0" + } + }, + "node_modules/jsonld-streaming-parser/node_modules/@types/readable-stream": { + "version": "2.3.15", + "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-2.3.15.tgz", + "integrity": "sha512-oM5JSKQCcICF1wvGgmecmHldZ48OZamtMxcGGVICOJA8o8cahXC1zEVAif8iwoc5j8etxFaRFnf095+CDsuoFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "safe-buffer": "~5.1.1" + } + }, + "node_modules/jsonld-streaming-parser/node_modules/canonicalize": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/canonicalize/-/canonicalize-1.0.8.tgz", + "integrity": "sha512-0CNTVCLZggSh7bc5VkX5WWPWO+cyZbNd07IHIsSXLia/eAq+r836hgk+8BKoEh7949Mda87VUOitx5OddVj64A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/jsonld-streaming-serializer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/jsonld-streaming-serializer/-/jsonld-streaming-serializer-2.1.0.tgz", + "integrity": "sha512-COHdLoeMTnrqHMoFhN3PoAwqnrKrpPC7/ACb0WbELYvt+HSOIFN3v4IJP7fOtLNQ4GeaeYkvbeWJ7Jo4EjxMDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rdfjs/types": "*", + "@types/readable-stream": "^2.3.13", + "buffer": "^6.0.3", + "jsonld-context-parser": "^2.0.0", + "readable-stream": "^4.0.0" + } + }, + "node_modules/jsonld-streaming-serializer/node_modules/@types/readable-stream": { + "version": "2.3.15", + "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-2.3.15.tgz", + "integrity": "sha512-oM5JSKQCcICF1wvGgmecmHldZ48OZamtMxcGGVICOJA8o8cahXC1zEVAif8iwoc5j8etxFaRFnf095+CDsuoFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "safe-buffer": "~5.1.1" + } + }, + "node_modules/keygrip": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", + "integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tsscmp": "1.0.6" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/koa": { + "version": "2.16.4", + "resolved": "https://registry.npmjs.org/koa/-/koa-2.16.4.tgz", + "integrity": "sha512-3An0GCLDSR34tsCO4H8Tef8Pp2ngtaZDAZnsWJYelqXUK5wyiHvGItgK/xcSkmHLSTn1Jcho1mRQs2ehRzvKKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "^1.3.5", + "cache-content-type": "^1.0.0", + "content-disposition": "~0.5.2", + "content-type": "^1.0.4", + "cookies": "~0.9.0", + "debug": "^4.3.2", + "delegates": "^1.0.0", + "depd": "^2.0.0", + "destroy": "^1.0.4", + "encodeurl": "^1.0.2", + "escape-html": "^1.0.3", + "fresh": "~0.5.2", + "http-assert": "^1.3.0", + "http-errors": "^1.6.3", + "is-generator-function": "^1.0.7", + "koa-compose": "^4.1.0", + "koa-convert": "^2.0.0", + "on-finished": "^2.3.0", + "only": "~0.0.2", + "parseurl": "^1.3.2", + "statuses": "^1.5.0", + "type-is": "^1.6.16", + "vary": "^1.1.2" + }, + "engines": { + "node": "^4.8.4 || ^6.10.1 || ^7.10.1 || >= 8.1.4" + } + }, + "node_modules/koa-compose": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/koa-compose/-/koa-compose-4.1.0.tgz", + "integrity": "sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==", + "dev": true, + "license": "MIT" + }, + "node_modules/koa-convert": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/koa-convert/-/koa-convert-2.0.0.tgz", + "integrity": "sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA==", + "dev": true, + "license": "MIT", + "dependencies": { + "co": "^4.6.0", + "koa-compose": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/koa/node_modules/http-errors": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/koa/node_modules/http-errors/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/koa/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", + "dev": true, + "license": "MIT" + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/loader-runner": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", + "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.orderby": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/lodash.orderby/-/lodash.orderby-4.18.0.tgz", + "integrity": "sha512-XSSpOxgihAM5kawpay9vl0e9r73l+LJIh03NzJBF33DWb8XgSM9Bvl1mEpA0ydrvoOeTVbNZBo2gY7zfw22EQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/logform": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", + "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/lowercase-keys": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz", + "integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/marked": { + "version": "9.1.6", + "resolved": "https://registry.npmjs.org/marked/-/marked-9.1.6.tgz", + "integrity": "sha512-jcByLnIFkd5gSXZmjNvS1TlmRhCXZjIzHYlaGkPlLIekG55JDR2Z4va9tZwCiP+/RDERiNhMOFu01xd6O5ct1Q==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 16" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/microdata-rdf-streaming-parser": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/microdata-rdf-streaming-parser/-/microdata-rdf-streaming-parser-2.0.1.tgz", + "integrity": "sha512-oEEYP3OwPGOtoE4eIyJvX1eJXI7VkGR4gKYqpEufaRXc2ele/Tkid/KMU3Los13wGrOq6woSxLEGOYSHzpRvwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rdfjs/types": "*", + "htmlparser2": "^8.0.0", + "rdf-data-factory": "^1.1.0", + "readable-stream": "^4.1.0", + "relative-to-absolute-iri": "^1.0.2" + } + }, + "node_modules/microdata-rdf-streaming-parser/node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz", + "integrity": "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/n3": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/n3/-/n3-1.26.0.tgz", + "integrity": "sha512-SQknS0ua90rN+3RHuk8BeIqeYyqIH/+ecViZxX08jR4j6MugqWRjtONl3uANG/crWXnOM2WIqBJtjIhVYFha+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=12.0" + } + }, + "node_modules/nanoid": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.7.tgz", + "integrity": "sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiate": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/negotiate/-/negotiate-1.0.1.tgz", + "integrity": "sha512-KBCIM4dAIT9j/pSXLHHQbZG74NmKNXTtxU2zHN0HG6uzzuFE01m1UdGoUmVHmACiBuCAOL7KwfqSW1oUQBj/vg==", + "dev": true + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/nodemailer": { + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", + "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", + "dev": true, + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-url": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.1.1.tgz", + "integrity": "sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/oidc-provider": { + "version": "8.8.1", + "resolved": "https://registry.npmjs.org/oidc-provider/-/oidc-provider-8.8.1.tgz", + "integrity": "sha512-qVChpayTwojUREJxLkFofUSK8kiSRIdzPrVSsoGibqRHl/YO60ege94OZS8vh7zaK+zxcG/Gu8UMaYB5ulohCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@koa/cors": "^5.0.0", + "@koa/router": "^13.1.0", + "debug": "^4.4.0", + "eta": "^3.5.0", + "got": "^13.0.0", + "jose": "^5.9.6", + "jsesc": "^3.1.0", + "koa": "^2.15.4", + "nanoid": "^5.0.9", + "object-hash": "^3.0.0", + "oidc-token-hash": "^5.0.3", + "quick-lru": "^7.0.0", + "raw-body": "^3.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/oidc-token-hash": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz", + "integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fn.name": "1.x.x" + } + }, + "node_modules/only": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/only/-/only-0.0.2.tgz", + "integrity": "sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==", + "dev": true + }, + "node_modules/opener": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", + "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", + "dev": true, + "license": "(WTFPL OR MIT)", + "bin": { + "opener": "bin/opener-bin.js" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-cancelable": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz", + "integrity": "sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-type": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz", + "integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/portfinder": { + "version": "1.0.38", + "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz", + "integrity": "sha512-rEwq/ZHlJIKw++XtLAO8PPuOQA/zaPJOZJ37BVuN97nLpMJeuDVLVGRwbFoBgLudgdTMP2hdRJP++H+8QOA3vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async": "^3.2.6", + "debug": "^4.3.6" + }, + "engines": { + "node": ">= 10.12" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/promise-polyfill": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-1.1.6.tgz", + "integrity": "sha512-7rrONfyLkDEc7OJ5QBkqa4KI4EBhCd340xRuIUPGCfu13znS+vx+VDdrT9ODAJHlXm7w4lbxN3DRjyv58EuzDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/property-expr": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", + "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/quick-lru": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-7.3.0.tgz", + "integrity": "sha512-k9lSsjl36EJdK7I06v7APZCbyGT2vMTsYSRX1Q2nbYmnkBqgUhRkAuzH08Ciotteu/PLJmIF2+tti7o3C/ts2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/rdf-data-factory": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/rdf-data-factory/-/rdf-data-factory-1.1.3.tgz", + "integrity": "sha512-ny6CI7m2bq4lfQQmDYvcb2l1F9KtGwz9chipX4oWu2aAtVoXjb7k3d8J1EsgAsEbMXnBipB/iuRen5H2fwRWWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rdfjs/types": "^1.0.0" + } + }, + "node_modules/rdf-dereference": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/rdf-dereference/-/rdf-dereference-2.2.0.tgz", + "integrity": "sha512-6geM3CSUlXTK3n4OoKsL95M7XwKXoxiwK7cf4e/+Dj0X/ll77ihFN5j9VhLGXNYbMXDlm30kBg/VU6ymMv6o/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/actor-dereference-fallback": "^2.0.2", + "@comunica/actor-dereference-file": "^2.0.2", + "@comunica/actor-dereference-http": "^2.0.2", + "@comunica/actor-dereference-rdf-parse": "^2.6.0", + "@comunica/actor-http-fetch": "^2.0.1", + "@comunica/actor-http-proxy": "^2.0.1", + "@comunica/actor-rdf-parse-html": "^2.0.1", + "@comunica/actor-rdf-parse-html-microdata": "^2.0.1", + "@comunica/actor-rdf-parse-html-rdfa": "^2.0.1", + "@comunica/actor-rdf-parse-html-script": "^2.0.1", + "@comunica/actor-rdf-parse-jsonld": "^2.0.1", + "@comunica/actor-rdf-parse-n3": "^2.0.1", + "@comunica/actor-rdf-parse-rdfxml": "^2.0.1", + "@comunica/actor-rdf-parse-shaclc": "^2.6.0", + "@comunica/actor-rdf-parse-xml-rdfa": "^2.0.1", + "@comunica/bus-dereference": "^2.0.2", + "@comunica/bus-dereference-rdf": "^2.0.2", + "@comunica/bus-http": "^2.0.1", + "@comunica/bus-init": "^2.0.1", + "@comunica/bus-rdf-parse": "^2.0.1", + "@comunica/bus-rdf-parse-html": "^2.0.1", + "@comunica/config-query-sparql": "^2.0.1", + "@comunica/context-entries": "^2.8.1", + "@comunica/core": "^2.0.1", + "@comunica/mediator-combine-pipeline": "^2.0.1", + "@comunica/mediator-combine-union": "^2.0.1", + "@comunica/mediator-number": "^2.0.1", + "@comunica/mediator-race": "^2.0.1", + "@rdfjs/types": "*", + "process": "^0.11.10", + "rdf-string": "^1.6.0", + "stream-to-string": "^1.2.0" + }, + "bin": { + "rdf-dereference": "bin/Runner.js" + } + }, + "node_modules/rdf-isomorphic": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/rdf-isomorphic/-/rdf-isomorphic-1.3.1.tgz", + "integrity": "sha512-6uIhsXTVp2AtO6f41PdnRV5xZsa0zVZQDTBdn0br+DZuFf5M/YD+T6m8hKDUnALI6nFL/IujTMLgEs20MlNidQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rdfjs/types": "*", + "hash.js": "^1.1.7", + "rdf-string": "^1.6.0", + "rdf-terms": "^1.7.0" + } + }, + "node_modules/rdf-literal": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/rdf-literal/-/rdf-literal-1.3.2.tgz", + "integrity": "sha512-79Stlu3sXy0kq9/decHFLf3xNPuY6sfhFPhd/diWErgaFr0Ekyg38Vh9bnVcqDYu48CFRi0t+hrFii49n92Hbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rdfjs/types": "*", + "rdf-data-factory": "^1.1.0" + } + }, + "node_modules/rdf-object": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/rdf-object/-/rdf-object-1.14.0.tgz", + "integrity": "sha512-/KSUWr7onDtL7d81kOpcUzJ2vHYOYJc2KU9WzBZRYydBhK0Sksh5Hg4VCQNaxUEvYEgdrrTuq9SLpOOCmag0rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rdfjs/types": "*", + "jsonld-context-parser": "^2.0.2", + "rdf-data-factory": "^1.1.0", + "rdf-string": "^1.6.0", + "streamify-array": "^1.0.1" + } + }, + "node_modules/rdf-parse": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/rdf-parse/-/rdf-parse-2.3.3.tgz", + "integrity": "sha512-N5XEHm+ajFzwo/vVNzB4tDtvqMwBosbVJmZl5DlzplQM9ejlJBlN/43i0ImAb/NMtJJgQPC3jYnkCKGA7wdo/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/actor-http-fetch": "^2.0.1", + "@comunica/actor-http-proxy": "^2.0.1", + "@comunica/actor-rdf-parse-html": "^2.0.1", + "@comunica/actor-rdf-parse-html-microdata": "^2.0.1", + "@comunica/actor-rdf-parse-html-rdfa": "^2.0.1", + "@comunica/actor-rdf-parse-html-script": "^2.0.1", + "@comunica/actor-rdf-parse-jsonld": "^2.0.1", + "@comunica/actor-rdf-parse-n3": "^2.0.1", + "@comunica/actor-rdf-parse-rdfxml": "^2.0.1", + "@comunica/actor-rdf-parse-shaclc": "^2.6.2", + "@comunica/actor-rdf-parse-xml-rdfa": "^2.0.1", + "@comunica/bus-http": "^2.0.1", + "@comunica/bus-init": "^2.0.1", + "@comunica/bus-rdf-parse": "^2.0.1", + "@comunica/bus-rdf-parse-html": "^2.0.1", + "@comunica/config-query-sparql": "^2.0.1", + "@comunica/core": "^2.0.1", + "@comunica/mediator-combine-pipeline": "^2.0.1", + "@comunica/mediator-combine-union": "^2.0.1", + "@comunica/mediator-number": "^2.0.1", + "@comunica/mediator-race": "^2.0.1", + "@rdfjs/types": "*", + "readable-stream": "^4.3.0", + "stream-to-string": "^1.2.0" + } + }, + "node_modules/rdf-quad": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/rdf-quad/-/rdf-quad-1.5.0.tgz", + "integrity": "sha512-LnCYx8XbRVW1wr6UiZPSy2Tv7bXAtEwuyck/68dANhFu8VMnGS+QfUNP3b9YI6p4Bfd/fyDx5E3x81IxGV6BzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "rdf-data-factory": "^1.0.1", + "rdf-literal": "^1.2.0", + "rdf-string": "^1.5.0" + } + }, + "node_modules/rdf-serialize": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/rdf-serialize/-/rdf-serialize-2.2.3.tgz", + "integrity": "sha512-t3AvH3lw1NUufCUjf6/pxOyU/cPBJ0J3TkMP+FuUJKMmsJ1FzFdNkpsIMp9QFmWtqUYijyhYpVfJ4Tqprl+1RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@comunica/actor-rdf-serialize-jsonld": "^2.6.6", + "@comunica/actor-rdf-serialize-n3": "^2.6.6", + "@comunica/actor-rdf-serialize-shaclc": "^2.6.0", + "@comunica/bus-init": "^2.0.1", + "@comunica/bus-rdf-serialize": "^2.0.1", + "@comunica/config-query-sparql": "^2.0.1", + "@comunica/core": "^2.0.1", + "@comunica/mediator-combine-pipeline": "^2.0.1", + "@comunica/mediator-combine-union": "^2.0.1", + "@comunica/mediator-race": "^2.0.1", + "@rdfjs/types": "*", + "readable-stream": "^4.3.0", + "stream-to-string": "^1.1.0" + } + }, + "node_modules/rdf-store-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/rdf-store-stream/-/rdf-store-stream-2.0.1.tgz", + "integrity": "sha512-znGaibHLvbRE0BrDcXHRleRcLKlHYP6ADr1RFJ3yA28QBmhOjxxgbBFTvCMzgsxvBIqdaFS8Vd2FG4NefJL4Mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rdfjs/types": "*", + "rdf-stores": "^1.0.0" + } + }, + "node_modules/rdf-stores": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/rdf-stores/-/rdf-stores-1.0.0.tgz", + "integrity": "sha512-wqp7M5409rbhpWQE0C1vyVysbz++aD2vEkZ6yueSxhDtyLvznS41R3cKiuUpm3ikc/yTpaCZwPo4iyKEaAwBIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rdfjs/types": "*", + "asynciterator": "^3.8.0", + "rdf-data-factory": "^1.1.1", + "rdf-string": "^1.6.2", + "rdf-terms": "^1.9.1" + } + }, + "node_modules/rdf-streaming-store": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rdf-streaming-store/-/rdf-streaming-store-1.1.5.tgz", + "integrity": "sha512-Rfd3qo1otF/Jfau/lAFX8J1ZPorN0eaHoIkAlenIIcdZjq9AoIP85rEa4Sn+yMZOqNU1Kc4cCPUv5CFHhpAT2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rdfjs/types": "*", + "@types/n3": "^1.10.4", + "@types/readable-stream": "^4.0.15", + "n3": "^1.16.3", + "rdf-string": "^1.6.2", + "rdf-terms": "^1.9.1", + "readable-stream": "^4.3.0" + } + }, + "node_modules/rdf-string": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/rdf-string/-/rdf-string-1.6.3.tgz", + "integrity": "sha512-HIVwQ2gOqf+ObsCLSUAGFZMIl3rh9uGcRf1KbM85UDhKqP+hy6qj7Vz8FKt3GA54RiThqK3mNcr66dm1LP0+6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rdfjs/types": "*", + "rdf-data-factory": "^1.1.0" + } + }, + "node_modules/rdf-string-ttl": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/rdf-string-ttl/-/rdf-string-ttl-1.3.2.tgz", + "integrity": "sha512-yqolaVoUvTaSC5aaQuMcB4BL54G/pCGsV4jQH87f0TvAx8zHZG0koh7XWrjva/IPGcVb1QTtaeEdfda5mcddJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rdfjs/types": "*", + "rdf-data-factory": "^1.1.0" + } + }, + "node_modules/rdf-terms": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/rdf-terms/-/rdf-terms-1.11.0.tgz", + "integrity": "sha512-iKlVgnMopRKl9pHVNrQrax7PtZKRCT/uJIgYqvuw1VVQb88zDvurtDr1xp0rt7N9JtKtFwUXoIQoEsjyRo20qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rdfjs/types": "*", + "rdf-data-factory": "^1.1.0", + "rdf-string": "^1.6.0" + } + }, + "node_modules/rdf-validate-datatype": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/rdf-validate-datatype/-/rdf-validate-datatype-0.1.5.tgz", + "integrity": "sha512-gU+cD+AT1LpFwbemuEmTDjwLyFwJDiw21XHyIofKhFnEpXODjShBuxhgDGnZqW3qIEwu/vECjOecuD60e5ngiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rdfjs/namespace": "^1.1.0", + "@rdfjs/to-ntriples": "^2.0.0" + }, + "engines": { + "node": ">=10.4" + } + }, + "node_modules/rdf-validate-shacl": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/rdf-validate-shacl/-/rdf-validate-shacl-0.4.5.tgz", + "integrity": "sha512-tGYnssuPzmsPua1dju4hEtGkT1zouvwzVTNrFhNiqj2aZFO5pQ7lvLd9Cv9H9vKAlpIdC/x0zL6btxG3PCss0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rdfjs/dataset": "^1.1.1", + "@rdfjs/namespace": "^1.0.0", + "@rdfjs/term-set": "^1.1.0", + "clownface": "^1.4.0", + "debug": "^4.3.2", + "rdf-literal": "^1.3.0", + "rdf-validate-datatype": "^0.1.5" + } + }, + "node_modules/rdfa-streaming-parser": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/rdfa-streaming-parser/-/rdfa-streaming-parser-2.0.1.tgz", + "integrity": "sha512-7Yyaj030LO7iQ38Wh/RNLVeYrVFJeyx3dpCK7C1nvX55eIN/gE4HWfbg4BYI9X7Bd+eUIUMVeiKYLmYjV6apow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rdfjs/types": "*", + "htmlparser2": "^8.0.0", + "rdf-data-factory": "^1.1.0", + "readable-stream": "^4.0.0", + "relative-to-absolute-iri": "^1.0.2" + } + }, + "node_modules/rdfa-streaming-parser/node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, + "node_modules/rdfxml-streaming-parser": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/rdfxml-streaming-parser/-/rdfxml-streaming-parser-2.4.0.tgz", + "integrity": "sha512-f+tdI1wxOiPzMbFWRtOwinwPsqac0WIN80668yFKcVdFCSTGOWTM70ucQGUSdDZZo7pce/UvZgV0C3LDj0P7tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rdfjs/types": "*", + "@rubensworks/saxes": "^6.0.1", + "@types/readable-stream": "^2.3.13", + "buffer": "^6.0.3", + "rdf-data-factory": "^1.1.0", + "readable-stream": "^4.4.2", + "relative-to-absolute-iri": "^1.0.0", + "validate-iri": "^1.0.0" + } + }, + "node_modules/rdfxml-streaming-parser/node_modules/@types/readable-stream": { + "version": "2.3.15", + "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-2.3.15.tgz", + "integrity": "sha512-oM5JSKQCcICF1wvGgmecmHldZ48OZamtMxcGGVICOJA8o8cahXC1zEVAif8iwoc5j8etxFaRFnf095+CDsuoFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "safe-buffer": "~5.1.1" + } + }, + "node_modules/readable-from-web": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/readable-from-web/-/readable-from-web-1.0.0.tgz", + "integrity": "sha512-tei03fQhxqLEklpIvocFUR9hO42hiyYvdhwoNHAjJztPAQ8QS1NqF2AhLwzGxIGidPBJ4MCqB48wn7OAFCfhsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/readable-stream": "^4.0.0", + "readable-stream": "^4.0.0" + } + }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/readable-stream-node-to-web": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/readable-stream-node-to-web/-/readable-stream-node-to-web-1.0.1.tgz", + "integrity": "sha512-OGzi2VKLa8H259kAx7BIwuRrXHGcxeHj4RdASSgEGBP9Q2wowdPvBc65upF4Q9O05qWgKqBw1+9PiLTtObl7uQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/rechoir": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", + "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve": "^1.20.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "dev": true, + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/relative-to-absolute-iri": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/relative-to-absolute-iri/-/relative-to-absolute-iri-1.0.8.tgz", + "integrity": "sha512-U1TmhrhCmXKkDL9mI8gBbF5TN6TKcuv28k5+H3gMCAjoz0TyyHAICHlaGDZsTEBSu2Y3HhDKc8e6X9n33qeIqA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/rubensworks/" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/responselike": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-3.0.0.tgz", + "integrity": "sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/schema-utils/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/schema-utils/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/schema-utils/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/secure-compare": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/secure-compare/-/secure-compare-3.0.1.tgz", + "integrity": "sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "node_modules/shaclc-parse": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/shaclc-parse/-/shaclc-parse-1.4.3.tgz", + "integrity": "sha512-MQJWVFjfzzMUvieFO0STWjIo49ywy63UkVSsr0e8+8xHUns6X+i3yWYxNKd+GtSEJjBNZxxrUubog+hnd7PvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rdfjs/types": "^2.0.0", + "n3": "^1.16.3" + } + }, + "node_modules/shaclc-parse/node_modules/@rdfjs/types": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@rdfjs/types/-/types-2.0.1.tgz", + "integrity": "sha512-uyAzpugX7KekAXAHq26m3JlUIZJOC0uSBhpnefGV5i15bevDyyejoB7I+9MKeUrzXD8OOUI3+4FeV1wwQr5ihA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/shaclc-write": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/shaclc-write/-/shaclc-write-1.6.3.tgz", + "integrity": "sha512-R6rHiDov9kppjXTTaSwuaOUC3JXhJWvlrxR++Eng1jNdtD3oywkAagRu6mS5rzTcMA4WBxLUXdJxu4CbPOG04A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jeswr/prefixcc": "^1.2.1", + "n3": "^2.0.0", + "rdf-string-ttl": "^2.0.1" + } + }, + "node_modules/shaclc-write/node_modules/@rdfjs/types": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@rdfjs/types/-/types-2.0.1.tgz", + "integrity": "sha512-uyAzpugX7KekAXAHq26m3JlUIZJOC0uSBhpnefGV5i15bevDyyejoB7I+9MKeUrzXD8OOUI3+4FeV1wwQr5ihA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/shaclc-write/node_modules/n3": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/n3/-/n3-2.0.3.tgz", + "integrity": "sha512-um/toGVENTarHBYIK2TdH6ByBhW75WpdKpv8iTYt9wF2QfBk8s8a16iaWZFUAAC1BKfGdb99kfgx6pltdDwfKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=12.0" + } + }, + "node_modules/shaclc-write/node_modules/rdf-data-factory": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/rdf-data-factory/-/rdf-data-factory-2.0.2.tgz", + "integrity": "sha512-WzPoYHwQYWvIP9k+7IBLY1b4nIDitzAK4mA37WumAF/Cjvu/KOtYJH9IPZnUTWNSd5K2+pq4vrcE9WZC4sRHhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rdfjs/types": "^2.0.0" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/rubensworks/" + } + }, + "node_modules/shaclc-write/node_modules/rdf-string-ttl": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/rdf-string-ttl/-/rdf-string-ttl-2.0.1.tgz", + "integrity": "sha512-xIZdC6uur9OwaCQrOsjuzLpnQCNGuJFWnYIAdF48tg3wzG5QzsgDMTJISCVh+M2p1e9ivIXlEauSdWokAhpZgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "rdf-data-factory": "^2.0.0" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/rubensworks/" + } + }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/spark-md5": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/spark-md5/-/spark-md5-3.0.2.tgz", + "integrity": "sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/sparqlalgebrajs": { + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/sparqlalgebrajs/-/sparqlalgebrajs-4.3.8.tgz", + "integrity": "sha512-Xo1/5icRtVk2N38BrY9NXN8N/ZPjULlns7sDHv0nlcGOsOediBLWVy8LmV+Q90RHvb3atZZbrFy3VqrM4iXciA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rdfjs/types": "*", + "@types/sparqljs": "^3.1.3", + "fast-deep-equal": "^3.1.3", + "minimist": "^1.2.6", + "rdf-data-factory": "^1.1.0", + "rdf-isomorphic": "^1.3.0", + "rdf-string": "^1.6.0", + "rdf-terms": "^1.10.0", + "sparqljs": "^3.7.1" + }, + "bin": { + "sparqlalgebrajs": "bin/sparqlalgebrajs.js" + } + }, + "node_modules/sparqljs": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/sparqljs/-/sparqljs-3.7.4.tgz", + "integrity": "sha512-hb4C84gf7KM7vz+iG595mPwvqxOvBJCm9L3dCxtV2zZDht6ZMmMG0tHeeqFeqwb9875yU9U4lhYe4LYRvWj0BQ==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT", + "dependencies": { + "rdf-data-factory": "^1.1.2" + }, + "bin": { + "sparqljs": "bin/sparql-to-json" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/sparqljson-parse": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/sparqljson-parse/-/sparqljson-parse-3.3.0.tgz", + "integrity": "sha512-XrmkCsrx4n69Ak63Ju7t91hVlWw7YhEPPdA+giW2GRTosQxPur0JWh7rQin/8aT0WjKZgBgGpsNnVBYyyWUq1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bergos/jsonparse": "^1.4.1", + "@types/readable-stream": "^4.0.0", + "rdf-data-factory": "^2.0.0", + "readable-stream": "^4.0.0" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/rubensworks/" + } + }, + "node_modules/sparqljson-parse/node_modules/@rdfjs/types": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@rdfjs/types/-/types-2.0.1.tgz", + "integrity": "sha512-uyAzpugX7KekAXAHq26m3JlUIZJOC0uSBhpnefGV5i15bevDyyejoB7I+9MKeUrzXD8OOUI3+4FeV1wwQr5ihA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/sparqljson-parse/node_modules/rdf-data-factory": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/rdf-data-factory/-/rdf-data-factory-2.0.2.tgz", + "integrity": "sha512-WzPoYHwQYWvIP9k+7IBLY1b4nIDitzAK4mA37WumAF/Cjvu/KOtYJH9IPZnUTWNSd5K2+pq4vrcE9WZC4sRHhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rdfjs/types": "^2.0.0" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/rubensworks/" + } + }, + "node_modules/sparqljson-to-tree": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sparqljson-to-tree/-/sparqljson-to-tree-3.0.2.tgz", + "integrity": "sha512-8h/ZEPPBhBlMbgMX1TOumJQku2mLYYdwd/octsDa/bdqdNcMeAcB7S2Qh4SEZ+0pPNed9CBk1d5TEUpwJlcdmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rdfjs/types": "*", + "rdf-literal": "^1.3.2", + "sparqljson-parse": "^2.0.0" + } + }, + "node_modules/sparqljson-to-tree/node_modules/@types/readable-stream": { + "version": "2.3.15", + "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-2.3.15.tgz", + "integrity": "sha512-oM5JSKQCcICF1wvGgmecmHldZ48OZamtMxcGGVICOJA8o8cahXC1zEVAif8iwoc5j8etxFaRFnf095+CDsuoFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "safe-buffer": "~5.1.1" + } + }, + "node_modules/sparqljson-to-tree/node_modules/sparqljson-parse": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/sparqljson-parse/-/sparqljson-parse-2.2.0.tgz", + "integrity": "sha512-2TfvNvUsaJyWfCrq3ExdDdbF9LBLzIUCricg+D1YCYbbmyTzscgCtRk4KcIyJF178DtfCt4BkKzbKl8IXMHp8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bergos/jsonparse": "^1.4.1", + "@rdfjs/types": "*", + "@types/readable-stream": "^2.3.13", + "rdf-data-factory": "^1.1.0", + "readable-stream": "^4.0.0" + } + }, + "node_modules/sparqlxml-parse": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/sparqlxml-parse/-/sparqlxml-parse-3.3.0.tgz", + "integrity": "sha512-FUVgcUr2YePDtDuu/UcMTF2ODiKBQdZdcfTSvaR3QhWVAcBmIGX4r4apsePK1zCc1kkRR53JBHXHJho/Pk538g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rubensworks/saxes": "^6.0.1", + "@types/readable-stream": "^4.0.0", + "buffer": "^6.0.3", + "rdf-data-factory": "^2.0.0", + "readable-stream": "^4.5.2" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/rubensworks/" + } + }, + "node_modules/sparqlxml-parse/node_modules/@rdfjs/types": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@rdfjs/types/-/types-2.0.1.tgz", + "integrity": "sha512-uyAzpugX7KekAXAHq26m3JlUIZJOC0uSBhpnefGV5i15bevDyyejoB7I+9MKeUrzXD8OOUI3+4FeV1wwQr5ihA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/sparqlxml-parse/node_modules/rdf-data-factory": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/rdf-data-factory/-/rdf-data-factory-2.0.2.tgz", + "integrity": "sha512-WzPoYHwQYWvIP9k+7IBLY1b4nIDitzAK4mA37WumAF/Cjvu/KOtYJH9IPZnUTWNSd5K2+pq4vrcE9WZC4sRHhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rdfjs/types": "^2.0.0" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/rubensworks/" + } + }, + "node_modules/stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stream-to-string": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/stream-to-string/-/stream-to-string-1.2.1.tgz", + "integrity": "sha512-WsvTDNF8UYs369Yko3pcdTducQtYpzEZeOV7cTuReyFvOoA9S/DLJ6sYK+xPafSPHhUMpaxiljKYnT6JSFztIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "promise-polyfill": "^1.1.6" + } + }, + "node_modules/streamify-array": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/streamify-array/-/streamify-array-1.0.1.tgz", + "integrity": "sha512-ZnswaBcC6B1bhPLSQOlC6CdaDUSzU0wr2lvvHpbHNms8V7+DLd8uEAzDAWpsjxbFkijBHhuObFO/qqu52DZUMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/streamify-string": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/streamify-string/-/streamify-string-1.0.1.tgz", + "integrity": "sha512-RXvBglotrvSIuQQ7oC55pdV40wZ/17gTb68ipMC4LA0SqMN4Sqfsf31Dpei7qXpYqZQ8ueVnPglUvtep3tlhqw==", + "dev": true, + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tapable": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.46.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz", + "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.4.0.tgz", + "integrity": "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser-webpack-plugin/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tiny-case": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", + "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tiny-set-immediate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tiny-set-immediate/-/tiny-set-immediate-1.0.2.tgz", + "integrity": "sha512-EVbaM4zXFWS4CIqVoPzY7XIioQ5LU1p49AHizwPO1KyFyp/gxy5SA8mDmfDVl/2WLQiHgUL+esO6Ig+KhpUxUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/ts-guards": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/ts-guards/-/ts-guards-0.5.1.tgz", + "integrity": "sha512-Y6P/VJnwARiPMfxO7rvaYaz5tGQ5TQ0Wnb2cWIxMpFOioYkhsT8XaCrJX6wYPNFACa4UOrN5SPqhwpM8NolAhQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/ts-loader": { + "version": "9.5.7", + "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.7.tgz", + "integrity": "sha512-/ZNrKgA3K3PtpMYOC71EeMWIloGw3IYEa5/t1cyz2r5/PyUwTXGzYJvcD3kfUvmhlfpz1rhV8B2O6IVTQ0avsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.0.0", + "micromatch": "^4.0.0", + "semver": "^7.3.4", + "source-map": "^0.7.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "typescript": "*", + "webpack": "^5.0.0" + } + }, + "node_modules/ts-loader/node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.x" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/union": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/union/-/union-0.5.0.tgz", + "integrity": "sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA==", + "dev": true, + "dependencies": { + "qs": "^6.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/uritemplate": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/uritemplate/-/uritemplate-0.3.4.tgz", + "integrity": "sha512-enADBvHfhjrwxFMTVWeIIYz51SZ91uC6o2MR/NQTVljJB6HTZ8eQL3Q7JBj3RxNISA14MOwJaU3vpf5R6dyxHA==", + "dev": true + }, + "node_modules/url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", + "dev": true, + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/validate-iri": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/validate-iri/-/validate-iri-1.0.1.tgz", + "integrity": "sha512-gLXi7351CoyVVQw8XE5sgpYawRKatxE7kj/xmCxXOZS1kMdtcqC0ILIqLuVEVnAUQSL/evOGG3eQ+8VgbdnstA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/watchpack": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/web-streams-ponyfill": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/web-streams-ponyfill/-/web-streams-ponyfill-1.4.2.tgz", + "integrity": "sha512-LCHW+fE2UBJ2vjhqJujqmoxh1ytEDEr0dPO3CabMdMDJPKmsaxzS90V1Ar6LtNE5VHLqxR4YMEj1i4lzMAccIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/webpack": { + "version": "5.105.4", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz", + "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.16.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.20.0", + "es-module-lexer": "^2.0.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.3.1", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.17", + "watchpack": "^2.5.1", + "webpack-sources": "^3.3.4" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-cli": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-6.0.1.tgz", + "integrity": "sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@discoveryjs/json-ext": "^0.6.1", + "@webpack-cli/configtest": "^3.0.1", + "@webpack-cli/info": "^3.0.1", + "@webpack-cli/serve": "^3.0.1", + "colorette": "^2.0.14", + "commander": "^12.1.0", + "cross-spawn": "^7.0.3", + "envinfo": "^7.14.0", + "fastest-levenshtein": "^1.0.12", + "import-local": "^3.0.2", + "interpret": "^3.1.1", + "rechoir": "^0.8.0", + "webpack-merge": "^6.0.1" + }, + "bin": { + "webpack-cli": "bin/cli.js" + }, + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.82.0" + }, + "peerDependenciesMeta": { + "webpack-bundle-analyzer": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/webpack-cli/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/webpack-merge": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-6.0.1.tgz", + "integrity": "sha512-hXXvrjtx2PLYx4qruKl+kyRSLc52V+cCvMxRjmKwoA+CBbbF5GfIBtR6kCvl0fYGqTUPKB+1ktVmTHqMOzgCBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/webpack-sources": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.4.tgz", + "integrity": "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/webpack/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wildcard": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", + "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/winston": { + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", + "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.8", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.7.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.9.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", + "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "logform": "^2.7.0", + "readable-stream": "^3.6.2", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/winston/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/ylru": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/ylru/-/ylru-1.4.0.tgz", + "integrity": "sha512-2OQsPNEmBCvXuFlIni/a+Rn+R2pHW9INm0BxXJ4hVDA8TirqMj+J/Rp9ItLatT/5pZqWwefVrTQcHpixsxnVlA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yup": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.7.1.tgz", + "integrity": "sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "property-expr": "^2.0.5", + "tiny-case": "^1.0.3", + "toposort": "^2.0.2", + "type-fest": "^2.19.0" + } + } + } +} diff --git a/apps/extension/package.json b/apps/extension/package.json new file mode 100644 index 000000000..2a8ce01ac --- /dev/null +++ b/apps/extension/package.json @@ -0,0 +1,30 @@ +{ + "name": "solid-browser-extension", + "version": "0.1.0", + "private": true, + "description": "Browser extension for Solid authentication", + "license": "MIT", + "author": "Jesse Wright", + "scripts": { + "build": "webpack", + "build:dev": "webpack --mode development --watch", + "test:e2e": "npm run build && playwright test --config e2e/playwright.config.ts", + "clean": "rm -rf dist" + }, + "dependencies": { + "@uvdsl/solid-oidc-client-browser": "^0.2.2", + "jose": "^5.9.6" + }, + "devDependencies": { + "@playwright/test": "^1.49.0", + "@solid/community-server": "^7.1.3", + "@types/chrome": "^0.0.287", + "copy-webpack-plugin": "^12.0.2", + "http-server": "^14.1.1", + "ts-loader": "^9.5.1", + "ts-node": "^10.9.2", + "typescript": "^5.7.2", + "webpack": "^5.97.1", + "webpack-cli": "^6.0.1" + } +} diff --git a/apps/extension/src/background/auth-flow.ts b/apps/extension/src/background/auth-flow.ts new file mode 100644 index 000000000..ae822290a --- /dev/null +++ b/apps/extension/src/background/auth-flow.ts @@ -0,0 +1,307 @@ +import * as jose from 'jose'; +import { + saveAuthParams, + loadAuthParams, + clearAuthParams, + exportDpopKeyPair, + saveSession, + type StoredSession, +} from './session-database'; + +interface OidcConfig { + authorization_endpoint: string; + token_endpoint: string; + registration_endpoint?: string; + issuer: string; +} + +async function fetchOidcConfig(idpUrl: string): Promise { + const wellKnown = idpUrl.replace(/\/$/, '') + '/.well-known/openid-configuration'; + const response = await fetch(wellKnown); + if (!response.ok) { + throw new Error(`Failed to fetch OIDC configuration from ${wellKnown}: ${response.status}`); + } + return response.json(); +} + +function getRedirectUri(): string { + return chrome.identity.getRedirectURL('callback'); +} + +async function dynamicClientRegistration( + registrationEndpoint: string, + redirectUri: string +): Promise<{ client_id: string; client_secret?: string }> { + const response = await fetch(registrationEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + application_type: 'web', + redirect_uris: [redirectUri], + token_endpoint_auth_method: 'none', + id_token_signed_response_alg: 'ES256', + grant_types: ['authorization_code', 'refresh_token'], + scope: 'openid webid offline_access', + client_name: 'Solid Browser Extension', + }), + }); + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`Dynamic client registration failed: ${response.status} ${errorBody}`); + } + return response.json(); +} + +function generateCodeVerifier(): string { + const array = new Uint8Array(32); + crypto.getRandomValues(array); + return jose.base64url.encode(array); +} + +async function generateCodeChallenge(verifier: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(verifier); + const hash = await crypto.subtle.digest('SHA-256', data); + return jose.base64url.encode(new Uint8Array(hash)); +} + +function generateState(): string { + const array = new Uint8Array(16); + crypto.getRandomValues(array); + return jose.base64url.encode(array); +} + +export async function initiateLogin(idpUrl: string): Promise { + const oidcConfig = await fetchOidcConfig(idpUrl); + const redirectUri = getRedirectUri(); + + // Register client dynamically + if (!oidcConfig.registration_endpoint) { + throw new Error('IDP does not support dynamic client registration'); + } + + const registration = await dynamicClientRegistration( + oidcConfig.registration_endpoint, + redirectUri + ); + const clientId = registration.client_id; + + // Generate PKCE + const codeVerifier = generateCodeVerifier(); + const codeChallenge = await generateCodeChallenge(codeVerifier); + const state = generateState(); + + // Store auth params for token exchange + await saveAuthParams({ + codeVerifier, + state, + clientId, + tokenEndpoint: oidcConfig.token_endpoint, + issuer: oidcConfig.issuer, + redirectUri, + }); + + // Build authorization URL + const params = new URLSearchParams({ + response_type: 'code', + client_id: clientId, + redirect_uri: redirectUri, + scope: 'openid webid offline_access', + state, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + prompt: 'consent', + }); + + const authUrl = `${oidcConfig.authorization_endpoint}?${params.toString()}`; + + // Launch the auth flow using chrome.identity + const callbackUrl = await new Promise((resolve, reject) => { + chrome.identity.launchWebAuthFlow( + { url: authUrl, interactive: true }, + (responseUrl) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + } else if (!responseUrl) { + reject(new Error('No response URL from auth flow')); + } else { + resolve(responseUrl); + } + } + ); + }); + + // Extract code, state, iss from callback URL + const callbackParams = new URL(callbackUrl).searchParams; + const code = callbackParams.get('code'); + const returnedState = callbackParams.get('state'); + const iss = callbackParams.get('iss'); + + if (!code || !returnedState) { + const error = callbackParams.get('error'); + const errorDesc = callbackParams.get('error_description'); + throw new Error(`Auth failed: ${error} - ${errorDesc}`); + } + + // Handle the redirect (exchange code for tokens) + return handleRedirect(code, returnedState, iss || oidcConfig.issuer); +} + +async function createDpopProof( + keyPair: CryptoKeyPair, + method: string, + url: string, + nonce?: string +): Promise { + const publicJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey); + const header: Record = { + alg: 'ES256', + typ: 'dpop+jwt', + jwk: { + kty: publicJwk.kty, + crv: publicJwk.crv, + x: publicJwk.x, + y: publicJwk.y, + }, + }; + + const payload: Record = { + jti: crypto.randomUUID(), + htm: method, + htu: url, + iat: Math.floor(Date.now() / 1000), + }; + if (nonce) { + payload.nonce = nonce; + } + + const privateKey = await crypto.subtle.importKey( + 'jwk', + await crypto.subtle.exportKey('jwk', keyPair.privateKey), + { name: 'ECDSA', namedCurve: 'P-256' }, + false, + ['sign'] + ); + + return new jose.SignJWT(payload as jose.JWTPayload) + .setProtectedHeader(header as jose.JWTHeaderParameters) + .sign(privateKey); +} + +export async function handleRedirect( + code: string, + state: string, + iss: string +): Promise { + const authParams = await loadAuthParams(); + if (!authParams) { + throw new Error('No auth params found — login flow not initiated'); + } + + // Validate state + if (state !== authParams.state) { + throw new Error('State mismatch — possible CSRF attack'); + } + + // Validate issuer + if (iss !== authParams.issuer) { + throw new Error('Issuer mismatch'); + } + + // Generate DPoP key pair (extractable for persistence) + const keyPair = await crypto.subtle.generateKey( + { name: 'ECDSA', namedCurve: 'P-256' }, + true, + ['sign', 'verify'] + ); + + const tokenEndpoint = authParams.tokenEndpoint as string; + const dpopProof = await createDpopProof(keyPair, 'POST', tokenEndpoint); + + // Exchange code for tokens + const tokenResponse = await fetch(tokenEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + DPoP: dpopProof, + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: authParams.redirectUri as string, + client_id: authParams.clientId as string, + code_verifier: authParams.codeVerifier as string, + }), + }); + + if (!tokenResponse.ok) { + const errorBody = await tokenResponse.text(); + throw new Error(`Token exchange failed: ${tokenResponse.status} ${errorBody}`); + } + + const tokens = await tokenResponse.json(); + + // Decode the ID token to extract WebID + const idTokenPayload = jose.decodeJwt(tokens.id_token); + const webId = (idTokenPayload.webid as string) || (idTokenPayload.sub as string); + if (!webId) { + throw new Error('No WebID found in ID token'); + } + + const storedKeyPair = await exportDpopKeyPair(keyPair); + + const session: StoredSession = { + webId, + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + idToken: tokens.id_token, + dpopKeyPair: storedKeyPair, + tokenEndpoint, + issuer: authParams.issuer as string, + clientId: authParams.clientId as string, + expiresAt: Date.now() + (tokens.expires_in ?? 600) * 1000, + }; + + await saveSession(session); + await clearAuthParams(); + + return session; +} + +export async function refreshAccessToken( + session: StoredSession, + keyPair: CryptoKeyPair +): Promise { + const dpopProof = await createDpopProof(keyPair, 'POST', session.tokenEndpoint); + + const response = await fetch(session.tokenEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + DPoP: dpopProof, + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: session.refreshToken, + client_id: session.clientId, + }), + }); + + if (!response.ok) { + throw new Error(`Token refresh failed: ${response.status}`); + } + + const tokens = await response.json(); + + const updatedSession: StoredSession = { + ...session, + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token ?? session.refreshToken, + expiresAt: Date.now() + (tokens.expires_in ?? 600) * 1000, + }; + + await saveSession(updatedSession); + return updatedSession; +} + +export { createDpopProof }; diff --git a/apps/extension/src/background/service-worker.ts b/apps/extension/src/background/service-worker.ts new file mode 100644 index 000000000..42214e2d1 --- /dev/null +++ b/apps/extension/src/background/service-worker.ts @@ -0,0 +1,172 @@ +import { initiateLogin, refreshAccessToken, createDpopProof } from './auth-flow'; +import { + loadSession, + clearSession, + importDpopKeyPair, + saveClientId, + loadClientIds, + type StoredSession, +} from './session-database'; + +let currentSession: StoredSession | null = null; +let currentKeyPair: CryptoKeyPair | null = null; + +async function ensureSession(): Promise<{ session: StoredSession; keyPair: CryptoKeyPair } | null> { + if (!currentSession) { + currentSession = await loadSession(); + } + if (!currentSession) return null; + + if (!currentKeyPair) { + currentKeyPair = await importDpopKeyPair(currentSession.dpopKeyPair); + } + + // Refresh token if expired or about to expire (30s buffer) + if (currentSession.expiresAt < Date.now() + 30_000) { + try { + currentSession = await refreshAccessToken(currentSession, currentKeyPair); + } catch { + // Refresh failed — session is dead + currentSession = null; + currentKeyPair = null; + await clearSession(); + return null; + } + } + + return { session: currentSession, keyPair: currentKeyPair }; +} + +function broadcastStateChange(webId: string | null): void { + chrome.tabs.query({}, (tabs) => { + for (const tab of tabs) { + if (tab.id) { + chrome.tabs.sendMessage(tab.id, { + type: 'SOLID_STATE_CHANGED', + webId, + }).catch(() => { + // Tab may not have content script loaded + }); + } + } + }); +} + +async function handleAuthFetch( + message: { + requestId: string; + url: string; + method: string; + headers: Record; + body: string | null; + } +): Promise<{ + requestId: string; + status?: number; + statusText?: string; + headers?: Record; + body?: string; + error?: string; +}> { + const ctx = await ensureSession(); + if (!ctx) { + return { requestId: message.requestId, error: 'Not authenticated' }; + } + + const { session, keyPair } = ctx; + + try { + const dpopProof = await createDpopProof(keyPair, message.method, message.url); + + const fetchHeaders: Record = { + ...message.headers, + Authorization: `DPoP ${session.accessToken}`, + DPoP: dpopProof, + }; + + const response = await fetch(message.url, { + method: message.method, + headers: fetchHeaders, + body: message.body, + }); + + const responseHeaders: Record = {}; + response.headers.forEach((value, key) => { + responseHeaders[key] = value; + }); + + const body = await response.text(); + + return { + requestId: message.requestId, + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + body, + }; + } catch (err) { + return { + requestId: message.requestId, + error: err instanceof Error ? err.message : 'Fetch failed', + }; + } +} + +chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + if (message.type === 'SOLID_LOGIN') { + initiateLogin(message.idpUrl) + .then((session) => { + currentSession = session; + currentKeyPair = null; // Will be reimported on next use + broadcastStateChange(session.webId); + sendResponse({ ok: true, webId: session.webId }); + }) + .catch((err) => { + sendResponse({ error: err.message }); + }); + return true; + } + + if (message.type === 'SOLID_FETCH_REQUEST') { + handleAuthFetch(message).then(sendResponse); + return true; + } + + if (message.type === 'SOLID_GET_STATE') { + ensureSession().then((ctx) => { + sendResponse({ + webId: ctx?.session.webId ?? null, + isActive: ctx !== null, + }); + }); + return true; + } + + if (message.type === 'SOLID_SET_CLIENT_ID') { + saveClientId(message.origin, message.clientId) + .then(() => sendResponse({ ok: true })) + .catch((err) => sendResponse({ error: err.message })); + return true; + } + + if (message.type === 'SOLID_LOGOUT') { + currentSession = null; + currentKeyPair = null; + clearSession() + .then(() => { + broadcastStateChange(null); + sendResponse({ ok: true }); + }) + .catch((err) => sendResponse({ error: err.message })); + return true; + } + + return false; +}); + +// Restore session on service worker startup +ensureSession().then((ctx) => { + if (ctx) { + broadcastStateChange(ctx.session.webId); + } +}); diff --git a/apps/extension/src/background/session-database.ts b/apps/extension/src/background/session-database.ts new file mode 100644 index 000000000..8bcbeb3ed --- /dev/null +++ b/apps/extension/src/background/session-database.ts @@ -0,0 +1,85 @@ +const STORAGE_KEY = 'solid-session'; + +export interface StoredSession { + webId: string; + accessToken: string; + refreshToken: string; + idToken: string; + dpopKeyPair: { + publicKey: JsonWebKey; + privateKey: JsonWebKey; + }; + tokenEndpoint: string; + issuer: string; + clientId: string; + expiresAt: number; +} + +export interface ClientIdMapping { + [origin: string]: string; +} + +export async function loadSession(): Promise { + const result = await chrome.storage.local.get(STORAGE_KEY); + return result[STORAGE_KEY] ?? null; +} + +export async function saveSession(session: StoredSession): Promise { + await chrome.storage.local.set({ [STORAGE_KEY]: session }); +} + +export async function clearSession(): Promise { + await chrome.storage.local.remove(STORAGE_KEY); +} + +export async function loadClientIds(): Promise { + const result = await chrome.storage.local.get('solid-client-ids'); + return result['solid-client-ids'] ?? {}; +} + +export async function saveClientId(origin: string, clientId: string): Promise { + const mapping = await loadClientIds(); + mapping[origin] = clientId; + await chrome.storage.local.set({ 'solid-client-ids': mapping }); +} + +export async function loadAuthParams(): Promise | null> { + const result = await chrome.storage.local.get('solid-auth-params'); + return result['solid-auth-params'] ?? null; +} + +export async function saveAuthParams(params: Record): Promise { + await chrome.storage.local.set({ 'solid-auth-params': params }); +} + +export async function clearAuthParams(): Promise { + await chrome.storage.local.remove('solid-auth-params'); +} + +export async function exportDpopKeyPair( + keyPair: CryptoKeyPair +): Promise<{ publicKey: JsonWebKey; privateKey: JsonWebKey }> { + const publicKey = await crypto.subtle.exportKey('jwk', keyPair.publicKey); + const privateKey = await crypto.subtle.exportKey('jwk', keyPair.privateKey); + return { publicKey, privateKey }; +} + +export async function importDpopKeyPair( + stored: { publicKey: JsonWebKey; privateKey: JsonWebKey } +): Promise { + const publicKey = await crypto.subtle.importKey( + 'jwk', + stored.publicKey, + { name: 'ECDSA', namedCurve: 'P-256' }, + true, + ['verify'] + ); + const privateKey = await crypto.subtle.importKey( + 'jwk', + stored.privateKey, + { name: 'ECDSA', namedCurve: 'P-256' }, + true, + ['sign'] + ); + return { publicKey, privateKey }; +} diff --git a/apps/extension/src/content/content-script.ts b/apps/extension/src/content/content-script.ts new file mode 100644 index 000000000..b25b6ed91 --- /dev/null +++ b/apps/extension/src/content/content-script.ts @@ -0,0 +1,86 @@ +export {}; + +const SOLID_EXT_PREFIX = 'solid-browser-ext'; + +// Page -> Background: relay messages from MAIN world to service worker +window.addEventListener('message', (event) => { + if (event.source !== window) return; + if (event.data?.source !== SOLID_EXT_PREFIX) return; + + const { type } = event.data; + + if (type === 'SOLID_FETCH_REQUEST') { + chrome.runtime.sendMessage({ + type: 'SOLID_FETCH_REQUEST', + requestId: event.data.requestId, + url: event.data.url, + method: event.data.method, + headers: event.data.headers, + body: event.data.body, + }, (response) => { + window.postMessage({ + source: SOLID_EXT_PREFIX, + type: 'SOLID_FETCH_RESPONSE', + ...response, + }, '*'); + }); + } + + if (type === 'SOLID_GET_STATE') { + chrome.runtime.sendMessage({ type: 'SOLID_GET_STATE' }, (response) => { + window.postMessage({ + source: SOLID_EXT_PREFIX, + type: 'SOLID_STATE_UPDATE', + webId: response?.webId ?? null, + }, '*'); + }); + } + + if (type === 'SOLID_SET_CLIENT_ID') { + chrome.runtime.sendMessage({ + type: 'SOLID_SET_CLIENT_ID', + origin: event.data.origin, + clientId: event.data.clientId, + }); + } + + if (type === 'SOLID_LOGIN') { + chrome.runtime.sendMessage({ + type: 'SOLID_LOGIN', + actionId: event.data.actionId, + idpUrl: event.data.idpUrl, + }, (response) => { + window.postMessage({ + source: SOLID_EXT_PREFIX, + type: 'SOLID_ACTION_RESPONSE', + actionId: event.data.actionId, + error: response?.error ?? null, + }, '*'); + }); + } + + if (type === 'SOLID_LOGOUT') { + chrome.runtime.sendMessage({ + type: 'SOLID_LOGOUT', + actionId: event.data.actionId, + }, (response) => { + window.postMessage({ + source: SOLID_EXT_PREFIX, + type: 'SOLID_ACTION_RESPONSE', + actionId: event.data.actionId, + error: response?.error ?? null, + }, '*'); + }); + } +}); + +// Background -> Page: listen for state broadcasts from service worker +chrome.runtime.onMessage.addListener((message) => { + if (message.type === 'SOLID_STATE_CHANGED') { + window.postMessage({ + source: SOLID_EXT_PREFIX, + type: 'SOLID_STATE_UPDATE', + webId: message.webId, + }, '*'); + } +}); diff --git a/apps/extension/src/inject/inject.ts b/apps/extension/src/inject/inject.ts new file mode 100644 index 000000000..aac9c6e20 --- /dev/null +++ b/apps/extension/src/inject/inject.ts @@ -0,0 +1,139 @@ +export {}; + +const SOLID_EXT_PREFIX = 'solid-browser-ext'; + +interface SolidExtension { + readonly webId: string | null; + fetch(input: RequestInfo | URL, init?: RequestInit): Promise; + setClientId(clientId: string): void; + login(idpUrl: string): Promise; + logout(): Promise; +} + +const pendingRequests = new Map void; + reject: (reason: Error) => void; +}>(); + +const pendingActions = new Map void; + reject: (reason: Error) => void; +}>(); + +let currentWebId: string | null = null; + +window.addEventListener('message', (event) => { + if (event.source !== window) return; + if (event.data?.source !== SOLID_EXT_PREFIX) return; + + const { type } = event.data; + + if (type === 'SOLID_FETCH_RESPONSE') { + const pending = pendingRequests.get(event.data.requestId); + if (!pending) return; + pendingRequests.delete(event.data.requestId); + + if (event.data.error) { + pending.reject(new Error(event.data.error)); + } else { + pending.resolve(new Response(event.data.body, { + status: event.data.status, + statusText: event.data.statusText, + headers: new Headers(event.data.headers), + })); + } + } + + if (type === 'SOLID_STATE_UPDATE') { + currentWebId = event.data.webId ?? null; + } + + if (type === 'SOLID_ACTION_RESPONSE') { + const pending = pendingActions.get(event.data.actionId); + if (!pending) return; + pendingActions.delete(event.data.actionId); + + if (event.data.error) { + pending.reject(new Error(event.data.error)); + } else { + pending.resolve(); + } + } +}); + +function solidFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + return new Promise((resolve, reject) => { + const requestId = crypto.randomUUID(); + pendingRequests.set(requestId, { resolve, reject }); + + window.postMessage({ + source: SOLID_EXT_PREFIX, + type: 'SOLID_FETCH_REQUEST', + requestId, + url: input.toString(), + method: init?.method || 'GET', + headers: init?.headers + ? Object.fromEntries(new Headers(init.headers).entries()) + : {}, + body: init?.body ?? null, + }, '*'); + }); +} + +function setClientId(clientId: string): void { + window.postMessage({ + source: SOLID_EXT_PREFIX, + type: 'SOLID_SET_CLIENT_ID', + origin: window.location.origin, + clientId, + }, '*'); +} + +function login(idpUrl: string): Promise { + return new Promise((resolve, reject) => { + const actionId = crypto.randomUUID(); + pendingActions.set(actionId, { resolve, reject }); + + window.postMessage({ + source: SOLID_EXT_PREFIX, + type: 'SOLID_LOGIN', + actionId, + idpUrl, + }, '*'); + }); +} + +function logout(): Promise { + return new Promise((resolve, reject) => { + const actionId = crypto.randomUUID(); + pendingActions.set(actionId, { resolve, reject }); + + window.postMessage({ + source: SOLID_EXT_PREFIX, + type: 'SOLID_LOGOUT', + actionId, + }, '*'); + }); +} + +const solid: SolidExtension = { + get webId() { + return currentWebId; + }, + fetch: solidFetch, + setClientId, + login, + logout, +}; + +Object.defineProperty(window, 'solid', { + value: solid, + writable: false, + configurable: false, +}); + +// Request current session state on load +window.postMessage({ + source: SOLID_EXT_PREFIX, + type: 'SOLID_GET_STATE', +}, '*'); diff --git a/apps/extension/src/manifest.json b/apps/extension/src/manifest.json new file mode 100644 index 000000000..481b7f31d --- /dev/null +++ b/apps/extension/src/manifest.json @@ -0,0 +1,42 @@ +{ + "manifest_version": 3, + "name": "Solid Browser Extension", + "version": "0.1.0", + "description": "Authenticate with Solid at the browser level", + "permissions": [ + "storage", + "activeTab", + "identity" + ], + "host_permissions": [ + "" + ], + "background": { + "service_worker": "background/service-worker.js", + "type": "module" + }, + "action": { + "default_popup": "popup/popup.html", + "default_title": "Solid Login" + }, + "content_scripts": [ + { + "matches": [""], + "js": ["inject/inject.js"], + "run_at": "document_start", + "world": "MAIN" + }, + { + "matches": [""], + "js": ["content/content-script.js"], + "run_at": "document_start", + "world": "ISOLATED" + } + ], + "web_accessible_resources": [ + { + "resources": ["redirect/redirect.html"], + "matches": [""] + } + ] +} diff --git a/apps/extension/src/popup/popup.css b/apps/extension/src/popup/popup.css new file mode 100644 index 000000000..9876d41b8 --- /dev/null +++ b/apps/extension/src/popup/popup.css @@ -0,0 +1,143 @@ +:root { + --solid-purple: #7C4DFF; + --solid-purple-dark: #651FFF; + --solid-purple-light: #B388FF; + --text-primary: #1a1a2e; + --text-secondary: #555; + --bg: #ffffff; + --border: #ddd; + --error: #d32f2f; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + width: 320px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg); + color: var(--text-primary); +} + +.container { + padding: 24px 20px; +} + +.header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 20px; +} + +.logo { + width: 36px; + height: 36px; +} + +.header h1 { + font-size: 20px; + font-weight: 700; + color: var(--solid-purple); +} + +.subtitle { + color: var(--text-secondary); + font-size: 13px; + margin-bottom: 16px; +} + +label { + display: block; + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 6px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +input[type="url"] { + width: 100%; + padding: 10px 12px; + border: 1.5px solid var(--border); + border-radius: 8px; + font-size: 14px; + outline: none; + transition: border-color 0.2s; +} + +input[type="url"]:focus { + border-color: var(--solid-purple); +} + +button { + width: 100%; + padding: 10px; + border: none; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s; +} + +#login-btn { + margin-top: 12px; + background: var(--solid-purple); + color: white; +} + +#login-btn:hover { + background: var(--solid-purple-dark); +} + +#logout-btn { + margin-top: 16px; + background: transparent; + color: var(--solid-purple); + border: 1.5px solid var(--solid-purple); +} + +#logout-btn:hover { + background: var(--solid-purple); + color: white; +} + +.webid { + font-size: 13px; + word-break: break-all; + background: #f5f0ff; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid var(--solid-purple-light); + color: var(--text-primary); +} + +.error { + color: var(--error); + font-size: 12px; + margin-top: 8px; +} + +.spinner { + width: 32px; + height: 32px; + border: 3px solid var(--solid-purple-light); + border-top-color: var(--solid-purple); + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin: 20px auto; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +#loading-view { + text-align: center; + color: var(--text-secondary); +} diff --git a/apps/extension/src/popup/popup.html b/apps/extension/src/popup/popup.html new file mode 100644 index 000000000..23a38cab3 --- /dev/null +++ b/apps/extension/src/popup/popup.html @@ -0,0 +1,47 @@ + + + + + + Solid Login + + + +
+
+ +

Solid

+
+ +
+

Sign in with your Solid identity

+
+ + + +
+ +
+ + + + +
+ + + diff --git a/apps/extension/src/popup/popup.ts b/apps/extension/src/popup/popup.ts new file mode 100644 index 000000000..85ff06210 --- /dev/null +++ b/apps/extension/src/popup/popup.ts @@ -0,0 +1,65 @@ +const loginView = document.getElementById('login-view')!; +const loadingView = document.getElementById('loading-view')!; +const loggedInView = document.getElementById('logged-in-view')!; +const loginForm = document.getElementById('login-form') as HTMLFormElement; +const idpInput = document.getElementById('idp-url') as HTMLInputElement; +const loginError = document.getElementById('login-error')!; +const webidDisplay = document.getElementById('webid-display')!; +const logoutBtn = document.getElementById('logout-btn')!; + +function showView(view: 'login' | 'loading' | 'logged-in') { + loginView.hidden = view !== 'login'; + loadingView.hidden = view !== 'loading'; + loggedInView.hidden = view !== 'logged-in'; +} + +// Check current session state on popup open +chrome.runtime.sendMessage({ type: 'SOLID_GET_STATE' }, (response) => { + if (response?.webId) { + webidDisplay.textContent = response.webId; + showView('logged-in'); + } else { + showView('login'); + } +}); + +loginForm.addEventListener('submit', (e) => { + e.preventDefault(); + loginError.hidden = true; + + const idpUrl = idpInput.value.trim(); + if (!idpUrl) return; + + showView('loading'); + + chrome.runtime.sendMessage( + { type: 'SOLID_LOGIN', idpUrl }, + (response) => { + if (response?.error) { + loginError.textContent = response.error; + loginError.hidden = false; + showView('login'); + } + // Login opens a new tab — popup will close. + // When user returns, they'll reopen popup and see logged-in state. + } + ); +}); + +logoutBtn.addEventListener('click', () => { + chrome.runtime.sendMessage({ type: 'SOLID_LOGOUT' }, () => { + showView('login'); + }); +}); + +// Listen for state changes while popup is open +chrome.runtime.onMessage.addListener((message) => { + if (message.type === 'SOLID_STATE_CHANGED') { + if (message.webId) { + webidDisplay.textContent = message.webId; + showView('logged-in'); + } else { + showView('login'); + } + } +}); diff --git a/apps/extension/src/redirect/redirect.html b/apps/extension/src/redirect/redirect.html new file mode 100644 index 000000000..9e1a36c65 --- /dev/null +++ b/apps/extension/src/redirect/redirect.html @@ -0,0 +1,45 @@ + + + + + Solid - Redirecting + + + +
+
+

Processing login...

+ +
+ + + diff --git a/apps/extension/src/redirect/redirect.ts b/apps/extension/src/redirect/redirect.ts new file mode 100644 index 000000000..28dd2264e --- /dev/null +++ b/apps/extension/src/redirect/redirect.ts @@ -0,0 +1,36 @@ +const params = new URLSearchParams(window.location.search); +const code = params.get('code'); +const state = params.get('state'); +const iss = params.get('iss'); +const statusEl = document.getElementById('status')!; +const errorEl = document.getElementById('error')!; +const spinnerEl = document.getElementById('spinner')!; + +if (code && state && iss) { + chrome.runtime.sendMessage( + { + type: 'SOLID_HANDLE_REDIRECT', + code, + state, + iss, + }, + (response) => { + spinnerEl.style.display = 'none'; + if (response?.error) { + statusEl.textContent = 'Login failed'; + errorEl.textContent = response.error; + errorEl.hidden = false; + } else { + statusEl.textContent = 'Logged in successfully!'; + statusEl.className = 'success'; + // Close this tab after a brief delay + setTimeout(() => window.close(), 1500); + } + } + ); +} else { + spinnerEl.style.display = 'none'; + statusEl.textContent = 'Invalid redirect'; + errorEl.textContent = 'Missing required parameters (code, state, or iss).'; + errorEl.hidden = false; +} diff --git a/apps/extension/test-site/app.js b/apps/extension/test-site/app.js new file mode 100644 index 000000000..a96585bcb --- /dev/null +++ b/apps/extension/test-site/app.js @@ -0,0 +1,58 @@ +async function init() { + const webidEl = document.getElementById('webid'); + const fetchResultEl = document.getElementById('fetch-result'); + const statusEl = document.getElementById('status'); + + // Wait for the solid extension to be available and authenticated + function waitForSolid() { + return new Promise((resolve) => { + // Check immediately + if (window.solid && window.solid.webId) { + return resolve(window.solid); + } + // Poll every 200ms + const interval = setInterval(() => { + if (window.solid && window.solid.webId) { + clearInterval(interval); + resolve(window.solid); + } + }, 200); + }); + } + + if (!window.solid) { + statusEl.textContent = 'Solid extension not detected'; + statusEl.className = 'error'; + return; + } + + statusEl.textContent = 'Extension detected, waiting for authentication...'; + + const solid = await waitForSolid(); + webidEl.textContent = solid.webId; + webidEl.className = ''; + statusEl.textContent = 'Authenticated! Fetching profile...'; + + try { + // Fetch the user's profile card using authenticated fetch + const response = await solid.fetch(solid.webId); + if (response.ok) { + const text = await response.text(); + fetchResultEl.textContent = text; + fetchResultEl.className = ''; + statusEl.textContent = 'Profile fetched successfully!'; + } else { + fetchResultEl.textContent = `HTTP ${response.status}: ${response.statusText}`; + fetchResultEl.className = 'error'; + statusEl.textContent = 'Fetch failed'; + statusEl.className = 'error'; + } + } catch (err) { + fetchResultEl.textContent = err.message; + fetchResultEl.className = 'error'; + statusEl.textContent = 'Fetch error'; + statusEl.className = 'error'; + } +} + +init(); diff --git a/apps/extension/test-site/index.html b/apps/extension/test-site/index.html new file mode 100644 index 000000000..e727c7cd5 --- /dev/null +++ b/apps/extension/test-site/index.html @@ -0,0 +1,58 @@ + + + + + + Solid Extension Test + + + +

Solid Extension Test

+ +
+
WebID
+
Waiting for Solid extension...
+
+ +
+
Authenticated Fetch Result
+
Waiting for authentication...
+
+ +
+
Status
+
Checking for Solid extension...
+
+ + + + diff --git a/apps/extension/tsconfig.json b/apps/extension/tsconfig.json new file mode 100644 index 000000000..9391d7e9c --- /dev/null +++ b/apps/extension/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "types": ["chrome"], + "declaration": false, + "sourceMap": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "e2e"] +} diff --git a/apps/extension/webpack.config.js b/apps/extension/webpack.config.js new file mode 100644 index 000000000..f5fdb03d3 --- /dev/null +++ b/apps/extension/webpack.config.js @@ -0,0 +1,41 @@ +const path = require('path'); +const CopyPlugin = require('copy-webpack-plugin'); + +module.exports = { + mode: 'production', + devtool: 'source-map', + entry: { + 'background/service-worker': './src/background/service-worker.ts', + 'popup/popup': './src/popup/popup.ts', + 'content/content-script': './src/content/content-script.ts', + 'inject/inject': './src/inject/inject.ts', + 'redirect/redirect': './src/redirect/redirect.ts', + }, + output: { + path: path.resolve(__dirname, 'dist'), + filename: '[name].js', + clean: true, + }, + resolve: { + extensions: ['.ts', '.js'], + }, + module: { + rules: [ + { + test: /\.ts$/, + use: 'ts-loader', + exclude: /node_modules/, + }, + ], + }, + plugins: [ + new CopyPlugin({ + patterns: [ + { from: 'src/manifest.json', to: 'manifest.json' }, + { from: 'src/popup/popup.html', to: 'popup/popup.html' }, + { from: 'src/popup/popup.css', to: 'popup/popup.css' }, + { from: 'src/redirect/redirect.html', to: 'redirect/redirect.html' }, + ], + }), + ], +}; From 7c9e559c443da26ba8a5dc27f0a3326fa606d33b Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Sat, 4 Apr 2026 14:41:20 +0100 Subject: [PATCH 03/50] chore: get profile card working --- apps/extension/.claude/settings.json | 4 +- .../.claude/skills/data-modelling.md | 195 +++++ .../.claude/skills/integration-guide.md | 678 ++++++++++++++++++ apps/extension/.claude/skills/playwright.md | 453 ++++++++++++ apps/extension/.claude/skills/servers.md | 221 ++++++ apps/extension/.claude/skills/spec.md | 315 ++++++++ apps/extension/.claude/skills/style-guide.md | 128 ++++ .../.claude/skills/test-driven-development.md | 371 ++++++++++ apps/extension/e2e/setup/css-config.json | 66 ++ apps/extension/e2e/setup/css-server.ts | 3 +- .../templates/pod/base/profile/card$.ttl.hbs | 14 + apps/extension/e2e/tests/helpers.ts | 6 +- apps/extension/package.json | 1 + apps/extension/scripts/dev.ts | 138 ++++ apps/extension/src/background/auth-flow.ts | 33 +- .../src/background/service-worker.ts | 2 +- apps/extension/src/content/content-script.ts | 2 +- apps/extension/src/icons/icon128.png | Bin 0 -> 4880 bytes apps/extension/src/icons/icon16.png | Bin 0 -> 594 bytes apps/extension/src/icons/icon32.png | Bin 0 -> 1173 bytes apps/extension/src/icons/icon48.png | Bin 0 -> 1740 bytes apps/extension/src/inject/inject.ts | 6 +- apps/extension/src/manifest.json | 14 +- apps/extension/src/popup/popup.html | 17 +- apps/extension/src/popup/popup.ts | 10 +- apps/extension/webpack.config.js | 1 + 26 files changed, 2654 insertions(+), 24 deletions(-) create mode 100644 apps/extension/.claude/skills/data-modelling.md create mode 100644 apps/extension/.claude/skills/integration-guide.md create mode 100644 apps/extension/.claude/skills/playwright.md create mode 100644 apps/extension/.claude/skills/servers.md create mode 100644 apps/extension/.claude/skills/spec.md create mode 100644 apps/extension/.claude/skills/style-guide.md create mode 100644 apps/extension/.claude/skills/test-driven-development.md create mode 100644 apps/extension/e2e/setup/css-config.json create mode 100644 apps/extension/e2e/setup/templates/pod/base/profile/card$.ttl.hbs create mode 100644 apps/extension/scripts/dev.ts create mode 100644 apps/extension/src/icons/icon128.png create mode 100644 apps/extension/src/icons/icon16.png create mode 100644 apps/extension/src/icons/icon32.png create mode 100644 apps/extension/src/icons/icon48.png diff --git a/apps/extension/.claude/settings.json b/apps/extension/.claude/settings.json index 33522c94c..3a7994574 100644 --- a/apps/extension/.claude/settings.json +++ b/apps/extension/.claude/settings.json @@ -1,5 +1,6 @@ { "permissions": { + "defaultMode": "auto", "allow": [ "WebFetch(domain:davidwalsh.name)", "Bash(curl -s http://localhost:3000/.account/login/)", @@ -12,7 +13,8 @@ "Bash(npx tsx:*)", "Bash(curl -s -X POST http://localhost:3000/.oidc/reg -H 'Content-Type: application/json' -d '{:*)", "Bash(curl -s -H \"Accept: text/html\" \"http://localhost:3000/.oidc/auth?response_type=code&client_id=test&redirect_uri=http://localhost:8080/callback&scope=openid+webid&state=test\")", - "Bash(curl -s -H \"Accept: text/html\" \"http://localhost:3000/.account/oidc/consent/\")" + "Bash(curl -s -H \"Accept: text/html\" \"http://localhost:3000/.account/oidc/consent/\")", + "Bash(curl -s -H \"Accept: text/turtle\" http://localhost:3000/test-pod/profile/card)" ] }, "mcpServers": { diff --git a/apps/extension/.claude/skills/data-modelling.md b/apps/extension/.claude/skills/data-modelling.md new file mode 100644 index 000000000..47416c367 --- /dev/null +++ b/apps/extension/.claude/skills/data-modelling.md @@ -0,0 +1,195 @@ +# Solid Data Modelling Skill + +You are an expert on data modelling for the Solid ecosystem. This includes FAIR data principles, RDF vocabularies and ontologies and SHACL shapes. + +--- + +## FAIR Data Principles in Solid + +FAIR Data Principles (*Findable, Accessible, Interoperable, Reusable*) describe guidelines for improving data usability in open and distributed systems. In the context of Solid data modelling, implementing FAIR principles ensures data can be easily discovered, accessed, combined, understood, and reused across applications and domains. + +| Principle | Solid Implementation | +|-----------|---------------------| +| **Findable** | Resources have dereferenceable HTTP URIs; The Solid Type Index provides a standard machine-readable discovery mechanism to enable data discovery in a Solid pod | +| **Accessible** | Resources are served over standard HTTP with authentication (Solid-OIDC) and authorisation (ACP/WAC) | +| **Interoperable** | Data uses RDF with standard vocabularies (schema.org, FOAF, LDP, etc.) | +| **Reusable** | Shapes (SHACL) define reusable data schemas; linked data enables cross-app data reuse | + +### Applying FAIR in Practice + +1. **Mint stable URIs** — use consistent URI patterns for resources +2. **Use standard vocabularies** — reuse standard vocabularies e.g. schema.org, FOAF, Dublin Core, SKOS where possible +3. **Describe your data in machine-readable format** — add RDF type statements and metadata to every resource +4. **Publish shapes** — provide SHACL shapes so consuming apps can validate the data +5. **Link datasets** — use `rdfs:seeAlso`, and `skos:exactMatch` to connect related data + +--- + +## RDF Vocabulary Selection + +### Core Vocabularies for Solid + +| Prefix | Vocabulary | Description | Namespace URL | +| -------------------- | ------------------ | -------------------------------------------- | ------------------------------------------------------------------------------------------ | +| ACP | `acp:` | Access Control Policy Language | [http://www.w3.org/ns/solid/acp#](http://www.w3.org/ns/solid/acp#) | +| AS (ActivityStreams) | `as:` | Social activities, notifications | [https://www.w3.org/ns/activitystreams#](https://www.w3.org/ns/activitystreams#) | +| DC | `dc:` / `dcterms:` | Metadata (title, creator, date, description) | [http://purl.org/dc/terms/](http://purl.org/dc/terms/) | +| FOAF | `foaf:` | Person, identity, social graph | [http://xmlns.com/foaf/0.1/](http://xmlns.com/foaf/0.1/) | +| ICAL | `ical:` | Calendar data | [http://www.w3.org/2002/12/cal/ical#](http://www.w3.org/2002/12/cal/ical#) | +| LDP | `ldp:` | Containers, membership | [http://www.w3.org/ns/ldp#](http://www.w3.org/ns/ldp#) | +| ORG | `org:` | Organization ontology | [http://www.w3.org/ns/org#](http://www.w3.org/ns/org#) | +| OWL | `owl:` | Ontology relations, sameAs | [http://www.w3.org/2002/07/owl#](http://www.w3.org/2002/07/owl#) | +| PIM | `pim:` | Storage, preferences, configuration | [http://www.w3.org/ns/pim/space#](http://www.w3.org/ns/pim/space#) | +| POSIX | `posix:` | POSIX file system permissions | [http://www.w3.org/ns/posix/stat#](http://www.w3.org/ns/posix/stat#) | +| RDF | `rdf:` | RDF syntax concepts | [http://www.w3.org/1999/02/22-rdf-syntax-ns#](http://www.w3.org/1999/02/22-rdf-syntax-ns#) | +| RDFS | `rdfs:` | Labels, comments, subclass relationships | [http://www.w3.org/2000/01/rdf-schema#](http://www.w3.org/2000/01/rdf-schema#) | +| Schema.org | `schema:` | Structured data vocabulary | [https://schema.org/](https://schema.org/) | +| Solid | `solid:` | OIDC, notifications, identity | [https://www.w3.org/ns/solid/](https://www.w3.org/ns/solid/) | +| vCard | `vcard:` | Contact information | [http://www.w3.org/2006/vcard/ns#](http://www.w3.org/2006/vcard/ns#) | + + +### Vocabulary Lookup + +Before creating custom RDF classes and properties, search existing ontologies/vocabularies: + +- **Linked Open Vocabularies (LOV)**: https://lov.linkeddata.es/ +- **schema.org**: https://schema.org/ — widely adopted, search-engine friendly + +--- + +## Shapes: SHACL + +Shapes define the expected structure of RDF data. They serve as schemas for validation and code generation. + +### SHACL (Shapes Constraint Language) + +[SHACL](https://www.w3.org/TR/shacl/) is the W3C Recommendation for RDF validation, more expressive for complex constraints. + +```turtle +@prefix sh: . +@prefix foaf: . + + + a sh:NodeShape ; + sh:targetClass foaf:Person ; + sh:property [ + sh:path foaf:name ; + sh:datatype xsd:string ; + sh:minCount 1 ; + ] ; + sh:property [ + sh:path foaf:mbox ; + sh:nodeKind sh:IRI ; + sh:maxCount 1 ; + ] . +``` + +### SHACL Shape Discovery + + +A number of **public SHACL shapes collections and published shapes** are available that you can reuse or study: + +- **[Solid Shapes Catalogue](https://github.com/solid/shapes)** + Provides reusable SHACL shapes used in Solid ecosystem applications. + +- **[W3C Data Shapes Working Group Resources](https://github.com/w3c/data-shapes)** + Official working group repository containing specifications, examples, and discussion materials related to RDF data shapes. + +- **[Awesome Semantic Shapes Curated List](https://github.com/w3c-cg/awesome-semantic-shapes)** + Broad index of SHACL and ShEx shape resources across domains and vocabularies. + +- **Shapes-of-You Semantic Index** + A public index of semantic web resources that helps researchers discover linked data artefacts including SHACL shapes, ontologies, mappings, and SPARQL assets. + https://index.semanticscience.org/ + +- **[SHACL Play! Shapes Catalog (SKOS & vocab constraint shapes)](https://shacl-play.sparna.fr/play/shapes-catalog)** + Provides ready-to-use SKOS validation shapes implementing many SKOS integrity constraints. + +- **[SEMICeu DCAT-AP SHACL Shapes](https://github.com/SEMICeu/dcat-ap_shacl/blob/master/README.md)** + Repository containing SHACL shapes for the DCAT-AP data catalogue application profile. +--- + +## MCP Adapters for Ontology Discovery + +Model Context Protocol (MCP) adapters allow AI tools and development environments to discover and work with ontologies and shapes programmatically. + +### Discovering Ontologies via MCP + +When building an MCP adapter for ontology discovery, key capabilities to expose: + +1. **Search by term** — find classes and properties matching a keyword +2. **Shape retrieval** — fetch SHACL shapes for a known type +3. **Type index traversal** — discover where a user stores instances of a given type on their pod + +### Recommended Approach for Ontology Discovery in Apps + +``` +1. Check LOV (lov.linkeddata.es) for existing vocabulary terms +2. Use SHACL shapes (from Solid shapes repo) to validate data +3. For pod-specific discovery, traverse the pod's Type Index +``` + +--- + +### Structure + +```turtle +# Public type index at /settings/publicTypeIndex.ttl + +@prefix solid: . +@prefix schema: . + +<#registration-contacts> + a solid:TypeRegistration ; + solid:forClass schema:Person ; + solid:instanceContainer . + +<#registration-notes> + a solid:TypeRegistration ; + solid:forClass schema:TextObject ; + solid:instance . +``` + +### Two Registration Types + +| Property | Meaning | +|----------|---------| +| `solid:instanceContainer` | All instances of this type live in this container | +| `solid:instance` | A single document containing instances of this type | + +### Accessing the Solid Type Index + +1. Fetch the user's WebID profile +2. Find `solid:publicTypeIndex` and/or `solid:privateTypeIndex` predicates +3. Fetch the Solid Type Index document +4. Query for `solid:TypeRegistration` entries with matching `solid:forClass` + +--- + +## Data Modelling Checklist + +When modelling a new data type for Solid: + +- [ ] Check for existing SHACL shapes with types that meet your requirements +- [ ] Check LOV for an existing vocabulary/class +- [ ] Define a SHACL shape for the type +- [ ] Add `rdf:type` statements to all instances +- [ ] Register the type in the pod's Solid Type Index +- [ ] Use stable, dereferenceable URIs for subjects +- [ ] Add `dcterms:created`, `dcterms:modified` metadata +- [ ] Document the shape in a public shapes repository +- [ ] Validate data against the shape before writing to the pod + +--- + +## Resources + +| Resource | URL | +|----------|-----| +| FAIR Principles | https://www.go-fair.org/fair-principles/ | +| Linked Open Vocabularies | https://lov.linkeddata.es/ | +| W3C SHACL Spec | https://www.w3.org/TR/shacl/ | +| W3C Data Shapes repo | https://github.com/w3c/data-shapes | +| Solid type indexes | https://solid.github.io/type-indexes/ | +| Shapes resources | https://github.com/w3c-cg/awesome-semantic-shapes | +| Solid Developer Documentation | https://dev.solidproject.org/ | diff --git a/apps/extension/.claude/skills/integration-guide.md b/apps/extension/.claude/skills/integration-guide.md new file mode 100644 index 000000000..ed08d2436 --- /dev/null +++ b/apps/extension/.claude/skills/integration-guide.md @@ -0,0 +1,678 @@ +# Solid Integration Guide Skill + +You are an expert on integrating JavaScript/TypeScript applications with the Solid ecosystem. You know the Inrupt SDK, LDO, and solid-client-authn-js libraries in depth, and can guide developers through authentication, reading, writing, and managing access to Solid pods. + +Based application being built by user, recommend library to use and get confirmation from the user. + +--- + +## Library Overview + +| Library | Package | Best for | +|---------|---------|----------| +| @inrupt/solid-client | `@inrupt/solid-client` | Reading/writing structured RDF data (SolidDataset/Thing API) | +| solid-client-authn-browser | `@inrupt/solid-client-authn-browser` | Browser authentication (OIDC + DPoP) | +| solid-client-authn-node | `@inrupt/solid-client-authn-node` | Node.js / server-side authentication | +| LDO | `ldo` / `@ldo/solid` | TypeScript-first RDF with ShEx shapes; type-safe linked data | +| @ldo/connected-solid | `@ldo/connected-solid` | LDO + Solid pods: containers, WAC, live updates | +| N3.js | `n3` | Low-level RDF parsing, serialising, and in-memory quad storage | + +--- + +## Authentication with solid-client-authn-js + +**Docs**: https://inrupt.github.io/solid-client-authn-js/ +**Repo**: https://github.com/inrupt/solid-client-authn-js + +### Browser Authentication + +```bash +npm install @inrupt/solid-client-authn-browser +``` + +```typescript +import { + login, + logout, + handleIncomingRedirect, + getDefaultSession, + fetch, // authenticated fetch — use this for all pod requests +} from "@inrupt/solid-client-authn-browser"; + +// 1. Handle redirect on page load (restore session after OIDC redirect) +await handleIncomingRedirect({ restorePreviousSession: true }); + +// 2. Check if already logged in +const session = getDefaultSession(); +if (!session.info.isLoggedIn) { + // 3. Redirect user to their OIDC provider + await login({ + oidcIssuer: "https://solidcommunity.net", + redirectUrl: window.location.href, + clientName: "My Solid App", + }); +} + +// 4. Use the authenticated session's fetch for all pod requests +const response = await fetch("https://alice.solidcommunity.net/profile/card"); +``` + +### Node.js Authentication (Client Credentials) + +```bash +npm install @inrupt/solid-client-authn-node +``` + +```typescript +import { Session } from "@inrupt/solid-client-authn-node"; + +const session = new Session(); + +await session.login({ + oidcIssuer: "https://solidcommunity.net", + clientId: "your-client-id", + clientSecret: "your-client-secret", + clientName: "My Node App", +}); + +// Use session.fetch for authenticated requests +const response = await session.fetch("https://alice.solidcommunity.net/data/"); +``` + +### Key Rules + +- Always use the session's `fetch` (not global `fetch`) for requests to Solid pods +- Call `handleIncomingRedirect` on every page load to restore session after OIDC callback +- Store the `oidcIssuer` — prompt users to enter their pod provider if unknown +- Tokens are DPoP-bound; do not strip or modify Authorization headers + +--- + +## Reading and Writing with @inrupt/solid-client + +**Docs**: https://docs.inrupt.com/developer-tools/javascript/client-libraries/ +**Package**: `@inrupt/solid-client` + +```bash +npm install @inrupt/solid-client @inrupt/solid-client-authn-browser +``` + +### Core Concepts + +| Concept | Description | +|---------|-------------| +| `SolidDataset` | An RDF document fetched from a pod | +| `Thing` | A named set of RDF triples within a SolidDataset (identified by URL) | +| `saveSolidDatasetAt` | Write a modified SolidDataset back to the pod | +| `getThing` / `setThing` | Read/modify a Thing within a dataset | +| `buildThing` / `createThing` | Construct new Things | + +### Read a Resource + +```typescript +import { + getSolidDataset, + getThing, + getStringNoLocale, + getUrl, +} from "@inrupt/solid-client"; +import { fetch } from "@inrupt/solid-client-authn-browser"; +import { FOAF, SCHEMA_INRUPT } from "@inrupt/vocab-common-rdf"; + +const dataset = await getSolidDataset( + "https://alice.solidcommunity.net/profile/card", + { fetch } +); + +const profile = getThing( + dataset, + "https://alice.solidcommunity.net/profile/card#me" +); + +const name = getStringNoLocale(profile, FOAF.name); +const storage = getUrl(profile, "http://www.w3.org/ns/pim/space#storage"); +``` + +### Write a Resource + +```typescript +import { + getSolidDataset, + getThing, + setThing, + setStringNoLocale, + saveSolidDatasetAt, + buildThing, + createThing, +} from "@inrupt/solid-client"; +import { fetch } from "@inrupt/solid-client-authn-browser"; +import { SCHEMA_INRUPT } from "@inrupt/vocab-common-rdf"; + +// Fetch existing dataset +const dataset = await getSolidDataset( + "https://alice.solidcommunity.net/notes/note1.ttl", + { fetch } +); + +// Modify an existing Thing +let note = getThing( + dataset, + "https://alice.solidcommunity.net/notes/note1.ttl#note" +); +note = setStringNoLocale(note, SCHEMA_INRUPT.text, "Updated note content"); + +// Save back to the pod +const updatedDataset = setThing(dataset, note); +await saveSolidDatasetAt( + "https://alice.solidcommunity.net/notes/note1.ttl", + updatedDataset, + { fetch } +); +``` + +### Create a New Resource + +```typescript +import { + createSolidDataset, + setThing, + buildThing, + createThing, + saveSolidDatasetAt, +} from "@inrupt/solid-client"; +import { fetch } from "@inrupt/solid-client-authn-browser"; +import { RDF, SCHEMA_INRUPT } from "@inrupt/vocab-common-rdf"; + +const newNote = buildThing(createThing({ name: "my-note" })) + .addUrl(RDF.type, "https://schema.org/TextObject") + .addStringNoLocale(SCHEMA_INRUPT.text, "My first note") + .build(); + +const dataset = setThing(createSolidDataset(), newNote); + +// saveSolidDatasetAt creates intermediate containers automatically +await saveSolidDatasetAt( + "https://alice.solidcommunity.net/notes/my-note.ttl", + dataset, + { fetch } +); +``` + +### ACL Management + +```typescript +import { + getSolidDatasetWithAcl, + getResourceAcl, + setAgentResourceAccess, + saveAclFor, +} from "@inrupt/solid-client"; + +const datasetWithAcl = await getSolidDatasetWithAcl(resourceUrl, { fetch }); +const resourceAcl = getResourceAcl(datasetWithAcl); + +const updatedAcl = setAgentResourceAccess( + resourceAcl, + "https://bob.solidcommunity.net/profile/card#me", + { read: true, write: false, append: false, control: false } +); + +await saveAclFor(datasetWithAcl, updatedAcl, { fetch }); +``` + +--- + +## LDO (Linked Data Objects) + +**Docs**: https://ldo.js.org/ +**Package**: `ldo`, `@ldo/solid`, `@ldo/connected-solid` + +LDO provides TypeScript-typed access to RDF data using ShEx shapes. It generates TypeScript interfaces from shapes, so you work with familiar object literals instead of raw RDF APIs. + +### Install + +```bash +npm install ldo @ldo/solid +npm install --save-dev @ldo/cli +``` + +### Generate TypeScript from ShEx + +```bash +# Given a ShEx file at ./shapes/Person.shex +npx ldo generate ./shapes +``` + +This generates: +- TypeScript interfaces (`.typings.ts`) +- JSON-LD context (`.context.ts`) +- Shape type (`.shapeTypes.ts`) + +### Read RDF as TypeScript Object + +```typescript +import { parseRdf } from "ldo"; +import { PersonShapeType } from "./shapes/PersonShapeType"; + +const turtle = ` +@prefix foaf: . + a foaf:Person ; foaf:name "Alice" . +`; + +const dataset = await parseRdf(turtle, { baseIRI: "https://alice.example.com/" }); +const ldoDataset = createLdoDataset([...dataset]); + +const person = ldoDataset + .usingType(PersonShapeType) + .fromSubject("https://alice.example.com/#me"); + +console.log(person.name); // "Alice" +``` + +### Write via LDO + +```typescript +const [person, setThing] = createLdoDataset().usingType(PersonShapeType).write(); +person.["@id"] = "https://alice.example.com/#me"; +person.name = "Alice Updated"; + +// Serialize back to Turtle +const turtle = await serialize(dataset, { format: "text/turtle" }); +``` + +### @ldo/connected-solid — LDO + Solid Pods + +Combines LDO with Solid pods for type-safe, live pod interaction: + +```typescript +import { createSolidLdoDataset } from "@ldo/connected-solid"; + +const solidLdoDataset = createSolidLdoDataset(); +await solidLdoDataset.auth.login({ oidcIssuer: "https://solidcommunity.net" }); + +// Read a resource into the dataset +await solidLdoDataset.getResource("https://alice.solidcommunity.net/notes/note1.ttl").read(); + +// Access typed object +const note = solidLdoDataset + .usingType(NoteShapeType) + .fromSubject("https://alice.solidcommunity.net/notes/note1.ttl#note"); + +// Mutate and commit +note.text = "Updated via LDO"; +await solidLdoDataset.commitChanges(); +``` + +**Additional @ldo/connected-solid features:** +- Container listing and creation +- WAC access control management +- Live update subscriptions (WebSocket notifications) + +--- + +## N3.js — Low-Level RDF Parsing and Storage + +**Repo**: https://github.com/rdfjs/N3.js +**Docs**: https://rdf.js.org/N3.js/ +**Package**: `n3` + +N3.js is a low-level, spec-compliant RDF library for JavaScript. It handles parsing, serialising, and in-memory storage of RDF quads. It implements the [RDF/JS interfaces](https://rdf.js.org/) and is the foundation other libraries build on. + +Use N3.js when you need direct control over RDF data — raw PATCH body construction, custom parsers, graph reasoning, or working with formats the higher-level libraries don't expose. + +### Install + +```bash +npm install n3 +``` + +```typescript +import * as N3 from "n3"; +// or specific imports: +import { Parser, Writer, Store, DataFactory, StreamParser, StreamWriter } from "n3"; +const { namedNode, literal, defaultGraph, quad } = DataFactory; +``` + +### DataFactory — Constructing RDF Terms + +```typescript +import { DataFactory } from "n3"; +const { namedNode, literal, blankNode, defaultGraph, quad } = DataFactory; + +// Named node (IRI) +const alice = namedNode("https://alice.solidcommunity.net/profile/card#me"); + +// Literal with language tag +const name = literal("Alice", "en"); + +// Literal with datatype +import { xsd } from "@tpluscode/rdf-ns-builders"; +const dob = literal("1990-01-01", namedNode("http://www.w3.org/2001/XMLSchema#date")); + +// Build a quad (subject, predicate, object, graph) +const myQuad = quad( + alice, + namedNode("http://xmlns.com/foaf/0.1/name"), + name, + defaultGraph() +); + +console.log(myQuad.subject.value); // "https://alice.solidcommunity.net/profile/card#me" +``` + +### Parser — Turtle / N-Triples / TriG / N-Quads to Quads + +```typescript +import { Parser } from "n3"; + +const turtle = ` + @prefix foaf: . + foaf:name "Alice" . +`; + +// Callback-based (async, streaming-friendly) +const parser = new Parser({ baseIRI: "https://alice.example.com/" }); +parser.parse(turtle, (error, quad, prefixes) => { + if (quad) { + console.log(quad.subject.value, quad.predicate.value, quad.object.value); + } else { + console.log("Done. Prefixes:", prefixes); + } +}); + +// Synchronous (returns array) +const quads = new Parser().parse(turtle); +``` + +**Format options** (pass as `{ format: "..." }`): + +| String | Parses | +|--------|--------| +| `"Turtle"` (default) | Turtle + permissive superset | +| `"N-Triples"` | N-Triples | +| `"TriG"` | TriG (named graphs) | +| `"N-Quads"` | N-Quads | +| `"N3"` / `"text/n3"` | N3 (with rules) | + +### Writer — Quads to Turtle / N-Triples / TriG + +```typescript +import { Writer, DataFactory } from "n3"; +const { namedNode, literal } = DataFactory; + +// Write to string +const writer = new Writer({ + prefixes: { foaf: "http://xmlns.com/foaf/0.1/" }, + // format: "N-Triples" // override default Turtle +}); + +writer.addQuad( + namedNode("https://alice.example.com/#me"), + namedNode("http://xmlns.com/foaf/0.1/name"), + literal("Alice", "en") +); + +writer.end((error, result) => { + console.log(result); + // @prefix foaf: . + // foaf:name "Alice"@en. +}); +``` + +### Store — In-Memory Quad Index + +`N3.Store` is an efficient indexed in-memory store with quad-pattern lookups. + +```typescript +import { Store, Parser, DataFactory } from "n3"; +const { namedNode } = DataFactory; + +const store = new Store(); + +// Add quads directly +store.addQuad( + namedNode("https://alice.example.com/#me"), + namedNode("http://xmlns.com/foaf/0.1/name"), + DataFactory.literal("Alice") +); + +// Or parse directly into the store +const parser = new Parser(); +const quads = parser.parse(` + @prefix foaf: . + foaf:name "Alice" ; foaf:knows . +`); +store.addQuads(quads); + +// Query: getQuads(subject, predicate, object, graph) — null = wildcard +const names = store.getQuads( + namedNode("https://alice.example.com/#me"), + namedNode("http://xmlns.com/foaf/0.1/name"), + null, + null +); +console.log(names[0].object.value); // "Alice" + +// Count matching quads +const count = store.countQuads(null, namedNode("http://xmlns.com/foaf/0.1/knows"), null, null); + +// Iterate over unique subjects +store.forSubjects( + (subject) => console.log(subject.value), + namedNode("http://xmlns.com/foaf/0.1/knows"), + null, + null +); + +// Remove quads +store.removeQuads(names); +// or by pattern: +store.removeMatches(namedNode("https://alice.example.com/#me"), null, null, null); +``` + +### StreamParser and StreamWriter — Large Files + +```typescript +import { StreamParser, StreamWriter } from "n3"; +import { createReadStream, createWriteStream } from "fs"; + +// Parse a large Turtle file as a stream +const inputStream = createReadStream("data.ttl"); +const streamParser = new StreamParser({ format: "Turtle" }); + +inputStream.pipe(streamParser); + +streamParser.on("data", (quad) => { + console.log(quad.subject.value); +}); +streamParser.on("end", () => console.log("Done")); + +// Transform stream: re-serialise to N-Triples +const streamWriter = new StreamWriter({ format: "N-Triples" }); +inputStream + .pipe(new StreamParser()) + .pipe(streamWriter) + .pipe(createWriteStream("output.nt")); +``` + +### SPARQL Queries with Comunica + +N3.js does not include a SPARQL engine, but its `Store` implements the RDF/JS interfaces so it works directly with [Comunica](https://comunica.dev/). + +```bash +npm install @comunica/query-sparql-rdfjs +``` + +```typescript +import { QueryEngine } from "@comunica/query-sparql-rdfjs"; +import { Store, Parser } from "n3"; + +const store = new Store(); +store.addQuads(new Parser().parse(` + @prefix foaf: . + <#alice> foaf:name "Alice" ; foaf:knows <#bob> . + <#bob> foaf:name "Bob" . +`)); + +const engine = new QueryEngine(); +const bindingsStream = await engine.queryBindings( + "SELECT ?name WHERE { ?s ?name }", + { sources: [store] } +); + +bindingsStream.on("data", (bindings) => { + console.log(bindings.get("name").value); +}); +``` + +### N3.js in a Solid Context + +N3.js is useful when working directly with Solid at the HTTP level — parsing response bodies, constructing N3 PATCH payloads, or processing raw Turtle resources. + +```typescript +import { fetch } from "@inrupt/solid-client-authn-browser"; +import { Parser, Writer, Store, DataFactory } from "n3"; +const { namedNode, literal } = DataFactory; + +// Fetch and parse a Solid resource as raw RDF +const response = await fetch("https://alice.solidcommunity.net/profile/card", { + headers: { Accept: "text/turtle" }, +}); +const turtle = await response.text(); + +const store = new Store(); +store.addQuads(new Parser({ baseIRI: response.url }).parse(turtle)); + +// Query the profile +const names = store.getQuads( + null, + namedNode("http://xmlns.com/foaf/0.1/name"), + null, + null +); +console.log(names.map((q) => q.object.value)); + +// Build an N3 Patch body +const patchWriter = new Writer({ format: "text/n3" }); +patchWriter.addQuad( + namedNode(""), + namedNode("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"), + namedNode("http://www.w3.org/ns/solid/terms#InsertDeletePatch") +); +// ... add solid:inserts quads +patchWriter.end(async (err, patchBody) => { + await fetch("https://alice.solidcommunity.net/profile/card", { + method: "PATCH", + headers: { "Content-Type": "text/n3" }, + body: patchBody, + }); +}); +``` + +--- + +## Choosing the Right Library + +| Scenario | Recommended Library | +|----------|-------------------| +| Browser app, structured data | `@inrupt/solid-client` + `solid-client-authn-browser` | +| Node.js script or API | `@inrupt/solid-client` + `solid-client-authn-node` | +| TypeScript-first, shape-driven | `ldo` + `@ldo/solid` | +| React app with live updates | `@ldo/connected-solid` | +| Raw HTTP, no library overhead | Native `fetch` with `solid-client-authn` session fetch | +| Low-level RDF parsing / serialising | `n3` (N3.js) | +| In-memory graph querying | `n3` Store + Comunica | +| Custom PATCH body construction | `n3` Writer (`text/n3` format) | +| Stream processing large RDF files | `n3` StreamParser + StreamWriter | + +--- + +## Common Patterns + +### Check if User is Logged In + +```typescript +import { getDefaultSession } from "@inrupt/solid-client-authn-browser"; + +const { isLoggedIn, webId } = getDefaultSession().info; +``` + +### Discover User's Storage + +```typescript +import { getSolidDataset, getThing, getUrl } from "@inrupt/solid-client"; + +const profile = await getSolidDataset(webId, { fetch }); +const profileThing = getThing(profile, webId); +const storageUrl = getUrl(profileThing, "http://www.w3.org/ns/pim/space#storage"); +``` + +For robust pod root discovery (handles servers that don't advertise `pim:storage` on the profile), use the recursive `getPodRoot` pattern from [Bashlib util.ts L73-104](https://github.com/SolidLabResearch/Bashlib/blob/80de25cbb4b3ed057f95e25bc057f1be9b00cef3/src/utils/util.ts#L73-L104). It uses a three-stage approach: + +1. HEAD request — checks the `Link` header for a `pim:Storage` relation on the resource +2. Dataset query — fetches the resource and looks for a `pim:storage` triple +3. Recursive traversal — walks up the URL path one segment at a time, repeating steps 1-2 + +```typescript +async function getPodRoot(url: string, fetch: typeof globalThis.fetch): Promise { + // Stage 1: check Link header + const headRes = await fetch(url, { method: "HEAD" }); + const linkHeader = headRes.headers.get("Link") ?? ""; + if (linkHeader.includes("http://www.w3.org/ns/pim/space#Storage")) { + return url; + } + + // Stage 2: check dataset for pim:storage triple + try { + const dataset = await getSolidDataset(url, { fetch }); + const thing = getThing(dataset, url); + const storage = getUrl(thing, "http://www.w3.org/ns/pim/space#storage"); + if (storage) return storage; + } catch {} + + // Stage 3: recurse up the URL path + const parent = new URL(url); + const segments = parent.pathname.replace(/\/$/, "").split("/"); + if (segments.length <= 1) return null; + parent.pathname = segments.slice(0, -1).join("/") + "/"; + return getPodRoot(parent.toString(), fetch); +} +``` + +### List a Container + +```typescript +import { getSolidDataset, getContainedResourceUrlAll } from "@inrupt/solid-client"; + +const container = await getSolidDataset(containerUrl, { fetch }); +const resourceUrls = getContainedResourceUrlAll(container); +``` + +--- + +## Troubleshooting + +| Problem | Likely Cause | Fix | +|---------|-------------|-----| +| 401 Unauthorized | Not using session `fetch` | Import `fetch` from authn library; pass to all solid-client calls | +| 403 Forbidden | Insufficient ACL/ACP | Check access control on the resource | +| 409 Conflict on save | Outdated dataset / ETag mismatch | Re-fetch the dataset before saving | +| CORS error in browser | Server CORS config | Use a server that supports CORS or proxy in dev | +| Session lost on refresh | `handleIncomingRedirect` not called | Call with `restorePreviousSession: true` on every page load | +| DPoP error | Token not DPoP-bound | Always use the authn library's `fetch`; never strip headers | + +--- + +## Key Links + +| Resource | URL | +|----------|-----| +| Inrupt SDK docs | https://docs.inrupt.com/sdk/javascript-sdk | +| @inrupt/solid-client npm | https://www.npmjs.com/package/@inrupt/solid-client | +| solid-client-authn-js | https://inrupt.github.io/solid-client-authn-js/ | +| solid-client-authn-js repo | https://github.com/inrupt/solid-client-authn-js | +| LDO docs | https://ldo.js.org/1.0.0-alpha.X/ | +| @ldo/connected-solid | https://ldo.js.org/1.0.0-alpha.X/api/connected-solid/ | +| N3.js repo | https://github.com/rdfjs/N3.js | +| N3.js docs | https://rdf.js.org/N3.js/ | +| Comunica (SPARQL over N3 Store) | https://comunica.dev/docs/query/advanced/rdfjs_querying/ | diff --git a/apps/extension/.claude/skills/playwright.md b/apps/extension/.claude/skills/playwright.md new file mode 100644 index 000000000..98c82145c --- /dev/null +++ b/apps/extension/.claude/skills/playwright.md @@ -0,0 +1,453 @@ +--- +name: playwright-skill +description: Complete browser automation with Playwright. Auto-detects dev servers, writes clean test scripts to /tmp. Test pages, fill forms, take screenshots, check responsive design, validate UX, test login flows, check links, automate any browser task. Use when user wants to test websites, automate browser interactions, validate web functionality, or perform any browser-based testing. +--- + +**IMPORTANT - Path Resolution:** +This skill can be installed in different locations (plugin system, manual installation, global, or project-specific). Before executing any commands, determine the skill directory based on where you loaded this SKILL.md file, and use that path in all commands below. Replace `$SKILL_DIR` with the actual discovered path. + +Common installation paths: + +- Plugin system: `~/.claude/plugins/marketplaces/playwright-skill/skills/playwright-skill` +- Manual global: `~/.claude/skills/playwright-skill` +- Project-specific: `/.claude/skills/playwright-skill` + +# Playwright Browser Automation + +General-purpose browser automation skill. I'll write custom Playwright code for any automation task you request and execute it via the universal executor. + +**CRITICAL WORKFLOW - Follow these steps in order:** + +1. **Auto-detect dev servers** - For localhost testing, ALWAYS run server detection FIRST: + + ```bash + cd $SKILL_DIR && node -e "require('./lib/helpers').detectDevServers().then(servers => console.log(JSON.stringify(servers)))" + ``` + + - If **1 server found**: Use it automatically, inform user + - If **multiple servers found**: Ask user which one to test + - If **no servers found**: Ask for URL or offer to help start dev server + +2. **Write scripts to /tmp** - NEVER write test files to skill directory; always use `/tmp/playwright-test-*.js` + +3. **Use visible browser by default** - Always use `headless: false` unless user specifically requests headless mode + +4. **Parameterize URLs** - Always make URLs configurable via environment variable or constant at top of script + +## How It Works + +1. You describe what you want to test/automate +2. I auto-detect running dev servers (or ask for URL if testing external site) +3. I write custom Playwright code in `/tmp/playwright-test-*.js` (won't clutter your project) +4. I execute it via: `cd $SKILL_DIR && node run.js /tmp/playwright-test-*.js` +5. Results displayed in real-time, browser window visible for debugging +6. Test files auto-cleaned from /tmp by your OS + +## Setup (First Time) + +```bash +cd $SKILL_DIR +npm run setup +``` + +This installs Playwright and Chromium browser. Only needed once. + +## Execution Pattern + +**Step 1: Detect dev servers (for localhost testing)** + +```bash +cd $SKILL_DIR && node -e "require('./lib/helpers').detectDevServers().then(s => console.log(JSON.stringify(s)))" +``` + +**Step 2: Write test script to /tmp with URL parameter** + +```javascript +// /tmp/playwright-test-page.js +const { chromium } = require('playwright'); + +// Parameterized URL (detected or user-provided) +const TARGET_URL = 'http://localhost:3001'; // <-- Auto-detected or from user + +(async () => { + const browser = await chromium.launch({ headless: false }); + const page = await browser.newPage(); + + await page.goto(TARGET_URL); + console.log('Page loaded:', await page.title()); + + await page.screenshot({ path: '/tmp/screenshot.png', fullPage: true }); + console.log('📸 Screenshot saved to /tmp/screenshot.png'); + + await browser.close(); +})(); +``` + +**Step 3: Execute from skill directory** + +```bash +cd $SKILL_DIR && node run.js /tmp/playwright-test-page.js +``` + +## Common Patterns + +### Test a Page (Multiple Viewports) + +```javascript +// /tmp/playwright-test-responsive.js +const { chromium } = require('playwright'); + +const TARGET_URL = 'http://localhost:3001'; // Auto-detected + +(async () => { + const browser = await chromium.launch({ headless: false, slowMo: 100 }); + const page = await browser.newPage(); + + // Desktop test + await page.setViewportSize({ width: 1920, height: 1080 }); + await page.goto(TARGET_URL); + console.log('Desktop - Title:', await page.title()); + await page.screenshot({ path: '/tmp/desktop.png', fullPage: true }); + + // Mobile test + await page.setViewportSize({ width: 375, height: 667 }); + await page.screenshot({ path: '/tmp/mobile.png', fullPage: true }); + + await browser.close(); +})(); +``` + +### Test Login Flow + +```javascript +// /tmp/playwright-test-login.js +const { chromium } = require('playwright'); + +const TARGET_URL = 'http://localhost:3001'; // Auto-detected + +(async () => { + const browser = await chromium.launch({ headless: false }); + const page = await browser.newPage(); + + await page.goto(`${TARGET_URL}/login`); + + await page.fill('input[name="email"]', 'test@example.com'); + await page.fill('input[name="password"]', 'password123'); + await page.click('button[type="submit"]'); + + // Wait for redirect + await page.waitForURL('**/dashboard'); + console.log('✅ Login successful, redirected to dashboard'); + + await browser.close(); +})(); +``` + +### Fill and Submit Form + +```javascript +// /tmp/playwright-test-form.js +const { chromium } = require('playwright'); + +const TARGET_URL = 'http://localhost:3001'; // Auto-detected + +(async () => { + const browser = await chromium.launch({ headless: false, slowMo: 50 }); + const page = await browser.newPage(); + + await page.goto(`${TARGET_URL}/contact`); + + await page.fill('input[name="name"]', 'John Doe'); + await page.fill('input[name="email"]', 'john@example.com'); + await page.fill('textarea[name="message"]', 'Test message'); + await page.click('button[type="submit"]'); + + // Verify submission + await page.waitForSelector('.success-message'); + console.log('✅ Form submitted successfully'); + + await browser.close(); +})(); +``` + +### Check for Broken Links + +```javascript +const { chromium } = require('playwright'); + +(async () => { + const browser = await chromium.launch({ headless: false }); + const page = await browser.newPage(); + + await page.goto('http://localhost:3000'); + + const links = await page.locator('a[href^="http"]').all(); + const results = { working: 0, broken: [] }; + + for (const link of links) { + const href = await link.getAttribute('href'); + try { + const response = await page.request.head(href); + if (response.ok()) { + results.working++; + } else { + results.broken.push({ url: href, status: response.status() }); + } + } catch (e) { + results.broken.push({ url: href, error: e.message }); + } + } + + console.log(`✅ Working links: ${results.working}`); + console.log(`❌ Broken links:`, results.broken); + + await browser.close(); +})(); +``` + +### Take Screenshot with Error Handling + +```javascript +const { chromium } = require('playwright'); + +(async () => { + const browser = await chromium.launch({ headless: false }); + const page = await browser.newPage(); + + try { + await page.goto('http://localhost:3000', { + waitUntil: 'networkidle', + timeout: 10000, + }); + + await page.screenshot({ + path: '/tmp/screenshot.png', + fullPage: true, + }); + + console.log('📸 Screenshot saved to /tmp/screenshot.png'); + } catch (error) { + console.error('❌ Error:', error.message); + } finally { + await browser.close(); + } +})(); +``` + +### Test Responsive Design + +```javascript +// /tmp/playwright-test-responsive-full.js +const { chromium } = require('playwright'); + +const TARGET_URL = 'http://localhost:3001'; // Auto-detected + +(async () => { + const browser = await chromium.launch({ headless: false }); + const page = await browser.newPage(); + + const viewports = [ + { name: 'Desktop', width: 1920, height: 1080 }, + { name: 'Tablet', width: 768, height: 1024 }, + { name: 'Mobile', width: 375, height: 667 }, + ]; + + for (const viewport of viewports) { + console.log( + `Testing ${viewport.name} (${viewport.width}x${viewport.height})`, + ); + + await page.setViewportSize({ + width: viewport.width, + height: viewport.height, + }); + + await page.goto(TARGET_URL); + await page.waitForTimeout(1000); + + await page.screenshot({ + path: `/tmp/${viewport.name.toLowerCase()}.png`, + fullPage: true, + }); + } + + console.log('✅ All viewports tested'); + await browser.close(); +})(); +``` + +## Inline Execution (Simple Tasks) + +For quick one-off tasks, you can execute code inline without creating files: + +```bash +# Take a quick screenshot +cd $SKILL_DIR && node run.js " +const browser = await chromium.launch({ headless: false }); +const page = await browser.newPage(); +await page.goto('http://localhost:3001'); +await page.screenshot({ path: '/tmp/quick-screenshot.png', fullPage: true }); +console.log('Screenshot saved'); +await browser.close(); +" +``` + +**When to use inline vs files:** + +- **Inline**: Quick one-off tasks (screenshot, check if element exists, get page title) +- **Files**: Complex tests, responsive design checks, anything user might want to re-run + +## Available Helpers + +Optional utility functions in `lib/helpers.js`: + +```javascript +const helpers = require('./lib/helpers'); + +// Detect running dev servers (CRITICAL - use this first!) +const servers = await helpers.detectDevServers(); +console.log('Found servers:', servers); + +// Safe click with retry +await helpers.safeClick(page, 'button.submit', { retries: 3 }); + +// Safe type with clear +await helpers.safeType(page, '#username', 'testuser'); + +// Take timestamped screenshot +await helpers.takeScreenshot(page, 'test-result'); + +// Handle cookie banners +await helpers.handleCookieBanner(page); + +// Extract table data +const data = await helpers.extractTableData(page, 'table.results'); +``` + +See `lib/helpers.js` for full list. + +## Custom HTTP Headers + +Configure custom headers for all HTTP requests via environment variables. Useful for: + +- Identifying automated traffic to your backend +- Getting LLM-optimized responses (e.g., plain text errors instead of styled HTML) +- Adding authentication tokens globally + +### Configuration + +**Single header (common case):** + +```bash +PW_HEADER_NAME=X-Automated-By PW_HEADER_VALUE=playwright-skill \ + cd $SKILL_DIR && node run.js /tmp/my-script.js +``` + +**Multiple headers (JSON format):** + +```bash +PW_EXTRA_HEADERS='{"X-Automated-By":"playwright-skill","X-Debug":"true"}' \ + cd $SKILL_DIR && node run.js /tmp/my-script.js +``` + +### How It Works + +Headers are automatically applied when using `helpers.createContext()`: + +```javascript +const context = await helpers.createContext(browser); +const page = await context.newPage(); +// All requests from this page include your custom headers +``` + +For scripts using raw Playwright API, use the injected `getContextOptionsWithHeaders()`: + +```javascript +const context = await browser.newContext( + getContextOptionsWithHeaders({ viewport: { width: 1920, height: 1080 } }), +); +``` + +## Advanced Usage + +For comprehensive Playwright API documentation, see [API_REFERENCE.md](API_REFERENCE.md): + +- Selectors & Locators best practices +- Network interception & API mocking +- Authentication & session management +- Visual regression testing +- Mobile device emulation +- Performance testing +- Debugging techniques +- CI/CD integration + +## Tips + +- **CRITICAL: Detect servers FIRST** - Always run `detectDevServers()` before writing test code for localhost testing +- **Custom headers** - Use `PW_HEADER_NAME`/`PW_HEADER_VALUE` env vars to identify automated traffic to your backend +- **Use /tmp for test files** - Write to `/tmp/playwright-test-*.js`, never to skill directory or user's project +- **Parameterize URLs** - Put detected/provided URL in a `TARGET_URL` constant at the top of every script +- **DEFAULT: Visible browser** - Always use `headless: false` unless user explicitly asks for headless mode +- **Headless mode** - Only use `headless: true` when user specifically requests "headless" or "background" execution +- **Slow down:** Use `slowMo: 100` to make actions visible and easier to follow +- **Wait strategies:** Use `waitForURL`, `waitForSelector`, `waitForLoadState` instead of fixed timeouts +- **Error handling:** Always use try-catch for robust automation +- **Console output:** Use `console.log()` to track progress and show what's happening + +## Troubleshooting + +**Playwright not installed:** + +```bash +cd $SKILL_DIR && npm run setup +``` + +**Module not found:** +Ensure running from skill directory via `run.js` wrapper + +**Browser doesn't open:** +Check `headless: false` and ensure display available + +**Element not found:** +Add wait: `await page.waitForSelector('.element', { timeout: 10000 })` + +## Example Usage + +``` +User: "Test if the marketing page looks good" + +Claude: I'll test the marketing page across multiple viewports. Let me first detect running servers... +[Runs: detectDevServers()] +[Output: Found server on port 3001] +I found your dev server running on http://localhost:3001 + +[Writes custom automation script to /tmp/playwright-test-marketing.js with URL parameterized] +[Runs: cd $SKILL_DIR && node run.js /tmp/playwright-test-marketing.js] +[Shows results with screenshots from /tmp/] +``` + +``` +User: "Check if login redirects correctly" + +Claude: I'll test the login flow. First, let me check for running servers... +[Runs: detectDevServers()] +[Output: Found servers on ports 3000 and 3001] +I found 2 dev servers. Which one should I test? +- http://localhost:3000 +- http://localhost:3001 + +User: "Use 3001" + +[Writes login automation to /tmp/playwright-test-login.js] +[Runs: cd $SKILL_DIR && node run.js /tmp/playwright-test-login.js] +[Reports: ✅ Login successful, redirected to /dashboard] +``` + +## Notes + +- Each automation is custom-written for your specific request +- Not limited to pre-built scripts - any browser task possible +- Auto-detects running dev servers to eliminate hardcoded URLs +- Test scripts written to `/tmp` for automatic cleanup (no clutter) +- Code executes reliably with proper module resolution via `run.js` +- Progressive disclosure - API_REFERENCE.md loaded only when advanced features needed diff --git a/apps/extension/.claude/skills/servers.md b/apps/extension/.claude/skills/servers.md new file mode 100644 index 000000000..8a48e456a --- /dev/null +++ b/apps/extension/.claude/skills/servers.md @@ -0,0 +1,221 @@ +# Solid Servers Skill + +You are an expert on Solid server implementations — both public hosted servers and local development setups. Use this knowledge to help developers choose, configure, run, and debug Solid servers. + +--- + +## Public Solid Servers + +| Server | URL | Notes | +|--------|-----|-------| +| solidcommunity.net | https://solidcommunity.net | Powered by Pivot CSS; widely used community server | +| solidweb.org | https://solidweb.org | Community-hosted | +| start.inrupt.com | https://start.inrupt.com | Inrupt's hosted pod service (ESS) | + +### Choosing a Public Server or allow interoperability for all servers. Ask the user + +- **solidcommunity.net** — best for open community apps; uses older DPoP-based OIDC (Pivot flavour) +- **solidweb.org** — alternative community option +- **start.inrupt.com (ESS)** — enterprise-grade, uses Inrupt's Enterprise Solid Server; best compatibility with Inrupt SDK + +--- + +## Community Solid Server (CSS) + +**Repo**: https://github.com/CommunitySolidServer/CommunitySolidServer +**Docs**: https://communitysolidserver.github.io/CommunitySolidServer/latest/ + +The reference open-source implementation of the Solid specification. Highly modular and configurable. + +### Requirements + +- Node.js 18+ +- npm or npx + +### Quick Start (in-memory, ephemeral) + +```bash +npx @solid/community-server +``` + +Server available at http://localhost:3000/ + +### Persistent File-Based Storage + +```bash +npx @solid/community-server -c @css:config/file.json -f ./data +``` + +Data stored in `./data/` directory. + +### Global Install + +```bash +npm install -g @solid/community-server +community-solid-server -c @css:config/file.json -f ./data +``` + +### Key CLI Flags + +| Flag | Default | Description | +|------|---------|-------------| +| `--port, -p` | `3000` | TCP port to listen on | +| `--baseUrl, -b` | `http://localhost:3000/` | Base URL used in generated resource URIs | +| `--config, -c` | `@css:config/default.json` | Configuration preset | +| `--rootFilePath, -f` | `./` | Directory for file-based storage | +| `--loggingLevel, -l` | `info` | Log level (`debug`, `info`, `warn`, `error`) | +| `--workers, -w` | `1` | Number of worker threads | +| `--sparqlEndpoint` | — | SPARQL endpoint URL for SPARQL-backed storage | + +### Environment Variables + +All flags can be set as environment variables using the `CSS_` prefix: + +```bash +CSS_PORT=4000 +CSS_LOGGING_LEVEL=debug +CSS_ROOT_FILE_PATH=/var/solid/data +``` + +### Storage Backends + +| Config | Backend | Use case | +|--------|---------|---------| +| `@css:config/default.json` | In-memory | Quick testing, no persistence | +| `@css:config/file.json` | File system | Local dev with persistence | +| `--sparqlEndpoint` | SPARQL | Production or graph DB backends | + +### Docker + +```bash +# Run with file-based storage mounted at ~/Solid +docker run --rm \ + -v ~/Solid:/data \ + -p 3000:3000 \ + solidproject/community-server:latest \ + -c @css:config/file.json -f /data +``` + +### Kubernetes + +Use the official Helm chart: https://github.com/CommunitySolidServer/css-helm-chart + +### Build from Source + +```bash +git clone https://github.com/CommunitySolidServer/CommunitySolidServer +cd CommunitySolidServer +npm ci +npm start +``` + +### Access Control + +CSS supports both **WAC** (Web Access Control) and **ACP** (Access Control Policy) depending on configuration. Default config uses WAC. + +--- + +## Pivot CSS + +**Repo**: https://github.com/solid-contrib/pivot +**Used by**: solidcommunity.net + +Pivot is a spec-compliant Solid server built as a thin wrapper/remix of CSS. It takes a different philosophy on consent and access control. + +### Key Differences from CSS + +| Aspect | CSS | Pivot | +|--------|-----|-------| +| OIDC version | Latest Solid-OIDC (UMA) | Older DPoP-based OIDC | +| Access control model | WAC (default) or ACP | User-centric, DPoP | +| Auth approach | Standard | App gets full pod access when authenticated | +| Focus | Reference implementation | Simplified consent management | + +### When to Use Pivot + +- Building apps that target **solidcommunity.net** users +- When you need the exact server behaviour of solidcommunity.net locally +- Testing compatibility with the older Solid-OIDC DPoP flow + +### Running Pivot Locally + +```bash +git clone https://github.com/solid-contrib/pivot +cd pivot +npm install +npm start +``` + +Default port: 3000 (same as CSS) + +--- + +## Local Development Tips + +### Running Two Servers Side-by-Side + +To test cross-server behaviour (e.g., Alice on CSS, Bob on Pivot): + +```bash +# CSS on port 3000 +npx @solid/community-server -p 3000 -c @css:config/file.json -f ./data-css + +# Pivot on port 3001 +cd pivot && PORT=3001 npm start +``` + +### Setting baseUrl for Deployed Environments + +When deploying behind a reverse proxy (Nginx, Caddy): + +```bash +npx @solid/community-server \ + -b https://solid.example.com/ \ + -c @css:config/file.json \ + -f /var/solid/data +``` + +The `--baseUrl` must match the public-facing URL exactly — resources are minted using this value. + +### Debugging + +```bash +npx @solid/community-server -l debug +``` + +Or via environment variable: + +```bash +CSS_LOGGING_LEVEL=debug npx @solid/community-server +``` + +### Creating a Test Account (CSS) + +Navigate to http://localhost:3000/ — CSS provides a UI for pod registration when running locally. + +--- + +## Conformance Testing + +Use the Solid conformance test harness to verify your server implementation: + +- Repo: https://github.com/solid-contrib/conformance-test-harness +- Inrupt test suite info: https://www.inrupt.com/blog/conformance-test-suite + +```bash +git clone https://github.com/solid-contrib/conformance-test-harness +cd conformance-test-harness +# See repo README for configuration and execution steps +``` + +--- + +## Quick Reference + +| Task | Command | +|------|---------| +| Start CSS (ephemeral) | `npx @solid/community-server` | +| Start CSS (persistent) | `npx @solid/community-server -c @css:config/file.json -f ./data` | +| Start CSS (custom port) | `npx @solid/community-server -p 4000` | +| Start CSS (Docker) | `docker run --rm -v ~/Solid:/data -p 3000:3000 solidproject/community-server:latest -c @css:config/file.json -f /data` | +| Debug logging | `npx @solid/community-server -l debug` | diff --git a/apps/extension/.claude/skills/spec.md b/apps/extension/.claude/skills/spec.md new file mode 100644 index 000000000..67234bd15 --- /dev/null +++ b/apps/extension/.claude/skills/spec.md @@ -0,0 +1,315 @@ +# Solid Specifications Skill + +You are an expert on the Solid Protocol and its companion specifications. Use this knowledge to help developers understand, implement, and debug Solid-compliant applications. + +## Core Specifications + +| Spec | Version | URL | Purpose | +|------|---------|-----|---------| +| Solid Protocol | v0.11.0 | https://solidproject.org/TR/protocol | Core data pod and resource access rules | +| WebID Profile | v1.0.0 | https://solid.github.io/webid-profile/ | Identity and agent description | +| Solid-OIDC | v0.1.0 | https://solidproject.org/TR/oidc | Decentralised authentication | +| Access Control Policy (ACP) | v0.9.0 | https://solidproject.org/TR/acp | Fine-grained authorisation | + +--- + +## 1. Solid Protocol + +**What it is**: The foundation spec defining how data pods expose resources over HTTP using Linked Data principles. + +### Key Concepts + +**Storage**: A space of URIs giving agents controlled access to resources. Each storage has a root container. + +**Resources and Containers**: Resources live at HTTP URIs. Container URIs end with `/`. Containers hold other resources and containers, forming a hierarchy. + +**WebID**: An HTTP URI that identifies an agent. Dereferencing it returns an RDF document describing them. + +### HTTP Behaviour + +- Servers MUST support GET, HEAD, OPTIONS, PUT, POST, PATCH, DELETE +- All write requests MUST include `Content-Type`; missing headers → 400 +- Servers MUST support Turtle (`text/turtle`) and JSON-LD (`application/ld+json`) +- HTTPS is required; HTTP → HTTPS redirect if both schemes are exposed + +### Content Negotiation + +``` +GET /profile/card HTTP/1.1 +Accept: text/turtle +``` + +Servers must honour `Accept` headers and respond with matching RDF serialisation. + +### PATCH with N3 + +``` +PATCH /data/notes.ttl HTTP/1.1 +Content-Type: text/n3 + +@prefix solid: . +<> a solid:InsertDeletePatch; + solid:inserts { <#note1> "Hello world" . }. +``` + +- 409 Conflict if conditions produce zero or multiple matches. + +### Auxiliary Resources + +- **ACL resources**: control access (`Link: ; rel="acl"`) +- **Description resources**: metadata about a resource (`Link: ; rel="describedby"`) +- Auxiliary resources are deleted automatically when their parent is deleted. + +### Notifications + +The Solid Notifications Protocol allows clients to subscribe to resource changes. Servers act as Resource Server, Subscription Server, Notification Sender, and Notification Receiver. + +### Common Mistakes + +- Forgetting trailing `/` on container URIs +- Not sending `Content-Type` on PATCH/PUT +- Using WAC and ACP interchangeably — servers implement one or both, check which +- Assuming CORS is sufficient for access control — always use ACL/ACP + +--- + +## 2. WebID Profile + +**What it is**: An RDF document at a WebID URI describing a social agent's identity, storage locations, and preferences. + +### Minimum Required Triples + +```turtle +@prefix foaf: . +@prefix pim: . + + + a foaf:Agent ; + pim:preferencesFile ; + pim:storage . +``` + +### Key Predicates + +| Predicate | Required | Description | +|-----------|----------|-------------| +| `rdf:type foaf:Agent` | Yes | Identifies the subject as an agent | +| `pim:preferencesFile` | Yes | Pointer to private preferences document | +| `pim:storage` | Recommended | Location of the agent's pod storage | +| `solid:oidcIssuer` | Recommended | The OIDC Identity Provider for this WebID | +| `ldp:inbox` | Optional | Inbox for incoming notifications | +| `rdfs:seeAlso` | Optional | Links to extended profile documents | + +### Preferences Document + +- Must be typed as `pim:ConfigurationFile` +- Private (not publicly readable) +- Holds app settings and personalisation data + +### Type Index + +- Public and private type index documents link resource types to storage locations +- Discovered via the WebID profile +- Enables apps to find where a user stores specific types of data + +### Modifying a Profile + +Use PATCH (N3 Patch) for targeted updates. Servers protect `solid:oidcIssuer` — attempting to change it returns 409. + +```turtle +# Read profile +GET /profile/card HTTP/1.1 +Accept: text/turtle + +# Patch profile to add name +PATCH /profile/card HTTP/1.1 +Content-Type: text/n3 + +@prefix solid: . +@prefix foaf: . +<> a solid:InsertDeletePatch; + solid:inserts { <#me> foaf:name "Alice" . }. +``` + +--- + +## 3. Solid-OIDC + +**What it is**: An authentication layer built on OAuth 2.0 + OpenID Connect that works without pre-established trust between resource servers and identity providers. + +### Flow Overview + +1. App redirects user to their OIDC Identity Provider (IdP) +2. IdP issues a DPoP-bound ID Token containing the `webid` claim +3. App presents the ID Token + DPoP proof to the Resource Server +4. Resource Server validates the token by checking the WebID profile for `solid:oidcIssuer` + +### Authorization Code Flow with PKCE + +This is the **mandatory** flow. Never use Implicit Flow. + +``` +1. App generates code_verifier + code_challenge (PKCE) +2. Redirect → IdP /authorize?response_type=code&code_challenge=... +3. IdP issues auth code +4. App exchanges code for tokens at /token endpoint +5. App receives id_token + access_token (DPoP-bound) +``` + +### DPoP-Bound ID Token Claims + +```json +{ + "webid": "https://alice.solidcommunity.net/profile/card#me", + "iss": "https://solidcommunity.net", + "aud": ["https://myapp.example", "solid"], + "cnf": { "jkt": "" }, + "exp": 1700000000, + "iat": 1699996400 +} +``` + +### DPoP Proof Header + +Every resource request must include a DPoP proof JWT: + +``` +Authorization: DPoP +DPoP: +``` + +### Client Identification + +Two options: +1. **Dereferenceable URI** — client ID is a URL that returns a JSON-LD client registration document +2. **Traditional OIDC registration** — dynamic or static client registration (optional in decentralised context) + +### WebID Issuer Validation + +Resource servers must verify that the `iss` claim in the ID Token matches a `solid:oidcIssuer` triple in the user's WebID Profile. This prevents token misuse. + +### Common Mistakes + +- Using Implicit Flow (not supported) +- Forgetting to include `"solid"` in the `aud` claim +- Not binding tokens with DPoP +- Checking the wrong WebID for issuer validation + +--- + +## 4. Access Control Policy (ACP) + +**What it is**: An RDF-based language for defining who can access what on a Solid pod, using a deny-overrides policy model. + +### Core Objects + +``` +Resource ──has ACR──▶ Access Control Resource (ACR) + │ + Access Control + │ + Policy + ┌────┴────┐ + Allow Deny + │ │ + Matcher Matcher +``` + +### Access Control Resource (ACR) + +Each resource has an associated ACR linked via: + +``` +Link: ; rel="http://www.w3.org/ns/solid/acp#accessControl" +``` + +### Matchers + +Matchers define conditions for a policy. They evaluate against the **request context** (agent, client, issuer, target resource). + +```turtle +@prefix acp: . + +<#matcher-alice> + a acp:Matcher ; + acp:agent . +``` + +Special agents: +- `acp:PublicAgent` — anyone (including unauthenticated) +- `acp:AuthenticatedAgent` — any authenticated user +- `acp:CreatorAgent` — whoever created the resource +- `acp:OwnerAgent` — the pod owner + +### Policies + +```turtle +<#policy-read> + a acp:Policy ; + acp:allow acp:Read ; + acp:anyOf <#matcher-alice> . +``` + +Policy satisfaction logic: + +| Condition | Meaning | +|-----------|---------| +| `acp:allOf` | ALL listed matchers must match | +| `acp:anyOf` | AT LEAST ONE listed matcher must match | +| `acp:noneOf` | NONE of the listed matchers must match | + +### Access Control (connecting ACR to Policy) + +```turtle +<#access-control> + a acp:AccessControl ; + acp:apply <#policy-read> . +``` + +### Grant Logic + +Access mode is granted **if and only if**: +- At least one satisfied policy **allows** it, AND +- No satisfied policy **denies** it + +**Deny overrides allow.** + +### Common Access Patterns + +**Public read access:** +```turtle +<#matcher-public> a acp:Matcher ; acp:agent acp:PublicAgent . +<#policy-public> a acp:Policy ; acp:allow acp:Read ; acp:anyOf <#matcher-public> . +``` + +**Authenticated write access:** +```turtle +<#matcher-auth> a acp:Matcher ; acp:agent acp:AuthenticatedAgent . +<#policy-write> a acp:Policy ; acp:allow acp:Write, acp:Append ; acp:anyOf <#matcher-auth> . +``` + +### ACP vs WAC + +| | ACP | WAC | +|-|-----|-----| +| Model | Policy + Matchers (RDF) | ACL rules (RDF) | +| Deny support | Yes (deny overrides) | Limited | +| Granularity | Fine-grained, composable | Basic | +| Spec status | Active development | Older, widely deployed | + +--- + +## Quick Reference: Spec URLs + +- Solid Protocol: https://solidproject.org/TR/protocol +- WebID Profile: https://solid.github.io/webid-profile/ +- Solid-OIDC: https://solidproject.org/TR/oidc +- ACP: https://solidproject.org/TR/acp +- Solid Notifications: https://solidproject.org/TR/notifications-protocol +- Web Access Control: https://solidproject.org/TR/wac + +--- + +## Other awesome skills + +For more Claude/LLM skills (document processing, dev tools, automation, MCP, etc.), see [Awesome Claude Skills](https://github.com/ComposioHQ/awesome-claude-skills). diff --git a/apps/extension/.claude/skills/style-guide.md b/apps/extension/.claude/skills/style-guide.md new file mode 100644 index 000000000..68796a4ea --- /dev/null +++ b/apps/extension/.claude/skills/style-guide.md @@ -0,0 +1,128 @@ +# Solid Style Guide Skill + +You are an expert on Solid Project brand and visual identity guidelines. Use this knowledge to help teams correctly represent the Solid brand in apps, communications, events, and assets. + +**Official guidelines**: https://solidproject.org/logo_usage_guidelines + +--- + +## Brand Colours + +### Primary Colour + +| Name | Hex | RGB | +|------|-----|-----| +| Royal Lavender | `#7C4DFF` | R124 G77 B255 | + +Royal Lavender is the core brand colour. It is used: +- As a background for the outlined Solid logo variant +- In mastheads and footers +- For primary CTA (call-to-action) buttons + +### Secondary Colours + +| Name | Hex | Notes | +|------|-----|-------| +| Deep Sky Blue | (part of gradient) | Used in the brand gradient | +| Cerulean Blue | (part of gradient) | Used in the brand gradient | + +### The Solid Gradient + +The gradient combines Royal Lavender, Deep Sky Blue, and Cerulean Blue in equal thirds (33/33/33). + +**Allowed uses:** +- As a background +- As an overlay on a greyscale image + +**Not allowed uses:** +- In the logo itself +- In typography +- In icons or other graphics + +--- + +## Logo + +### Two Official Variants + +1. **Standard logo** — works on any flat colour background or overlaid on an image +2. **Outlined logo** — to be used **only** on a Royal Lavender (`#7C4DFF`) background + +### Logo Usage Rules + +**Do:** +- Use the logo on clean, flat-colour backgrounds +- Use the outlined variant on Royal Lavender backgrounds only +- Maintain clear space (exclusion zone) around the logo +- Download official logo files from solidproject.org + +**Do not:** +- Use the logo on complex patterns +- Place the logo in close proximity to other logos, typography, or graphic elements +- Modify, recolour, rotate, or distort the logo +- Use the gradient in or behind the logo +- Use unofficial or recreated versions of the logo + +--- + +## Typography + +Use the typography guidance from the official style guide at https://solidproject.org/logo_usage_guidelines. Do not substitute unapproved fonts in Solid-branded materials. + +--- + +## Iconography and Graphics + +- Do not use the Solid gradient inside icons or graphics +- Keep graphic elements visually distinct from the logo +- Maintain visual hierarchy so the Solid logo is clearly identifiable + +--- + +## Application Contexts + +The brand guidelines apply when representing Solid in: + +| Context | Guidance | +|---------|---------| +| Apps | Use approved logo + colours in UI; do not modify logo | +| Communications (email, docs) | Follow colour and typography rules | +| Events | Use official logo files; follow exclusion zone rules | +| Projects (repos, sites) | Do not use the gradient in logo or wordmark | +| Assets (banners, icons) | Outlined logo on Royal Lavender only | + +--- + +## Quick Colour Reference + +```css +/* Primary brand colour */ +--solid-royal-lavender: #7C4DFF; + +/* Use for: mastheads, footers, primary CTAs, outlined logo background */ +background-color: #7C4DFF; +``` + +--- + +## Getting Official Assets + +All official Solid logos and brand assets are available at: +https://solidproject.org/logo_usage_guidelines + +logo reference + + + + + + + + + + +icons reference +https://heroicons.com/ + + +Always use official assets — do not recreate or approximate the logo. diff --git a/apps/extension/.claude/skills/test-driven-development.md b/apps/extension/.claude/skills/test-driven-development.md new file mode 100644 index 000000000..34a9f2b25 --- /dev/null +++ b/apps/extension/.claude/skills/test-driven-development.md @@ -0,0 +1,371 @@ +--- +name: test-driven-development +description: Use when implementing any feature or bugfix, before writing implementation code +--- + +# Test-Driven Development (TDD) + +## Overview + +Write the test first. Watch it fail. Write minimal code to pass. + +**Core principle:** If you didn't watch the test fail, you don't know if it tests the right thing. + +**Violating the letter of the rules is violating the spirit of the rules.** + +## When to Use + +**Always:** +- New features +- Bug fixes +- Refactoring +- Behavior changes + +**Exceptions (ask your human partner):** +- Throwaway prototypes +- Generated code +- Configuration files + +Thinking "skip TDD just this once"? Stop. That's rationalization. + +## The Iron Law + +``` +NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST +``` + +Write code before the test? Delete it. Start over. + +**No exceptions:** +- Don't keep it as "reference" +- Don't "adapt" it while writing tests +- Don't look at it +- Delete means delete + +Implement fresh from tests. Period. + +## Red-Green-Refactor + +```dot +digraph tdd_cycle { + rankdir=LR; + red [label="RED\nWrite failing test", shape=box, style=filled, fillcolor="#ffcccc"]; + verify_red [label="Verify fails\ncorrectly", shape=diamond]; + green [label="GREEN\nMinimal code", shape=box, style=filled, fillcolor="#ccffcc"]; + verify_green [label="Verify passes\nAll green", shape=diamond]; + refactor [label="REFACTOR\nClean up", shape=box, style=filled, fillcolor="#ccccff"]; + next [label="Next", shape=ellipse]; + + red -> verify_red; + verify_red -> green [label="yes"]; + verify_red -> red [label="wrong\nfailure"]; + green -> verify_green; + verify_green -> refactor [label="yes"]; + verify_green -> green [label="no"]; + refactor -> verify_green [label="stay\ngreen"]; + verify_green -> next; + next -> red; +} +``` + +### RED - Write Failing Test + +Write one minimal test showing what should happen. + + +```typescript +test('retries failed operations 3 times', async () => { + let attempts = 0; + const operation = () => { + attempts++; + if (attempts < 3) throw new Error('fail'); + return 'success'; + }; + + const result = await retryOperation(operation); + + expect(result).toBe('success'); + expect(attempts).toBe(3); +}); +``` +Clear name, tests real behavior, one thing + + + +```typescript +test('retry works', async () => { + const mock = jest.fn() + .mockRejectedValueOnce(new Error()) + .mockRejectedValueOnce(new Error()) + .mockResolvedValueOnce('success'); + await retryOperation(mock); + expect(mock).toHaveBeenCalledTimes(3); +}); +``` +Vague name, tests mock not code + + +**Requirements:** +- One behavior +- Clear name +- Real code (no mocks unless unavoidable) + +### Verify RED - Watch It Fail + +**MANDATORY. Never skip.** + +```bash +npm test path/to/test.test.ts +``` + +Confirm: +- Test fails (not errors) +- Failure message is expected +- Fails because feature missing (not typos) + +**Test passes?** You're testing existing behavior. Fix test. + +**Test errors?** Fix error, re-run until it fails correctly. + +### GREEN - Minimal Code + +Write simplest code to pass the test. + + +```typescript +async function retryOperation(fn: () => Promise): Promise { + for (let i = 0; i < 3; i++) { + try { + return await fn(); + } catch (e) { + if (i === 2) throw e; + } + } + throw new Error('unreachable'); +} +``` +Just enough to pass + + + +```typescript +async function retryOperation( + fn: () => Promise, + options?: { + maxRetries?: number; + backoff?: 'linear' | 'exponential'; + onRetry?: (attempt: number) => void; + } +): Promise { + // YAGNI +} +``` +Over-engineered + + +Don't add features, refactor other code, or "improve" beyond the test. + +### Verify GREEN - Watch It Pass + +**MANDATORY.** + +```bash +npm test path/to/test.test.ts +``` + +Confirm: +- Test passes +- Other tests still pass +- Output pristine (no errors, warnings) + +**Test fails?** Fix code, not test. + +**Other tests fail?** Fix now. + +### REFACTOR - Clean Up + +After green only: +- Remove duplication +- Improve names +- Extract helpers + +Keep tests green. Don't add behavior. + +### Repeat + +Next failing test for next feature. + +## Good Tests + +| Quality | Good | Bad | +|---------|------|-----| +| **Minimal** | One thing. "and" in name? Split it. | `test('validates email and domain and whitespace')` | +| **Clear** | Name describes behavior | `test('test1')` | +| **Shows intent** | Demonstrates desired API | Obscures what code should do | + +## Why Order Matters + +**"I'll write tests after to verify it works"** + +Tests written after code pass immediately. Passing immediately proves nothing: +- Might test wrong thing +- Might test implementation, not behavior +- Might miss edge cases you forgot +- You never saw it catch the bug + +Test-first forces you to see the test fail, proving it actually tests something. + +**"I already manually tested all the edge cases"** + +Manual testing is ad-hoc. You think you tested everything but: +- No record of what you tested +- Can't re-run when code changes +- Easy to forget cases under pressure +- "It worked when I tried it" ≠ comprehensive + +Automated tests are systematic. They run the same way every time. + +**"Deleting X hours of work is wasteful"** + +Sunk cost fallacy. The time is already gone. Your choice now: +- Delete and rewrite with TDD (X more hours, high confidence) +- Keep it and add tests after (30 min, low confidence, likely bugs) + +The "waste" is keeping code you can't trust. Working code without real tests is technical debt. + +**"TDD is dogmatic, being pragmatic means adapting"** + +TDD IS pragmatic: +- Finds bugs before commit (faster than debugging after) +- Prevents regressions (tests catch breaks immediately) +- Documents behavior (tests show how to use code) +- Enables refactoring (change freely, tests catch breaks) + +"Pragmatic" shortcuts = debugging in production = slower. + +**"Tests after achieve the same goals - it's spirit not ritual"** + +No. Tests-after answer "What does this do?" Tests-first answer "What should this do?" + +Tests-after are biased by your implementation. You test what you built, not what's required. You verify remembered edge cases, not discovered ones. + +Tests-first force edge case discovery before implementing. Tests-after verify you remembered everything (you didn't). + +30 minutes of tests after ≠ TDD. You get coverage, lose proof tests work. + +## Common Rationalizations + +| Excuse | Reality | +|--------|---------| +| "Too simple to test" | Simple code breaks. Test takes 30 seconds. | +| "I'll test after" | Tests passing immediately prove nothing. | +| "Tests after achieve same goals" | Tests-after = "what does this do?" Tests-first = "what should this do?" | +| "Already manually tested" | Ad-hoc ≠ systematic. No record, can't re-run. | +| "Deleting X hours is wasteful" | Sunk cost fallacy. Keeping unverified code is technical debt. | +| "Keep as reference, write tests first" | You'll adapt it. That's testing after. Delete means delete. | +| "Need to explore first" | Fine. Throw away exploration, start with TDD. | +| "Test hard = design unclear" | Listen to test. Hard to test = hard to use. | +| "TDD will slow me down" | TDD faster than debugging. Pragmatic = test-first. | +| "Manual test faster" | Manual doesn't prove edge cases. You'll re-test every change. | +| "Existing code has no tests" | You're improving it. Add tests for existing code. | + +## Red Flags - STOP and Start Over + +- Code before test +- Test after implementation +- Test passes immediately +- Can't explain why test failed +- Tests added "later" +- Rationalizing "just this once" +- "I already manually tested it" +- "Tests after achieve the same purpose" +- "It's about spirit not ritual" +- "Keep as reference" or "adapt existing code" +- "Already spent X hours, deleting is wasteful" +- "TDD is dogmatic, I'm being pragmatic" +- "This is different because..." + +**All of these mean: Delete code. Start over with TDD.** + +## Example: Bug Fix + +**Bug:** Empty email accepted + +**RED** +```typescript +test('rejects empty email', async () => { + const result = await submitForm({ email: '' }); + expect(result.error).toBe('Email required'); +}); +``` + +**Verify RED** +```bash +$ npm test +FAIL: expected 'Email required', got undefined +``` + +**GREEN** +```typescript +function submitForm(data: FormData) { + if (!data.email?.trim()) { + return { error: 'Email required' }; + } + // ... +} +``` + +**Verify GREEN** +```bash +$ npm test +PASS +``` + +**REFACTOR** +Extract validation for multiple fields if needed. + +## Verification Checklist + +Before marking work complete: + +- [ ] Every new function/method has a test +- [ ] Watched each test fail before implementing +- [ ] Each test failed for expected reason (feature missing, not typo) +- [ ] Wrote minimal code to pass each test +- [ ] All tests pass +- [ ] Output pristine (no errors, warnings) +- [ ] Tests use real code (mocks only if unavoidable) +- [ ] Edge cases and errors covered + +Can't check all boxes? You skipped TDD. Start over. + +## When Stuck + +| Problem | Solution | +|---------|----------| +| Don't know how to test | Write wished-for API. Write assertion first. Ask your human partner. | +| Test too complicated | Design too complicated. Simplify interface. | +| Must mock everything | Code too coupled. Use dependency injection. | +| Test setup huge | Extract helpers. Still complex? Simplify design. | + +## Debugging Integration + +Bug found? Write failing test reproducing it. Follow TDD cycle. Test proves fix and prevents regression. + +Never fix bugs without a test. + +## Testing Anti-Patterns + +When adding mocks or test utilities, read @testing-anti-patterns.md to avoid common pitfalls: +- Testing mock behavior instead of real behavior +- Adding test-only methods to production classes +- Mocking without understanding dependencies + +## Final Rule + +``` +Production code → test exists and failed first +Otherwise → not TDD +``` + +No exceptions without your human partner's permission. \ No newline at end of file diff --git a/apps/extension/e2e/setup/css-config.json b/apps/extension/e2e/setup/css-config.json new file mode 100644 index 000000000..8969a79d6 --- /dev/null +++ b/apps/extension/e2e/setup/css-config.json @@ -0,0 +1,66 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^7.0.0/components/context.jsonld", + "import": [ + "css:config/app/init/initialize-intro.json", + "css:config/app/main/default.json", + "css:config/app/variables/default.json", + "css:config/http/handler/default.json", + "css:config/http/middleware/default.json", + "css:config/http/notifications/all.json", + "css:config/http/server-factory/http.json", + "css:config/http/static/default.json", + "css:config/identity/access/public.json", + "css:config/identity/email/default.json", + "css:config/identity/handler/default.json", + "css:config/identity/oidc/default.json", + "css:config/identity/ownership/token.json", + "css:config/ldp/authentication/dpop-bearer.json", + "css:config/ldp/authorization/webacl.json", + "css:config/ldp/handler/default.json", + "css:config/ldp/metadata-parser/default.json", + "css:config/ldp/metadata-writer/default.json", + "css:config/ldp/modes/default.json", + "css:config/storage/backend/memory.json", + "css:config/storage/key-value/resource-store.json", + "css:config/storage/location/root.json", + "css:config/storage/middleware/default.json", + "css:config/util/auxiliary/acl.json", + "css:config/util/identifiers/suffix.json", + "css:config/util/index/default.json", + "css:config/util/logging/winston.json", + "css:config/util/representation-conversion/default.json", + "css:config/util/resource-locker/memory.json", + "css:config/util/variables/default.json" + ], + "@graph": [ + { + "comment": "Pod manager using our custom templates (with pim:storage in profile).", + "@id": "urn:solid-server:default:PodManager", + "@type": "GeneratedPodManager", + "store": { "@id": "urn:solid-server:default:ResourceStore" }, + "resourcesGenerator": { "@id": "urn:solid-server:default:PodResourcesGenerator" } + }, + { + "@id": "urn:solid-server:default:PodResourcesGenerator", + "@type": "StaticFolderGenerator", + "templateFolder": "./e2e/setup/templates/pod", + "resourcesGenerator": { "@id": "urn:solid-server:default:TemplatedResourcesGenerator" } + }, + { + "@id": "urn:solid-server:default:TemplatedResourcesGenerator", + "@type": "SubfolderResourcesGenerator", + "subfolders": [ "base" ], + "resourcesGenerator": { + "@type": "BaseResourcesGenerator", + "factory": { + "@type": "ExtensionBasedMapperFactory" + }, + "templateEngine": { + "@id": "urn:solid-server:default:TemplateEngine" + }, + "metadataStrategy": { "@id": "urn:solid-server:default:MetadataStrategy" }, + "store": { "@id": "urn:solid-server:default:ResourceStore" } + } + } + ] +} diff --git a/apps/extension/e2e/setup/css-server.ts b/apps/extension/e2e/setup/css-server.ts index ce7492c4c..850f14f61 100644 --- a/apps/extension/e2e/setup/css-server.ts +++ b/apps/extension/e2e/setup/css-server.ts @@ -9,11 +9,12 @@ const CSS_BASE_URL = `http://localhost:${CSS_PORT}`; export async function startCSSServer(): Promise { const seedConfig = path.resolve(__dirname, 'seed.json'); + const cssConfig = path.resolve(__dirname, 'css-config.json'); serverProcess = spawn('npx', [ '@solid/community-server', '-p', String(CSS_PORT), - '-c', '@css:config/default.json', + '-c', cssConfig, '--seedConfig', seedConfig, '-l', 'warn', ], { diff --git a/apps/extension/e2e/setup/templates/pod/base/profile/card$.ttl.hbs b/apps/extension/e2e/setup/templates/pod/base/profile/card$.ttl.hbs new file mode 100644 index 000000000..6dbc59f88 --- /dev/null +++ b/apps/extension/e2e/setup/templates/pod/base/profile/card$.ttl.hbs @@ -0,0 +1,14 @@ +@prefix foaf: . +@prefix pim: . +@prefix solid: . + +<> + a foaf:PersonalProfileDocument; + foaf:maker <{{webId}}>; + foaf:primaryTopic <{{webId}}>. + +<{{webId}}> + {{#if name}}foaf:name "{{name}}";{{/if}} + {{#if oidcIssuer}}solid:oidcIssuer <{{oidcIssuer}}>;{{/if}} + pim:storage <{{base.path}}>; + a foaf:Person. diff --git a/apps/extension/e2e/tests/helpers.ts b/apps/extension/e2e/tests/helpers.ts index ad59a37df..7b79495ba 100644 --- a/apps/extension/e2e/tests/helpers.ts +++ b/apps/extension/e2e/tests/helpers.ts @@ -1,12 +1,12 @@ import type { BrowserContext } from '@playwright/test'; -const CSS_BASE = 'http://localhost:3000'; +const TEST_WEBID = 'http://localhost:3000/test-pod/profile/card#me'; export async function performLogin(context: BrowserContext, extensionId: string) { - // Open popup and initiate login + // Open popup and initiate login with WebID const popupPage = await context.newPage(); await popupPage.goto(`chrome-extension://${extensionId}/popup/popup.html`); - await popupPage.fill('#idp-url', CSS_BASE); + await popupPage.fill('#webid-input', TEST_WEBID); // Listen for the CSS login page to open before clicking const loginPagePromise = context.waitForEvent('page'); diff --git a/apps/extension/package.json b/apps/extension/package.json index 2a8ce01ac..d2a6c7839 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -8,6 +8,7 @@ "scripts": { "build": "webpack", "build:dev": "webpack --mode development --watch", + "dev": "npx tsx scripts/dev.ts", "test:e2e": "npm run build && playwright test --config e2e/playwright.config.ts", "clean": "rm -rf dist" }, diff --git a/apps/extension/scripts/dev.ts b/apps/extension/scripts/dev.ts new file mode 100644 index 000000000..aa492b4c9 --- /dev/null +++ b/apps/extension/scripts/dev.ts @@ -0,0 +1,138 @@ +import { chromium } from 'playwright'; +import { spawn, type ChildProcess } from 'child_process'; +import path from 'path'; +import fs from 'fs'; +import os from 'os'; +import http from 'http'; + +const CSS_PORT = 3000; +const TEST_SITE_PORT = 8080; +const ROOT = path.resolve(__dirname, '..'); + +const children: ChildProcess[] = []; + +function cleanup() { + for (const child of children) child.kill(); + process.exit(); +} +process.on('SIGINT', cleanup); +process.on('SIGTERM', cleanup); + +async function waitForServer(url: string, timeoutMs = 30_000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + await new Promise((resolve, reject) => { + const req = http.get(url, (res) => { res.resume(); resolve(); }); + req.on('error', reject); + req.setTimeout(1000, () => { req.destroy(); reject(); }); + }); + return; + } catch { + await new Promise((r) => setTimeout(r, 500)); + } + } + throw new Error(`${url} did not start in ${timeoutMs}ms`); +} + +const extPath = path.join(ROOT, 'dist'); +const extArgs = [ + `--disable-extensions-except=${extPath}`, + `--load-extension=${extPath}`, +]; + +async function discoverExtensionId(userDataDir: string): Promise { + const ctx = await chromium.launchPersistentContext(userDataDir, { + channel: 'chromium', + headless: false, + args: extArgs, + }); + let sw = ctx.serviceWorkers()[0]; + if (!sw) sw = await ctx.waitForEvent('serviceworker'); + const extensionId = sw.url().split('/')[2]; + await ctx.close(); + return extensionId; +} + +function pinExtension(userDataDir: string, extensionId: string) { + const prefsPath = path.join(userDataDir, 'Default', 'Preferences'); + const prefs = JSON.parse(fs.readFileSync(prefsPath, 'utf-8')); + // Chrome stores pinned extensions under extensions.pinned_extensions + prefs.extensions = prefs.extensions || {}; + prefs.extensions.pinned_extensions = prefs.extensions.pinned_extensions || []; + if (!prefs.extensions.pinned_extensions.includes(extensionId)) { + prefs.extensions.pinned_extensions.push(extensionId); + } + fs.writeFileSync(prefsPath, JSON.stringify(prefs)); +} + +(async () => { + // 1. Build + console.log('Building extension...'); + const build = spawn('npx', ['webpack'], { cwd: ROOT, stdio: 'inherit' }); + await new Promise((resolve, reject) => { + build.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`Build failed (${code})`)))); + }); + + // 2. Start CSS + console.log('Starting Community Solid Server...'); + const css = spawn('npx', [ + '@solid/community-server', + '-p', String(CSS_PORT), + '-c', path.join(ROOT, 'e2e/setup/css-config.json'), + '--seedConfig', path.join(ROOT, 'e2e/setup/seed.json'), + '-l', 'warn', + ], { stdio: 'pipe' }); + children.push(css); + css.stderr?.on('data', (d: Buffer) => { + const msg = d.toString(); + if (msg.includes('Error')) console.error('[CSS]', msg); + }); + await waitForServer(`http://localhost:${CSS_PORT}`); + console.log(` CSS ready on http://localhost:${CSS_PORT}`); + + // 3. Start test site + console.log('Starting test site...'); + const site = spawn('npx', [ + 'http-server', path.join(ROOT, 'test-site'), + '-p', String(TEST_SITE_PORT), + '-s', + ], { stdio: 'pipe' }); + children.push(site); + await waitForServer(`http://localhost:${TEST_SITE_PORT}`); + console.log(` Test site ready on http://localhost:${TEST_SITE_PORT}`); + + // 4. Launch browser briefly to discover extension ID, then pin it + console.log('Configuring browser...'); + const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'solid-ext-dev-')); + const extensionId = await discoverExtensionId(userDataDir); + pinExtension(userDataDir, extensionId); + + // 5. Relaunch with extension pinned + console.log('Launching browser with extension pinned...'); + const context = await chromium.launchPersistentContext(userDataDir, { + channel: 'chromium', + headless: false, + args: extArgs, + }); + + // Open the test site + const page = context.pages()[0] || await context.newPage(); + await page.goto(`http://localhost:${TEST_SITE_PORT}`); + + console.log('\n-----------------------------------------------'); + console.log(' Ready! Browser is open with the extension.'); + console.log(''); + console.log(' Test user credentials:'); + console.log(' WebID: http://localhost:3000/test-pod/profile/card#me'); + console.log(' Email: test@example.com'); + console.log(' Password: test-password-123'); + console.log(''); + console.log(' Test site: http://localhost:8080'); + console.log(' CSS: http://localhost:3000'); + console.log('-----------------------------------------------'); + console.log(' Close the browser or press Ctrl+C to stop.\n'); + + // Wait for browser to close + context.on('close', cleanup); +})(); diff --git a/apps/extension/src/background/auth-flow.ts b/apps/extension/src/background/auth-flow.ts index ae822290a..4d2601a43 100644 --- a/apps/extension/src/background/auth-flow.ts +++ b/apps/extension/src/background/auth-flow.ts @@ -28,6 +28,36 @@ function getRedirectUri(): string { return chrome.identity.getRedirectURL('callback'); } +async function resolveIssuerFromWebId(webId: string): Promise { + // Fetch the WebID profile document as Turtle + const response = await fetch(webId, { + headers: { Accept: 'text/turtle' }, + }); + if (!response.ok) { + throw new Error(`Failed to fetch WebID profile from ${webId}: ${response.status}`); + } + const turtle = await response.text(); + + // Parse solid:oidcIssuer from the Turtle document + // Matches both full URI and prefixed forms + const issuerPatterns = [ + /solid:oidcIssuer\s+<([^>]+)>/, + /http:\/\/www\.w3\.org\/ns\/solid\/terms#oidcIssuer>\s+<([^>]+)>/, + ]; + + for (const pattern of issuerPatterns) { + const match = turtle.match(pattern); + if (match) { + return match[1]; + } + } + + throw new Error( + `No solid:oidcIssuer found in WebID profile at ${webId}. ` + + 'Make sure your WebID profile contains a solid:oidcIssuer triple.' + ); +} + async function dynamicClientRegistration( registrationEndpoint: string, redirectUri: string @@ -71,7 +101,8 @@ function generateState(): string { return jose.base64url.encode(array); } -export async function initiateLogin(idpUrl: string): Promise { +export async function initiateLogin(webId: string): Promise { + const idpUrl = await resolveIssuerFromWebId(webId); const oidcConfig = await fetchOidcConfig(idpUrl); const redirectUri = getRedirectUri(); diff --git a/apps/extension/src/background/service-worker.ts b/apps/extension/src/background/service-worker.ts index 42214e2d1..1c29601a9 100644 --- a/apps/extension/src/background/service-worker.ts +++ b/apps/extension/src/background/service-worker.ts @@ -114,7 +114,7 @@ async function handleAuthFetch( chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { if (message.type === 'SOLID_LOGIN') { - initiateLogin(message.idpUrl) + initiateLogin(message.webId) .then((session) => { currentSession = session; currentKeyPair = null; // Will be reimported on next use diff --git a/apps/extension/src/content/content-script.ts b/apps/extension/src/content/content-script.ts index b25b6ed91..426d48a6c 100644 --- a/apps/extension/src/content/content-script.ts +++ b/apps/extension/src/content/content-script.ts @@ -48,7 +48,7 @@ window.addEventListener('message', (event) => { chrome.runtime.sendMessage({ type: 'SOLID_LOGIN', actionId: event.data.actionId, - idpUrl: event.data.idpUrl, + webId: event.data.webId, }, (response) => { window.postMessage({ source: SOLID_EXT_PREFIX, diff --git a/apps/extension/src/icons/icon128.png b/apps/extension/src/icons/icon128.png new file mode 100644 index 0000000000000000000000000000000000000000..672357bfdb52b91a939b9e94b8c6a86dc378eeea GIT binary patch literal 4880 zcmZ8lWmpu-*WV=;C8d#O5ox3ZC3cCW^U^I#w{#0jFCir@CENv96r?+sl#m4kqy$8| zrSskUe*8Z(bLPW5GbevDXHLAXwh9?BBQXE~AX8I?>EUL?f8zlNM==oiFm3|bXsEyd z_y4ux_VRZC09BtFOx_?cf3F}Y)W9$c(W#$F%n|XGOP~x#2w-kqloqq{t>$}a&Rt|) zz7`HGj>-kt59O#uhH918`ts}Z$!RZN8rnu9CO7j`E!{LFP%78MnorSB9CTDa>G^G> z7#j|0JV5{HI4AymP&5@3bg-mtT zD<++N1|HSspQL=~3{Xe&N0>)EmTSRm_iEsOWehZeE%}(`Hay7y@}Q0p_dp?kGy23? zf&~F^pEf@g7)~n3>qmIVH@;_Ze}3H^GGSF9y)BQUtbT9_YnlFW31T&?_dIhb;S@HKNCrNlL05MNam3vI*C z8C1(q1#hf`$#sJM-0VPJ_6Midt?`NFc29x)^^rssBJcR?iw8{@4;HuQ0F;5`@=|JBSTkDWqyV$=ykFMLt4QxtYk}149&jIc z6^VT`*D<#~N1eJ?QC#vwzyTOAXWHqGGll4zW{T_xDBN*q4|DKZ^3E$o?^BiK>HHQj z3}}a!{{pX7V%Eebvep%!8OlJ{Y8$9-{}b4go0uUI39lgU_v+_GntW?oE)1I9axN4C+7MQ;di}if;AWrA$sEhZM*3)!)|E zKq_Rr^dd!wLZBi`_iEZYL+}e5eG?k%_{>vN2VCq9bqOHk0MnsF>sNl&>-M&x?1K~; zdiq~J4XaZeI&EU^zr@to(oOqj9qmbe#V~W)<k3dY&6hUV~vRt+Z)t8@9BaSXUdzyUj1J7g!C-wiD8 z0!DEH1ccR$P8pL7X9x4V{5V6VRX`zDNs$@RVl>%#(CMO0m403$3 zQ<4hPWDT;1mE7G0Aw@bVc>knlmNJCrF3H6S9w^#tj1K~q9b3Ll+u;0F*wq=mR$sMA za++5^uF|=%$=H#bo12lHt^ev(l}bEMu5V^h#?;vNM4E81&td_n@XCyBRH>UKt2N~~ zQ)Co_>j}+V2x9f-albUwtZ}I`JX~Ev1O2%!+|+AkKS7?*EW~x!f(RuTv~2;{nfSuZm;P{U+Po=3Gn($ei|*HGq%}Y zH%rNM*+e>9%xT*7Dg~F>O!Xa^rXz*jW`$i`TzG_p`m2UTIg1?|!lLG{d71f16U&qX zKKI(=m{}}mrM7K{pAv?UvM`N{)@=B<4h;{NqS5hhuFtSjb~?<#$`r0L+Dy+pLxejO zMtVaMvmcFYd)Ymsd3~_9%fsN`0ioYM3LG3B#vGj21{_%7IM!t@QhKC3{}a|sO} z$hv$N_$~1#E{bgtu8Q98JEoI3MHY<@p zSX&i2TbnGm7~7&7YNeL#3hunkC(Cu%KoqQ`h?yPxUmb67F%sZyXj6Sg>*V2O^)6FS ztW=Pb58vtijN`QnhU!akV8tmq;`(fF7y-lw*ivmRcSj|YGWMw(^19t7Zs|^4#+IA{ zR@xw=Rwnns8OAEzuL{p+YkWhOyCUoy9pnD|9!T+7?gE+O{j zX908Tr$5^!G8W{dN3{-DZ_y2(*~dmlQ94Bscymonx@^dq=ds;xv-5j*7vpJTOnJB{ zO9J07-QIl#UO)arQaM|E8LtY;6ZrF65Wh$0EDlul9?d`Lmw%sYpwVvVfj#vP#tz{& z5A)~la}!^a&r<)JlSLjNOR^=1=JKFR+u&@LD0xjcvZD_Zt{=|J-g|p8Iz-}mYg1)! ziFu?&b~u5L&VR9Mch)xBWJhk{uWurQx0a|{{tTfG90yC zuG5>Mk7Hvgy?`E; zwcwp{ET;nR#a}iV8jtF`p*5`so_#Ek0b_P0^{C6or(4T)!f6b07tE-D1c(qGFz~!n zhI!ucO}6_84OD-<=<&-vymiN^tya<-fv?@*RZQz(;S{ z7u)_Am2+Uz7Mc&~9Dn{wwBo36c0>HpkTu9OJ1BJ&a9VFe$IGgu+J`9@tlP!+0ve(r zz6WHo?!)lMFa0um_$kWuUb%mvM0QcUJ{JkKz-uQl$JRcx%lmaT|Nkn_#)#BC==zwm zs{OhAIQnoq-GEUrdB?LAKDKBDO4gL&WO$B!!b+E>?Nj6D0sxmF?#YL!tGXIsq*(7& z| z`)iJEZ(P0Vvl^z0fF;b6iB*cR1S`P63Dd~_+>O!)NpNU>hi@EiW5F*&g&NRJ(!b0SfFbmr2Su$ z1K+u{bYlIDJaR$WG~|F=>9!;xz9O;6StI;3vA;~mXdK1wJDN0F&-X`#lDUpD$KM^{ z^Q@-hk6GQ1QS;6-lchVSQIwDMnHKzr$vPSd%YVV6e)OBGRc z*TK6EGUDlJEGYq-6s8sx={Pu)X+PH)`miWu6*ODO+p8ZPx%d?PU0GjQ0GdcIEE6 zGzDS@hnam*E$}sL#g<2NaC66TD8{mb3GjdkiHW}xPTpp;k=*2qjm#CwD^uhIqPE6u zDC6gjZQh7}@Eg81m}X~h|BtcpKm6!SpCh~p!D2|cS$I*V|5%#xQr-Bn<>0sq zK`eY#v7wXLUcAWGd}PWzKSOD1YD$0?@i8vxJZyD6@ojMUt!;U3zwsxL{c4FiMg-CT zm=d?PHACf-3bV}o`de{>?vLNjG+FjA?Gc0gPmz`O@>AvLrbXdf#>o!@Q7b`nUwgho z9)^;Qb214v$(%(>2%PJlW%?u@%dqOeoVQcLw`_h!p#zytr=cs}n?FPc^guU8ugT~b z5Xk)ASuqADNmp9hCFQ`KBLkNXt%c0oYp74{ZnpuW%gJ_59!P&9mt2sNa?dnZpt;t) zF(py5T=yK3P;oyrAUE3T*{f(D|8<*?f(X)BRxM>;iA^P^@)|30s@^UC5sW`n_O2h4 zeu_P>>_K)7#)ox4C%-RagU_gtS^dE;Y6eCNwS*EHnguoB*;&8zXUia8=cvd1! zY&^2UN|&Eb93aw6Pllw6uSV1dT%{L79?$nRsK-mHz?K?5K{Udl;A5dfUV`5<8|J3- zKf9+~H7UZGy4g|a<35I?Lx^J$-q zi*P&Fu6suzo#rB{ZFgNX;uHtQ+6l%UJLrFLd~^%K1g+~UDyRkf79Xd6Sb;jwyaUH!%Pu zFP;_gkk~sbcFp&?RrcVTfy$OWR-F)l6CQ&B;F8*{QWp3aGPXw6-lgb2{kj5&ynoD4 zLb>(A9plwvENT9^vUlv@ymM=5S?zyA%p%h;v0+j05fr&oDtl7LVBkFwdSEtb(#TB` zVZEQnt#~{_7+>&ukk6vhpTxP4hI0PA){k#x-j?7m+37aX?~E^lh5FU zHi#7NBOVz+mJ+k(Jk!f6?)@}9Z`uRqi$?acrLf{_mxbQQdXgs5)J<~Et*q}_U-^x5 z@(ZvIG|?w7=KzsN-i|jDnaZV`ZjMt=nl=Z@6e*P6O{qyH`}ahCu$4w&t=$?fu58P` zV88Pb3q=Q==Ydh@KwDe2y%LgvkQH^b2I3Skp9=qN{?+4U*2YbJVuk@{m(Q`qWK$el z4r{)kAL#%Y09P!LbT{k%@u&YTx>Z8%3+HI*#nv>**n3iXOZ(`-#+6^NH7JYjFie2~ zfY5UhVIhq&%91n8t!C4<`PX5e0XQM{mMAQ*O^qUc@RsFnMwzM~fXn=SgwMFO$9sLe z^a&R@z!Dy9A;DDN{#%fZ2q*LafqcEMaus+FWe#)pQI<#Po}fqICd=DEYvyq_)x)W& tJ*OU%v3i!-bvq5{|5l7}H6*G#Hapen#FZGC$G8#~Kut*-R;gee`G0g>Nu2-y literal 0 HcmV?d00001 diff --git a/apps/extension/src/icons/icon16.png b/apps/extension/src/icons/icon16.png new file mode 100644 index 0000000000000000000000000000000000000000..b9252cb5466a576633a6c24da58ff8ca96f81475 GIT binary patch literal 594 zcmV-Y0A};B?{=XgOer}RN^xVGB z`##V6yzdwO6KW%o_$H z$c$mAP=(W~8{UPuy_pywHV=^y6Cz?JHxvB&DFb(g@dC883LX9;YOPI5@OjQcDy|4W zr_I;H8DP>h5E0At6t_}KU^EJW8(B1qkjv%Jbsd0x2TME**7IW-B5xQMkta&2f(DTO z&fx4z+yL%9g2f6-DMW;1G6{gqF6JuXXY5EV6EJV^VVaSZRJpkDUSsBIc{9T>_?_I& z_>j(ntGa2hnud>IMoa_Q@SSfTTbX-RHrdqy#|QJ|bF~MpXf%KW#~5p{mkTYe!s+2W z4UPh_#T5Ydc9l4LZQa4v1il`|OoOIo?soSR2e35?`@4(yjzF`!o@_P?z&6+V4UV27 z;czlx8YrcThx)nXaKX|Sjm1wInN>w8uOkJWbV4z8vkm~e6T14isFW%|)$SsqW?v9} zeUCP_z=;40hfmV3loB(+S_<*^a>3=;$P)@G{QisSXJ#UH2?qr literal 0 HcmV?d00001 diff --git a/apps/extension/src/icons/icon32.png b/apps/extension/src/icons/icon32.png new file mode 100644 index 0000000000000000000000000000000000000000..b125aa4153807c14bea4c7ffc98966ea40b65503 GIT binary patch literal 1173 zcmV;G1Zw+J*MI=hK4%FbZsngDV`2fSr znR8}Z%A>C2Je+^8wf}#wb@tk4!++gmFb7q&0gHj@fMiZ&cAeWyl54nW< z*|7iJ8z)cMzU?p&Vw0-o%ydoxz{0OTq57ZT@CAf=;pC@G0qSl@fU4RD`q*%6&+y4p zcC;D#d)cU}mgUZsffYwus2u3Kc>&>mIJ$=lV42mpsWz(W>b=7Gt*aOpi==V)2$;7b zMrCyp$ss!yf$xvlIk?k-Y8rRC;OW=-V`Lc>A`;1rv6|}1!@G>+BCup_gsR#EBYcX@ z8NpqU#N$=N<11FCKNM&3?1WKR6pl1A7I-3?%pgEj3xk(vX#d>kbo-Pf{yWo4a=Bat zgF#|3>&7sCvbp!P?}sa0tXEZEz6`86u#?e=xDmW-W)=X98a0YQAV4S-GL1RiYQho; z__B#&V6~+HRV}&nE6<&7Gq33PbLL8k2!6kxKp;RcXxyu6<_F-+VfgbGo>$e96qdO) zo;lb=p;d7EJ_k-W%w1;8gorS9%mhB%27hsSLcKt={)5f=V* z(mD{}M5}{iE#|H4DX>xZlpVVrdM*n+mxY18+(*p0Zy*-o;naJ&1Ypy==N7bm;Gp9R zy9poRQ|cR|6qV%2eWvMEE|UP4o7Ov~4HB#H+1pNjJYg15RH7_d8^u$YUqr=JaJl(7 z^?p59s#@B0p5NYjmV)f>z*nM-9+L*u7UAB-QN~SllWpCdHr{J=5gss;i@@U>8Sqy! zK}0S=#z!b3f2r!0Ni{T_J!TA?IxoS@Md=Sv6)s*35DJyz^?D7yW#f`8Sr=tTqst^< zQVsYkc{N22=031#ZaqCVbB424B9xbx6AFb0hr_0Eo84Rnn+<9or4QI_z_TXqE%ozC z)f{7R)FPb#Wo2a~5((mQiKaz-s}hf7x6e; z=&AU4Bl300000NkvXXu0mjfvR5!x literal 0 HcmV?d00001 diff --git a/apps/extension/src/icons/icon48.png b/apps/extension/src/icons/icon48.png new file mode 100644 index 0000000000000000000000000000000000000000..4eab6f5f1d011afcf956fb2d06a81f9f755e7c87 GIT binary patch literal 1740 zcmV;-1~d7IP)k}Q~Zfe&rymjs*}T^c{l=bZmO=Q;0r&I?y?1!IEbx=>Z81J?j~V~imIG>XXK zTo!vKpsIzyOTg-Zeti9Ba&bG1!Xn5oAOmay){4kEFY9w5@Q1!0R=)LHX#D_Gy;{f$ zKw$O)`1Vg=`~?0YBDZ^5??qWvZ%&+N$BRFO?oMxkIw2Z^d!B>5Aj?Fg*6lik!20+8 z2#NE#hUbiA0_y(+z>}(KxLMC{-mb4EH1@4f-RW2&0(r0-@2?mf{)d1L)pLvxSDdN_87GT_Hfy0)iXRmTDG%^SF? zWv{hfVOW_~kzstX;`2LrROssw;tf8|e`f7|&h@amhAv=+i1bj&mS z5%|h&DZaWQh2PbY8^+mQ;jhmIXm2)8f|k9|-onJGb9eyQU}eWLg;X^Ptoz67)^FRz zccoaoayWQZC7DdRA`mK4ZhbIGu+UoPo!0>PnW~mp>DZltpT`>rA8IrgMkgzazB}?% ziwK!ahTh&@8?(b07g1(>DQzil`xxT?p$K@`O2?Fds(xOTd+Ih@JOAR+6k?LX`T6-6 zhCzFKJL;lQnmE~BXUA)x%6+Oj#Y}Fs1CKYoPeI24Ys0gy%UH@np%9Tsgm^qoDrH}L zozBkj!|?IH2m&=`@=*j-byg$raErA@$OD&sKXEZGFvtxi&eEU zm89YMwMcK5v*BxRO0oP$DaWRK*Y*VacM1W2Xq2q?q;SsWeQ*KcC_MNQ86%HtMdV^> z$|}tw+r2cuBXQ;wH1EN8&DXdaxbMRAY>Nwy zws>d$wwxX(5q*Ltw^}YCdj$0dCu`bE9iqWai-F=D^ zCyz0&Viv)m{e5JhA6~pah|_E-G6^2|9T_9Qbt3ZNrLs}`Eg~QJ4Yn*T-Cqy$5w zf3c}0%)ELk2M@*_JJb9^s9K${cLY?e0wchd?BGoa456lS35iLj?UOUY;V|Xp<+Qc6 zkxtu}PQzg^8I3{ZjU+L73xfp#5jo=X@#<1n*J^VV78X)gRz_!Mr)^hy;NSL@YuWd~ z=jRm>IcO%gD;d9blK_?VM@Z8W76nsk0msBs9XZ2<+K4WyU}+=Ktz(j z<5ksQ1RT|KhTjiWcLDGV5gBl^#>*9HQ)CkR=iib`Xznoo8!(}iR={DTFbvlOL?i<| zR<&yIetNkPj>6Jv03H?**K=^M-^c{mcKR60|NJZ*ZS@wU6((N|cdUcr&+&KQTO#7R z&(7s5WrV>k{$|f{__XWdRnIegK8Q?&38et+09J{J`TWHKWBJg%09XKw&(+2d`ha~R ivS*BTuHXuC!T$hTz#IfV9*p4t0000; setClientId(clientId: string): void; - login(idpUrl: string): Promise; + login(webId: string): Promise; logout(): Promise; } @@ -89,7 +89,7 @@ function setClientId(clientId: string): void { }, '*'); } -function login(idpUrl: string): Promise { +function login(webId: string): Promise { return new Promise((resolve, reject) => { const actionId = crypto.randomUUID(); pendingActions.set(actionId, { resolve, reject }); @@ -98,7 +98,7 @@ function login(idpUrl: string): Promise { source: SOLID_EXT_PREFIX, type: 'SOLID_LOGIN', actionId, - idpUrl, + webId, }, '*'); }); } diff --git a/apps/extension/src/manifest.json b/apps/extension/src/manifest.json index 481b7f31d..654501219 100644 --- a/apps/extension/src/manifest.json +++ b/apps/extension/src/manifest.json @@ -15,9 +15,21 @@ "service_worker": "background/service-worker.js", "type": "module" }, + "icons": { + "16": "icons/icon16.png", + "32": "icons/icon32.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + }, "action": { "default_popup": "popup/popup.html", - "default_title": "Solid Login" + "default_title": "Solid Login", + "default_icon": { + "16": "icons/icon16.png", + "32": "icons/icon32.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } }, "content_scripts": [ { diff --git a/apps/extension/src/popup/popup.html b/apps/extension/src/popup/popup.html index 23a38cab3..8568880cb 100644 --- a/apps/extension/src/popup/popup.html +++ b/apps/extension/src/popup/popup.html @@ -9,9 +9,14 @@
-

Solid

@@ -19,11 +24,11 @@

Solid

Sign in with your Solid identity

- + diff --git a/apps/extension/src/popup/popup.ts b/apps/extension/src/popup/popup.ts index 85ff06210..81fcb2574 100644 --- a/apps/extension/src/popup/popup.ts +++ b/apps/extension/src/popup/popup.ts @@ -2,7 +2,7 @@ const loginView = document.getElementById('login-view')!; const loadingView = document.getElementById('loading-view')!; const loggedInView = document.getElementById('logged-in-view')!; const loginForm = document.getElementById('login-form') as HTMLFormElement; -const idpInput = document.getElementById('idp-url') as HTMLInputElement; +const webidInput = document.getElementById('webid-input') as HTMLInputElement; const loginError = document.getElementById('login-error')!; const webidDisplay = document.getElementById('webid-display')!; const logoutBtn = document.getElementById('logout-btn')!; @@ -27,21 +27,19 @@ loginForm.addEventListener('submit', (e) => { e.preventDefault(); loginError.hidden = true; - const idpUrl = idpInput.value.trim(); - if (!idpUrl) return; + const webId = webidInput.value.trim(); + if (!webId) return; showView('loading'); chrome.runtime.sendMessage( - { type: 'SOLID_LOGIN', idpUrl }, + { type: 'SOLID_LOGIN', webId }, (response) => { if (response?.error) { loginError.textContent = response.error; loginError.hidden = false; showView('login'); } - // Login opens a new tab — popup will close. - // When user returns, they'll reopen popup and see logged-in state. } ); }); diff --git a/apps/extension/webpack.config.js b/apps/extension/webpack.config.js index f5fdb03d3..a94a15c48 100644 --- a/apps/extension/webpack.config.js +++ b/apps/extension/webpack.config.js @@ -35,6 +35,7 @@ module.exports = { { from: 'src/popup/popup.html', to: 'popup/popup.html' }, { from: 'src/popup/popup.css', to: 'popup/popup.css' }, { from: 'src/redirect/redirect.html', to: 'redirect/redirect.html' }, + { from: 'src/icons', to: 'icons' }, ], }), ], From 712b3703b054d8000a3b77df9eb23e47ef87b58c Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Sat, 4 Apr 2026 21:37:34 +0100 Subject: [PATCH 04/50] wip: support static identifiers --- .../skills/playwright-e2e-testing/SKILL.md | 1086 +++++++++++++++++ .../.claude/skills/playwright-e2e-testing | 1 + .../.kiro/skills/playwright-e2e-testing | 1 + apps/extension/e2e/setup/global-setup.ts | 49 +- apps/extension/e2e/setup/global-teardown.ts | 10 +- .../templates/pod/base/private/notes$.ttl.hbs | 6 + .../e2e/setup/templates/pod/wac/.acl.hbs | 26 + .../setup/templates/pod/wac/README.acl.hbs | 14 + .../templates/pod/wac/profile/card.acl.hbs | 19 + .../e2e/tests/authenticated-fetch.spec.ts | 8 +- .../e2e/tests/client-registration.spec.ts | 104 ++ apps/extension/e2e/tests/helpers.ts | 34 +- apps/extension/scripts/dev.ts | 59 +- apps/extension/skills-lock.json | 10 + apps/extension/skills/playwright-e2e-testing | 1 + apps/extension/src/background/auth-flow.ts | 25 +- .../src/background/service-worker.ts | 7 +- apps/extension/src/content/content-script.ts | 2 + apps/extension/src/inject/inject.ts | 5 + apps/extension/test-site/app.js | 31 +- apps/extension/test-site/index.html | 2 +- apps/extension/test-site/server.ts | 94 ++ apps/extension/test-site/with-client-id.html | 93 ++ apps/extension/test-site/with-client-id.js | 93 ++ 24 files changed, 1664 insertions(+), 116 deletions(-) create mode 100644 apps/extension/.agents/skills/playwright-e2e-testing/SKILL.md create mode 120000 apps/extension/.claude/skills/playwright-e2e-testing create mode 120000 apps/extension/.kiro/skills/playwright-e2e-testing create mode 100644 apps/extension/e2e/setup/templates/pod/base/private/notes$.ttl.hbs create mode 100644 apps/extension/e2e/setup/templates/pod/wac/.acl.hbs create mode 100644 apps/extension/e2e/setup/templates/pod/wac/README.acl.hbs create mode 100644 apps/extension/e2e/setup/templates/pod/wac/profile/card.acl.hbs create mode 100644 apps/extension/e2e/tests/client-registration.spec.ts create mode 100644 apps/extension/skills-lock.json create mode 120000 apps/extension/skills/playwright-e2e-testing create mode 100644 apps/extension/test-site/server.ts create mode 100644 apps/extension/test-site/with-client-id.html create mode 100644 apps/extension/test-site/with-client-id.js diff --git a/apps/extension/.agents/skills/playwright-e2e-testing/SKILL.md b/apps/extension/.agents/skills/playwright-e2e-testing/SKILL.md new file mode 100644 index 000000000..632c2af38 --- /dev/null +++ b/apps/extension/.agents/skills/playwright-e2e-testing/SKILL.md @@ -0,0 +1,1086 @@ +--- +name: playwright-e2e-testing +description: "Playwright modern end-to-end testing framework with cross-browser automation, auto-wait, and built-in test runner" +progressive_disclosure: + entry_point: + summary: "Playwright modern end-to-end testing framework with cross-browser automation, auto-wait, and built-in test runner" + when_to_use: "When writing tests, implementing playwright-e2e-testing, or ensuring code quality." + quick_start: "1. Review the core concepts below. 2. Apply patterns to your use case. 3. Follow best practices for implementation." +--- +# Playwright E2E Testing Skill + +--- +progressive_disclosure: + entry_point: + summary: "Modern E2E testing framework with cross-browser automation and built-in test runner" + when_to_use: + - "When testing web applications end-to-end" + - "When needing cross-browser testing" + - "When testing user flows and interactions" + - "When needing screenshot/video recording" + quick_start: + - "npm init playwright@latest" + - "Choose TypeScript and test location" + - "npx playwright test" + - "npx playwright show-report" + token_estimate: + entry: 75-90 + full: 4200-5200 +--- + + + +## Overview + +Playwright is a modern end-to-end testing framework that provides cross-browser automation with a built-in test runner, auto-wait mechanisms, and excellent developer experience. + +### Key Features +- **Auto-wait**: Automatically waits for elements to be ready +- **Cross-browser**: Chromium, Firefox, WebKit support +- **Built-in runner**: Parallel execution, retries, reporters +- **Network control**: Mock and intercept network requests +- **Debugging**: UI mode, trace viewer, inspector + +--- + + + +## Installation + +```bash +# Initialize new Playwright project +npm init playwright@latest + +# Or add to existing project +npm install -D @playwright/test + +# Install browsers +npx playwright install +``` + +### Configuration + +```typescript +// playwright.config.ts +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + + use: { + baseURL: 'http://localhost:3000', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + { + name: 'Mobile Chrome', + use: { ...devices['Pixel 5'] }, + }, + ], + + webServer: { + command: 'npm run start', + url: 'http://localhost:3000', + reuseExistingServer: !process.env.CI, + }, +}); +``` + +## Fundamentals + +### Basic Test Structure + +```typescript +import { test, expect } from '@playwright/test'; + +test('basic test', async ({ page }) => { + await page.goto('https://example.com'); + + // Wait for element and check visibility + const title = page.locator('h1'); + await expect(title).toBeVisible(); + await expect(title).toHaveText('Example Domain'); + + // Get page title + await expect(page).toHaveTitle(/Example/); +}); + +test.describe('User authentication', () => { + test('should login successfully', async ({ page }) => { + await page.goto('/login'); + await page.fill('[name="username"]', 'testuser'); + await page.fill('[name="password"]', 'password123'); + await page.click('button[type="submit"]'); + + await expect(page).toHaveURL('/dashboard'); + await expect(page.locator('.welcome-message')).toContainText('Welcome'); + }); + + test('should show error for invalid credentials', async ({ page }) => { + await page.goto('/login'); + await page.fill('[name="username"]', 'invalid'); + await page.fill('[name="password"]', 'wrong'); + await page.click('button[type="submit"]'); + + await expect(page.locator('.error-message')).toBeVisible(); + await expect(page.locator('.error-message')).toHaveText('Invalid credentials'); + }); +}); +``` + +### Test Hooks + +```typescript +import { test, expect } from '@playwright/test'; + +test.describe('Dashboard tests', () => { + test.beforeEach(async ({ page }) => { + // Run before each test + await page.goto('/dashboard'); + await page.waitForLoadState('networkidle'); + }); + + test.afterEach(async ({ page }) => { + // Cleanup after each test + await page.close(); + }); + + test.beforeAll(async ({ browser }) => { + // Run once before all tests in describe block + console.log('Starting test suite'); + }); + + test.afterAll(async ({ browser }) => { + // Run once after all tests + console.log('Test suite complete'); + }); + + test('displays user data', async ({ page }) => { + await expect(page.locator('.user-name')).toBeVisible(); + }); +}); +``` + +## Locator Strategies + +### Best Practice: Role-based Locators + +```typescript +import { test, expect } from '@playwright/test'; + +test('accessible locators', async ({ page }) => { + await page.goto('/form'); + + // By role (BEST - accessible and stable) + await page.getByRole('button', { name: 'Submit' }).click(); + await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com'); + await page.getByRole('checkbox', { name: 'Subscribe' }).check(); + await page.getByRole('link', { name: 'Learn more' }).click(); + + // By label (good for forms) + await page.getByLabel('Password').fill('secret123'); + + // By placeholder + await page.getByPlaceholder('Search...').fill('query'); + + // By text + await page.getByText('Welcome back').click(); + await page.getByText(/hello/i).isVisible(); + + // By test ID (good for dynamic content) + await page.getByTestId('user-profile').click(); + + // By title + await page.getByTitle('Close dialog').click(); + + // By alt text (images) + await page.getByAltText('User avatar').click(); +}); +``` + +### CSS and XPath Locators + +```typescript +test('CSS and XPath locators', async ({ page }) => { + // CSS selectors + await page.locator('button.primary').click(); + await page.locator('#user-menu').click(); + await page.locator('[data-testid="submit-btn"]').click(); + await page.locator('div.card:first-child').click(); + + // XPath (use sparingly) + await page.locator('xpath=//button[contains(text(), "Submit")]').click(); + + // Chaining locators + const form = page.locator('form#login-form'); + await form.locator('input[name="email"]').fill('user@example.com'); + await form.locator('button[type="submit"]').click(); + + // Filter locators + await page.getByRole('listitem') + .filter({ hasText: 'Product 1' }) + .getByRole('button', { name: 'Add to cart' }) + .click(); +}); +``` + +## Page Object Model + +### Page Class Pattern + +```typescript +// pages/LoginPage.ts +import { Page, Locator } from '@playwright/test'; + +export class LoginPage { + readonly page: Page; + readonly usernameInput: Locator; + readonly passwordInput: Locator; + readonly submitButton: Locator; + readonly errorMessage: Locator; + + constructor(page: Page) { + this.page = page; + this.usernameInput = page.getByLabel('Username'); + this.passwordInput = page.getByLabel('Password'); + this.submitButton = page.getByRole('button', { name: 'Log in' }); + this.errorMessage = page.locator('.error-message'); + } + + async goto() { + await this.page.goto('/login'); + } + + async login(username: string, password: string) { + await this.usernameInput.fill(username); + await this.passwordInput.fill(password); + await this.submitButton.click(); + } + + async expectErrorMessage(message: string) { + await this.errorMessage.waitFor({ state: 'visible' }); + await expect(this.errorMessage).toHaveText(message); + } +} + +// pages/DashboardPage.ts +export class DashboardPage { + readonly page: Page; + readonly welcomeMessage: Locator; + readonly logoutButton: Locator; + + constructor(page: Page) { + this.page = page; + this.welcomeMessage = page.locator('.welcome-message'); + this.logoutButton = page.getByRole('button', { name: 'Logout' }); + } + + async waitForLoad() { + await this.welcomeMessage.waitFor({ state: 'visible' }); + } + + async logout() { + await this.logoutButton.click(); + } +} + +// tests/auth.spec.ts +import { test, expect } from '@playwright/test'; +import { LoginPage } from '../pages/LoginPage'; +import { DashboardPage } from '../pages/DashboardPage'; + +test('successful login flow', async ({ page }) => { + const loginPage = new LoginPage(page); + const dashboard = new DashboardPage(page); + + await loginPage.goto(); + await loginPage.login('testuser', 'password123'); + + await dashboard.waitForLoad(); + await expect(dashboard.welcomeMessage).toContainText('Welcome'); +}); +``` + +### Component Pattern + +```typescript +// components/NavigationComponent.ts +import { Page, Locator } from '@playwright/test'; + +export class NavigationComponent { + readonly page: Page; + readonly homeLink: Locator; + readonly profileLink: Locator; + readonly searchInput: Locator; + + constructor(page: Page) { + this.page = page; + const nav = page.locator('nav'); + this.homeLink = nav.getByRole('link', { name: 'Home' }); + this.profileLink = nav.getByRole('link', { name: 'Profile' }); + this.searchInput = nav.getByPlaceholder('Search...'); + } + + async navigateToProfile() { + await this.profileLink.click(); + } + + async search(query: string) { + await this.searchInput.fill(query); + await this.searchInput.press('Enter'); + } +} +``` + +## User Interactions + +### Form Interactions + +```typescript +test('form interactions', async ({ page }) => { + await page.goto('/form'); + + // Text inputs + await page.fill('input[name="email"]', 'user@example.com'); + await page.type('textarea[name="message"]', 'Hello', { delay: 100 }); + + // Checkboxes + await page.check('input[type="checkbox"][name="subscribe"]'); + await page.uncheck('input[type="checkbox"][name="spam"]'); + + // Radio buttons + await page.check('input[type="radio"][value="option1"]'); + + // Select dropdowns + await page.selectOption('select[name="country"]', 'US'); + await page.selectOption('select[name="color"]', { label: 'Blue' }); + await page.selectOption('select[name="size"]', { value: 'large' }); + + // Multi-select + await page.selectOption('select[multiple]', ['value1', 'value2']); + + // File uploads + await page.setInputFiles('input[type="file"]', 'path/to/file.pdf'); + await page.setInputFiles('input[type="file"]', [ + 'file1.jpg', + 'file2.jpg' + ]); + + // Clear file input + await page.setInputFiles('input[type="file"]', []); +}); +``` + +### Mouse and Keyboard + +```typescript +test('mouse and keyboard interactions', async ({ page }) => { + // Click variations + await page.click('button'); + await page.dblclick('button'); // Double click + await page.click('button', { button: 'right' }); // Right click + await page.click('button', { modifiers: ['Shift'] }); // Shift+click + + // Hover + await page.hover('.tooltip-trigger'); + await expect(page.locator('.tooltip')).toBeVisible(); + + // Drag and drop + await page.dragAndDrop('#draggable', '#droppable'); + + // Keyboard + await page.keyboard.press('Enter'); + await page.keyboard.press('Control+A'); + await page.keyboard.type('Hello World'); + await page.keyboard.down('Shift'); + await page.keyboard.press('ArrowDown'); + await page.keyboard.up('Shift'); + + // Focus + await page.focus('input[name="email"]'); + await page.fill('input[name="email"]', 'test@example.com'); +}); +``` + +### Waiting Strategies + +```typescript +test('waiting strategies', async ({ page }) => { + // Wait for element + await page.waitForSelector('.dynamic-content'); + await page.waitForSelector('.modal', { state: 'visible' }); + await page.waitForSelector('.loading', { state: 'hidden' }); + + // Wait for load state + await page.waitForLoadState('load'); + await page.waitForLoadState('domcontentloaded'); + await page.waitForLoadState('networkidle'); + + // Wait for URL + await page.waitForURL('**/dashboard'); + await page.waitForURL(/\/product\/\d+/); + + // Wait for function + await page.waitForFunction(() => { + return document.querySelectorAll('.item').length > 5; + }); + + // Wait for timeout (avoid if possible) + await page.waitForTimeout(1000); + + // Wait for event + await page.waitForEvent('load'); + await page.waitForEvent('popup'); +}); +``` + +## Assertions + +### Common Assertions + +```typescript +import { test, expect } from '@playwright/test'; + +test('assertions', async ({ page }) => { + await page.goto('/dashboard'); + + // Visibility + await expect(page.locator('.header')).toBeVisible(); + await expect(page.locator('.loading')).toBeHidden(); + await expect(page.locator('.optional')).not.toBeVisible(); + + // Text content + await expect(page.locator('h1')).toHaveText('Dashboard'); + await expect(page.locator('h1')).toContainText('Dash'); + await expect(page.locator('.message')).toHaveText(/welcome/i); + + // Attributes + await expect(page.locator('button')).toBeEnabled(); + await expect(page.locator('button')).toBeDisabled(); + await expect(page.locator('input')).toHaveAttribute('type', 'email'); + await expect(page.locator('input')).toHaveValue('test@example.com'); + + // CSS + await expect(page.locator('.button')).toHaveClass('btn-primary'); + await expect(page.locator('.button')).toHaveClass(/btn-/); + await expect(page.locator('.element')).toHaveCSS('color', 'rgb(255, 0, 0)'); + + // Count + await expect(page.locator('.item')).toHaveCount(5); + + // URL and title + await expect(page).toHaveURL('http://localhost:3000/dashboard'); + await expect(page).toHaveURL(/dashboard$/); + await expect(page).toHaveTitle('Dashboard - My App'); + await expect(page).toHaveTitle(/Dashboard/); + + // Screenshot comparison + await expect(page).toHaveScreenshot('dashboard.png'); + await expect(page.locator('.widget')).toHaveScreenshot('widget.png'); +}); +``` + +### Custom Assertions + +```typescript +test('custom matchers', async ({ page }) => { + // Soft assertions (continue test on failure) + await expect.soft(page.locator('.title')).toHaveText('Welcome'); + await expect.soft(page.locator('.subtitle')).toBeVisible(); + + // Multiple elements + const items = page.locator('.item'); + await expect(items).toHaveCount(3); + await expect(items.nth(0)).toContainText('First'); + await expect(items.nth(1)).toContainText('Second'); + + // Poll assertions + await expect(async () => { + const response = await page.request.get('/api/status'); + expect(response.ok()).toBeTruthy(); + }).toPass({ + timeout: 10000, + intervals: [1000, 2000, 5000], + }); +}); +``` + +## Authentication Patterns + +### Storage State Pattern + +```typescript +// auth.setup.ts - Run once to save auth state +import { test as setup } from '@playwright/test'; + +const authFile = 'playwright/.auth/user.json'; + +setup('authenticate', async ({ page }) => { + await page.goto('/login'); + await page.fill('[name="username"]', 'testuser'); + await page.fill('[name="password"]', 'password123'); + await page.click('button[type="submit"]'); + + await page.waitForURL('/dashboard'); + + // Save authentication state + await page.context().storageState({ path: authFile }); +}); + +// playwright.config.ts +export default defineConfig({ + projects: [ + { + name: 'setup', + testMatch: /.*\.setup\.ts/, + }, + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + storageState: authFile, + }, + dependencies: ['setup'], + }, + ], +}); + +// tests/dashboard.spec.ts - Already authenticated +test('view dashboard', async ({ page }) => { + await page.goto('/dashboard'); + // Already logged in! + await expect(page.locator('.user-menu')).toBeVisible(); +}); +``` + +### Multiple User Roles + +```typescript +// fixtures/auth.ts +import { test as base } from '@playwright/test'; + +type Fixtures = { + adminPage: Page; + userPage: Page; +}; + +export const test = base.extend({ + adminPage: async ({ browser }, use) => { + const context = await browser.newContext({ + storageState: 'playwright/.auth/admin.json', + }); + const page = await context.newPage(); + await use(page); + await context.close(); + }, + + userPage: async ({ browser }, use) => { + const context = await browser.newContext({ + storageState: 'playwright/.auth/user.json', + }); + const page = await context.newPage(); + await use(page); + await context.close(); + }, +}); + +// tests/permissions.spec.ts +import { test } from '../fixtures/auth'; + +test('admin can access admin panel', async ({ adminPage }) => { + await adminPage.goto('/admin'); + await expect(adminPage.locator('.admin-panel')).toBeVisible(); +}); + +test('regular user cannot access admin panel', async ({ userPage }) => { + await userPage.goto('/admin'); + await expect(userPage.locator('.access-denied')).toBeVisible(); +}); +``` + +## Network Control + +### Request Mocking + +```typescript +test('mock API responses', async ({ page }) => { + // Mock API response + await page.route('**/api/users', route => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + users: [ + { id: 1, name: 'John Doe' }, + { id: 2, name: 'Jane Smith' }, + ], + }), + }); + }); + + await page.goto('/users'); + await expect(page.locator('.user-list')).toContainText('John Doe'); +}); + +test('mock with conditions', async ({ page }) => { + await page.route('**/api/**', route => { + const url = route.request().url(); + + if (url.includes('/users/1')) { + route.fulfill({ + status: 200, + body: JSON.stringify({ id: 1, name: 'Test User' }), + }); + } else if (url.includes('/users')) { + route.fulfill({ + status: 200, + body: JSON.stringify({ users: [] }), + }); + } else { + route.continue(); + } + }); +}); + +test('simulate network errors', async ({ page }) => { + await page.route('**/api/data', route => { + route.abort('failed'); + }); + + await page.goto('/data'); + await expect(page.locator('.error-message')).toBeVisible(); +}); +``` + +### Request Interception + +```typescript +test('intercept and modify requests', async ({ page }) => { + // Modify request headers + await page.route('**/api/**', route => { + const headers = route.request().headers(); + route.continue({ + headers: { + ...headers, + 'X-Custom-Header': 'test-value', + }, + }); + }); + + // Modify POST data + await page.route('**/api/submit', route => { + const postData = route.request().postDataJSON(); + route.continue({ + postData: JSON.stringify({ + ...postData, + timestamp: Date.now(), + }), + }); + }); +}); + +test('wait for API response', async ({ page }) => { + // Wait for specific request + const responsePromise = page.waitForResponse('**/api/users'); + await page.click('button#load-users'); + const response = await responsePromise; + + expect(response.status()).toBe(200); + const data = await response.json(); + expect(data.users).toHaveLength(10); +}); +``` + +## Test Organization + +### Custom Fixtures + +```typescript +// fixtures/todos.ts +import { test as base } from '@playwright/test'; + +type TodoFixtures = { + todoPage: TodoPage; + createTodo: (title: string) => Promise; +}; + +export const test = base.extend({ + todoPage: async ({ page }, use) => { + const todoPage = new TodoPage(page); + await todoPage.goto(); + await use(todoPage); + }, + + createTodo: async ({ page }, use) => { + const create = async (title: string) => { + await page.fill('.new-todo', title); + await page.press('.new-todo', 'Enter'); + }; + await use(create); + }, +}); + +// tests/todos.spec.ts +import { test } from '../fixtures/todos'; + +test('can create new todo', async ({ todoPage, createTodo }) => { + await createTodo('Buy groceries'); + await expect(todoPage.todoItems).toHaveCount(1); + await expect(todoPage.todoItems).toHaveText('Buy groceries'); +}); +``` + +### Test Tags and Filtering + +```typescript +test('smoke test', { tag: '@smoke' }, async ({ page }) => { + await page.goto('/'); + await expect(page).toHaveTitle('Home'); +}); + +test('regression test', { tag: ['@regression', '@critical'] }, async ({ page }) => { + // Complex test +}); + +// Run: npx playwright test --grep @smoke +// Run: npx playwright test --grep-invert @slow +``` + +## Visual Testing + +### Screenshot Comparison + +```typescript +test('visual regression', async ({ page }) => { + await page.goto('/dashboard'); + + // Full page screenshot + await expect(page).toHaveScreenshot('dashboard.png', { + maxDiffPixels: 100, + }); + + // Element screenshot + await expect(page.locator('.widget')).toHaveScreenshot('widget.png'); + + // Full page with scroll + await expect(page).toHaveScreenshot('full-page.png', { + fullPage: true, + }); + + // Mask dynamic elements + await expect(page).toHaveScreenshot('masked.png', { + mask: [page.locator('.timestamp'), page.locator('.avatar')], + }); + + // Custom threshold + await expect(page).toHaveScreenshot('comparison.png', { + maxDiffPixelRatio: 0.05, // 5% difference allowed + }); +}); +``` + +### Video and Trace + +```typescript +// playwright.config.ts +export default defineConfig({ + use: { + video: 'retain-on-failure', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, +}); + +// Programmatic video +test('record video', async ({ page }) => { + await page.goto('/'); + // Test actions... + + // Video saved automatically to test-results/ +}); + +// View trace: npx playwright show-trace trace.zip +``` + +## Parallel Execution + +### Test Sharding + +```typescript +// playwright.config.ts +export default defineConfig({ + fullyParallel: true, + workers: process.env.CI ? 4 : undefined, +}); + +// Run shards in CI +// npx playwright test --shard=1/4 +// npx playwright test --shard=2/4 +// npx playwright test --shard=3/4 +// npx playwright test --shard=4/4 +``` + +### Serial Tests + +```typescript +test.describe.configure({ mode: 'serial' }); + +test.describe('order matters', () => { + let orderId: string; + + test('create order', async ({ page }) => { + // Create order + orderId = await createOrder(page); + }); + + test('verify order', async ({ page }) => { + // Use orderId from previous test + await verifyOrder(page, orderId); + }); +}); +``` + +## CI/CD Integration + +### GitHub Actions + +```yaml +# .github/workflows/playwright.yml +name: Playwright Tests +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: npm ci + + - name: Install Playwright Browsers + run: npx playwright install --with-deps + + - name: Run Playwright tests + run: npx playwright test + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 +``` + +### Docker + +```dockerfile +FROM mcr.microsoft.com/playwright:v1.40.0-jammy + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY . . + +CMD ["npx", "playwright", "test"] +``` + +## Debugging + +### UI Mode + +```bash +# Interactive debugging +npx playwright test --ui + +# Debug specific test +npx playwright test --debug login.spec.ts + +# Step through test +npx playwright test --headed --slow-mo=1000 +``` + +### Trace Viewer + +```typescript +// Generate trace +test('with trace', async ({ page }) => { + await page.context().tracing.start({ screenshots: true, snapshots: true }); + + // Test actions + await page.goto('/'); + + await page.context().tracing.stop({ path: 'trace.zip' }); +}); + +// View: npx playwright show-trace trace.zip +``` + +### Console Logs + +```typescript +test('capture console', async ({ page }) => { + page.on('console', msg => console.log(`Browser: ${msg.text()}`)); + page.on('pageerror', error => console.error(`Error: ${error.message}`)); + + await page.goto('/'); +}); +``` + +## Best Practices + +### 1. Use Stable Locators +```typescript +// ✅ Good - Role-based, stable +await page.getByRole('button', { name: 'Submit' }).click(); +await page.getByLabel('Email').fill('test@example.com'); + +// ❌ Bad - Fragile, implementation-dependent +await page.click('button.btn-primary.submit-btn'); +await page.fill('div > form > input:nth-child(3)'); +``` + +### 2. Leverage Auto-Waiting +```typescript +// ✅ Good - Auto-waits +await page.click('button'); +await expect(page.locator('.result')).toBeVisible(); + +// ❌ Bad - Manual waits +await page.waitForTimeout(2000); +await page.click('button'); +``` + +### 3. Use Page Object Model +```typescript +// ✅ Good - Reusable, maintainable +const loginPage = new LoginPage(page); +await loginPage.login('user', 'pass'); + +// ❌ Bad - Duplicated selectors +await page.fill('[name="username"]', 'user'); +await page.fill('[name="password"]', 'pass'); +``` + +### 4. Parallel-Safe Tests +```typescript +// ✅ Good - Isolated +test('user signup', async ({ page }) => { + const uniqueEmail = `user-${Date.now()}@test.com`; + await signUp(page, uniqueEmail); +}); + +// ❌ Bad - Shared state +test('user signup', async ({ page }) => { + await signUp(page, 'test@test.com'); // Conflicts in parallel +}); +``` + +### 5. Handle Flakiness +```typescript +// ✅ Good - Wait for network idle +await page.goto('/', { waitUntil: 'networkidle' }); +await expect(page.locator('.data')).toBeVisible(); + +// Configure retries +test.describe(() => { + test.use({ retries: 2 }); + + test('flaky test', async ({ page }) => { + // Test with auto-retry + }); +}); +``` + +## Common Patterns + +### Multi-Page Scenarios + +```typescript +test('popup handling', async ({ page, context }) => { + // Listen for new page + const popupPromise = context.waitForEvent('page'); + await page.click('a[target="_blank"]'); + const popup = await popupPromise; + + await popup.waitForLoadState(); + await expect(popup).toHaveTitle('New Window'); + await popup.close(); +}); +``` + +### Conditional Logic + +```typescript +test('handle optional elements', async ({ page }) => { + await page.goto('/'); + + // Close modal if present + const modal = page.locator('.modal'); + if (await modal.isVisible()) { + await page.click('.modal .close-button'); + } + + // Or use count + const cookieBanner = page.locator('.cookie-banner'); + if ((await cookieBanner.count()) > 0) { + await page.click('.accept-cookies'); + } +}); +``` + +### Data-Driven Tests + +```typescript +const testCases = [ + { input: 'hello', expected: 'HELLO' }, + { input: 'World', expected: 'WORLD' }, + { input: '123', expected: '123' }, +]; + +for (const { input, expected } of testCases) { + test(`transforms "${input}" to "${expected}"`, async ({ page }) => { + await page.goto('/transform'); + await page.fill('input', input); + await page.click('button'); + await expect(page.locator('.result')).toHaveText(expected); + }); +} +``` + +## Resources + +- [Playwright Documentation](https://playwright.dev/) +- [Best Practices Guide](https://playwright.dev/docs/best-practices) +- [API Reference](https://playwright.dev/docs/api/class-playwright) +- [GitHub Examples](https://github.com/microsoft/playwright/tree/main/examples) diff --git a/apps/extension/.claude/skills/playwright-e2e-testing b/apps/extension/.claude/skills/playwright-e2e-testing new file mode 120000 index 000000000..af1fd9079 --- /dev/null +++ b/apps/extension/.claude/skills/playwright-e2e-testing @@ -0,0 +1 @@ +../../.agents/skills/playwright-e2e-testing \ No newline at end of file diff --git a/apps/extension/.kiro/skills/playwright-e2e-testing b/apps/extension/.kiro/skills/playwright-e2e-testing new file mode 120000 index 000000000..af1fd9079 --- /dev/null +++ b/apps/extension/.kiro/skills/playwright-e2e-testing @@ -0,0 +1 @@ +../../.agents/skills/playwright-e2e-testing \ No newline at end of file diff --git a/apps/extension/e2e/setup/global-setup.ts b/apps/extension/e2e/setup/global-setup.ts index 3075ed045..660d331b4 100644 --- a/apps/extension/e2e/setup/global-setup.ts +++ b/apps/extension/e2e/setup/global-setup.ts @@ -1,9 +1,7 @@ import { startCSSServer } from './css-server'; -import { spawn, type ChildProcess } from 'child_process'; -import path from 'path'; -import http from 'http'; +import { startTestSiteServer } from '../../test-site/server'; +import type http from 'http'; -let testSiteProcess: ChildProcess | null = null; const TEST_SITE_PORT = 8080; async function globalSetup() { @@ -12,48 +10,11 @@ async function globalSetup() { await startCSSServer(); console.log('CSS server ready on http://localhost:3000'); - // Start test site server - const testSitePath = path.resolve(__dirname, '../../test-site'); - testSiteProcess = spawn('npx', [ - 'http-server', testSitePath, - '-p', String(TEST_SITE_PORT), - '-s', // Silent - ], { - stdio: 'pipe', - env: { ...process.env }, - }); - - // Wait for test site to be ready - await waitForServer(`http://localhost:${TEST_SITE_PORT}`, 10_000); + // Start test site server (with dynamic Client ID Document endpoint) + const server = await startTestSiteServer({ port: TEST_SITE_PORT }); console.log(`Test site ready on http://localhost:${TEST_SITE_PORT}`); - // Store the process for teardown - (globalThis as Record).__TEST_SITE_PROCESS__ = testSiteProcess; -} - -async function waitForServer(url: string, timeoutMs: number): Promise { - const start = Date.now(); - - while (Date.now() - start < timeoutMs) { - try { - await new Promise((resolve, reject) => { - const req = http.get(url, (res) => { - res.resume(); - resolve(); - }); - req.on('error', reject); - req.setTimeout(1000, () => { - req.destroy(); - reject(new Error('timeout')); - }); - }); - return; - } catch { - await new Promise((r) => setTimeout(r, 500)); - } - } - - throw new Error(`Server at ${url} did not start within ${timeoutMs}ms`); + (globalThis as Record).__TEST_SITE_SERVER__ = server; } export default globalSetup; diff --git a/apps/extension/e2e/setup/global-teardown.ts b/apps/extension/e2e/setup/global-teardown.ts index 071188942..7db26c8d4 100644 --- a/apps/extension/e2e/setup/global-teardown.ts +++ b/apps/extension/e2e/setup/global-teardown.ts @@ -1,14 +1,12 @@ import { stopCSSServer } from './css-server'; -import type { ChildProcess } from 'child_process'; +import type http from 'http'; async function globalTeardown() { - // Stop CSS server await stopCSSServer(); - // Stop test site server - const testSiteProcess = (globalThis as Record).__TEST_SITE_PROCESS__ as ChildProcess | undefined; - if (testSiteProcess) { - testSiteProcess.kill('SIGTERM'); + const server = (globalThis as Record).__TEST_SITE_SERVER__ as http.Server | undefined; + if (server) { + server.close(); } } diff --git a/apps/extension/e2e/setup/templates/pod/base/private/notes$.ttl.hbs b/apps/extension/e2e/setup/templates/pod/base/private/notes$.ttl.hbs new file mode 100644 index 000000000..9e0b6436e --- /dev/null +++ b/apps/extension/e2e/setup/templates/pod/base/private/notes$.ttl.hbs @@ -0,0 +1,6 @@ +@prefix schema: . + +<#note1> + a schema:TextDigitalDocument; + schema:name "Private Note"; + schema:text "This is a private note only visible to the pod owner.". diff --git a/apps/extension/e2e/setup/templates/pod/wac/.acl.hbs b/apps/extension/e2e/setup/templates/pod/wac/.acl.hbs new file mode 100644 index 000000000..a974c3810 --- /dev/null +++ b/apps/extension/e2e/setup/templates/pod/wac/.acl.hbs @@ -0,0 +1,26 @@ +# Root ACL resource for the agent account +@prefix acl: . +@prefix foaf: . + +# The homepage is readable by the public +<#public> + a acl:Authorization; + acl:agentClass foaf:Agent; + acl:accessTo <./>; + acl:mode acl:Read. + +# The owner has full access to every resource in their pod. +# Other agents have no access rights, +# unless specifically authorized in other .acl resources. +<#owner> + a acl:Authorization; + acl:agent <{{webId}}>; + # Optional owner email, to be used for account recovery: + {{#if email}}acl:agent ;{{/if}} + # Set the access to the root storage folder itself + acl:accessTo <./>; + # All resources will inherit this authorization, by default + acl:default <./>; + # The owner has all of the access modes allowed + acl:mode + acl:Read, acl:Write, acl:Control. diff --git a/apps/extension/e2e/setup/templates/pod/wac/README.acl.hbs b/apps/extension/e2e/setup/templates/pod/wac/README.acl.hbs new file mode 100644 index 000000000..d4b9c28a3 --- /dev/null +++ b/apps/extension/e2e/setup/templates/pod/wac/README.acl.hbs @@ -0,0 +1,14 @@ +@prefix acl: . +@prefix foaf: . + +<#public> + a acl:Authorization; + acl:accessTo <./README>; + acl:agentClass foaf:Agent; + acl:mode acl:Read. + +<#owner> + a acl:Authorization; + acl:accessTo <./README>; + acl:agent <{{webId}}>; + acl:mode acl:Read, acl:Write, acl:Control. diff --git a/apps/extension/e2e/setup/templates/pod/wac/profile/card.acl.hbs b/apps/extension/e2e/setup/templates/pod/wac/profile/card.acl.hbs new file mode 100644 index 000000000..91224c0b2 --- /dev/null +++ b/apps/extension/e2e/setup/templates/pod/wac/profile/card.acl.hbs @@ -0,0 +1,19 @@ +# ACL resource for the WebID profile document +@prefix acl: . +@prefix foaf: . + +# The WebID profile is readable by the public. +# This is required for discovery and verification, +# e.g. when checking identity providers. +<#public> + a acl:Authorization; + acl:agentClass foaf:Agent; + acl:accessTo <./card>; + acl:mode acl:Read. + +# The owner has full access to the profile +<#owner> + a acl:Authorization; + acl:agent <{{webId}}>; + acl:accessTo <./card>; + acl:mode acl:Read, acl:Write, acl:Control. diff --git a/apps/extension/e2e/tests/authenticated-fetch.spec.ts b/apps/extension/e2e/tests/authenticated-fetch.spec.ts index e04410e51..4f0311e52 100644 --- a/apps/extension/e2e/tests/authenticated-fetch.spec.ts +++ b/apps/extension/e2e/tests/authenticated-fetch.spec.ts @@ -3,7 +3,7 @@ import { performLogin } from './helpers'; const TEST_SITE = 'http://localhost:8080'; -test('authenticated fetch displays WebID and profile data', async ({ context, extensionId }) => { +test('authenticated fetch retrieves private resource', async ({ context, extensionId }) => { await performLogin(context, extensionId); // Navigate to the test site @@ -13,9 +13,9 @@ test('authenticated fetch displays WebID and profile data', async ({ context, ex // Wait for the WebID to be displayed await expect(page.locator('#webid')).toContainText('test-pod', { timeout: 15_000 }); - // Wait for the fetch result to contain profile data - await expect(page.locator('#fetch-result')).not.toContainText('Waiting', { timeout: 15_000 }); + // Wait for the private resource content to appear + await expect(page.locator('#fetch-result')).toContainText('Private Note', { timeout: 15_000 }); // The status should indicate success - await expect(page.locator('#status')).toContainText('successfully'); + await expect(page.locator('#status')).toContainText('Private resource fetched successfully'); }); diff --git a/apps/extension/e2e/tests/client-registration.spec.ts b/apps/extension/e2e/tests/client-registration.spec.ts new file mode 100644 index 000000000..ef1c56107 --- /dev/null +++ b/apps/extension/e2e/tests/client-registration.spec.ts @@ -0,0 +1,104 @@ +import { test, expect } from '../fixtures'; +import { completeOidcLogin } from './helpers'; + +const TEST_SITE = 'http://localhost:8080'; +const TEST_WEBID = 'http://localhost:3000/test-pod/profile/card#me'; + +function buildClientIdUrl(redirectUri: string, clientName: string): string { + return `http://localhost:8080/client-id?redirect_uri=${encodeURIComponent(redirectUri)}&client_name=${encodeURIComponent(clientName)}`; +} + +test('dynamic registration: login without a static client ID', async ({ context, extensionId }) => { + // This is the default flow — no client ID set, uses dynamic registration + const popupPage = await context.newPage(); + await popupPage.goto(`chrome-extension://${extensionId}/popup/popup.html`); + await popupPage.fill('#webid-input', TEST_WEBID); + + const loginPagePromise = context.waitForEvent('page'); + await popupPage.click('#login-btn'); + await completeOidcLogin(await loginPagePromise); + + await popupPage.goto(`chrome-extension://${extensionId}/popup/popup.html`); + await popupPage.locator('#logged-in-view').waitFor({ state: 'visible', timeout: 15_000 }); + await popupPage.close(); + + // Verify authenticated fetch works + const page = await context.newPage(); + await page.goto(TEST_SITE); + await expect(page.locator('#fetch-result')).toContainText('Private Note', { timeout: 15_000 }); +}); + +test('static registration: extension-owned client ID', async ({ context, extensionId }) => { + // Build the static Client ID Document URL using the extension's redirect URI + const redirectUri = `https://${extensionId}.chromiumapp.org/callback`; + const clientIdUrl = buildClientIdUrl(redirectUri, 'Solid Browser Extension'); + + // Set the client ID for the extension via the popup before logging in + // We do this by evaluating in the popup context which sends the message directly + const popupPage = await context.newPage(); + await popupPage.goto(`chrome-extension://${extensionId}/popup/popup.html`); + + // Send SOLID_SET_CLIENT_ID directly from the popup context + await popupPage.evaluate((cid) => { + chrome.runtime.sendMessage({ + type: 'SOLID_SET_CLIENT_ID', + origin: 'chrome-extension://' + chrome.runtime.id, + clientId: cid, + }); + }, clientIdUrl); + + // Now log in — the service worker will use the stored client ID + await popupPage.fill('#webid-input', TEST_WEBID); + + // Need to also pass clientId in the login message from the popup + // Since the popup sends SOLID_LOGIN directly, we override the submit handler + await popupPage.evaluate((params) => { + chrome.runtime.sendMessage({ + type: 'SOLID_LOGIN', + webId: params.webId, + clientId: params.clientId, + }, () => {}); + }, { webId: TEST_WEBID, clientId: clientIdUrl }); + + // Wait for the login page to open + const loginPage = await context.waitForEvent('page', { timeout: 15_000 }); + await completeOidcLogin(loginPage); + + await popupPage.goto(`chrome-extension://${extensionId}/popup/popup.html`); + await popupPage.locator('#logged-in-view').waitFor({ state: 'visible', timeout: 15_000 }); + await popupPage.close(); + + // Verify authenticated fetch works + const page = await context.newPage(); + await page.goto(TEST_SITE); + await expect(page.locator('#fetch-result')).toContainText('Private Note', { timeout: 15_000 }); +}); + +test('static registration: application-owned client ID via setClientId', async ({ context, extensionId }) => { + // Build the static Client ID Document URL using the extension's redirect URI + const redirectUri = `https://${extensionId}.chromiumapp.org/callback`; + const clientIdUrl = buildClientIdUrl(redirectUri, 'Test Application'); + + // Navigate to the test site and call solid.setClientId() before login + const page = await context.newPage(); + await page.goto(TEST_SITE); + + // Wait for the extension to inject window.solid + await page.waitForFunction(() => typeof (window as any).solid !== 'undefined', null, { timeout: 5_000 }); + + // Set the client ID for this origin + await page.evaluate((cid) => { + (window as any).solid.setClientId(cid); + }, clientIdUrl); + + // Now trigger login from the page — solid.login() will include the clientId + const loginPagePromise = context.waitForEvent('page'); + await page.evaluate((webId) => { + (window as any).solid.login(webId); + }, TEST_WEBID); + + await completeOidcLogin(await loginPagePromise); + + // Wait for the page to update with the authenticated data + await expect(page.locator('#fetch-result')).toContainText('Private Note', { timeout: 15_000 }); +}); diff --git a/apps/extension/e2e/tests/helpers.ts b/apps/extension/e2e/tests/helpers.ts index 7b79495ba..bf547b2e2 100644 --- a/apps/extension/e2e/tests/helpers.ts +++ b/apps/extension/e2e/tests/helpers.ts @@ -1,19 +1,12 @@ -import type { BrowserContext } from '@playwright/test'; +import type { BrowserContext, Page } from '@playwright/test'; const TEST_WEBID = 'http://localhost:3000/test-pod/profile/card#me'; -export async function performLogin(context: BrowserContext, extensionId: string) { - // Open popup and initiate login with WebID - const popupPage = await context.newPage(); - await popupPage.goto(`chrome-extension://${extensionId}/popup/popup.html`); - await popupPage.fill('#webid-input', TEST_WEBID); - - // Listen for the CSS login page to open before clicking - const loginPagePromise = context.waitForEvent('page'); - await popupPage.click('#login-btn'); - - // Get the CSS login page (opened by chrome.identity.launchWebAuthFlow) - const loginPage = await loginPagePromise; +/** + * Complete the CSS OIDC login + consent flow on an already-opened login page. + * The page should be the CSS login page opened by chrome.identity.launchWebAuthFlow. + */ +export async function completeOidcLogin(loginPage: Page) { await loginPage.waitForLoadState('networkidle'); // CSS auto-redirects to password login page @@ -32,8 +25,21 @@ export async function performLogin(context: BrowserContext, extensionId: string) // Wait for the Authorize button to be enabled (JS populates WebIDs then enables it) await loginPage.waitForSelector('#authorize:not([disabled])', { timeout: 10_000 }); await loginPage.click('#authorize'); +} + +/** + * Perform a full login flow via the extension popup using dynamic registration. + */ +export async function performLogin(context: BrowserContext, extensionId: string) { + const popupPage = await context.newPage(); + await popupPage.goto(`chrome-extension://${extensionId}/popup/popup.html`); + await popupPage.fill('#webid-input', TEST_WEBID); + + const loginPagePromise = context.waitForEvent('page'); + await popupPage.click('#login-btn'); + + await completeOidcLogin(await loginPagePromise); - // Wait for the auth flow to complete by polling the popup for logged-in state await popupPage.goto(`chrome-extension://${extensionId}/popup/popup.html`); await popupPage.locator('#logged-in-view').waitFor({ state: 'visible', timeout: 15_000 }); diff --git a/apps/extension/scripts/dev.ts b/apps/extension/scripts/dev.ts index aa492b4c9..a5566c82d 100644 --- a/apps/extension/scripts/dev.ts +++ b/apps/extension/scripts/dev.ts @@ -4,15 +4,19 @@ import path from 'path'; import fs from 'fs'; import os from 'os'; import http from 'http'; +import { startTestSiteServer } from '../test-site/server'; const CSS_PORT = 3000; -const TEST_SITE_PORT = 8080; +const BASIC_SITE_PORT = 8080; +const CLIENT_ID_SITE_PORT = 8081; const ROOT = path.resolve(__dirname, '..'); const children: ChildProcess[] = []; +const servers: http.Server[] = []; function cleanup() { for (const child of children) child.kill(); + for (const server of servers) server.close(); process.exit(); } process.on('SIGINT', cleanup); @@ -57,7 +61,6 @@ async function discoverExtensionId(userDataDir: string): Promise { function pinExtension(userDataDir: string, extensionId: string) { const prefsPath = path.join(userDataDir, 'Default', 'Preferences'); const prefs = JSON.parse(fs.readFileSync(prefsPath, 'utf-8')); - // Chrome stores pinned extensions under extensions.pinned_extensions prefs.extensions = prefs.extensions || {}; prefs.extensions.pinned_extensions = prefs.extensions.pinned_extensions || []; if (!prefs.extensions.pinned_extensions.includes(extensionId)) { @@ -91,34 +94,40 @@ function pinExtension(userDataDir: string, extensionId: string) { await waitForServer(`http://localhost:${CSS_PORT}`); console.log(` CSS ready on http://localhost:${CSS_PORT}`); - // 3. Start test site - console.log('Starting test site...'); - const site = spawn('npx', [ - 'http-server', path.join(ROOT, 'test-site'), - '-p', String(TEST_SITE_PORT), - '-s', - ], { stdio: 'pipe' }); - children.push(site); - await waitForServer(`http://localhost:${TEST_SITE_PORT}`); - console.log(` Test site ready on http://localhost:${TEST_SITE_PORT}`); - - // 4. Launch browser briefly to discover extension ID, then pin it + // 3. Discover extension ID (needed to build the client ID URL) console.log('Configuring browser...'); const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'solid-ext-dev-')); const extensionId = await discoverExtensionId(userDataDir); pinExtension(userDataDir, extensionId); - // 5. Relaunch with extension pinned - console.log('Launching browser with extension pinned...'); + // 4. Build the Client ID Document URL for the static-registration site + const redirectUri = `https://${extensionId}.chromiumapp.org/callback`; + const clientIdUrl = `http://localhost:${CLIENT_ID_SITE_PORT}/client-id?redirect_uri=${encodeURIComponent(redirectUri)}&client_name=${encodeURIComponent('My Test App')}`; + + // 5. Start two test site servers + console.log('Starting test sites...'); + + const basicServer = await startTestSiteServer({ port: BASIC_SITE_PORT }); + servers.push(basicServer); + console.log(` Basic site ready on http://localhost:${BASIC_SITE_PORT}`); + + const clientIdServer = await startTestSiteServer({ port: CLIENT_ID_SITE_PORT, clientIdUrl }); + servers.push(clientIdServer); + console.log(` Client ID site ready on http://localhost:${CLIENT_ID_SITE_PORT}`); + + // 6. Relaunch with extension pinned, open both sites + console.log('Launching browser...'); const context = await chromium.launchPersistentContext(userDataDir, { channel: 'chromium', headless: false, args: extArgs, }); - // Open the test site - const page = context.pages()[0] || await context.newPage(); - await page.goto(`http://localhost:${TEST_SITE_PORT}`); + const page1 = context.pages()[0] || await context.newPage(); + await page1.goto(`http://localhost:${BASIC_SITE_PORT}/`); + + const page2 = await context.newPage(); + await page2.goto(`http://localhost:${CLIENT_ID_SITE_PORT}/with-client-id.html`); console.log('\n-----------------------------------------------'); console.log(' Ready! Browser is open with the extension.'); @@ -128,11 +137,17 @@ function pinExtension(userDataDir: string, extensionId: string) { console.log(' Email: test@example.com'); console.log(' Password: test-password-123'); console.log(''); - console.log(' Test site: http://localhost:8080'); - console.log(' CSS: http://localhost:3000'); + console.log(' Sample sites (open in browser tabs):'); + console.log(''); + console.log(' 1. Basic site (dynamic registration):'); + console.log(` http://localhost:${BASIC_SITE_PORT}/`); + console.log(''); + console.log(' 2. App with static client ID (solid.setClientId):'); + console.log(` http://localhost:${CLIENT_ID_SITE_PORT}/with-client-id.html`); + console.log(''); + console.log(' CSS: http://localhost:3000'); console.log('-----------------------------------------------'); console.log(' Close the browser or press Ctrl+C to stop.\n'); - // Wait for browser to close context.on('close', cleanup); })(); diff --git a/apps/extension/skills-lock.json b/apps/extension/skills-lock.json new file mode 100644 index 000000000..7614a41e7 --- /dev/null +++ b/apps/extension/skills-lock.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "skills": { + "playwright-e2e-testing": { + "source": "bobmatnyc/claude-mpm-skills", + "sourceType": "github", + "computedHash": "ce264ac6f9cbeba1fd354d4b34eac7f3758d630aabaf4b59a836e3db980c8486" + } + } +} diff --git a/apps/extension/skills/playwright-e2e-testing b/apps/extension/skills/playwright-e2e-testing new file mode 120000 index 000000000..e455616cf --- /dev/null +++ b/apps/extension/skills/playwright-e2e-testing @@ -0,0 +1 @@ +../.agents/skills/playwright-e2e-testing \ No newline at end of file diff --git a/apps/extension/src/background/auth-flow.ts b/apps/extension/src/background/auth-flow.ts index 4d2601a43..04af784e9 100644 --- a/apps/extension/src/background/auth-flow.ts +++ b/apps/extension/src/background/auth-flow.ts @@ -101,22 +101,27 @@ function generateState(): string { return jose.base64url.encode(array); } -export async function initiateLogin(webId: string): Promise { +export async function initiateLogin(webId: string, staticClientId?: string): Promise { const idpUrl = await resolveIssuerFromWebId(webId); const oidcConfig = await fetchOidcConfig(idpUrl); const redirectUri = getRedirectUri(); - // Register client dynamically - if (!oidcConfig.registration_endpoint) { - throw new Error('IDP does not support dynamic client registration'); + let clientId: string; + if (staticClientId) { + // Use a static (dereferenceable) client identifier — skip dynamic registration + clientId = staticClientId; + } else { + // Register client dynamically + if (!oidcConfig.registration_endpoint) { + throw new Error('IDP does not support dynamic client registration'); + } + const registration = await dynamicClientRegistration( + oidcConfig.registration_endpoint, + redirectUri + ); + clientId = registration.client_id; } - const registration = await dynamicClientRegistration( - oidcConfig.registration_endpoint, - redirectUri - ); - const clientId = registration.client_id; - // Generate PKCE const codeVerifier = generateCodeVerifier(); const codeChallenge = await generateCodeChallenge(codeVerifier); diff --git a/apps/extension/src/background/service-worker.ts b/apps/extension/src/background/service-worker.ts index 1c29601a9..eb8433706 100644 --- a/apps/extension/src/background/service-worker.ts +++ b/apps/extension/src/background/service-worker.ts @@ -114,7 +114,12 @@ async function handleAuthFetch( chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { if (message.type === 'SOLID_LOGIN') { - initiateLogin(message.webId) + const loginWithClientId = async () => { + const clientIds = await loadClientIds(); + const staticClientId = message.clientId || clientIds[message.origin] || undefined; + return initiateLogin(message.webId, staticClientId); + }; + loginWithClientId() .then((session) => { currentSession = session; currentKeyPair = null; // Will be reimported on next use diff --git a/apps/extension/src/content/content-script.ts b/apps/extension/src/content/content-script.ts index 426d48a6c..53e2cd18a 100644 --- a/apps/extension/src/content/content-script.ts +++ b/apps/extension/src/content/content-script.ts @@ -49,6 +49,8 @@ window.addEventListener('message', (event) => { type: 'SOLID_LOGIN', actionId: event.data.actionId, webId: event.data.webId, + origin: event.data.origin, + clientId: event.data.clientId, }, (response) => { window.postMessage({ source: SOLID_EXT_PREFIX, diff --git a/apps/extension/src/inject/inject.ts b/apps/extension/src/inject/inject.ts index 196dea9ab..c64753798 100644 --- a/apps/extension/src/inject/inject.ts +++ b/apps/extension/src/inject/inject.ts @@ -81,6 +81,7 @@ function solidFetch(input: RequestInfo | URL, init?: RequestInit): Promise { return new Promise((resolve, reject) => { const actionId = crypto.randomUUID(); @@ -99,6 +102,8 @@ function login(webId: string): Promise { type: 'SOLID_LOGIN', actionId, webId, + origin: window.location.origin, + clientId: currentClientId, }, '*'); }); } diff --git a/apps/extension/test-site/app.js b/apps/extension/test-site/app.js index a96585bcb..925bbd1e8 100644 --- a/apps/extension/test-site/app.js +++ b/apps/extension/test-site/app.js @@ -1,17 +1,15 @@ async function init() { - const webidEl = document.getElementById('webid'); - const fetchResultEl = document.getElementById('fetch-result'); - const statusEl = document.getElementById('status'); + var webidEl = document.getElementById('webid'); + var fetchResultEl = document.getElementById('fetch-result'); + var statusEl = document.getElementById('status'); // Wait for the solid extension to be available and authenticated function waitForSolid() { - return new Promise((resolve) => { - // Check immediately + return new Promise(function (resolve) { if (window.solid && window.solid.webId) { return resolve(window.solid); } - // Poll every 200ms - const interval = setInterval(() => { + var interval = setInterval(function () { if (window.solid && window.solid.webId) { clearInterval(interval); resolve(window.solid); @@ -28,21 +26,26 @@ async function init() { statusEl.textContent = 'Extension detected, waiting for authentication...'; - const solid = await waitForSolid(); + var solid = await waitForSolid(); webidEl.textContent = solid.webId; webidEl.className = ''; - statusEl.textContent = 'Authenticated! Fetching profile...'; + + // Derive the pod storage root from the WebID + // e.g. http://localhost:3000/test-pod/profile/card#me -> http://localhost:3000/test-pod/ + var podRoot = solid.webId.split('/profile/')[0] + '/'; + var privateUrl = podRoot + 'private/notes'; + + statusEl.textContent = 'Authenticated! Fetching private resource...'; try { - // Fetch the user's profile card using authenticated fetch - const response = await solid.fetch(solid.webId); + var response = await solid.fetch(privateUrl); if (response.ok) { - const text = await response.text(); + var text = await response.text(); fetchResultEl.textContent = text; fetchResultEl.className = ''; - statusEl.textContent = 'Profile fetched successfully!'; + statusEl.textContent = 'Private resource fetched successfully!'; } else { - fetchResultEl.textContent = `HTTP ${response.status}: ${response.statusText}`; + fetchResultEl.textContent = 'HTTP ' + response.status + ': ' + response.statusText; fetchResultEl.className = 'error'; statusEl.textContent = 'Fetch failed'; statusEl.className = 'error'; diff --git a/apps/extension/test-site/index.html b/apps/extension/test-site/index.html index e727c7cd5..87e4ee2ba 100644 --- a/apps/extension/test-site/index.html +++ b/apps/extension/test-site/index.html @@ -44,7 +44,7 @@

Solid Extension Test

-
Authenticated Fetch Result
+
Private Resource (authenticated fetch)
Waiting for authentication...
diff --git a/apps/extension/test-site/server.ts b/apps/extension/test-site/server.ts new file mode 100644 index 000000000..4efde10f8 --- /dev/null +++ b/apps/extension/test-site/server.ts @@ -0,0 +1,94 @@ +import http from 'http'; +import fs from 'fs'; +import path from 'path'; + +const STATIC_DIR = __dirname; +const MIME_TYPES: Record = { + '.html': 'text/html', + '.js': 'application/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.jsonld': 'application/ld+json', + '.png': 'image/png', + '.svg': 'image/svg+xml', +}; + +export interface TestSiteOptions { + port: number; + /** If set, the server injects this client ID into the app config. */ + clientIdUrl?: string; +} + +export function createTestSiteServer(opts: TestSiteOptions): http.Server { + const { port, clientIdUrl } = opts; + + const server = http.createServer((req, res) => { + const url = new URL(req.url || '/', `http://localhost:${port}`); + + // Dynamic Client ID Document endpoint + // GET /client-id?redirect_uri=...&client_name=... + if (url.pathname === '/client-id') { + const redirectUri = url.searchParams.get('redirect_uri'); + const clientName = url.searchParams.get('client_name') || 'Test Application'; + if (!redirectUri) { + res.writeHead(400, { 'Content-Type': 'text/plain' }); + res.end('Missing redirect_uri parameter'); + return; + } + const clientId = `http://localhost:${port}/client-id?redirect_uri=${encodeURIComponent(redirectUri)}&client_name=${encodeURIComponent(clientName)}`; + const doc = { + '@context': ['https://www.w3.org/ns/solid/oidc-context.jsonld'], + client_id: clientId, + client_name: clientName, + redirect_uris: [redirectUri], + scope: 'openid profile offline_access webid', + grant_types: ['refresh_token', 'authorization_code'], + response_types: ['code'], + token_endpoint_auth_method: 'none', + }; + res.writeHead(200, { + 'Content-Type': 'application/ld+json', + 'Access-Control-Allow-Origin': '*', + }); + res.end(JSON.stringify(doc, null, 2)); + return; + } + + // Injected config endpoint — returns runtime configuration for the app + if (url.pathname === '/config.json') { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }); + res.end(JSON.stringify({ clientIdUrl: clientIdUrl || null })); + return; + } + + // Static file serving + const filePath = path.join(STATIC_DIR, url.pathname === '/' ? 'index.html' : url.pathname); + const ext = path.extname(filePath); + const contentType = MIME_TYPES[ext] || 'application/octet-stream'; + + fs.readFile(filePath, (err, data) => { + if (err) { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('Not Found'); + return; + } + res.writeHead(200, { + 'Content-Type': contentType, + 'Access-Control-Allow-Origin': '*', + }); + res.end(data); + }); + }); + + return server; +} + +export function startTestSiteServer(opts: TestSiteOptions): Promise { + return new Promise((resolve) => { + const server = createTestSiteServer(opts); + server.listen(opts.port, () => resolve(server)); + }); +} diff --git a/apps/extension/test-site/with-client-id.html b/apps/extension/test-site/with-client-id.html new file mode 100644 index 000000000..183f1fbd4 --- /dev/null +++ b/apps/extension/test-site/with-client-id.html @@ -0,0 +1,93 @@ + + + + + + Solid Test - App with Client ID + + + +

Solid Test - App with Client ID

+

This site sets its own client identifier via solid.setClientId()

+ +
+
Client ID
+
Not set
+
+ +
+
Login
+ + +
+ +
+
WebID
+
Not authenticated
+
+ +
+
Private Resource (authenticated fetch)
+
Waiting for authentication...
+
+ +
+
Status
+
Checking for Solid extension...
+
+ + + + diff --git a/apps/extension/test-site/with-client-id.js b/apps/extension/test-site/with-client-id.js new file mode 100644 index 000000000..602d988ee --- /dev/null +++ b/apps/extension/test-site/with-client-id.js @@ -0,0 +1,93 @@ +async function init() { + var clientIdEl = document.getElementById('client-id-display'); + var webidInput = document.getElementById('webid-input'); + var loginBtn = document.getElementById('login-btn'); + var webidEl = document.getElementById('webid'); + var fetchResultEl = document.getElementById('fetch-result'); + var statusEl = document.getElementById('status'); + + if (!window.solid) { + statusEl.textContent = 'Solid extension not detected'; + statusEl.className = 'error'; + return; + } + + statusEl.textContent = 'Extension detected. Loading client ID...'; + + // Fetch the client ID from the server config + try { + var configRes = await fetch('/config.json'); + var config = await configRes.json(); + + if (config.clientIdUrl) { + window.solid.setClientId(config.clientIdUrl); + clientIdEl.textContent = config.clientIdUrl; + clientIdEl.className = ''; + statusEl.textContent = 'Client ID set. Enter your WebID and click Login.'; + } else { + clientIdEl.textContent = 'No client ID configured on this server'; + clientIdEl.className = 'error'; + statusEl.textContent = 'Server has no clientIdUrl in config.'; + statusEl.className = 'error'; + } + } catch (err) { + clientIdEl.textContent = 'Failed to load config: ' + err.message; + clientIdEl.className = 'error'; + statusEl.textContent = 'Config error'; + statusEl.className = 'error'; + return; + } + + // Login button handler + loginBtn.addEventListener('click', async function () { + var webId = webidInput.value.trim(); + if (!webId) return; + + statusEl.textContent = 'Logging in...'; + statusEl.className = ''; + + try { + await window.solid.login(webId); + } catch (err) { + statusEl.textContent = 'Login error: ' + err.message; + statusEl.className = 'error'; + return; + } + }); + + // Poll for authentication state + var interval = setInterval(async function () { + if (!window.solid.webId) return; + clearInterval(interval); + + webidEl.textContent = window.solid.webId; + webidEl.className = ''; + + var podRoot = window.solid.webId.split('/profile/')[0] + '/'; + var privateUrl = podRoot + 'private/notes'; + + statusEl.textContent = 'Authenticated! Fetching private resource...'; + + try { + var response = await window.solid.fetch(privateUrl); + if (response.ok) { + var text = await response.text(); + fetchResultEl.textContent = text; + fetchResultEl.className = ''; + statusEl.textContent = 'Private resource fetched successfully!'; + } else { + fetchResultEl.textContent = 'HTTP ' + response.status + ': ' + response.statusText; + fetchResultEl.className = 'error'; + statusEl.textContent = 'Fetch failed'; + statusEl.className = 'error'; + } + } catch (err) { + fetchResultEl.textContent = err.message; + fetchResultEl.className = 'error'; + statusEl.textContent = 'Fetch error'; + statusEl.className = 'error'; + } + }, 200); +} + +init(); From 5aba28637dc00e210e66286f493697bfc7780b9b Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Sat, 4 Apr 2026 23:59:16 +0100 Subject: [PATCH 05/50] WIP: adding client identifiers --- apps/extension/.roborev.toml | 188 ++++++++++++++++ apps/extension/e2e/setup/global-setup.ts | 48 +++- apps/extension/e2e/setup/global-teardown.ts | 7 +- apps/extension/e2e/setup/seed.json | 5 +- .../templates/pod/base/profile/card$.ttl.hbs | 2 + .../templates/pod/base/profile/photo.jpg | Bin 0 -> 5220 bytes .../pod/wac/profile/photo.jpg.acl.hbs | 17 ++ .../e2e/tests/client-registration.spec.ts | 81 ++----- apps/extension/e2e/tests/login.spec.ts | 3 +- apps/extension/package-lock.json | 208 +++++++++++++++--- apps/extension/package.json | 5 +- apps/extension/scripts/dev.ts | 18 +- apps/extension/src/background/auth-flow.ts | 22 +- .../src/background/service-worker.ts | 68 ++++-- .../src/background/session-database.ts | 40 ++++ apps/extension/src/content/content-script.ts | 2 + apps/extension/src/icons/icon128.png | Bin 4880 -> 4589 bytes apps/extension/src/icons/icon16.png | Bin 594 -> 642 bytes apps/extension/src/icons/icon32.png | Bin 1173 -> 1157 bytes apps/extension/src/icons/icon48.png | Bin 1740 -> 1683 bytes apps/extension/src/icons/solid.svg | 9 + apps/extension/src/inject/inject.ts | 29 ++- apps/extension/src/popup/popup.css | 137 +++++++++++- apps/extension/src/popup/popup.html | 29 +-- apps/extension/src/popup/popup.ts | 146 ++++++++++-- apps/extension/test-site/server.ts | 23 +- 26 files changed, 895 insertions(+), 192 deletions(-) create mode 100644 apps/extension/.roborev.toml create mode 100644 apps/extension/e2e/setup/templates/pod/base/profile/photo.jpg create mode 100644 apps/extension/e2e/setup/templates/pod/wac/profile/photo.jpg.acl.hbs create mode 100644 apps/extension/src/icons/solid.svg diff --git a/apps/extension/.roborev.toml b/apps/extension/.roborev.toml new file mode 100644 index 000000000..803288664 --- /dev/null +++ b/apps/extension/.roborev.toml @@ -0,0 +1,188 @@ +# Default agent for this repo when no workflow-specific agent is set. +agent = '@github/copilot' +# Default model for this repo when no workflow-specific model is set. +model = '' +# Backup agent for this repo if the primary agent fails. +backup_agent = '' +# Backup model for this repo if the primary model fails. +backup_model = '' +# Number of related reviews to include as context for this repo. +review_context_count = 0 +# Extra review instructions added to prompts for this repo. +review_guidelines = '' +# Override the review job timeout in minutes for this repo. +job_timeout_minutes = 0 +# Branches that should be skipped for automatic review in this repo. +excluded_branches = [] +# Commit message substrings that should skip review for this repo. +excluded_commit_patterns = [] +# Display name shown for this repo in the TUI and output. +display_name = '' +# Reasoning level for reviews in this repo: fast, standard, medium, thorough, or maximum. +review_reasoning = '' +# Reasoning level for refine in this repo: fast, standard, medium, thorough, or maximum. +refine_reasoning = '' +# Reasoning level for fix in this repo: fast, standard, medium, thorough, or maximum. +fix_reasoning = '' +# Minimum severity for fix in this repo: critical, high, medium, or low. +fix_min_severity = '' +# Minimum severity for refine in this repo: critical, high, medium, low. +refine_min_severity = '' +# Filenames or glob patterns to exclude from review diffs for this repo. +exclude_patterns = [] +# Automatic post-commit review mode for this repo: commit or branch. +post_commit_review = '' +reuse_review_session_lookback = 0 +# Agent override for standard review in this repo. +review_agent = '' +# Agent override for fast review in this repo. +review_agent_fast = '' +# Agent override for standard review in this repo. +review_agent_standard = '' +# Agent override for medium review in this repo. +review_agent_medium = '' +# Agent override for thorough review in this repo. +review_agent_thorough = '' +# Agent override for maximum review in this repo. +review_agent_maximum = '' +# Agent override for refine in this repo. +refine_agent = '' +# Agent override for fast refine in this repo. +refine_agent_fast = '' +# Agent override for standard refine in this repo. +refine_agent_standard = '' +# Agent override for medium refine in this repo. +refine_agent_medium = '' +# Agent override for thorough refine in this repo. +refine_agent_thorough = '' +# Agent override for maximum refine in this repo. +refine_agent_maximum = '' +# Model override for standard review in this repo. +review_model = '' +# Model override for fast review in this repo. +review_model_fast = '' +# Model override for standard review in this repo. +review_model_standard = '' +# Model override for medium review in this repo. +review_model_medium = '' +# Model override for thorough review in this repo. +review_model_thorough = '' +# Model override for maximum review in this repo. +review_model_maximum = '' +# Model override for standard refine in this repo. +refine_model = '' +# Model override for fast refine in this repo. +refine_model_fast = '' +# Model override for standard refine in this repo. +refine_model_standard = '' +# Model override for medium refine in this repo. +refine_model_medium = '' +# Model override for thorough refine in this repo. +refine_model_thorough = '' +# Model override for maximum refine in this repo. +refine_model_maximum = '' +# Agent override for fix in this repo. +fix_agent = '' +# Agent override for fast fix in this repo. +fix_agent_fast = '' +# Agent override for standard fix in this repo. +fix_agent_standard = '' +# Agent override for medium fix in this repo. +fix_agent_medium = '' +# Agent override for thorough fix in this repo. +fix_agent_thorough = '' +# Agent override for maximum fix in this repo. +fix_agent_maximum = '' +# Model override for standard fix in this repo. +fix_model = '' +# Model override for fast fix in this repo. +fix_model_fast = '' +# Model override for standard fix in this repo. +fix_model_standard = '' +# Model override for medium fix in this repo. +fix_model_medium = '' +# Model override for thorough fix in this repo. +fix_model_thorough = '' +# Model override for maximum fix in this repo. +fix_model_maximum = '' +# Agent override for security review in this repo. +security_agent = '' +# Agent override for fast security review in this repo. +security_agent_fast = '' +# Agent override for standard security review in this repo. +security_agent_standard = '' +# Agent override for medium security review in this repo. +security_agent_medium = '' +# Agent override for thorough security review in this repo. +security_agent_thorough = '' +# Agent override for maximum security review in this repo. +security_agent_maximum = '' +# Model override for standard security review in this repo. +security_model = '' +# Model override for fast security review in this repo. +security_model_fast = '' +# Model override for standard security review in this repo. +security_model_standard = '' +# Model override for medium security review in this repo. +security_model_medium = '' +# Model override for thorough security review in this repo. +security_model_thorough = '' +# Model override for maximum security review in this repo. +security_model_maximum = '' +# Agent override for design review in this repo. +design_agent = '' +# Agent override for fast design review in this repo. +design_agent_fast = '' +# Agent override for standard design review in this repo. +design_agent_standard = '' +# Agent override for medium design review in this repo. +design_agent_medium = '' +# Agent override for thorough design review in this repo. +design_agent_thorough = '' +# Agent override for maximum design review in this repo. +design_agent_maximum = '' +# Model override for standard design review in this repo. +design_model = '' +# Model override for fast design review in this repo. +design_model_fast = '' +# Model override for standard design review in this repo. +design_model_standard = '' +# Model override for medium design review in this repo. +design_model_medium = '' +# Model override for thorough design review in this repo. +design_model_thorough = '' +# Model override for maximum design review in this repo. +design_model_maximum = '' +# Backup agent for review in this repo. +review_backup_agent = '' +# Backup agent for refine in this repo. +refine_backup_agent = '' +# Backup agent for fix in this repo. +fix_backup_agent = '' +# Backup agent for security review in this repo. +security_backup_agent = '' +# Backup agent for design review in this repo. +design_backup_agent = '' +# Backup model for review in this repo. +review_backup_model = '' +# Backup model for refine in this repo. +refine_backup_model = '' +# Backup model for fix in this repo. +fix_backup_model = '' +# Backup model for security review in this repo. +security_backup_model = '' +# Backup model for design review in this repo. +design_backup_model = '' +hooks = [] +# Maximum prompt size for this repo before falling back to file paths. +max_prompt_size = 0 + +[ci] +# Override the agents used by CI for this repo. +agents = [] +# Override the review types used by CI for this repo. +review_types = [] +# Override the CI reasoning level for this repo: fast, standard, medium, thorough, or maximum. +reasoning = '' +# Override the minimum CI severity included in synthesized output. +min_severity = '' diff --git a/apps/extension/e2e/setup/global-setup.ts b/apps/extension/e2e/setup/global-setup.ts index 660d331b4..5d5747b97 100644 --- a/apps/extension/e2e/setup/global-setup.ts +++ b/apps/extension/e2e/setup/global-setup.ts @@ -1,20 +1,60 @@ +import { chromium } from 'playwright'; +import path from 'path'; import { startCSSServer } from './css-server'; import { startTestSiteServer } from '../../test-site/server'; import type http from 'http'; const TEST_SITE_PORT = 8080; +const APP_SITE_PORT = 8081; + +async function discoverExtensionId(): Promise { + const extPath = path.resolve(__dirname, '../../dist'); + const ctx = await chromium.launchPersistentContext('', { + channel: 'chromium', + headless: false, + args: [ + `--disable-extensions-except=${extPath}`, + `--load-extension=${extPath}`, + ], + }); + let sw = ctx.serviceWorkers()[0]; + if (!sw) sw = await ctx.waitForEvent('serviceworker'); + const extensionId = sw.url().split('/')[2]; + await ctx.close(); + return extensionId; +} async function globalSetup() { - // Start Community Solid Server console.log('Starting Community Solid Server...'); await startCSSServer(); console.log('CSS server ready on http://localhost:3000'); - // Start test site server (with dynamic Client ID Document endpoint) - const server = await startTestSiteServer({ port: TEST_SITE_PORT }); + console.log('Discovering extension ID...'); + const extensionId = await discoverExtensionId(); + const redirectUri = `https://${extensionId}.chromiumapp.org/callback`; + console.log(` Extension ID: ${extensionId}`); + + // Port 8080: basic test site + extension's Client ID Document + const server = await startTestSiteServer({ + port: TEST_SITE_PORT, + redirectUri, + clientName: 'Solid Browser Extension', + }); console.log(`Test site ready on http://localhost:${TEST_SITE_PORT}`); - (globalThis as Record).__TEST_SITE_SERVER__ = server; + // Port 8081: app site with its own Client ID Document + const appClientIdUrl = `http://localhost:${APP_SITE_PORT}/client-id`; + const appServer = await startTestSiteServer({ + port: APP_SITE_PORT, + redirectUri, + clientName: 'Test Application', + clientIdUrl: appClientIdUrl, + }); + console.log(`App site ready on http://localhost:${APP_SITE_PORT}`); + + const globals = globalThis as Record; + globals.__TEST_SITE_SERVER__ = server; + globals.__APP_SITE_SERVER__ = appServer; } export default globalSetup; diff --git a/apps/extension/e2e/setup/global-teardown.ts b/apps/extension/e2e/setup/global-teardown.ts index 7db26c8d4..323fbfff9 100644 --- a/apps/extension/e2e/setup/global-teardown.ts +++ b/apps/extension/e2e/setup/global-teardown.ts @@ -4,9 +4,10 @@ import type http from 'http'; async function globalTeardown() { await stopCSSServer(); - const server = (globalThis as Record).__TEST_SITE_SERVER__ as http.Server | undefined; - if (server) { - server.close(); + const globals = globalThis as Record; + for (const key of ['__TEST_SITE_SERVER__', '__APP_SITE_SERVER__']) { + const server = globals[key] as http.Server | undefined; + if (server) server.close(); } } diff --git a/apps/extension/e2e/setup/seed.json b/apps/extension/e2e/setup/seed.json index 4a973b2d7..b5896d20b 100644 --- a/apps/extension/e2e/setup/seed.json +++ b/apps/extension/e2e/setup/seed.json @@ -4,7 +4,10 @@ "password": "test-password-123", "pods": [ { - "name": "test-pod" + "name": "test-pod", + "settings": { + "name": "Alex Wilder" + } } ] } diff --git a/apps/extension/e2e/setup/templates/pod/base/profile/card$.ttl.hbs b/apps/extension/e2e/setup/templates/pod/base/profile/card$.ttl.hbs index 6dbc59f88..afa18f651 100644 --- a/apps/extension/e2e/setup/templates/pod/base/profile/card$.ttl.hbs +++ b/apps/extension/e2e/setup/templates/pod/base/profile/card$.ttl.hbs @@ -1,6 +1,7 @@ @prefix foaf: . @prefix pim: . @prefix solid: . +@prefix vcard: . <> a foaf:PersonalProfileDocument; @@ -9,6 +10,7 @@ <{{webId}}> {{#if name}}foaf:name "{{name}}";{{/if}} + vcard:hasPhoto "{{base.path}}profile/photo.jpg"; {{#if oidcIssuer}}solid:oidcIssuer <{{oidcIssuer}}>;{{/if}} pim:storage <{{base.path}}>; a foaf:Person. diff --git a/apps/extension/e2e/setup/templates/pod/base/profile/photo.jpg b/apps/extension/e2e/setup/templates/pod/base/profile/photo.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f1bf407db81093101047df8a180da6380243bbc4 GIT binary patch literal 5220 zcmbW*S5OmPmjLi!fCwr*^d^KBiu7s#1rh=%(z{AGAP9tBMCn9o1Qeuq2t<%xM3E}J zN2(B{g8~wfzWl%4`F3WXcK6&lPxs83`kuloaHYloXU85am^BI%;YvDrzPg+Us;IOsuRdOw7#eTmn4o9Q>Tj z%)DZ}{DMLvP!TpBaY=DuNdaLI;eVQtfj}VYtJI9t)QrOH%z$@fH3JP-a%j%HJ zeE>N<1;b6LJCuwDwje%FCh3>Sc~|-GR=0xwStY+`C=ZeeNf;OON11m%ME_VM-e4+sp8h>VJkiN#=Fr=+I6d7GY* zUr<<7TvA$A{-LI}uD+qMsrgGsXIFPm@7KPu@rlVP!nf&}CF1grmDRQNjm^FN-v^{W zhkuWb|8bE4fd9$*x9oqo=r3JY$jO1^pnqIsSNtv$NKa02Q;L$|jseKllaWvQW?=0ImbcE{6xC2dDuKH-+;X zB$+b?Yn{?EmRGjI)vsGcKoY!E*bR6+dzLH?S4M+B2XO86Ry5VcR3`s$Hi1fa$l}e! z2RQ~UIYj22Q=rh_IS)II&P@gv{t^*lvg?#&-ADTS;0~AL-L{FhGVa%>0p6fGX{41) zKTn@pTwu_Yd-?j)Qq>aFvt`}JoU@y7m9Qq@9=OSQ`I-e`HBqRo8+0JI;$;I~dHKjF zF8r_G1pwy4oP^-@e#-sn79SSpl|>okpu7_iK`WaF%hJZ}T6ZW881kRdxOOZO9x4C_ ze|Uu4aNjuRja$@f(MLVF0H~eUskt=2+8lFPCuQjTkj-J(NwsSX*~?CxAeeE)N*ae1 zI`h@uU!@UuN9t;eps-9S2I>?{|4MDU44g`H7{aB-I~lzV!e$^+TUcHIPWD?|jEFR&3x7|4+v9t17~?xktSuJo}_oa4AW0!tRZ1 z%;PBLCIL#a;Uz1&Z_#AoN$6zEnk}38LPH)QGeeTP8uy*Agy6zM8 zK-QOR(I=bed;8md%J9;}S_yT|-9WQSf3!tx#R!9rFc3!AdL%zH|CPL-k8%Icd&I73 zeT|Ntv~sU{J4}szfx**PaqG@K8}}fLbaRwZQV_-m{g7pnJYfoZikE3P6)nW$vO^;C zv2i0;Mv+6iJxgOP`5){iCrDuX%Uwvga98c9K7oWG~10#?@n4P59B8 z#pKxav9uaEC>DUSHyM7zDo~|-F1HkT)^F`%)WUhX`teg2gIHk5MElEYY}1(C^n5Hb zy~-jJ3)pn+d$!O5YrN&vTT?L4T4<2TzU}67b0sbbwv}2nUhLT67H6N254CI%&y5BS zBvo@Efk(#j??yHwEG@IZQ-#?NExsiBZdsq>LYU$s-v~8wj*G8A$x!CmFBqVebHf7t z&`+x2MRKb~nEEKU&T@2=9c^{R^h*1>=Yd?c3iR%s6vxKds>~_3PMz@J8nKWnBc7>d zUQg3_OndvrAZndu&@qI=oj-Z3Mm~NHthgpRax#$=H&jk@RS8_KIK5r39FFk3F(H)7 zFSjMBEf}W6Qc!C9mk|Dhpd-H1znhrCfA)Ugbxs!ky+ju1H+z$&j+ zJhIwG-p*c}Twgw$cC48FXt>a)savd+9HUN&Bvou$bp3wniquUP#??ga>9vS)fA;v32qjh{~LV}r@?OI%X>+e%sR~e+J z?7*9MPIhFq^0rRB2c+SO(lsxW0(Z5J-^FULY}+XK>Li<%UniTLqIjZYpTsff42HbW zlf)BCMzX`Xvs?#@TEmbE7O}?1l3RbNXQYhGsu|uD?+l{xnMk$i4!?dLm#5qHdxL`8 zZIp9=aDg-@mHb0Zddd!u<2cRKi0>kePO`p<`SScJ`C5pXQ~>MMt)uZdGd)<*hW8srgTg6NLvVv4z}cu+6b3 zjaIJ*lUIKlrNze@jc^yt)b0%JS|Iv}*pNO?8=8JyzAe2kj)KEk3AtW~zh-6Y8M8B} z`jOrW>fAN%Rzl3&=Ak8oIn*8l82iHdl~+8BsvCqR4F>Xj31#>cDh#44k1tT=CqfbndNb7r4<|}O0^@F zB323A4WpS2*trAQYy~zN;rEKwQ+SN+8%UsmR`?7Ttn1V@Bn)9$7a;4#p281aGc!v# zq;#9aIhV;+ML6L@pW-cFRewhj=PHZ+?~4`3U85OfjsVNx%jjDE7m^m!TlL#&ZAb-62<|OYaP$7 z!=sQ)i(Uget*?J&lM;C%#oQ%0P9)m-Yae~|-gtQd0I>czQ+#jZ`ozKTkiPqTfUL{b zrMXqZ;MM(l=-6WK@@+mE=}-m=tIW#A4dJirb&7wz4X(^Z*p$b9K6UN!Y1BMUA!^+; z<7eG@cma@`5CzZP#uS!4fNgcPYS1eui2q?0k9%!pJO8%Y_$dDzp+xbD?>Ch<2=H>@ zeNkn)ewxq%NOY#QRw=nCjU|Ta%}TONJ9mCvaeXZRTymNF=Uq{hNlo?eb!E?{9{CK1 zCJFv;uew2OxIam5eYDNc!R;4~P}M(BVGfiXOvbo)I-(4Iy@cp?mm_+7Cp6*ekoE>P zFf_+<_OFRj^`R^ctN_4Xk?TgKwq%wno4dBC$ToSb=o75zP*wQsRi-q5>d%G&VJ+S z78YM4h$#y25g6gN2V$TT-!g3Ii9KCL;O@S(@ovVBc0Gc1Fg^Kxa13+Btrw5u6(67p za<6e5!MwS@3Y4f$Q8N40Nhro{G{q6N<>q3L59rXv6?)RH+d_}P=v0KJaI$auw2@7i z8IBfwjIS~^Vk$+18j#4%yk9@n3vUOnX0wE?-KhXcC^R^?gu@=Whv$sm;`yz-yzZ5TO_GyHqMo0A{@j;8V zrO5fB(wb^#GR|$3a>tkxt4YSS+-_*xR%)0)|HkjP-22L_f zd&lP`dgTH?do=xv2(dmmM39@Qg2e}~E|2dq6&#hOtH^<5ZR6|`cjW2T$Gzv3_Qb20 zS!;_ZqpW=gwUOs?Ykm|4^X4LtJtU`jvV08<7N%|=|IQvV)2!a~D$Z|!N~+JC6I;|8 z3e#L>Kj^G&S(~)-CTIKkfxTvsycDdfydJbg%C+M)8XW}B<`CUOtTVX>YIpH2)lbhJ z9h}g>-F^Q}=viuu_RUCrF@2cleL)jPoReIu;8J0{zce!LT(xe-Vry!~H|s_wk2Jh- z4rDYpUdwZ>5F}$wh2#u=+p}fnyjxdW1BL5gg9v);mNotFRYQHn1`<2B8na4fDuiB5 za&nbm<{kmfdnkV?rMIlUb6vEe`q81?j`)ZgW*J#q!OHM-m-b1bq)uZ+19;b^{roBi;Y+Vt#6J$i-2ue( zQ+G8sG&g~Q4ZGO!SL(f7^T94KcYDKlQ49E*aN3xaZiH{|;#!gGMpf+NMhSI}03Mx; zeKs~`aI7>f?FLEiAyvl6AXB)?Nw|%K_13`eiIB|J*f4-UXu|0OX;4QS5~kuO3VF)I zd(gvAIybTVxvK$e0s>#a?s<)Ak7*CBiSiY!vwAJXf-0KB$Wixm7=A$@b7X4(3*Qbl&OE`M^g-(~NslZocq7-o&s^y}v; zdHo1&5qPY0qiJXj-~C%x1pT#)h}HADr(^8CVSNF>_^gfXx!D4;_oALZYYwju(XV@2 zIzP>J0{2##JCrm1NfKua8SL~ZIV|)jAib9f(eLtOaD4wzQhz=ktbPV9OcQqt;V#2l z_j`<-u4AQ?+~byEiarJUaFq*^8=I_(?`}F(UFxRd&-qr@?=$Qvqx*^qNTmS4(-os{!&e>jQN_~M`wQPLNMG@0af@yr~;5rNOtPXU1{+&rAQA6@J+ z2HKaf=O~N|E{uYje57Rqm!R<-LGBWd{rmb~ClZG>lFx%oKXug56zzV$m*AbC?Td6% zmK;VXo=Lj)pIQ{?FJ;*gM3k0W%P)<8 z)g7Fa`dvr&SQ0y+jqvB1-OPq9}^uUz7eSt>J72)HpRK^|9~cf>-H%ejxnqO~dS`Dj^~ zFQqhPP1_Op3=-;aWfHc!aoipp+--GthXnmN;QDpmNSEO!rkX0OZgP|G6V5uDiXk5- z+e4mX)3hAicS}jBN5d>1bUKbqW%p!dYM)Ks7p?@u8$e9Mw!1xv;*p7|_}kdN=pU)- zkVK8l=iX+o)#vOm>?K0J5lv}tV2YbF+v8LhfO`D#S?@CBxp|lO$i|0-&u=VaQ9^H4 zANTO+y=<4uof5K=n2}jg9_&W zRb$gvM%-?Dy|jwI44*V`Tk2iqk@uq2X(^qznEx9^*?=i?uM$0elH4gokfYeKR|MXRg}EH09dv*Z;Y3r=f8Y4 zDSDQ?|FM}o0R4G(;RCn9S~ZtU93f;6*unCUr>W@)QOz#(llOEl5Kl$IlesxLeKxX5 z$-u5Ex^_Qsk!O}V>BvB=d60bg98n0w?_~p4YvBy0rqHU@;<}fsDG(Qnc(Dhy zV-a!8LdBw{8_VNQPv6GKd|I$2oMI+92bxfvzA=nS?iKT*I!Y#aKwp?^q_dNf6XYbr zTLyW}J$72Z8W(qD!B8>~|B23OC2a{*a{bnv^Dg?J{>-vxuW~82fj;M5!xTo;JT}v;GyVFpK)E-{ zQR~>JLZwo3dY!>)rc5(3xCr)pzO4x|s--hu4NLBbg0`n5ef~i|(_G2CFK+R?-;c)K zGcw(O__9yzyL=TKl{nKNN9q2zC^pqX&qsIu!uLoyv>S(s`@MTN77zKuLZ!68J+I6# z&P8wPi$`$*C0%7V8(I^TY+3shd%v!`_NhYrB?xz8Z+ZWjea8P(N(xf;M>W$x%X8EX z2?U^J^*9y{c?2){Sw$i89bpsOtCTb&oc}TbF5I57m~aPY#(1=y&~c*y^nC#%sx9ph(=w_B&J((r{twgS+BX0I literal 0 HcmV?d00001 diff --git a/apps/extension/e2e/setup/templates/pod/wac/profile/photo.jpg.acl.hbs b/apps/extension/e2e/setup/templates/pod/wac/profile/photo.jpg.acl.hbs new file mode 100644 index 000000000..098918558 --- /dev/null +++ b/apps/extension/e2e/setup/templates/pod/wac/profile/photo.jpg.acl.hbs @@ -0,0 +1,17 @@ +# ACL resource for the profile photo +@prefix acl: . +@prefix foaf: . + +# The profile photo is readable by the public. +<#public> + a acl:Authorization; + acl:agentClass foaf:Agent; + acl:accessTo <./photo.jpg>; + acl:mode acl:Read. + +# The owner has full access to the photo +<#owner> + a acl:Authorization; + acl:agent <{{webId}}>; + acl:accessTo <./photo.jpg>; + acl:mode acl:Read, acl:Write, acl:Control. diff --git a/apps/extension/e2e/tests/client-registration.spec.ts b/apps/extension/e2e/tests/client-registration.spec.ts index ef1c56107..6d89f176c 100644 --- a/apps/extension/e2e/tests/client-registration.spec.ts +++ b/apps/extension/e2e/tests/client-registration.spec.ts @@ -2,14 +2,11 @@ import { test, expect } from '../fixtures'; import { completeOidcLogin } from './helpers'; const TEST_SITE = 'http://localhost:8080'; +const APP_SITE = 'http://localhost:8081'; const TEST_WEBID = 'http://localhost:3000/test-pod/profile/card#me'; -function buildClientIdUrl(redirectUri: string, clientName: string): string { - return `http://localhost:8080/client-id?redirect_uri=${encodeURIComponent(redirectUri)}&client_name=${encodeURIComponent(clientName)}`; -} - -test('dynamic registration: login without a static client ID', async ({ context, extensionId }) => { - // This is the default flow — no client ID set, uses dynamic registration +test('extension client ID: default login uses the extension client identifier', async ({ context, extensionId }) => { + // The default popup login uses EXTENSION_CLIENT_ID (http://localhost:8080/client-id) const popupPage = await context.newPage(); await popupPage.goto(`chrome-extension://${extensionId}/popup/popup.html`); await popupPage.fill('#webid-input', TEST_WEBID); @@ -28,70 +25,20 @@ test('dynamic registration: login without a static client ID', async ({ context, await expect(page.locator('#fetch-result')).toContainText('Private Note', { timeout: 15_000 }); }); -test('static registration: extension-owned client ID', async ({ context, extensionId }) => { - // Build the static Client ID Document URL using the extension's redirect URI - const redirectUri = `https://${extensionId}.chromiumapp.org/callback`; - const clientIdUrl = buildClientIdUrl(redirectUri, 'Solid Browser Extension'); - - // Set the client ID for the extension via the popup before logging in - // We do this by evaluating in the popup context which sends the message directly - const popupPage = await context.newPage(); - await popupPage.goto(`chrome-extension://${extensionId}/popup/popup.html`); - - // Send SOLID_SET_CLIENT_ID directly from the popup context - await popupPage.evaluate((cid) => { - chrome.runtime.sendMessage({ - type: 'SOLID_SET_CLIENT_ID', - origin: 'chrome-extension://' + chrome.runtime.id, - clientId: cid, - }); - }, clientIdUrl); - - // Now log in — the service worker will use the stored client ID - await popupPage.fill('#webid-input', TEST_WEBID); - - // Need to also pass clientId in the login message from the popup - // Since the popup sends SOLID_LOGIN directly, we override the submit handler - await popupPage.evaluate((params) => { - chrome.runtime.sendMessage({ - type: 'SOLID_LOGIN', - webId: params.webId, - clientId: params.clientId, - }, () => {}); - }, { webId: TEST_WEBID, clientId: clientIdUrl }); - - // Wait for the login page to open - const loginPage = await context.waitForEvent('page', { timeout: 15_000 }); - await completeOidcLogin(loginPage); - - await popupPage.goto(`chrome-extension://${extensionId}/popup/popup.html`); - await popupPage.locator('#logged-in-view').waitFor({ state: 'visible', timeout: 15_000 }); - await popupPage.close(); - - // Verify authenticated fetch works +test('app client ID: website sets its own client identifier via setClientId', async ({ context, extensionId }) => { + // Navigate to the app site (port 8081) which sets its own client ID const page = await context.newPage(); - await page.goto(TEST_SITE); - await expect(page.locator('#fetch-result')).toContainText('Private Note', { timeout: 15_000 }); -}); - -test('static registration: application-owned client ID via setClientId', async ({ context, extensionId }) => { - // Build the static Client ID Document URL using the extension's redirect URI - const redirectUri = `https://${extensionId}.chromiumapp.org/callback`; - const clientIdUrl = buildClientIdUrl(redirectUri, 'Test Application'); - - // Navigate to the test site and call solid.setClientId() before login - const page = await context.newPage(); - await page.goto(TEST_SITE); - - // Wait for the extension to inject window.solid - await page.waitForFunction(() => typeof (window as any).solid !== 'undefined', null, { timeout: 5_000 }); + await page.goto(`${APP_SITE}/with-client-id.html`); - // Set the client ID for this origin - await page.evaluate((cid) => { - (window as any).solid.setClientId(cid); - }, clientIdUrl); + // Wait for the extension to inject window.solid and the app to set the client ID + await page.waitForFunction( + () => typeof (window as any).solid !== 'undefined', + null, + { timeout: 5_000 }, + ); - // Now trigger login from the page — solid.login() will include the clientId + // The app fetches /config.json which returns its client ID (http://localhost:8081/client-id) + // and calls solid.setClientId() automatically. Trigger login from the page. const loginPagePromise = context.waitForEvent('page'); await page.evaluate((webId) => { (window as any).solid.login(webId); diff --git a/apps/extension/e2e/tests/login.spec.ts b/apps/extension/e2e/tests/login.spec.ts index 59ad98e94..0e0f49a2c 100644 --- a/apps/extension/e2e/tests/login.spec.ts +++ b/apps/extension/e2e/tests/login.spec.ts @@ -8,5 +8,6 @@ test('login via popup with Community Solid Server', async ({ context, extensionI const popupPage = await context.newPage(); await popupPage.goto(`chrome-extension://${extensionId}/popup/popup.html`); await expect(popupPage.locator('#logged-in-view')).toBeVisible({ timeout: 10_000 }); - await expect(popupPage.locator('#webid-display')).toContainText('test-pod'); + await expect(popupPage.locator('#profile-name')).toContainText('Alex Wilder'); + await expect(popupPage.locator('#profile-photo')).toBeVisible(); }); diff --git a/apps/extension/package-lock.json b/apps/extension/package-lock.json index 0a6b73e4b..eb096ac5a 100644 --- a/apps/extension/package-lock.json +++ b/apps/extension/package-lock.json @@ -9,8 +9,11 @@ "version": "0.1.0", "license": "MIT", "dependencies": { + "@rdfjs/wrapper": "^0.32.0", + "@solid/object": "^0.4.0", "@uvdsl/solid-oidc-client-browser": "^0.2.2", - "jose": "^5.9.6" + "jose": "^5.9.6", + "n3": "^2.0.3" }, "devDependencies": { "@playwright/test": "^1.49.0", @@ -755,6 +758,20 @@ "fetch-sparql-endpoint": "bin/fetch-sparql-endpoint.js" } }, + "node_modules/@comunica/actor-query-operation-sparql-endpoint/node_modules/n3": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/n3/-/n3-1.26.0.tgz", + "integrity": "sha512-SQknS0ua90rN+3RHuk8BeIqeYyqIH/+ecViZxX08jR4j6MugqWRjtONl3uANG/crWXnOM2WIqBJtjIhVYFha+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=12.0" + } + }, "node_modules/@comunica/actor-query-operation-sparql-endpoint/node_modules/sparqljson-parse": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/sparqljson-parse/-/sparqljson-parse-2.2.0.tgz", @@ -1561,6 +1578,20 @@ "n3": "^1.17.0" } }, + "node_modules/@comunica/actor-rdf-parse-n3/node_modules/n3": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/n3/-/n3-1.26.0.tgz", + "integrity": "sha512-SQknS0ua90rN+3RHuk8BeIqeYyqIH/+ecViZxX08jR4j6MugqWRjtONl3uANG/crWXnOM2WIqBJtjIhVYFha+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=12.0" + } + }, "node_modules/@comunica/actor-rdf-parse-rdfxml": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-parse-rdfxml/-/actor-rdf-parse-rdfxml-2.10.0.tgz", @@ -1725,6 +1756,20 @@ "fetch-sparql-endpoint": "bin/fetch-sparql-endpoint.js" } }, + "node_modules/@comunica/actor-rdf-resolve-hypermedia-sparql/node_modules/n3": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/n3/-/n3-1.26.0.tgz", + "integrity": "sha512-SQknS0ua90rN+3RHuk8BeIqeYyqIH/+ecViZxX08jR4j6MugqWRjtONl3uANG/crWXnOM2WIqBJtjIhVYFha+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=12.0" + } + }, "node_modules/@comunica/actor-rdf-resolve-hypermedia-sparql/node_modules/sparqljson-parse": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/sparqljson-parse/-/sparqljson-parse-2.2.0.tgz", @@ -1865,6 +1910,20 @@ "n3": "^1.17.0" } }, + "node_modules/@comunica/actor-rdf-serialize-n3/node_modules/n3": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/n3/-/n3-1.26.0.tgz", + "integrity": "sha512-SQknS0ua90rN+3RHuk8BeIqeYyqIH/+ecViZxX08jR4j6MugqWRjtONl3uANG/crWXnOM2WIqBJtjIhVYFha+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=12.0" + } + }, "node_modules/@comunica/actor-rdf-serialize-shaclc": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/@comunica/actor-rdf-serialize-shaclc/-/actor-rdf-serialize-shaclc-2.10.0.tgz", @@ -1982,6 +2041,20 @@ "fetch-sparql-endpoint": "bin/fetch-sparql-endpoint.js" } }, + "node_modules/@comunica/actor-rdf-update-hypermedia-sparql/node_modules/n3": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/n3/-/n3-1.26.0.tgz", + "integrity": "sha512-SQknS0ua90rN+3RHuk8BeIqeYyqIH/+ecViZxX08jR4j6MugqWRjtONl3uANG/crWXnOM2WIqBJtjIhVYFha+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=12.0" + } + }, "node_modules/@comunica/actor-rdf-update-hypermedia-sparql/node_modules/sparqljson-parse": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/sparqljson-parse/-/sparqljson-parse-2.2.0.tgz", @@ -3275,6 +3348,15 @@ "@types/node": "*" } }, + "node_modules/@rdfjs/wrapper": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@rdfjs/wrapper/-/wrapper-0.32.0.tgz", + "integrity": "sha512-xJdRz/Jk11S9xlqRRBkLWxIxWUuIQqy/BeNvp94m34m1DTEwYMa+wHdznPTEvDEHXRGSFOs1guZ1Z0swW+axcg==", + "license": "MIT", + "engines": { + "node": ">=24.0.0" + } + }, "node_modules/@rubensworks/saxes": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@rubensworks/saxes/-/saxes-6.0.1.tgz", @@ -3377,6 +3459,20 @@ "node": ">=10" } }, + "node_modules/@solid/access-token-verifier/node_modules/n3": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/n3/-/n3-1.26.0.tgz", + "integrity": "sha512-SQknS0ua90rN+3RHuk8BeIqeYyqIH/+ecViZxX08jR4j6MugqWRjtONl3uANG/crWXnOM2WIqBJtjIhVYFha+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=12.0" + } + }, "node_modules/@solid/community-server": { "version": "7.1.9", "resolved": "https://registry.npmjs.org/@solid/community-server/-/community-server-7.1.9.tgz", @@ -3482,6 +3578,20 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/@solid/community-server/node_modules/n3": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/n3/-/n3-1.26.0.tgz", + "integrity": "sha512-SQknS0ua90rN+3RHuk8BeIqeYyqIH/+ecViZxX08jR4j6MugqWRjtONl3uANG/crWXnOM2WIqBJtjIhVYFha+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=12.0" + } + }, "node_modules/@solid/community-server/node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", @@ -3489,6 +3599,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@solid/object": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@solid/object/-/object-0.4.0.tgz", + "integrity": "sha512-Q26pEDsgHqEtdoDiErs5OOd4veatzeex0O94zI7pzQj3zZ6cawECcRIP8vcPCiJ/95NlDK4qbEOeMAUm4FiiSA==", + "license": "MIT", + "dependencies": { + "rdfjs-wrapper": "^0.15.0" + }, + "engines": { + "node": ">=24.0.0" + } + }, "node_modules/@szmarczak/http-timer": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz", @@ -4313,7 +4435,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "dev": true, "license": "MIT", "dependencies": { "event-target-shim": "^5.0.0" @@ -4537,7 +4658,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -4660,7 +4780,6 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, "funding": [ { "type": "github", @@ -5738,7 +5857,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -5755,7 +5873,6 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.8.x" @@ -5894,6 +6011,20 @@ "@types/node": "*" } }, + "node_modules/fetch-sparql-endpoint/node_modules/n3": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/n3/-/n3-1.26.0.tgz", + "integrity": "sha512-SQknS0ua90rN+3RHuk8BeIqeYyqIH/+ecViZxX08jR4j6MugqWRjtONl3uANG/crWXnOM2WIqBJtjIhVYFha+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=12.0" + } + }, "node_modules/fetch-sparql-endpoint/node_modules/rdf-data-factory": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/rdf-data-factory/-/rdf-data-factory-2.0.2.tgz", @@ -6635,7 +6766,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, "funding": [ { "type": "github", @@ -7572,10 +7702,9 @@ "license": "MIT" }, "node_modules/n3": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/n3/-/n3-1.26.0.tgz", - "integrity": "sha512-SQknS0ua90rN+3RHuk8BeIqeYyqIH/+ecViZxX08jR4j6MugqWRjtONl3uANG/crWXnOM2WIqBJtjIhVYFha+w==", - "dev": true, + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/n3/-/n3-2.0.3.tgz", + "integrity": "sha512-um/toGVENTarHBYIK2TdH6ByBhW75WpdKpv8iTYt9wF2QfBk8s8a16iaWZFUAAC1BKfGdb99kfgx6pltdDwfKA==", "license": "MIT", "dependencies": { "buffer": "^6.0.3", @@ -8101,7 +8230,6 @@ "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6.0" @@ -8430,6 +8558,20 @@ "readable-stream": "^4.3.0" } }, + "node_modules/rdf-streaming-store/node_modules/n3": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/n3/-/n3-1.26.0.tgz", + "integrity": "sha512-SQknS0ua90rN+3RHuk8BeIqeYyqIH/+ecViZxX08jR4j6MugqWRjtONl3uANG/crWXnOM2WIqBJtjIhVYFha+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=12.0" + } + }, "node_modules/rdf-string": { "version": "1.6.3", "resolved": "https://registry.npmjs.org/rdf-string/-/rdf-string-1.6.3.tgz", @@ -8528,6 +8670,16 @@ "entities": "^4.4.0" } }, + "node_modules/rdfjs-wrapper": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/rdfjs-wrapper/-/rdfjs-wrapper-0.15.0.tgz", + "integrity": "sha512-yHysnxO473o4lRW1FJLmsf3PtxQRpkZVd9j4oPo+HhuqBFFwswuXZjjy/j+mqon61dQnbMQHHLhx68hpvy1HSA==", + "deprecated": "Please use @rdfjs/wrapper instead.", + "license": "MIT", + "engines": { + "node": ">=24.0.0" + } + }, "node_modules/rdfxml-streaming-parser": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/rdfxml-streaming-parser/-/rdfxml-streaming-parser-2.4.0.tgz", @@ -8571,7 +8723,6 @@ "version": "4.7.0", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", - "dev": true, "license": "MIT", "dependencies": { "abort-controller": "^3.0.0", @@ -8945,6 +9096,20 @@ "@types/node": "*" } }, + "node_modules/shaclc-parse/node_modules/n3": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/n3/-/n3-1.26.0.tgz", + "integrity": "sha512-SQknS0ua90rN+3RHuk8BeIqeYyqIH/+ecViZxX08jR4j6MugqWRjtONl3uANG/crWXnOM2WIqBJtjIhVYFha+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=12.0" + } + }, "node_modules/shaclc-write": { "version": "1.6.3", "resolved": "https://registry.npmjs.org/shaclc-write/-/shaclc-write-1.6.3.tgz", @@ -8967,20 +9132,6 @@ "@types/node": "*" } }, - "node_modules/shaclc-write/node_modules/n3": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/n3/-/n3-2.0.3.tgz", - "integrity": "sha512-um/toGVENTarHBYIK2TdH6ByBhW75WpdKpv8iTYt9wF2QfBk8s8a16iaWZFUAAC1BKfGdb99kfgx6pltdDwfKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer": "^6.0.3", - "readable-stream": "^4.0.0" - }, - "engines": { - "node": ">=12.0" - } - }, "node_modules/shaclc-write/node_modules/rdf-data-factory": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/rdf-data-factory/-/rdf-data-factory-2.0.2.tgz", @@ -9382,7 +9533,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.2.0" @@ -9392,7 +9542,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, "funding": [ { "type": "github", @@ -10018,6 +10167,7 @@ "integrity": "sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.6.1", "@webpack-cli/configtest": "^3.0.1", diff --git a/apps/extension/package.json b/apps/extension/package.json index d2a6c7839..a2733f17e 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -13,8 +13,11 @@ "clean": "rm -rf dist" }, "dependencies": { + "@rdfjs/wrapper": "^0.32.0", + "@solid/object": "^0.4.0", "@uvdsl/solid-oidc-client-browser": "^0.2.2", - "jose": "^5.9.6" + "jose": "^5.9.6", + "n3": "^2.0.3" }, "devDependencies": { "@playwright/test": "^1.49.0", diff --git a/apps/extension/scripts/dev.ts b/apps/extension/scripts/dev.ts index a5566c82d..4fa6e5887 100644 --- a/apps/extension/scripts/dev.ts +++ b/apps/extension/scripts/dev.ts @@ -100,18 +100,26 @@ function pinExtension(userDataDir: string, extensionId: string) { const extensionId = await discoverExtensionId(userDataDir); pinExtension(userDataDir, extensionId); - // 4. Build the Client ID Document URL for the static-registration site + // 4. Start two test site servers, both configured with the extension's redirect URI const redirectUri = `https://${extensionId}.chromiumapp.org/callback`; - const clientIdUrl = `http://localhost:${CLIENT_ID_SITE_PORT}/client-id?redirect_uri=${encodeURIComponent(redirectUri)}&client_name=${encodeURIComponent('My Test App')}`; + const appClientIdUrl = `http://localhost:${CLIENT_ID_SITE_PORT}/client-id`; - // 5. Start two test site servers console.log('Starting test sites...'); - const basicServer = await startTestSiteServer({ port: BASIC_SITE_PORT }); + const basicServer = await startTestSiteServer({ + port: BASIC_SITE_PORT, + redirectUri, + clientName: 'Solid Browser Extension', + }); servers.push(basicServer); console.log(` Basic site ready on http://localhost:${BASIC_SITE_PORT}`); - const clientIdServer = await startTestSiteServer({ port: CLIENT_ID_SITE_PORT, clientIdUrl }); + const clientIdServer = await startTestSiteServer({ + port: CLIENT_ID_SITE_PORT, + redirectUri, + clientName: 'My Test App', + clientIdUrl: appClientIdUrl, + }); servers.push(clientIdServer); console.log(` Client ID site ready on http://localhost:${CLIENT_ID_SITE_PORT}`); diff --git a/apps/extension/src/background/auth-flow.ts b/apps/extension/src/background/auth-flow.ts index 04af784e9..7891ea37b 100644 --- a/apps/extension/src/background/auth-flow.ts +++ b/apps/extension/src/background/auth-flow.ts @@ -24,6 +24,11 @@ async function fetchOidcConfig(idpUrl: string): Promise { return response.json(); } +// The extension's dereferenceable Client ID Document URL. +// This URL must resolve to a JSON-LD document with the extension's redirect_uri. +// In production, this would be hosted by the extension developer. +const EXTENSION_CLIENT_ID = 'http://localhost:8080/client-id'; + function getRedirectUri(): string { return chrome.identity.getRedirectURL('callback'); } @@ -106,21 +111,8 @@ export async function initiateLogin(webId: string, staticClientId?: string): Pro const oidcConfig = await fetchOidcConfig(idpUrl); const redirectUri = getRedirectUri(); - let clientId: string; - if (staticClientId) { - // Use a static (dereferenceable) client identifier — skip dynamic registration - clientId = staticClientId; - } else { - // Register client dynamically - if (!oidcConfig.registration_endpoint) { - throw new Error('IDP does not support dynamic client registration'); - } - const registration = await dynamicClientRegistration( - oidcConfig.registration_endpoint, - redirectUri - ); - clientId = registration.client_id; - } + // Use the provided client ID, or the extension's own dereferenceable client identifier + const clientId = staticClientId || EXTENSION_CLIENT_ID; // Generate PKCE const codeVerifier = generateCodeVerifier(); diff --git a/apps/extension/src/background/service-worker.ts b/apps/extension/src/background/service-worker.ts index eb8433706..b8692e583 100644 --- a/apps/extension/src/background/service-worker.ts +++ b/apps/extension/src/background/service-worker.ts @@ -2,11 +2,18 @@ import { initiateLogin, refreshAccessToken, createDpopProof } from './auth-flow' import { loadSession, clearSession, + clearProfile, importDpopKeyPair, saveClientId, loadClientIds, + saveProfile, + loadProfile, + loadPastProfiles, type StoredSession, } from './session-database'; +import { Parser } from 'n3'; +import { Agent, WebIdDataset } from '@solid/object'; +import { DataFactory } from 'n3'; let currentSession: StoredSession | null = null; let currentKeyPair: CryptoKeyPair | null = null; @@ -26,7 +33,6 @@ async function ensureSession(): Promise<{ session: StoredSession; keyPair: Crypt try { currentSession = await refreshAccessToken(currentSession, currentKeyPair); } catch { - // Refresh failed — session is dead currentSession = null; currentKeyPair = null; await clearSession(); @@ -37,16 +43,42 @@ async function ensureSession(): Promise<{ session: StoredSession; keyPair: Crypt return { session: currentSession, keyPair: currentKeyPair }; } -function broadcastStateChange(webId: string | null): void { +function parseProfileFromTurtle(webId: string, turtle: string): { name: string | null; photoUrl: string | null } { + const parser = new Parser({ baseIRI: webId.split('#')[0] }); + const store = new (require('n3').Store)(); + store.addQuads(parser.parse(turtle)); + + const dataset = new WebIdDataset(store, DataFactory); + const agent = dataset.mainSubject; + return { + name: agent?.name ?? null, + photoUrl: agent?.photoUrl ?? null, + }; +} + +async function fetchAndStoreProfile(webId: string): Promise { + try { + const response = await fetch(webId, { + headers: { Accept: 'text/turtle' }, + }); + if (!response.ok) return; + const turtle = await response.text(); + const { name, photoUrl } = parseProfileFromTurtle(webId, turtle); + await saveProfile({ webId, name, photoUrl, turtle }); + } catch { + // Profile fetch is best-effort + } +} + +function broadcastStateChange(webId: string | null, profileTurtle?: string): void { chrome.tabs.query({}, (tabs) => { for (const tab of tabs) { if (tab.id) { chrome.tabs.sendMessage(tab.id, { type: 'SOLID_STATE_CHANGED', webId, - }).catch(() => { - // Tab may not have content script loaded - }); + profileTurtle: profileTurtle ?? null, + }).catch(() => {}); } } }); @@ -120,10 +152,12 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { return initiateLogin(message.webId, staticClientId); }; loginWithClientId() - .then((session) => { + .then(async (session) => { currentSession = session; - currentKeyPair = null; // Will be reimported on next use - broadcastStateChange(session.webId); + currentKeyPair = null; + await fetchAndStoreProfile(session.webId); + const profile = await loadProfile(); + broadcastStateChange(session.webId, profile?.turtle); sendResponse({ ok: true, webId: session.webId }); }) .catch((err) => { @@ -138,12 +172,19 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { } if (message.type === 'SOLID_GET_STATE') { - ensureSession().then((ctx) => { + (async () => { + const ctx = await ensureSession(); + const profile = await loadProfile(); + const pastProfiles = await loadPastProfiles(); sendResponse({ webId: ctx?.session.webId ?? null, isActive: ctx !== null, + profileTurtle: profile?.turtle ?? null, + profileName: profile?.name ?? null, + profilePhotoUrl: profile?.photoUrl ?? null, + pastProfiles, }); - }); + })(); return true; } @@ -157,7 +198,7 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { if (message.type === 'SOLID_LOGOUT') { currentSession = null; currentKeyPair = null; - clearSession() + Promise.all([clearSession(), clearProfile()]) .then(() => { broadcastStateChange(null); sendResponse({ ok: true }); @@ -170,8 +211,9 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { }); // Restore session on service worker startup -ensureSession().then((ctx) => { +ensureSession().then(async (ctx) => { if (ctx) { - broadcastStateChange(ctx.session.webId); + const profile = await loadProfile(); + broadcastStateChange(ctx.session.webId, profile?.turtle); } }); diff --git a/apps/extension/src/background/session-database.ts b/apps/extension/src/background/session-database.ts index 8bcbeb3ed..2939df9ce 100644 --- a/apps/extension/src/background/session-database.ts +++ b/apps/extension/src/background/session-database.ts @@ -32,6 +32,46 @@ export async function clearSession(): Promise { await chrome.storage.local.remove(STORAGE_KEY); } +export interface StoredProfile { + webId: string; + name: string | null; + photoUrl: string | null; + turtle: string; +} + +export async function saveProfile(profile: StoredProfile): Promise { + await chrome.storage.local.set({ 'solid-profile': profile }); + // Also add to past profiles + const past = await loadPastProfiles(); + const existing = past.findIndex((p) => p.webId === profile.webId); + if (existing >= 0) { + past[existing] = { webId: profile.webId, name: profile.name, photoUrl: profile.photoUrl }; + } else { + past.unshift({ webId: profile.webId, name: profile.name, photoUrl: profile.photoUrl }); + } + await chrome.storage.local.set({ 'solid-past-profiles': past }); +} + +export async function loadProfile(): Promise { + const result = await chrome.storage.local.get('solid-profile'); + return result['solid-profile'] ?? null; +} + +export async function clearProfile(): Promise { + await chrome.storage.local.remove('solid-profile'); +} + +export interface PastProfile { + webId: string; + name: string | null; + photoUrl: string | null; +} + +export async function loadPastProfiles(): Promise { + const result = await chrome.storage.local.get('solid-past-profiles'); + return result['solid-past-profiles'] ?? []; +} + export async function loadClientIds(): Promise { const result = await chrome.storage.local.get('solid-client-ids'); return result['solid-client-ids'] ?? {}; diff --git a/apps/extension/src/content/content-script.ts b/apps/extension/src/content/content-script.ts index 53e2cd18a..4cee0e615 100644 --- a/apps/extension/src/content/content-script.ts +++ b/apps/extension/src/content/content-script.ts @@ -32,6 +32,7 @@ window.addEventListener('message', (event) => { source: SOLID_EXT_PREFIX, type: 'SOLID_STATE_UPDATE', webId: response?.webId ?? null, + profileTurtle: response?.profileTurtle ?? null, }, '*'); }); } @@ -83,6 +84,7 @@ chrome.runtime.onMessage.addListener((message) => { source: SOLID_EXT_PREFIX, type: 'SOLID_STATE_UPDATE', webId: message.webId, + profileTurtle: message.profileTurtle ?? null, }, '*'); } }); diff --git a/apps/extension/src/icons/icon128.png b/apps/extension/src/icons/icon128.png index 672357bfdb52b91a939b9e94b8c6a86dc378eeea..3da348504b9e2e8025afc5517984c244fc92d4a7 100644 GIT binary patch literal 4589 zcmVX@66<#-#hcp%=64Zh71{cE|Tg3grk(o2Sx#h0R2eAGRBDl znt*!XGZERH;_}!?i<_&I>IKXO&Icv}S*b0NG1`E|z*E4JBGQuL@}yn>N~v>!-=UxQ zoPn%) zNQnRfmH$uRWP3cU`vg8;4C_}y;}#v)oe_ZnL!hh@Moom_-_mhmECkLF5ufYjb2~!- zrPQOqh1R%rpTNtHz}5|^D3N;U7}6V7R>v=06A@ zF6{nKd#s4SvD0DtC6MK@eRZ~o{N2^ExS3gCh_?nf7V4h%Sr`gj>t-2T6M$0cP~fUyc+E21?Dup& zSOEW8W{bK;DK*Tk{M~E;?ga7x{H-wm&o1Vi=KSM+@a>-x@&b3bmA5MbP)eNuoD~c& zegPV{x{-7DV8_?6^hI0Lxk{`7C)8dkr3Cmrfy!^*1=VvCNvU%Nl`1a2t+iWKKIn4) z;JQ|FlYT{u&YREJMp***-N{~^NgW?Q0Aw@R-VTDUr1ucCNcfRJ}j zn?GW`+7YJw$QSN0n-KZ=`IMEFv1ZL0wr)+G7fN0qC_2R4OYF-JC+AfZvjD=qdzQ?D zZxWt>M zDuW{Mcsw*TG_Z2zO7`q=Z8Hv|L>U&wv6?rm6N6#)?H5_a!StF3(FCdvryRHf9k4t^e8009q_kaBiL zjx~+&#?!|4L$DDpf?$`hWy=;<%i%Op=4d+fD(dKw`#Bcxo|hkWXH$nSmU~$9L25;y zl%lS#jc~D)_j;+5dbtcHagZN?A_u1 zhOAN6X>N+5#iFjg+S%|Tj7lqd-qh&&1T8pU6>rOP*UE` ztn1C0bGgl%b8V}I9^gjn_aOlU){)N$hL^vow{%N284)DM?k4#7HCxo#N~w}yz}fVZF0+8Mt$`quf*<^?urbXxbjpYzaW<}r7{-U{+A4sm;D>D+ zA;so&QOSrPF_en%D?Bpz!76~z7;*0o^My*3j0h5A&rVw~RM%Dkq_59a7#R^HfW3A@ z?|N1NGz33*3(S`%Su!Gsk3RMdXG7yVs{leP{ewzz+&0ey84<*W2n;N-MTP3xTFoH~ z^PFBVbcFeWIYCAQT{Cn9;jZ-W20vH@@I02DV$|`*%jFm;6TvULhX_X7jl>nYt^tbx zL}VTM07C$dIVqywNNZ9ef{OGPfwusT{+=yrfr!)w1K~aW%>-J3+&nmbrs?82OGX5d zOrB|L{uOYm^?REDMC3ytWcfFKhJGRJPGm%|p8-Q)+zeaPUqs}6Yeckn{cC}ppx2S7 zyMCBy@>I{wX2}Z~5zM&Mww26Y;J?Fv6CnT**^0%%aQIPh#Mtm(r9air^lTACskh&X zNL~2vIvVZ&4hU^O5T>MVLr)b!l$LuV+QdJC4(*(jQfFfsF1-ARdCTD0ziy?ZJUREU z=H_Nh*c+2X}T(^lb;>w3|g0>`7{PKRli*rLuAk>{fQw!`^85&0Xi zC>TCr7UcJLs)*6@Bk9Tq@Or&eR8&w{SQv`ixN#$!H*fCbD(C4}toZ3YE#$b|5_k(> z@(*lLORzl1;dC|OdnMop=+zs(`@>|5=MEl^hvCDAQ&v_MGUiN$2)qS~ng8jMyHkLZ z&WGOn*K*o{D@3Hjg}zP&AR;S)hlAmoOL=*D=taF8clf%M#+WIeNvgJ0T*Jl$0=h_;9kavRoBGYqQxV(~}J+Uto)>1@4XI z+L#0&B7Q8}!yQxv$DPs11!+uSVIfsjRd~H#*F@0twOIj7I1>sFwnbebA~9{58nfAl zh%5pY1jCbO!a)OLy*j<{dc9OuR#I44Xj}w-A2e(-CxHBZaQr#8sMkg0rC6_z*Y4*R zKr84j;CtuCd372Q94eNUmQr6|Z%hQ6)|x#`;^YhT5Iuk&eTYQN#3g`$&A@%Z@KIBs zv?87qIz}nyx15cI!Gi}=US7_YEnAF;VAXr(Je?074r9J+i@MM1_ZJI^SpMA%Yy~~U znQ^Ie!@@l&aH9q&C@7$+s*0wjCPziEZx4LF*o<+dh@S7a-1`Q?*7{>1?$U|KPP7OB zJu!6Dlz3B#PSdv3O)vz9iawvuF%c}Cm(9N2X8xzqll73?!VMx4w>M{^@_YdJC>S{9 zLR&wSFty85YlaCeTem%mBpM2x_$3=GxdCgDd; zM5=+%Dfbi3g#N{erlx7wEPPt+?)ESp(YtqV`u6Qhb94NsKfUsB4)tryv}S#aV50rV z$QMK;;UhbeE#)=9K7j08-4!HD^)uP*{3bmX8$2ElUT^GY)3vw5YfohJ$?N7sJoO^m zENm+Y91>wADFKMcmsl)KswTjpqmoHcj|85*Bge>Q)F#=t7oNQ%m-k;XXMt2&4o6P3 zMcpMLN$ud2^rIFyLud_wV`R2`3HM(~(tY{BLliTwZKeO<^!8a*3RW)mP(3%B9R{a- zMc}eKZ5~@&fg=K=vc%I(wqO8Cso6kiXSx^fgJrKIpKfj*j62oOxYPV;b4q9se=Ds0 z*u&BnJZ$;W{OIMFli|$&uti-YB9A4Tu2TXKw7=OnfIU0l?w{z15GTmVg+oWTQ+8xK zeG8Q<%d64YyF+N)Dr{PpMO{snvC~1r>E(rA{0R#7o8)~Ad^<22OA5!@tniaWm(0_K zq#2UulnY_v{$aKPCX2{BPNeNv+a)6J0e=aG$Dam8L!C;!=Q3chzLyrBajNpcK>-A~ z4t=6E9uG{N<8bOdkZG6Ld@`ERCVGzHr~pLdD=c0`BgevsW1P&PXK~nQC_mN~b-Rc- zdOo{}*Vj9N^})cjIl48-5Fc4t5#BxZXpb#t^Km*s5!r`!Fa==XArT&`8Diku)1bJ_ z7In3VIPPa;WB?m*LkjIrdBP8&cV81Z9hi9YBb;!S0?(Sr%j68k73kfm$?F3ro@X$p z1B?^Tvpq0fdiNxQF#(9ka>Bfd1`jip+kwNt5*Rzx7WHUg;|?R3p2PeV&;(ImYBEG& zy8Wej7jT2g{EP}fM83x2Rdnbm?ZJ{EI_1a0;YZt|ZWR%ujtg$sVBLdWEd*fd#oBft zgB?#coN|#Zsuq3kjAC2>BI3hhKGwfjZ(xwY&Ua3am^!~IR_~r zP%yxaoVy2o4$}9;!W$y;4>$65(+u}2zz2Gre_CdpAjGsekhkBex{Wx!dy>I50f@+I z;E%!Zs7Wxo8*N8xicymy{3`Ajk7r zTeQJTfJU^L*LJtc;HoEvh%{n(A?mD<$$ zKp?L-lvcv1iE!kksGDxS51eRXwXZ8u=7FJ<>J2=N<*-Wo_xj+<=3S|A8+U>%k6zj9 zThyrs^?YEKh$MeXbpoW+1t(xld>L>XU~n7Cv}6~0^X7X^t@Oo0TKHfor4B-$Z+|{e z*~QPhJ8RKf^4}NOfi0z^g#fH5rOJS@z))ZSkOq5iCq)Z-^M=j9N7mCiGGxe*A+`KJ XC!Wny7powZ00000NkvXXu0mjf?!m?h literal 4880 zcmZ8lWmpu-*WV=;C8d#O5ox3ZC3cCW^U^I#w{#0jFCir@CENv96r?+sl#m4kqy$8| zrSskUe*8Z(bLPW5GbevDXHLAXwh9?BBQXE~AX8I?>EUL?f8zlNM==oiFm3|bXsEyd z_y4ux_VRZC09BtFOx_?cf3F}Y)W9$c(W#$F%n|XGOP~x#2w-kqloqq{t>$}a&Rt|) zz7`HGj>-kt59O#uhH918`ts}Z$!RZN8rnu9CO7j`E!{LFP%78MnorSB9CTDa>G^G> z7#j|0JV5{HI4AymP&5@3bg-mtT zD<++N1|HSspQL=~3{Xe&N0>)EmTSRm_iEsOWehZeE%}(`Hay7y@}Q0p_dp?kGy23? zf&~F^pEf@g7)~n3>qmIVH@;_Ze}3H^GGSF9y)BQUtbT9_YnlFW31T&?_dIhb;S@HKNCrNlL05MNam3vI*C z8C1(q1#hf`$#sJM-0VPJ_6Midt?`NFc29x)^^rssBJcR?iw8{@4;HuQ0F;5`@=|JBSTkDWqyV$=ykFMLt4QxtYk}149&jIc z6^VT`*D<#~N1eJ?QC#vwzyTOAXWHqGGll4zW{T_xDBN*q4|DKZ^3E$o?^BiK>HHQj z3}}a!{{pX7V%Eebvep%!8OlJ{Y8$9-{}b4go0uUI39lgU_v+_GntW?oE)1I9axN4C+7MQ;di}if;AWrA$sEhZM*3)!)|E zKq_Rr^dd!wLZBi`_iEZYL+}e5eG?k%_{>vN2VCq9bqOHk0MnsF>sNl&>-M&x?1K~; zdiq~J4XaZeI&EU^zr@to(oOqj9qmbe#V~W)<k3dY&6hUV~vRt+Z)t8@9BaSXUdzyUj1J7g!C-wiD8 z0!DEH1ccR$P8pL7X9x4V{5V6VRX`zDNs$@RVl>%#(CMO0m403$3 zQ<4hPWDT;1mE7G0Aw@bVc>knlmNJCrF3H6S9w^#tj1K~q9b3Ll+u;0F*wq=mR$sMA za++5^uF|=%$=H#bo12lHt^ev(l}bEMu5V^h#?;vNM4E81&td_n@XCyBRH>UKt2N~~ zQ)Co_>j}+V2x9f-albUwtZ}I`JX~Ev1O2%!+|+AkKS7?*EW~x!f(RuTv~2;{nfSuZm;P{U+Po=3Gn($ei|*HGq%}Y zH%rNM*+e>9%xT*7Dg~F>O!Xa^rXz*jW`$i`TzG_p`m2UTIg1?|!lLG{d71f16U&qX zKKI(=m{}}mrM7K{pAv?UvM`N{)@=B<4h;{NqS5hhuFtSjb~?<#$`r0L+Dy+pLxejO zMtVaMvmcFYd)Ymsd3~_9%fsN`0ioYM3LG3B#vGj21{_%7IM!t@QhKC3{}a|sO} z$hv$N_$~1#E{bgtu8Q98JEoI3MHY<@p zSX&i2TbnGm7~7&7YNeL#3hunkC(Cu%KoqQ`h?yPxUmb67F%sZyXj6Sg>*V2O^)6FS ztW=Pb58vtijN`QnhU!akV8tmq;`(fF7y-lw*ivmRcSj|YGWMw(^19t7Zs|^4#+IA{ zR@xw=Rwnns8OAEzuL{p+YkWhOyCUoy9pnD|9!T+7?gE+O{j zX908Tr$5^!G8W{dN3{-DZ_y2(*~dmlQ94Bscymonx@^dq=ds;xv-5j*7vpJTOnJB{ zO9J07-QIl#UO)arQaM|E8LtY;6ZrF65Wh$0EDlul9?d`Lmw%sYpwVvVfj#vP#tz{& z5A)~la}!^a&r<)JlSLjNOR^=1=JKFR+u&@LD0xjcvZD_Zt{=|J-g|p8Iz-}mYg1)! ziFu?&b~u5L&VR9Mch)xBWJhk{uWurQx0a|{{tTfG90yC zuG5>Mk7Hvgy?`E; zwcwp{ET;nR#a}iV8jtF`p*5`so_#Ek0b_P0^{C6or(4T)!f6b07tE-D1c(qGFz~!n zhI!ucO}6_84OD-<=<&-vymiN^tya<-fv?@*RZQz(;S{ z7u)_Am2+Uz7Mc&~9Dn{wwBo36c0>HpkTu9OJ1BJ&a9VFe$IGgu+J`9@tlP!+0ve(r zz6WHo?!)lMFa0um_$kWuUb%mvM0QcUJ{JkKz-uQl$JRcx%lmaT|Nkn_#)#BC==zwm zs{OhAIQnoq-GEUrdB?LAKDKBDO4gL&WO$B!!b+E>?Nj6D0sxmF?#YL!tGXIsq*(7& z| z`)iJEZ(P0Vvl^z0fF;b6iB*cR1S`P63Dd~_+>O!)NpNU>hi@EiW5F*&g&NRJ(!b0SfFbmr2Su$ z1K+u{bYlIDJaR$WG~|F=>9!;xz9O;6StI;3vA;~mXdK1wJDN0F&-X`#lDUpD$KM^{ z^Q@-hk6GQ1QS;6-lchVSQIwDMnHKzr$vPSd%YVV6e)OBGRc z*TK6EGUDlJEGYq-6s8sx={Pu)X+PH)`miWu6*ODO+p8ZPx%d?PU0GjQ0GdcIEE6 zGzDS@hnam*E$}sL#g<2NaC66TD8{mb3GjdkiHW}xPTpp;k=*2qjm#CwD^uhIqPE6u zDC6gjZQh7}@Eg81m}X~h|BtcpKm6!SpCh~p!D2|cS$I*V|5%#xQr-Bn<>0sq zK`eY#v7wXLUcAWGd}PWzKSOD1YD$0?@i8vxJZyD6@ojMUt!;U3zwsxL{c4FiMg-CT zm=d?PHACf-3bV}o`de{>?vLNjG+FjA?Gc0gPmz`O@>AvLrbXdf#>o!@Q7b`nUwgho z9)^;Qb214v$(%(>2%PJlW%?u@%dqOeoVQcLw`_h!p#zytr=cs}n?FPc^guU8ugT~b z5Xk)ASuqADNmp9hCFQ`KBLkNXt%c0oYp74{ZnpuW%gJ_59!P&9mt2sNa?dnZpt;t) zF(py5T=yK3P;oyrAUE3T*{f(D|8<*?f(X)BRxM>;iA^P^@)|30s@^UC5sW`n_O2h4 zeu_P>>_K)7#)ox4C%-RagU_gtS^dE;Y6eCNwS*EHnguoB*;&8zXUia8=cvd1! zY&^2UN|&Eb93aw6Pllw6uSV1dT%{L79?$nRsK-mHz?K?5K{Udl;A5dfUV`5<8|J3- zKf9+~H7UZGy4g|a<35I?Lx^J$-q zi*P&Fu6suzo#rB{ZFgNX;uHtQ+6l%UJLrFLd~^%K1g+~UDyRkf79Xd6Sb;jwyaUH!%Pu zFP;_gkk~sbcFp&?RrcVTfy$OWR-F)l6CQ&B;F8*{QWp3aGPXw6-lgb2{kj5&ynoD4 zLb>(A9plwvENT9^vUlv@ymM=5S?zyA%p%h;v0+j05fr&oDtl7LVBkFwdSEtb(#TB` zVZEQnt#~{_7+>&ukk6vhpTxP4hI0PA){k#x-j?7m+37aX?~E^lh5FU zHi#7NBOVz+mJ+k(Jk!f6?)@}9Z`uRqi$?acrLf{_mxbQQdXgs5)J<~Et*q}_U-^x5 z@(ZvIG|?w7=KzsN-i|jDnaZV`ZjMt=nl=Z@6e*P6O{qyH`}ahCu$4w&t=$?fu58P` zV88Pb3q=Q==Ydh@KwDe2y%LgvkQH^b2I3Skp9=qN{?+4U*2YbJVuk@{m(Q`qWK$el z4r{)kAL#%Y09P!LbT{k%@u&YTx>Z8%3+HI*#nv>**n3iXOZ(`-#+6^NH7JYjFie2~ zfY5UhVIhq&%91n8t!C4<`PX5e0XQM{mMAQ*O^qUc@RsFnMwzM~fXn=SgwMFO$9sLe z^a&R@z!Dy9A;DDN{#%fZ2q*LafqcEMaus+FWe#)pQI<#Po}fqICd=DEYvyq_)x)W& tJ*OU%v3i!-bvq5{|5l7}H6*G#Hapen#FZGC$G8#~Kut*-R;gee`G0g>Nu2-y diff --git a/apps/extension/src/icons/icon16.png b/apps/extension/src/icons/icon16.png index b9252cb5466a576633a6c24da58ff8ca96f81475..f0caab6d88e031fe88bf1c03b025567e19e87afb 100644 GIT binary patch delta 613 zcmV-r0-F8O1cC*S7YYvu1^@s6?Q>5rks%d-H%UZ6R49>sl1)fdVHAd+^Ucgd)tQZ0Y!95_&Ngb`{~kJ`$c5I4e%#Q%Rn!M07X?cjjblOodCe)asd#J z!PPTf;?@)M5jK#!{z&&*wue{^i(^N_kMR3%hIw&8a&Okw7S*J4XgNJ=o`BY)IiYbb8;o zF!jwLdkm;KNT-N=21sqCwa)5Lzrn;nFIoI2_8cMB(8NX&iKG-Ps+gIb(I?G$dcJ%_ z^gl!a3d_+ohiFccmng_kk_&8JK|oMMCNqJ*67$mIHFPDX00000NkvXXu0mjfl9nE7 delta 565 zcmV-50?Pe@1=0kN7YYyv1^@s6AM^iVks%d-2T4RhR5*>Lkxxj|aTvxw-`}Rc9}`_m z*A^K|XF&`?W{?%qMT9~kI^-d`O^^s8FBKT`BvF^}Pzgm)mjwGmQI`>fOau=xD2fg< zv%{Q=t&!ILza8d&ZjwUu+`iBIKF|BS?-%|PY9o>OCZo{^=L&f&)x5QiqPLSr&fRK% zw1%&KWHQos|1!bY{NJbTY$uq>$c$mAP=(W~8{UPuy_pywHV=^y6Cz?JHxvB&DFb(g z@dC883LX9;YOPI5@OjQcDy|4Wr_I;H8DP>h5E0At6t_}KU^EJW8(B1qkjv%Jbsd0x z2TME**7IW-B5xQMkta&2f(DTO&fx5SOWXkNJ%YswN-0EyWHJeW%`WCD;b-hfEfX+r z@L`&fl~lR7@Lpr)X?ZilF!-I^&iIhdgR8n}u$qRCVMa^?+3=ljA6uDwRW{kx0mldP z_Y()O zH46K?i};Q}v%8*bHVeQu*ZK{Po+9CJGGQ7hrHY68x#V!c(ie@zPa2t3MJcZ%1)X$4 zF?F*J0J{^q`naf+DnQllBBEwr5Pf}*HnzZt01Jmt(yo*eGr?L4@%M7U<=I^9@_@gW z^A&MTf(qha<$5&2K&e<;(=3P}x06YiM_t(};XmUK3Hq@;M%W8&00000NkvXXu0mjf D8w&?z diff --git a/apps/extension/src/icons/icon32.png b/apps/extension/src/icons/icon32.png index b125aa4153807c14bea4c7ffc98966ea40b65503..00c76f2862deb0f567e5879031dd717bd25496f5 100644 GIT binary patch delta 1132 zcmV-y1e5!f355xe7YZE+1^@s6%bsqBks%d-I!Q!9R7i=fmS0Sjbr{D#&wEY+$KyHB z5d}#${KsT!YM8R2Gh8L9YqQ)UbF-PVx`^SvTJMn^?6OeBi?4$b1Y1}=XiLs~6hNXq{scMOc9FG+zamcgoc5$t$4ENpJiII=O zn-06n>G!6SDg1#FH$&@r zcBty=NeNI@35Thyt%|)oiK6yfBov~5QdKQA?K-gITs=?z`{%==hpxZiTs^CSSE79; zMXKr!^wCoJ1{r}~b6}E7SzZ+8(M&~R^hP7l)n;(9!9gf!_NBO?^h^Gkp2{K-=^wR6 zkKw+CYAgAyY~|$-gG^6V03wkHh7oK3hFQEO%=aHpL-oFOupdtTK<4H>lmQ=qjoQbm zsA^8&c=(x#ys+waF0DM+=kfd=SNXHSjkXEH1FN)a`cTab?n2 z(@olpIf&eEXm@+;Gp_C+ELcf}sy-pAy6IvAH9uCwSH5mrh_%~7w!Zf328Z`gV<-^6 z#&~Rf8&(vt$pG>uGxMj8IB7g$e|Kdqity40L6TgP7tnc+GtU70v$7|DmlRLLV+o2F z;q)6o-O_bj_RUE^#L)*6k(L2V3b_K*3*4K6t4%Z~vG+Iqnp?D8l%OQR>iOLupOj*bp8 zGOQw|rz*=gglMj@c52>#GRQ07n24MJ^Fj3P#$5~<4s$|`a|WN!hu7<+r>Do(-+tXX zhK2#fyBG%EAG3{qh{zwav-u+b8FSK|&e*qdN=gbIkB30OJb$aKV@Gm(i||v$BbLs8rMRJ}gf5_byxsD9 zs=Bl37Y@}{neEw&BV^8x5WPgB2oDQgZNkmVhV8OFw;Kw!@T!P>H<17dhiE>qmsJnz zQIb9v-mK(;(@DOFn0B;fp+rO-PD+bkBgXbi9L2j(r<1p$$!!IUh-jMsIEQodADldQ z6)apywTPUy^T&HvmX~dQodJ(;>g1l8uz3#yz`j^9abLcw`h1|5>Px3^^>!yl?wtwC y))GkZP$D8fPf5Ut2p_N>NVFH-z;7ZFSNi{1)r(E13}c)C0000iZ&cAeWyl54nW<*|7iJ8z)cMzU?p&Vw0-o%ydoxz{0OTq57ZT@CAf= z;pC@G0qSl@fU4RD`q*%6&+y4pcC;CP`g_@^s+Q%>m4OvUTBsc8yLkcOemJ^^3SgPl zxT!X(>gv71`K_xM7mK8E_XwD`B1UC(63HPu7J=`N*g3eTm*wbVlnH+Fn+SR_q6YaD_yKtRbRdgtU0ig(TTVb zylZ9_0E`+nia;PhC=@b{Io)c)5()USiDF>2r2thex%4Z~oo+L)==XExN{I-5zn?%L zKrm?Bt7_&4;LKt8^B101)shr{mbo>aIoL#@RdD-02TnK4U1rRLh%k1{1U}pbe|NdB z2_NB8>KmgJmE_2Mrs-8KlK_{0o7Ov~4HB#H z+1pNjJYg15RH7_d8^u$YUqr=JaJl(7^?p59s#@B0p5NYjmV)f>z*nM-9+L*u7UAB- zQN~SllWpCdHr{J=5gss;i@@U>8Sqy!K}0S=#z!b3f2r!0Ni{T_J!TA?IxoS@Md=Sv z6)s*35DJyz^?D7yW#f{6ELj(2N2ALmU{VeED|t0V4(2|vX>L6|HgkrvRw9&_mlFzw z2#3R_ahu&-2Ad6PAEgi2Y{0W7?k)B6O4S@=aMU850A*!mBoYbYapU%E-*4V6Rkcud z8!wB9p=ns11>5EFqNS52jNVcF@<&q-1i87-N0+v@g}Jv5?0lh zsvqEuY*?oOe3vy1BaaXjAa2mlxHI9%wk@zqBTQzyLVK`6PE^&(Nn{uDrTaHM6<^&Pj(TBc}MbM&4QaF2bfU@Ma8VOBF`z{nl;U+ zrZi6n}~r6w*cZb6Y~5DGDX z3pHTH(pspkEkaA#Iy0R){xMT5)4pZeiN^RPlQi$1_ucQi_x0X$&xQXJR8>@UzN+qj zR@IQE4)-_Od$Aa^t2e{mZ=vHbsHQp$Ndg4&3gCirD4Pt1!F#|> zBI1fU6$1+*VP33U0(I5>m1~cpu`u^>@cLLFA}_mn$CUw99R}?CbQS&`pASf*BgW5w zYvgdmor%6};bsO_{Y$JRA}(B8@z zV4+n^pUbGKo(`T6>Tc_V3@%ks}UdE^)jawtNV{GF8p(e%^Bi z9&Kx(aQi3DWT%cf$C#!`Nl6Kfjg8dT*Aq)a9S^C_x#)$?all=c?s_3Jq7Zsm%0!NxZMcu-YKAh8dqYGuP- zZeG3E6%PP;!<0Esgz*n|{%1XUNT__$M|*2Zhb>Foz7$4ZNTrC(HB{9PRP5N08c8am zQZefW39S{Xnri^XfUl(?b)tXEL_(_x6dOPXfSJ`lvVX%w=AhGme|8u^E%57kin2D?$7&je&Wg-&m-UnXK9L#r9ZgNL5C|B{1=>5Zp zvHqnPZLQ90e%>%;-jdW!Oq~ORa@Y;5h92p(s$La~v31q`$RG9gB=@`&iVIGHs?yll zNLyPQ!C(;6G#v$hbwyy+y{49>K`mzwBgWMyT4QCwV1Yip~ciJ!lWM;J}sFSreW(>MkYlcsv9G0Yae=;c(x{Q_BINvBti-bJg`Q zw2@*yV@hRGc6x|**@qVv!~7Mf2opu5DkTE| zs`}Q!UzqpWqJiH7qzaLE+B~Y1w?(8^HKH3%wb4a?fgl}alhUlt1kJ-i9 z8W52dVA-@=z~{1Pl@4V1VCq}|o=z<7mT*&@UYf;y=>-X*EuCqrs7UCP&U9+gI$EV4tY%uJco{#awNopje$axn^fFZ$ zI@m-^XDSyZ81J?j~V~imIG>XXKTo!vKpsIzyOTg-Zeti9Ba&bG1!Xn5oAOmay){4kE zFY9w5@Q1!0R=)LHX#D_Gy;{f$Kw$O)`1Vg=`~?0YBDZ^5??qWvZ%&+N$BRFO?oMxk zIw2Z^d!B>5Aj?Fg*6li`sP9JAB@I5#Bjs#RS52&f#1nJaR!*fPD4e!1Iz+k;A&KF)t;?S9Vn zu)2mWV1|hFS?M_)l!rPFa8>IE=0Xwp%55pWx*~<&)sY*<*NZY`DaIN4rj$7`U$raErA@$OD&sKXC`{4!fMB8yeEGL@v^`L#%Im$Tt(Z%VQJM=8gqeAo5_`*#Wf ze`u7f_oQ&n=6!Gh;V3-#5*Z_pYenQ@Y01vOwavTT1rN|rYfvx_mQ>s4`3=kS`0QkW zz1zJszaw$x6g2O_cg@$h8@TVn^K6R?j<$F+{Qlnz_H1*0i`}$;Cdu4|)+N&ohGM(^ z(r4KOB$q2<-R}+d@3cP?BJiF2lT5F)u3k>a3@#nvD4Re-Y^Ha^s<3@yfY!bCB>V>4 z_E3V->A8`ZJPUw^OP^&EczNC;7#HzwJd=SpUkGrt#XisT3!wVZB+;0+tqv8zyhQ-K za_RFC-b7?iUXZtczkY`&+ZT~cz}BbpIB~?jsuYY@R@Ed3m$)af_)f?R^0tWV9uaf4 zqN-M;Q|$lsz4&^LTN_-lF3Gft(W%hgeTow&k1?-e7QvwXePo~?Uc5hu(`+d+2_EIcO%gD;d9W_hV7hb*%}bx1rh16wC~{m>1h?5o?bax(74mj5tk%ja5pX>lZN-U7&O#c|NGOv z05dA-1)j09>zD_s`p747p4hl<^xfF!hxxapDXU0-6L6g1Kym<1b@=$`pMJVJ>|Nje z3{08BqatFNK5hwwfcTcDC~ey19OUJ|+=Wp6i1SDVTnx5EGwCmzk&og{J zh)jeDr2y;zR*8uD{KW!e`Ov)pSOARA)y5Db`ha~RvS*BTuHXuC!T$hTz#IfV9*p4t O0000 + + + + + + + + diff --git a/apps/extension/src/inject/inject.ts b/apps/extension/src/inject/inject.ts index c64753798..5a780f3c3 100644 --- a/apps/extension/src/inject/inject.ts +++ b/apps/extension/src/inject/inject.ts @@ -1,9 +1,13 @@ export {}; +import { Parser, Store, DataFactory } from 'n3'; +import { Agent, WebIdDataset } from '@solid/object'; + const SOLID_EXT_PREFIX = 'solid-browser-ext'; interface SolidExtension { readonly webId: string | null; + readonly profile: Agent | null; fetch(input: RequestInfo | URL, init?: RequestInit): Promise; setClientId(clientId: string): void; login(webId: string): Promise; @@ -21,6 +25,19 @@ const pendingActions = new Map(); let currentWebId: string | null = null; +let currentProfile: Agent | null = null; + +function buildProfile(webId: string, turtle: string): Agent | null { + try { + const parser = new Parser({ baseIRI: webId.split('#')[0] }); + const store = new Store(); + store.addQuads(parser.parse(turtle)); + const dataset = new WebIdDataset(store, DataFactory); + return dataset.mainSubject ?? null; + } catch { + return null; + } +} window.addEventListener('message', (event) => { if (event.source !== window) return; @@ -46,6 +63,11 @@ window.addEventListener('message', (event) => { if (type === 'SOLID_STATE_UPDATE') { currentWebId = event.data.webId ?? null; + if (currentWebId && event.data.profileTurtle) { + currentProfile = buildProfile(currentWebId, event.data.profileTurtle); + } else { + currentProfile = null; + } } if (type === 'SOLID_ACTION_RESPONSE') { @@ -80,6 +102,8 @@ function solidFetch(input: RequestInfo | URL, init?: RequestInit): Promise { return new Promise((resolve, reject) => { const actionId = crypto.randomUUID(); @@ -125,6 +147,9 @@ const solid: SolidExtension = { get webId() { return currentWebId; }, + get profile() { + return currentProfile; + }, fetch: solidFetch, setClientId, login, diff --git a/apps/extension/src/popup/popup.css b/apps/extension/src/popup/popup.css index 9876d41b8..cd4748e19 100644 --- a/apps/extension/src/popup/popup.css +++ b/apps/extension/src/popup/popup.css @@ -107,16 +107,6 @@ button { color: white; } -.webid { - font-size: 13px; - word-break: break-all; - background: #f5f0ff; - padding: 10px 12px; - border-radius: 8px; - border: 1px solid var(--solid-purple-light); - color: var(--text-primary); -} - .error { color: var(--error); font-size: 12px; @@ -141,3 +131,130 @@ button { text-align: center; color: var(--text-secondary); } + +/* Profile card (logged-in state) */ + +.profile-card { + display: flex; + align-items: center; + gap: 12px; + background: #f5f0ff; + padding: 14px; + border-radius: 10px; + border: 1px solid var(--solid-purple-light); +} + +.profile-photo { + width: 44px; + height: 44px; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; +} + +.profile-initials { + width: 44px; + height: 44px; + border-radius: 50%; + background: var(--solid-purple); + color: white; + align-items: center; + justify-content: center; + font-size: 18px; + font-weight: 700; + flex-shrink: 0; +} + +.profile-initials:not([hidden]) { + display: flex; +} + +.profile-info { + min-width: 0; +} + +.profile-name { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Past profiles list */ + +.past-profiles-label { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 8px; +} + +.past-profile-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-radius: 8px; + cursor: pointer; + transition: background-color 0.15s; + border: none; + background: none; + width: 100%; + text-align: left; +} + +.past-profile-item:hover { + background: #f5f0ff; +} + +.past-profile-photo { + width: 32px; + height: 32px; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; +} + +.past-profile-initials { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--solid-purple-light); + color: white; + align-items: center; + justify-content: center; + font-size: 13px; + font-weight: 700; + flex-shrink: 0; +} + +.past-profile-initials:not([hidden]) { + display: flex; +} + +.past-profile-name { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.add-account-icon { + background: var(--border); + color: var(--text-secondary); + font-size: 18px; +} + +.add-account-btn .past-profile-name { + color: var(--text-secondary); +} + +#webid-form-container { + margin-top: 12px; +} diff --git a/apps/extension/src/popup/popup.html b/apps/extension/src/popup/popup.html index 8568880cb..41e7b6295 100644 --- a/apps/extension/src/popup/popup.html +++ b/apps/extension/src/popup/popup.html @@ -9,7 +9,7 @@
-

Solid

Sign in with your Solid identity

- - - - - +
+
@@ -42,8 +40,13 @@

Solid

diff --git a/apps/extension/src/popup/popup.ts b/apps/extension/src/popup/popup.ts index 81fcb2574..b3b01fa04 100644 --- a/apps/extension/src/popup/popup.ts +++ b/apps/extension/src/popup/popup.ts @@ -3,9 +3,19 @@ const loadingView = document.getElementById('loading-view')!; const loggedInView = document.getElementById('logged-in-view')!; const loginForm = document.getElementById('login-form') as HTMLFormElement; const webidInput = document.getElementById('webid-input') as HTMLInputElement; +const webidFormContainer = document.getElementById('webid-form-container')!; const loginError = document.getElementById('login-error')!; -const webidDisplay = document.getElementById('webid-display')!; const logoutBtn = document.getElementById('logout-btn')!; +const profilePhoto = document.getElementById('profile-photo') as HTMLImageElement; +const profileInitials = document.getElementById('profile-initials')!; +const profileName = document.getElementById('profile-name')!; +const pastProfilesContainer = document.getElementById('past-profiles')!; + +interface PastProfile { + webId: string; + name: string | null; + photoUrl: string | null; +} function showView(view: 'login' | 'loading' | 'logged-in') { loginView.hidden = view !== 'login'; @@ -13,23 +23,101 @@ function showView(view: 'login' | 'loading' | 'logged-in') { loggedInView.hidden = view !== 'logged-in'; } -// Check current session state on popup open -chrome.runtime.sendMessage({ type: 'SOLID_GET_STATE' }, (response) => { - if (response?.webId) { - webidDisplay.textContent = response.webId; - showView('logged-in'); +function getInitials(name: string | null): string { + if (name) { + return name.split(/\s+/).map(w => w[0]).slice(0, 2).join('').toUpperCase(); + } + return 'S'; +} + +function renderAvatar( + photoEl: HTMLImageElement, + initialsEl: HTMLElement, + name: string | null, + photoUrl: string | null, +) { + if (photoUrl) { + photoEl.src = photoUrl; + photoEl.alt = name || ''; + photoEl.hidden = false; + initialsEl.hidden = true; } else { - showView('login'); + photoEl.hidden = true; + initialsEl.hidden = false; + initialsEl.textContent = getInitials(name); } -}); +} -loginForm.addEventListener('submit', (e) => { - e.preventDefault(); - loginError.hidden = true; +function showLoggedInProfile(name: string | null, photoUrl: string | null) { + profileName.textContent = name || 'Solid User'; + renderAvatar(profilePhoto, profileInitials, name, photoUrl); + showView('logged-in'); +} - const webId = webidInput.value.trim(); - if (!webId) return; +function renderPastProfiles(profiles: PastProfile[]) { + pastProfilesContainer.innerHTML = ''; + + if (profiles.length === 0) { + // No past profiles — show the WebID form directly + webidFormContainer.hidden = false; + return; + } + + // Hide the form by default when there are past profiles + webidFormContainer.hidden = true; + + const label = document.createElement('div'); + label.className = 'past-profiles-label'; + label.textContent = 'Recent accounts'; + pastProfilesContainer.appendChild(label); + + for (const p of profiles) { + const btn = document.createElement('button'); + btn.className = 'past-profile-item'; + btn.type = 'button'; + + const displayName = p.name || 'Solid User'; + const initials = getInitials(p.name); + + if (p.photoUrl) { + btn.innerHTML = ` + +
${escapeHtml(displayName)}
`; + } else { + btn.innerHTML = ` +
${escapeHtml(initials)}
+
${escapeHtml(displayName)}
`; + } + + btn.addEventListener('click', () => { + doLogin(p.webId); + }); + pastProfilesContainer.appendChild(btn); + } + + // "Add account" button + const addBtn = document.createElement('button'); + addBtn.className = 'past-profile-item add-account-btn'; + addBtn.type = 'button'; + addBtn.innerHTML = ` + +
Add account
`; + addBtn.addEventListener('click', () => { + webidFormContainer.hidden = false; + webidInput.focus(); + }); + pastProfilesContainer.appendChild(addBtn); +} + +function escapeHtml(str: string): string { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} + +function doLogin(webId: string) { + loginError.hidden = true; showView('loading'); chrome.runtime.sendMessage( @@ -42,11 +130,31 @@ loginForm.addEventListener('submit', (e) => { } } ); +} + +// Check current session state on popup open +chrome.runtime.sendMessage({ type: 'SOLID_GET_STATE' }, (response) => { + if (response?.webId) { + showLoggedInProfile(response.profileName, response.profilePhotoUrl); + } else { + renderPastProfiles(response?.pastProfiles ?? []); + showView('login'); + } +}); + +loginForm.addEventListener('submit', (e) => { + e.preventDefault(); + const webId = webidInput.value.trim(); + if (!webId) return; + doLogin(webId); }); logoutBtn.addEventListener('click', () => { chrome.runtime.sendMessage({ type: 'SOLID_LOGOUT' }, () => { - showView('login'); + chrome.runtime.sendMessage({ type: 'SOLID_GET_STATE' }, (response) => { + renderPastProfiles(response?.pastProfiles ?? []); + showView('login'); + }); }); }); @@ -54,10 +162,14 @@ logoutBtn.addEventListener('click', () => { chrome.runtime.onMessage.addListener((message) => { if (message.type === 'SOLID_STATE_CHANGED') { if (message.webId) { - webidDisplay.textContent = message.webId; - showView('logged-in'); + chrome.runtime.sendMessage({ type: 'SOLID_GET_STATE' }, (response) => { + showLoggedInProfile(response.profileName, response.profilePhotoUrl); + }); } else { - showView('login'); + chrome.runtime.sendMessage({ type: 'SOLID_GET_STATE' }, (response) => { + renderPastProfiles(response?.pastProfiles ?? []); + showView('login'); + }); } } }); diff --git a/apps/extension/test-site/server.ts b/apps/extension/test-site/server.ts index 4efde10f8..a2a870cd3 100644 --- a/apps/extension/test-site/server.ts +++ b/apps/extension/test-site/server.ts @@ -15,31 +15,32 @@ const MIME_TYPES: Record = { export interface TestSiteOptions { port: number; - /** If set, the server injects this client ID into the app config. */ + /** The redirect URI to include in the Client ID Document served at /client-id. */ + redirectUri?: string; + /** The client name for the Client ID Document. Defaults to the server's role. */ + clientName?: string; + /** If set, the /config.json endpoint returns this as the clientIdUrl for the app. */ clientIdUrl?: string; } export function createTestSiteServer(opts: TestSiteOptions): http.Server { - const { port, clientIdUrl } = opts; + const { port, redirectUri, clientName, clientIdUrl } = opts; const server = http.createServer((req, res) => { const url = new URL(req.url || '/', `http://localhost:${port}`); - // Dynamic Client ID Document endpoint - // GET /client-id?redirect_uri=...&client_name=... + // Client ID Document endpoint — clean URL, no query params in the client_id if (url.pathname === '/client-id') { - const redirectUri = url.searchParams.get('redirect_uri'); - const clientName = url.searchParams.get('client_name') || 'Test Application'; if (!redirectUri) { - res.writeHead(400, { 'Content-Type': 'text/plain' }); - res.end('Missing redirect_uri parameter'); + res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.end('Server not configured with a redirectUri'); return; } - const clientId = `http://localhost:${port}/client-id?redirect_uri=${encodeURIComponent(redirectUri)}&client_name=${encodeURIComponent(clientName)}`; + const selfUrl = `http://localhost:${port}/client-id`; const doc = { '@context': ['https://www.w3.org/ns/solid/oidc-context.jsonld'], - client_id: clientId, - client_name: clientName, + client_id: selfUrl, + client_name: clientName || 'Solid Application', redirect_uris: [redirectUri], scope: 'openid profile offline_access webid', grant_types: ['refresh_token', 'authorization_code'], From 93212d7287b00c6811756d60d5a65b6da037c1d1 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Sun, 5 Apr 2026 01:08:41 +0100 Subject: [PATCH 06/50] chore: configure roborev for cross-model review and document workflow Configure roborev to use GitHub Copilot (o3) as the reviewer, providing independent oversight from a different model than the code-writing agent. Add development workflow instructions to CLAUDE.md with explicit commit requirements for AI agents. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/extension/.roborev.toml | 18 ++++++++++--- apps/extension/CLAUDE.md | 50 ++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/apps/extension/.roborev.toml b/apps/extension/.roborev.toml index 803288664..8c5b4a0df 100644 --- a/apps/extension/.roborev.toml +++ b/apps/extension/.roborev.toml @@ -1,7 +1,7 @@ # Default agent for this repo when no workflow-specific agent is set. agent = '@github/copilot' # Default model for this repo when no workflow-specific model is set. -model = '' +model = 'o3' # Backup agent for this repo if the primary agent fails. backup_agent = '' # Backup model for this repo if the primary model fails. @@ -9,7 +9,19 @@ backup_model = '' # Number of related reviews to include as context for this repo. review_context_count = 0 # Extra review instructions added to prompts for this repo. -review_guidelines = '' +review_guidelines = ''' +Chrome browser extension (Manifest V3) providing Solid authentication. + +Focus on: +- Security: OAuth/OIDC flows, token handling, DPoP proofs, message passing between MAIN world, ISOLATED world, and service worker +- Chrome extension best practices: service worker suspension/wake-up, chrome.storage, chrome.identity +- Solid protocol compliance: WebID resolution, client identifiers, solid:oidcIssuer validation +- Message serialization: data over chrome.runtime and window.postMessage must be JSON-serializable + +Do not flag: +- Extractable DPoP keys (required because service workers suspend) +- CLIENT_ID constant pointing to localhost (test/dev configuration) +''' # Override the review job timeout in minutes for this repo. job_timeout_minutes = 0 # Branches that should be skipped for automatic review in this repo. @@ -29,7 +41,7 @@ fix_min_severity = '' # Minimum severity for refine in this repo: critical, high, medium, low. refine_min_severity = '' # Filenames or glob patterns to exclude from review diffs for this repo. -exclude_patterns = [] +exclude_patterns = ['dist/**', 'node_modules/**', '.skills/**', '.agents/**', 'e2e/setup/templates/pod/wac/**'] # Automatic post-commit review mode for this repo: commit or branch. post_commit_review = '' reuse_review_session_lookback = 0 diff --git a/apps/extension/CLAUDE.md b/apps/extension/CLAUDE.md index ecf3fa311..314bdac8f 100644 --- a/apps/extension/CLAUDE.md +++ b/apps/extension/CLAUDE.md @@ -54,6 +54,54 @@ Use the Context7 MCP server for up-to-date library documentation. Prefer Context - Community Solid Server configuration - Chrome Extension MV3 APIs +## Development Workflow + +This project uses **roborev** for continuous automated code review. Every git commit triggers an asynchronous review via a post-commit hook. Reviews are performed by GitHub Copilot (OpenAI o3) — a different model from the code-writing agent — to provide independent oversight. + +### Commit practices + +- **Commit frequently** — small, focused commits. Each commit is reviewed independently by roborev, so smaller commits produce more actionable feedback. +- **One concern per commit** — don't mix feature code, test changes, and config changes in a single commit. +- **Build before committing** — run `npm run build` to catch TypeScript/webpack errors. The reviewer can't run the build itself. +- **Run tests before committing** — run `npm run test:e2e` for any changes that touch the extension source, message protocol, or auth flow. + +### Reviewing and fixing + +After committing, roborev reviews asynchronously in the background. To interact with reviews: + +```bash +roborev tui # Interactive terminal UI — view all reviews +roborev show # Show review for current commit +roborev status # Check daemon health and queue +roborev fix # Auto-fix findings (runs in isolated worktree) +roborev refine # Iteratively fix → re-review → repeat until clean +``` + +Review verdicts: **P** (pass), **W** (warning), **F** (fail). + +### When roborev flags an issue + +1. Read the finding in `roborev tui` or `roborev show` +2. Either fix manually and commit, or run `roborev fix` to auto-remediate +3. If the finding is a false positive for this project (e.g. extractable DPoP keys, localhost CLIENT_ID), it is already suppressed in `.roborev.toml` review guidelines — if new patterns need suppressing, update the `review_guidelines` field there. + +### For AI agents (IMPORTANT — read before making any changes) + +**You MUST commit after every completed set of changes.** This is not optional. The roborev post-commit hook triggers an automated review by a separate model (GitHub Copilot / OpenAI o3), providing independent oversight of your work. If you do not commit, your changes are not reviewed, and issues may go undetected. + +Workflow: +1. Make your changes (code, config, docs, tests — whatever the task requires). +2. **Commit immediately** once the changes are complete and working. Do not wait for the user to ask you to commit. +3. After committing, run `roborev show` to check if the review has completed and whether there are findings. +4. If roborev flags issues, fix them and **commit again** (a new commit, not an amend). +5. For automated fix-review loops, use `roborev refine`. + +Rules: +- **Small, focused commits.** One concern per commit. Do not batch unrelated changes. +- **Build and test before committing.** Run `npm run build` and `npm run test:e2e` for source changes. +- **Never skip commits.** Even config-only or docs-only changes must be committed so they are reviewed. +- The `.roborev.toml` file configures review guidelines — read it to understand what the reviewer focuses on and what it ignores. + ## Key Conventions - TypeScript throughout, strict mode @@ -61,4 +109,6 @@ Use the Context7 MCP server for up-to-date library documentation. Prefer Context - Manifest V3 with `world: "MAIN"` for injection - DPoP keys stored as extractable JWK (service worker suspension) - Per-origin client ID support via `solid.setClientId()` +- Extension uses a dereferenceable client identifier (not dynamic registration) - Solid branding for popup: primary color #7C4DFF +- Profile data parsed with N3.js + `@solid/object` Agent class From cde9d0d48a0f906f2cb12cca2d2c7ab44469f9f4 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Fri, 19 Jun 2026 17:55:33 +0100 Subject: [PATCH 07/50] chore: suite build/gate scaffold (biome + vitest + @jeswr deps, ignore-scripts) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring the extension up to the suite gate: lint (biome) + typecheck (tsc) + test (vitest, jsdom) + build (webpack). Adds the pinned @jeswr deps consumed by the core rewrite — @jeswr/solid-dpop (RFC 9449 DPoP proof spec), @jeswr/solid-elements (the shared web-component popup chrome), @jeswr/solid-session-restore (the audited silent-restore decision) — all as git+https# so keyless npm ci resolves on CI (the #78 pattern). The lockfile's git transport is rewritten ssh -> https. ignore-scripts=true for supply-chain hardening. Model: claude-opus-4-8 Provenance: Opus 4.8 (Fable unavailable) — re-review/upgrade candidate Co-Authored-By: Claude Opus 4.8 --- apps/extension/.npmrc | 1 + apps/extension/biome.json | 38 + apps/extension/package-lock.json | 2919 ++++++++++++++++++++++++++---- apps/extension/package.json | 22 +- apps/extension/tsconfig.json | 13 +- apps/extension/vitest.config.ts | 15 + 6 files changed, 2666 insertions(+), 342 deletions(-) create mode 100644 apps/extension/.npmrc create mode 100644 apps/extension/biome.json create mode 100644 apps/extension/vitest.config.ts diff --git a/apps/extension/.npmrc b/apps/extension/.npmrc new file mode 100644 index 000000000..97b895e2f --- /dev/null +++ b/apps/extension/.npmrc @@ -0,0 +1 @@ +ignore-scripts=true diff --git a/apps/extension/biome.json b/apps/extension/biome.json new file mode 100644 index 000000000..3a3ec908c --- /dev/null +++ b/apps/extension/biome.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.0/schema.json", + "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, + "files": { + "includes": [ + "src/**/*.ts", + "test/**/*.ts", + "e2e/**/*.ts", + "scripts/**/*.ts" + ] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { + "noExplicitAny": "warn" + }, + "style": { + "noNonNullAssertion": "off", + "useImportType": "error" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "trailingCommas": "all", + "semicolons": "always" + } + } +} diff --git a/apps/extension/package-lock.json b/apps/extension/package-lock.json index eb096ac5a..f63f81598 100644 --- a/apps/extension/package-lock.json +++ b/apps/extension/package-lock.json @@ -1,33 +1,53 @@ { "name": "solid-browser-extension", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "solid-browser-extension", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "dependencies": { + "@jeswr/solid-dpop": "git+https://github.com/jeswr/solid-dpop.git#4bc5309fab6fe837a942581d331da90be2d552e3", + "@jeswr/solid-elements": "git+https://github.com/jeswr/solid-elements.git#06c12e9383e3e4be45ec236b30f02fd8fdc53dbc", + "@jeswr/solid-session-restore": "git+https://github.com/jeswr/solid-session-restore.git#1a6aedc52cd4b6cbd61859e0098034cf4a7e8282", "@rdfjs/wrapper": "^0.32.0", - "@solid/object": "^0.4.0", - "@uvdsl/solid-oidc-client-browser": "^0.2.2", + "@solid/object": "^0.6.0", "jose": "^5.9.6", "n3": "^2.0.3" }, "devDependencies": { + "@biomejs/biome": "^2.5.0", "@playwright/test": "^1.49.0", "@solid/community-server": "^7.1.3", "@types/chrome": "^0.0.287", + "@types/node": "^24.0.0", "copy-webpack-plugin": "^12.0.2", "http-server": "^14.1.1", + "jsdom": "^25.0.1", "ts-loader": "^9.5.1", "ts-node": "^10.9.2", "typescript": "^5.7.2", + "vitest": "^2.1.8", "webpack": "^5.97.1", "webpack-cli": "^6.0.1" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, "node_modules/@bergos/jsonparse": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/@bergos/jsonparse/-/jsonparse-1.4.2.tgz", @@ -41,6 +61,169 @@ "buffer": "^6.0.3" } }, + "node_modules/@biomejs/biome": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.0.tgz", + "integrity": "sha512-4kURkd9hAPrdDM3C9n82ycYgx8hvQcW6MjKTEejruj8rK0N8P3OPpdy8BvI8kt3KWY4ycF5XtDOrktetEfhfuw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.5.0", + "@biomejs/cli-darwin-x64": "2.5.0", + "@biomejs/cli-linux-arm64": "2.5.0", + "@biomejs/cli-linux-arm64-musl": "2.5.0", + "@biomejs/cli-linux-x64": "2.5.0", + "@biomejs/cli-linux-x64-musl": "2.5.0", + "@biomejs/cli-win32-arm64": "2.5.0", + "@biomejs/cli-win32-x64": "2.5.0" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.0.tgz", + "integrity": "sha512-Mn3Fwi3SA5fgmfCPqmzpWF2DLZnms3BVAhM088nTnGrTZmHS3wwIjcoZPqpXeNgd3DrrLH6xp8vTLIBuJoZiXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.0.tgz", + "integrity": "sha512-rg3VPL5P8mYro6pqlXYXuJWph21slVp3SZtAqWSrkZs40d2gTzYmHF8E/X1iTID25btmNKltNDJ926sqVBp7DQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.0.tgz", + "integrity": "sha512-tl+LW8fdD96/xdeWtWwc82LIOc5CoY7N2AsogLTp5R4ECErYt+8Jl/N68ezN9vzSiqPTxw6vjcihoLPYKZHrlw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.0.tgz", + "integrity": "sha512-vQdM4oSGaf7ZNeGO9w5+Y8SBtyser9M6znxYbm7Ec8wInxJu1WiKxFYZW5Auj2d80bcVvefuGGRxoFOE0eee8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.0.tgz", + "integrity": "sha512-zpEGf4RQbFEh8Vt7OmavLyyOzRbtcE9osCqrS1kfvt8jDvxwhKXLSf7n0ebr/ov0RJ9ssP+lhs6C8a9WwFvrQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.0.tgz", + "integrity": "sha512-+9hIcMngJ+yGUahXqZuZ8CoWKJE9SAZsFsM3QDvXpNsLbXZ9lqVzgBhOk/jTSYkOA0GLP9eu3teukqpLUojHMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.0.tgz", + "integrity": "sha512-jB0wAvTLI4itx5VidqVUejPQFhRUxiZ9l9FvZ26D5fl6t3qme+ZB4PD3bTSeL1vZ8NI2Rx/zj6H9zcESuGHKGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.0.tgz", + "integrity": "sha512-VT/lF+GId+67j8aDfLkxdxNoVApsPSTbyAtB3jJq0IWTrY77WXfbPfpngxq0bA6JCEv/7k8C9qWjDRKRznDlyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, "node_modules/@colors/colors": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", @@ -2868,6 +3051,123 @@ "node": ">=12" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@dabh/diagnostics": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", @@ -2890,77 +3190,468 @@ "node": ">=14.17.0" } }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + "node": ">=12" } }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=12" } }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + "node": ">=12" } }, - "node_modules/@eslint/config-array": { - "version": "0.21.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", - "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.5" - }, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=12" } }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0" - }, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=12" } }, - "node_modules/@eslint/core": { + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", @@ -3035,29 +3726,43 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -3087,9 +3792,9 @@ } }, "node_modules/@ioredis/commands": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", - "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz", + "integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==", "dev": true, "license": "MIT" }, @@ -3126,6 +3831,94 @@ "node-fetch": "^2.7.0" } }, + "node_modules/@jeswr/solid-dpop": { + "version": "0.1.0", + "resolved": "git+https://github.com/jeswr/solid-dpop.git#4bc5309fab6fe837a942581d331da90be2d552e3", + "integrity": "sha512-WgfFz+Cqq3R2WwH4XuFOs5SXaumB4B0Aq/tApnVM9TLLaw1azhYB0Q820/Js0K0PR7n/HBNw1TT3AUwlTj8h2g==", + "license": "MIT", + "dependencies": { + "jose": "^6.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@jeswr/solid-dpop/node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@jeswr/solid-elements": { + "version": "0.1.0", + "resolved": "git+https://github.com/jeswr/solid-elements.git#06c12e9383e3e4be45ec236b30f02fd8fdc53dbc", + "integrity": "sha512-lTBLslxVknu9f0b1urqqkw5HHviwFqjvu5PEyf8VmehRtaMBdPmFOhfjycRRpO5SoFRsV4cVnAsCmiI19Up4kQ==", + "license": "MIT", + "dependencies": { + "@lit/react": "^1.0.8", + "lit": "^3.3.1" + }, + "engines": { + "node": ">=20.12" + }, + "peerDependencies": { + "@jeswr/fetch-rdf": "^0.1.0", + "@jeswr/solid-session-restore": "*", + "@solid/object": "^0.6.0", + "@solid/reactive-authentication": "^0.1.3", + "dpop": "^2.1.1", + "n3": "^1.17.0 || ^2.0.0", + "oauth4webapi": "^3.8.6", + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "@jeswr/fetch-rdf": { + "optional": true + }, + "@jeswr/solid-session-restore": { + "optional": true + }, + "@solid/object": { + "optional": true + }, + "@solid/reactive-authentication": { + "optional": true + }, + "dpop": { + "optional": true + }, + "n3": { + "optional": true + }, + "oauth4webapi": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/@jeswr/solid-session-restore": { + "version": "0.1.0", + "resolved": "git+https://github.com/jeswr/solid-session-restore.git#1a6aedc52cd4b6cbd61859e0098034cf4a7e8282", + "integrity": "sha512-e9JtZumGrdeU4chsoVapv0kGhw2BabKWBn60md/03qaERReOwv4X9sapIzlV63oQSVS2CGU/bb3YQVfQ0k9vrA==", + "license": "MIT", + "peer": true, + "dependencies": { + "dpop": "^2.1.1", + "oauth4webapi": "^3.8.6" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -3228,6 +4021,30 @@ "node": ">= 18" } }, + "node_modules/@lit-labs/ssr-dom-shim": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.6.0.tgz", + "integrity": "sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@lit/react": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@lit/react/-/react-1.0.8.tgz", + "integrity": "sha512-p2+YcF+JE67SRX3mMlJ1TKCSTsgyOVdAwd/nxp3NuV1+Cb6MWALbN6nT7Ld4tpmYofcE5kcaSY1YBB9erY+6fw==", + "license": "BSD-3-Clause", + "peerDependencies": { + "@types/react": "17 || 18 || 19" + } + }, + "node_modules/@lit/reactive-element": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.1.2.tgz", + "integrity": "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.5.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3267,13 +4084,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", - "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz", + "integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.59.1" + "playwright": "1.61.0" }, "bin": { "playwright": "cli.js" @@ -3327,35 +4144,385 @@ "integrity": "sha512-QQ4yzVe1Rvae/GN9SnOhweHNpaxQtnAjeOVciP/yJ0Gfxtbphy2tM56ZsRLV04Qq5qMcSclZIe6irYyEzx/UwQ==", "dev": true, "license": "MIT", - "dependencies": { - "@rdfjs/to-ntriples": "^2.0.0" - } - }, - "node_modules/@rdfjs/to-ntriples": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@rdfjs/to-ntriples/-/to-ntriples-2.0.0.tgz", - "integrity": "sha512-nDhpfhx6W6HKsy4HjyLp3H1nbrX1CiUCWhWQwKcYZX1s9GOjcoQTwY7GUUbVec0hzdJDQBR6gnjxtENBDt482Q==", + "dependencies": { + "@rdfjs/to-ntriples": "^2.0.0" + } + }, + "node_modules/@rdfjs/to-ntriples": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@rdfjs/to-ntriples/-/to-ntriples-2.0.0.tgz", + "integrity": "sha512-nDhpfhx6W6HKsy4HjyLp3H1nbrX1CiUCWhWQwKcYZX1s9GOjcoQTwY7GUUbVec0hzdJDQBR6gnjxtENBDt482Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rdfjs/types": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rdfjs/types/-/types-1.1.2.tgz", + "integrity": "sha512-wqpOJK1QCbmsGNtyzYnojPU8gRDPid2JO0Q0kMtb4j65xhCK880cnKAfEOwC+dX85VJcCByQx5zOwyyfCjDJsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@rdfjs/wrapper": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@rdfjs/wrapper/-/wrapper-0.32.0.tgz", + "integrity": "sha512-xJdRz/Jk11S9xlqRRBkLWxIxWUuIQqy/BeNvp94m34m1DTEwYMa+wHdznPTEvDEHXRGSFOs1guZ1Z0swW+axcg==", + "license": "MIT", + "engines": { + "node": ">=24.0.0" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz", + "integrity": "sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.0.tgz", + "integrity": "sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.0.tgz", + "integrity": "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.0.tgz", + "integrity": "sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.0.tgz", + "integrity": "sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.0.tgz", + "integrity": "sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.0.tgz", + "integrity": "sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.0.tgz", + "integrity": "sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.0.tgz", + "integrity": "sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.0.tgz", + "integrity": "sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.0.tgz", + "integrity": "sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.0.tgz", + "integrity": "sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.0.tgz", + "integrity": "sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.0.tgz", + "integrity": "sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.0.tgz", + "integrity": "sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.0.tgz", + "integrity": "sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.0.tgz", + "integrity": "sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.0.tgz", + "integrity": "sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.0.tgz", + "integrity": "sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.0.tgz", + "integrity": "sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.0.tgz", + "integrity": "sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.0.tgz", + "integrity": "sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" - }, - "node_modules/@rdfjs/types": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@rdfjs/types/-/types-1.1.2.tgz", - "integrity": "sha512-wqpOJK1QCbmsGNtyzYnojPU8gRDPid2JO0Q0kMtb4j65xhCK880cnKAfEOwC+dX85VJcCByQx5zOwyyfCjDJsg==", + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.0.tgz", + "integrity": "sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==", + "cpu": [ + "ia32" + ], "dev": true, "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@rdfjs/wrapper": { - "version": "0.32.0", - "resolved": "https://registry.npmjs.org/@rdfjs/wrapper/-/wrapper-0.32.0.tgz", - "integrity": "sha512-xJdRz/Jk11S9xlqRRBkLWxIxWUuIQqy/BeNvp94m34m1DTEwYMa+wHdznPTEvDEHXRGSFOs1guZ1Z0swW+axcg==", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.0.tgz", + "integrity": "sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "engines": { - "node": ">=24.0.0" - } + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.0.tgz", + "integrity": "sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, "node_modules/@rubensworks/saxes": { "version": "6.0.1", @@ -3600,17 +4767,27 @@ "license": "MIT" }, "node_modules/@solid/object": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@solid/object/-/object-0.4.0.tgz", - "integrity": "sha512-Q26pEDsgHqEtdoDiErs5OOd4veatzeex0O94zI7pzQj3zZ6cawECcRIP8vcPCiJ/95NlDK4qbEOeMAUm4FiiSA==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@solid/object/-/object-0.6.0.tgz", + "integrity": "sha512-Ybme2HjbeWKZ6jOyYxacsFnAC6wPaifnSNO39bYW7PdbTEwgT56jG0Rp3TAWzXJvRjBzqMErUwvOMpqd8sVeOw==", "license": "MIT", + "peer": true, "dependencies": { - "rdfjs-wrapper": "^0.15.0" + "@rdfjs/wrapper": "^0.34.0" }, "engines": { "node": ">=24.0.0" } }, + "node_modules/@solid/object/node_modules/@rdfjs/wrapper": { + "version": "0.34.0", + "resolved": "https://registry.npmjs.org/@rdfjs/wrapper/-/wrapper-0.34.0.tgz", + "integrity": "sha512-psGw4IAH0E27P7Nlro0SzjqPJJ+aE3MkYKWfNbBk+vwT2Zt+gJJyuznuYg54peZs4rAF9FLoqCe4JdvQNDTzMw==", + "license": "MIT", + "engines": { + "node": ">=24.0.0" + } + }, "node_modules/@szmarczak/http-timer": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz", @@ -3773,32 +4950,10 @@ "@types/node": "*" } }, - "node_modules/@types/eslint": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", - "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "node_modules/@types/eslint-scope": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", - "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, @@ -3918,9 +5073,9 @@ "license": "MIT" }, "node_modules/@types/koa": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/koa/-/koa-3.0.2.tgz", - "integrity": "sha512-7TRzVOBcH/q8CfPh9AmHBQ8TZtimT4Sn+rw8//hXveI6+F41z93W8a+0B0O8L7apKQv+vKBIEZSECiL0Oo1JFA==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/koa/-/koa-3.0.3.tgz", + "integrity": "sha512-TdtNEJ7sYSrFQcVuS2ySsVqnq5EyE3oJbnfFJvkC9UtGP4Kpem5KE7r+ivHIbIAQAofSqnlB5D3vkfYO69TQpg==", "dev": true, "license": "MIT", "dependencies": { @@ -3987,9 +5142,9 @@ } }, "node_modules/@types/node": { - "version": "25.5.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", - "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", "dev": true, "license": "MIT", "peer": true, @@ -3998,9 +5153,9 @@ } }, "node_modules/@types/nodemailer": { - "version": "6.4.23", - "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.23.tgz", - "integrity": "sha512-aFV3/NsYFLSx9mbb5gtirBSXJnAlrusoKNuPbxsASWc7vrKLmIrTQRpdcxNcSFL3VW2A2XpeLEavwb2qMi6nlQ==", + "version": "6.4.24", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.24.tgz", + "integrity": "sha512-Ww4u0rT9wQNXh4JiQaIwx3QWdcOFXzOjQA2zc+jtFYNmQiT4mIUqcDin51bDFdkzKubFnQCZNK7FIHlPKQ/q9w==", "dev": true, "license": "MIT", "dependencies": { @@ -4047,9 +5202,9 @@ "license": "MIT" }, "node_modules/@types/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", "dev": true, "license": "MIT" }, @@ -4083,6 +5238,16 @@ "@types/node": "*" } }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, "node_modules/@types/readable-stream": { "version": "4.0.23", "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.23.tgz", @@ -4152,6 +5317,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, "node_modules/@types/uritemplate": { "version": "0.3.6", "resolved": "https://registry.npmjs.org/@types/uritemplate/-/uritemplate-0.3.6.tgz", @@ -4200,13 +5371,117 @@ "dev": true, "license": "MIT" }, - "node_modules/@uvdsl/solid-oidc-client-browser": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@uvdsl/solid-oidc-client-browser/-/solid-oidc-client-browser-0.2.2.tgz", - "integrity": "sha512-JhcfSPu+eVyPMl2Dz46jq9ZHZwfZSqzCrQiHkvFZyam9ZEGXmLF1QJs4O+MddiEJaF5rVeEPd20YWprp5drLKw==", + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, "license": "MIT", "dependencies": { - "jose": "^5.9.6" + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, "node_modules/@webassemblyjs/ast": { @@ -4458,9 +5733,9 @@ } }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", "peer": true, @@ -4507,10 +5782,20 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -4543,9 +5828,9 @@ } }, "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", "dependencies": { @@ -4613,6 +5898,16 @@ "dev": true, "license": "MIT" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -4647,6 +5942,13 @@ "asynciterator": "^3.9.0" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -4675,9 +5977,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.14", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.14.tgz", - "integrity": "sha512-fOVLPAsFTsQfuCkvahZkzq6nf8KvGWanlYoTh0SVA0A/PIUxQGU2AOZAoD95n2gFLVDW/jP6sbGLny95nmEuHA==", + "version": "2.10.38", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", + "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -4718,9 +6020,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, "license": "MIT", "dependencies": { @@ -4817,6 +6119,16 @@ "node": ">= 0.8" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/cache-content-type": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/cache-content-type/-/cache-content-type-1.0.1.tgz", @@ -4902,9 +6214,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001784", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001784.tgz", - "integrity": "sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==", + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", "dev": true, "funding": [ { @@ -4932,6 +6244,23 @@ "canonicalize": "bin/canonicalize.js" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4949,6 +6278,16 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chrome-trace-event": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", @@ -5001,9 +6340,9 @@ } }, "node_modules/cluster-key-slot": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", - "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz", + "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==", "dev": true, "license": "Apache-2.0", "engines": { @@ -5108,6 +6447,19 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", @@ -5318,6 +6670,47 @@ "node": ">= 8" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -5336,6 +6729,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -5365,6 +6765,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-equal": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", @@ -5389,6 +6799,16 @@ "node": ">=10" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/delegates": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", @@ -5496,6 +6916,16 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/dpop": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/dpop/-/dpop-2.1.1.tgz", + "integrity": "sha512-J0Of2JTiM4h5si0tlbPQ/lkqfZ5wAEVkKYBhkwyyANnPJfWH4VsR5uIkZ+T+OSPIwDYUg1fbd5Mmodd25HjY1w==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -5535,9 +6965,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.331", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", - "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==", + "version": "1.5.375", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.375.tgz", + "integrity": "sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q==", "dev": true, "license": "ISC" }, @@ -5576,14 +7006,14 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.20.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", - "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "version": "5.24.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.0.tgz", + "integrity": "sha512-SkE2t82KlkkxQRVMVLAGKxLfORGQfrkx5dkj+vlgXRVNEdPc4eZcR+J/Fvj8C+yKSFH5L0q3NFlyufOVQnCcYQ==", "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.3.0" + "tapable": "^2.3.3" }, "engines": { "node": ">=10.13.0" @@ -5636,16 +7066,16 @@ } }, "node_modules/es-module-lexer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "dev": true, "license": "MIT" }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "dev": true, "license": "MIT", "dependencies": { @@ -5655,6 +7085,61 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -5830,6 +7315,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -5878,6 +7373,16 @@ "node": ">=0.8.x" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5930,9 +7435,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "dev": true, "funding": [ { @@ -6077,9 +7582,9 @@ } }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "dev": true, "license": "MIT", "dependencies": { @@ -6168,9 +7673,9 @@ "license": "MIT" }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "dev": true, "funding": [ { @@ -6188,6 +7693,23 @@ } } }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/form-data-encoder": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-2.1.4.tgz", @@ -6209,9 +7731,9 @@ } }, "node_modules/fs-extra": { - "version": "11.3.4", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", - "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", + "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", "dev": true, "license": "MIT", "dependencies": { @@ -6531,9 +8053,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "dev": true, "license": "MIT", "dependencies": { @@ -6690,6 +8212,20 @@ "node": ">=8.0.0" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/http-server": { "version": "14.1.1", "resolved": "https://registry.npmjs.org/http-server/-/http-server-14.1.1.tgz", @@ -6745,6 +8281,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -6864,21 +8414,19 @@ } }, "node_modules/ioredis": { - "version": "5.10.1", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", - "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==", + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz", + "integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==", "dev": true, "license": "MIT", "dependencies": { - "@ioredis/commands": "1.5.1", - "cluster-key-slot": "^1.1.0", - "debug": "^4.3.4", - "denque": "^2.1.0", - "lodash.defaults": "^4.2.0", - "lodash.isarguments": "^3.1.0", - "redis-errors": "^1.2.0", - "redis-parser": "^3.0.0", - "standard-as-callback": "^2.1.0" + "@ioredis/commands": "1.10.0", + "cluster-key-slot": "1.1.1", + "debug": "4.4.3", + "denque": "2.1.0", + "redis-errors": "1.2.0", + "redis-parser": "3.0.0", + "standard-as-callback": "2.1.0" }, "engines": { "node": ">=12.22.0" @@ -6889,13 +8437,13 @@ } }, "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", "dev": true, "license": "MIT", "dependencies": { - "hasown": "^2.0.2" + "hasown": "^2.0.3" }, "engines": { "node": ">= 0.4" @@ -6980,6 +8528,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -7095,10 +8650,20 @@ } }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -7107,6 +8672,87 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/jsdom/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jsdom/node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -7127,13 +8773,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, - "license": "MIT" - }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -7149,9 +8788,9 @@ "license": "MIT" }, "node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", "dev": true, "license": "MIT", "dependencies": { @@ -7414,10 +9053,41 @@ "node": ">= 0.8.0" } }, + "node_modules/lit": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.3.tgz", + "integrity": "sha512-fycuvZg/hkpozL00lm1pEJH5nN/lr9ZXd6mJI2HSN4+Bzc+LDNdEApJ6HFbPkdFNHLvOplIIuJvxkS4XUxqirw==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit/reactive-element": "^2.1.0", + "lit-element": "^4.2.0", + "lit-html": "^3.3.0" + } + }, + "node_modules/lit-element": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.2.2.tgz", + "integrity": "sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.5.0", + "@lit/reactive-element": "^2.1.0", + "lit-html": "^3.3.0" + } + }, + "node_modules/lit-html": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.3.tgz", + "integrity": "sha512-el8M6jK2o3RXBnrSHX3ZKrsN8zEV63pSExTO1wYJz7QndGYZ8353e2a5PPX+qHe2aGayfnchQmkAojaWAREOIA==", + "license": "BSD-3-Clause", + "dependencies": { + "@types/trusted-types": "^2.0.2" + } + }, "node_modules/loader-runner": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", - "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", + "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", "dev": true, "license": "MIT", "engines": { @@ -7444,20 +9114,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash.defaults": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", - "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.isarguments": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", - "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", - "dev": true, - "license": "MIT" - }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -7490,6 +9146,13 @@ "node": ">= 12.0.0" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lowercase-keys": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz", @@ -7510,6 +9173,16 @@ "dev": true, "license": "ISC" }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", @@ -7702,10 +9375,11 @@ "license": "MIT" }, "node_modules/n3": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/n3/-/n3-2.0.3.tgz", - "integrity": "sha512-um/toGVENTarHBYIK2TdH6ByBhW75WpdKpv8iTYt9wF2QfBk8s8a16iaWZFUAAC1BKfGdb99kfgx6pltdDwfKA==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/n3/-/n3-2.0.4.tgz", + "integrity": "sha512-d2Sr4vvqombySfOARjw5jQky0EYMwkEXuQQjYe2glasDdI3hAT3l00aJZbAmR+AA59eFNjJbDkHCq9l5Dm2DIg==", "license": "MIT", + "peer": true, "dependencies": { "buffer": "^6.0.3", "readable-stream": "^4.0.0" @@ -7715,9 +9389,9 @@ } }, "node_modules/nanoid": { - "version": "5.1.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.7.tgz", - "integrity": "sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ==", + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz", + "integrity": "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==", "dev": true, "funding": [ { @@ -7784,13 +9458,41 @@ } } }, - "node_modules/node-releases": { - "version": "2.0.37", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", - "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "dev": true, "license": "MIT" }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.48", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz", + "integrity": "sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/nodemailer": { "version": "7.0.13", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", @@ -7824,6 +9526,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, + "node_modules/oauth4webapi": { + "version": "3.8.6", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz", + "integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -8024,6 +9743,32 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -8081,6 +9826,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8171,13 +9933,13 @@ } }, "node_modules/playwright": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", - "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz", + "integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.59.1" + "playwright-core": "1.61.0" }, "bin": { "playwright": "cli.js" @@ -8190,9 +9952,9 @@ } }, "node_modules/playwright-core": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", - "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz", + "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -8216,6 +9978,54 @@ "node": ">= 10.12" } }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -8283,9 +10093,9 @@ } }, "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -8670,16 +10480,6 @@ "entities": "^4.4.0" } }, - "node_modules/rdfjs-wrapper": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/rdfjs-wrapper/-/rdfjs-wrapper-0.15.0.tgz", - "integrity": "sha512-yHysnxO473o4lRW1FJLmsf3PtxQRpkZVd9j4oPo+HhuqBFFwswuXZjjy/j+mqon61dQnbMQHHLhx68hpvy1HSA==", - "deprecated": "Please use @rdfjs/wrapper instead.", - "license": "MIT", - "engines": { - "node": ">=24.0.0" - } - }, "node_modules/rdfxml-streaming-parser": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/rdfxml-streaming-parser/-/rdfxml-streaming-parser-2.4.0.tgz", @@ -8817,12 +10617,13 @@ "license": "MIT" }, "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", "dev": true, "license": "MIT", "dependencies": { + "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" @@ -8914,6 +10715,58 @@ "node": ">=0.10.0" } }, + "node_modules/rollup": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz", + "integrity": "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.0", + "@rollup/rollup-android-arm64": "4.62.0", + "@rollup/rollup-darwin-arm64": "4.62.0", + "@rollup/rollup-darwin-x64": "4.62.0", + "@rollup/rollup-freebsd-arm64": "4.62.0", + "@rollup/rollup-freebsd-x64": "4.62.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.0", + "@rollup/rollup-linux-arm-musleabihf": "4.62.0", + "@rollup/rollup-linux-arm64-gnu": "4.62.0", + "@rollup/rollup-linux-arm64-musl": "4.62.0", + "@rollup/rollup-linux-loong64-gnu": "4.62.0", + "@rollup/rollup-linux-loong64-musl": "4.62.0", + "@rollup/rollup-linux-ppc64-gnu": "4.62.0", + "@rollup/rollup-linux-ppc64-musl": "4.62.0", + "@rollup/rollup-linux-riscv64-gnu": "4.62.0", + "@rollup/rollup-linux-riscv64-musl": "4.62.0", + "@rollup/rollup-linux-s390x-gnu": "4.62.0", + "@rollup/rollup-linux-x64-gnu": "4.62.0", + "@rollup/rollup-linux-x64-musl": "4.62.0", + "@rollup/rollup-openbsd-x64": "4.62.0", + "@rollup/rollup-openharmony-arm64": "4.62.0", + "@rollup/rollup-win32-arm64-msvc": "4.62.0", + "@rollup/rollup-win32-ia32-msvc": "4.62.0", + "@rollup/rollup-win32-x64-gnu": "4.62.0", + "@rollup/rollup-win32-x64-msvc": "4.62.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -8980,6 +10833,19 @@ "dev": true, "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/schema-utils": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", @@ -9001,9 +10867,9 @@ } }, "node_modules/schema-utils/node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", "peer": true, @@ -9046,9 +10912,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", "dev": true, "license": "ISC", "bin": { @@ -9197,15 +11063,15 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -9217,14 +11083,14 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -9272,6 +11138,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -9302,6 +11175,16 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-support": { "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", @@ -9488,6 +11371,13 @@ "node": "*" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/standard-as-callback": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", @@ -9505,6 +11395,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stream-to-string": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/stream-to-string/-/stream-to-string-1.2.1.tgz", @@ -9625,10 +11522,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tapable": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", - "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "dev": true, "license": "MIT", "engines": { @@ -9640,11 +11544,12 @@ } }, "node_modules/terser": { - "version": "5.46.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz", - "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", + "version": "5.48.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.48.0.tgz", + "integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -9659,9 +11564,9 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.4.0.tgz", - "integrity": "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==", + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.1.tgz", + "integrity": "sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9681,12 +11586,39 @@ "webpack": "^5.1.0" }, "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, "@swc/core": { "optional": true }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, "esbuild": { "optional": true }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, "uglify-js": { "optional": true } @@ -9724,6 +11656,70 @@ "dev": true, "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -9754,12 +11750,31 @@ "dev": true, "license": "MIT" }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } }, "node_modules/triple-beam": { "version": "1.4.1", @@ -9779,9 +11794,9 @@ "license": "MIT" }, "node_modules/ts-loader": { - "version": "9.5.7", - "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.7.tgz", - "integrity": "sha512-/ZNrKgA3K3PtpMYOC71EeMWIloGw3IYEa5/t1cyz2r5/PyUwTXGzYJvcD3kfUvmhlfpz1rhV8B2O6IVTQ0avsg==", + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.6.1.tgz", + "integrity": "sha512-8FMHnmxtpncUAu0ZjkqpXnOTlwc9eY95esH8WVN94guTPPdkg2ofVdiVM5j8L2lmjiGerXd56zXb/D2JyVQPLg==", "dev": true, "license": "MIT", "dependencies": { @@ -9795,8 +11810,14 @@ "node": ">=12.0.0" }, "peerDependencies": { + "loader-utils": "*", "typescript": "*", - "webpack": "^5.0.0" + "webpack": "^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "loader-utils": { + "optional": true + } } }, "node_modules/ts-loader/node_modules/source-map": { @@ -10049,6 +12070,7 @@ "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", "dev": true, "funding": [ "https://github.com/sponsors/broofa", @@ -10083,14 +12105,190 @@ "node": ">= 0.8" } }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/watchpack": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", - "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", + "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", "dev": true, "license": "MIT", "dependencies": { - "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" }, "engines": { @@ -10105,21 +12303,23 @@ "license": "MIT" }, "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", "dev": true, - "license": "BSD-2-Clause" + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } }, "node_modules/webpack": { - "version": "5.105.4", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz", - "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", + "version": "5.107.2", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.107.2.tgz", + "integrity": "sha512-v7RhXaJbpMlV0D7hC7lb2EbnxkoeUqf9qhKr6lozx3Q48pmFrqqNRmZFUEGmi7pSwm6fCQ2H1IjvCkHqdpVdjQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", @@ -10129,21 +12329,20 @@ "acorn-import-phases": "^1.0.3", "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.20.0", - "es-module-lexer": "^2.0.0", + "enhanced-resolve": "^5.22.0", + "es-module-lexer": "^2.1.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", - "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.3.1", - "mime-types": "^2.1.27", + "loader-runner": "^4.3.2", + "mime-db": "^1.54.0", "neo-async": "^2.6.2", "schema-utils": "^4.3.3", "tapable": "^2.3.0", - "terser-webpack-plugin": "^5.3.17", + "terser-webpack-plugin": "^5.5.0", "watchpack": "^2.5.1", - "webpack-sources": "^3.3.4" + "webpack-sources": "^3.5.0" }, "bin": { "webpack": "bin/webpack.js" @@ -10167,7 +12366,6 @@ "integrity": "sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.6.1", "@webpack-cli/configtest": "^3.0.1", @@ -10231,15 +12429,22 @@ } }, "node_modules/webpack-sources": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.4.tgz", - "integrity": "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==", + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.0.tgz", + "integrity": "sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==", "dev": true, "license": "MIT", "engines": { "node": ">=10.13.0" } }, + "node_modules/webpack/node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/webpack/node_modules/eslint-scope": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", @@ -10264,6 +12469,16 @@ "node": ">=4.0" } }, + "node_modules/webpack/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/whatwg-encoding": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", @@ -10291,15 +12506,28 @@ "node": ">=0.10.0" } }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", "dev": true, "license": "MIT", "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" } }, "node_modules/which": { @@ -10318,6 +12546,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wildcard": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", @@ -10436,9 +12681,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "dev": true, "license": "MIT", "engines": { @@ -10457,6 +12702,16 @@ } } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/xmlchars": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", @@ -10482,9 +12737,9 @@ "license": "ISC" }, "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", "dev": true, "license": "MIT", "dependencies": { diff --git a/apps/extension/package.json b/apps/extension/package.json index a2733f17e..ad4664671 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -1,33 +1,45 @@ { "name": "solid-browser-extension", - "version": "0.1.0", + "version": "0.2.0", "private": true, - "description": "Browser extension for Solid authentication", + "description": "Browser extension (MV3) for Solid authentication — injects window.solid (DPoP-authenticated fetch) and a pinned avatar account menu, consistent with the @jeswr Solid app suite.", "license": "MIT", "author": "Jesse Wright", + "type": "module", "scripts": { + "lint": "biome check src test e2e scripts", + "lint:fix": "biome check --write src test e2e scripts", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", "build": "webpack", "build:dev": "webpack --mode development --watch", - "dev": "npx tsx scripts/dev.ts", + "gate": "npm run lint && npm run typecheck && npm test && npm run build", "test:e2e": "npm run build && playwright test --config e2e/playwright.config.ts", "clean": "rm -rf dist" }, "dependencies": { + "@jeswr/solid-dpop": "git+https://github.com/jeswr/solid-dpop.git#4bc5309fab6fe837a942581d331da90be2d552e3", + "@jeswr/solid-elements": "git+https://github.com/jeswr/solid-elements.git#06c12e9383e3e4be45ec236b30f02fd8fdc53dbc", + "@jeswr/solid-session-restore": "git+https://github.com/jeswr/solid-session-restore.git#1a6aedc52cd4b6cbd61859e0098034cf4a7e8282", "@rdfjs/wrapper": "^0.32.0", - "@solid/object": "^0.4.0", - "@uvdsl/solid-oidc-client-browser": "^0.2.2", + "@solid/object": "^0.6.0", "jose": "^5.9.6", "n3": "^2.0.3" }, "devDependencies": { + "@biomejs/biome": "^2.5.0", "@playwright/test": "^1.49.0", "@solid/community-server": "^7.1.3", "@types/chrome": "^0.0.287", + "@types/node": "^24.0.0", "copy-webpack-plugin": "^12.0.2", "http-server": "^14.1.1", + "jsdom": "^25.0.1", "ts-loader": "^9.5.1", "ts-node": "^10.9.2", "typescript": "^5.7.2", + "vitest": "^2.1.8", "webpack": "^5.97.1", "webpack-cli": "^6.0.1" } diff --git a/apps/extension/tsconfig.json b/apps/extension/tsconfig.json index 9391d7e9c..4e4648d45 100644 --- a/apps/extension/tsconfig.json +++ b/apps/extension/tsconfig.json @@ -3,16 +3,19 @@ "target": "ES2022", "module": "ES2022", "moduleResolution": "bundler", - "lib": ["ES2022", "DOM", "DOM.Iterable"], + "lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"], "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, "esModuleInterop": true, "skipLibCheck": true, - "outDir": "dist", - "rootDir": "src", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, "types": ["chrome"], - "declaration": false, "sourceMap": true }, - "include": ["src/**/*.ts"], + "include": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts"], "exclude": ["node_modules", "dist", "e2e"] } diff --git a/apps/extension/vitest.config.ts b/apps/extension/vitest.config.ts new file mode 100644 index 000000000..73fa48285 --- /dev/null +++ b/apps/extension/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config'; + +// Unit tests run in jsdom so the security-critical, browser-shaped core modules +// (DPoP proof generation, per-origin token boundary, the silent-restore decision +// wiring) are testable WITHOUT a packed extension or a real Solid server. The +// chrome.* extension APIs are stubbed per-test; the e2e Playwright suite (against a +// LOCAL CSS) covers the integrated extension. See e2e/playwright.config.ts. +export default defineConfig({ + test: { + environment: 'jsdom', + include: ['test/**/*.test.ts'], + exclude: ['node_modules/**', 'dist/**', 'e2e/**'], + globals: false, + }, +}); From bc8fc60e1a257d2a29f3a8ac7df470255748cb59 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Fri, 19 Jun 2026 17:59:34 +0100 Subject: [PATCH 08/50] =?UTF-8?q?feat(core):=20browser-safe=20DPoP=20+=20f?= =?UTF-8?q?ail-closed=20per-origin=20boundary=20+=20=C2=A78=20nonce=20clas?= =?UTF-8?q?sifier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The security-critical, unit-testable core of the service-worker auth path, split out so it is provable without a packed extension or a live server (37 vitest cases): - core/dpop.ts — RFC 9449 §4.2 DPoP proof generation on Web Crypto + jose (the SW has no node:crypto, so this reimplements @jeswr/solid-dpop's discipline with an IDENTICAL proof shape: typ/alg/jwk header with PUBLIC components only, htm/htu/jti/iat/ath/nonce payload, canonical htu). Extractable keypair (documented: MV3 SW suspension loses a non-extractable key and breaks the jkt-bound refresh) persisted as JWK; round-trip re-import verified against the original public key. - core/origin-policy.ts — the per-origin credential boundary. computeAllowedOrigins / isOriginAllowed fail closed so pod A's token never attaches to origin B (the token-leak attack a malicious page would try via solid.fetch). Cleartext guard (https-only, http for loopback in dev). isTokenEndpoint pins "never attach the resource token to /token". - core/www-authenticate.ts — conservative RFC 9449 §8 use_dpop_nonce classifier + quote-aware WWW-Authenticate parser (mirrors @jeswr/solid-elements). The adversarial test 'rejects a foreign origin (the token-leak attack)' genuinely fails without isOriginAllowed's fail-closed check. Model: claude-opus-4-8 Provenance: Opus 4.8 (Fable unavailable) — re-review/upgrade candidate Co-Authored-By: Claude Opus 4.8 --- apps/extension/src/background/core/dpop.ts | 152 ++++++++++++++++++ .../src/background/core/origin-policy.ts | 145 +++++++++++++++++ .../src/background/core/www-authenticate.ts | 126 +++++++++++++++ apps/extension/test/dpop.test.ts | 115 +++++++++++++ apps/extension/test/origin-policy.test.ts | 116 +++++++++++++ apps/extension/test/www-authenticate.test.ts | 58 +++++++ apps/extension/vitest.config.ts | 10 +- 7 files changed, 718 insertions(+), 4 deletions(-) create mode 100644 apps/extension/src/background/core/dpop.ts create mode 100644 apps/extension/src/background/core/origin-policy.ts create mode 100644 apps/extension/src/background/core/www-authenticate.ts create mode 100644 apps/extension/test/dpop.test.ts create mode 100644 apps/extension/test/origin-policy.test.ts create mode 100644 apps/extension/test/www-authenticate.test.ts diff --git a/apps/extension/src/background/core/dpop.ts b/apps/extension/src/background/core/dpop.ts new file mode 100644 index 000000000..304db0849 --- /dev/null +++ b/apps/extension/src/background/core/dpop.ts @@ -0,0 +1,152 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * DPoP proof generation (RFC 9449) for the MV3 service-worker context — the + * security-critical core of the Solid-OIDC path. + * + * This MIRRORS the discipline of `@jeswr/solid-dpop` (`src/dpop.ts`), reused as the + * canonical spec, but is reimplemented on **Web Crypto + jose** rather than + * `node:crypto`: an extension service worker has no Node `crypto` module, so the suite + * package's `node:crypto`-backed `createHash`/`randomUUID` cannot run here. The proof + * SHAPE is identical to the package's so behaviour matches the rest of the suite: + * + * - header `typ` = "dpop+jwt", `alg` = ES256, `jwk` = the PUBLIC key (kty/crv/x/y only). + * - payload `htm` = HTTP method (upper-cased), `htu` = canonical URI (no query/fragment), + * `iat` = now, `jti` = unique nonce, and — when presenting an access token — + * `ath` = base64url(SHA-256(access_token)). RFC 9449 §4.2 / §6.1. A + * server-supplied `nonce` (RFC 9449 §8) is echoed when present. + * + * The `ath` claim binds the proof to a specific access token; the `cnf.jkt` binds the + * token to this keypair. Nothing is hand-rolled beyond calling jose + Web Crypto: all + * JWS/base64url operations go through jose; the only direct crypto call is Web Crypto's + * SHA-256 for the `ath` digest (jose exposes no helper for it), exactly as the suite + * package does with node:crypto. + */ + +import * as jose from 'jose'; + +/** Signature algorithm used for the DPoP keypair. ES256 is the Solid-OIDC default. */ +export const DPOP_ALG = 'ES256' as const; + +/** The EC named curve paired with {@link DPOP_ALG}. */ +export const DPOP_CURVE = 'P-256' as const; + +/** + * Compute the RFC 9449 §4.2 `htu`: the request URI with query and fragment removed. + * The scheme + authority + path are normalised by the URL parser. Throws on an + * unparseable URI (a proof for a non-URL request must not be minted). + */ +export function canonicalHtu(uri: string): string { + const u = new URL(uri); + u.search = ''; + u.hash = ''; + return u.toString(); +} + +/** + * Compute the `ath` claim: base64url( SHA-256( ASCII(access_token) ) ). RFC 9449 §4.2. + * Web Crypto's SHA-256 + jose-style base64url (no padding, URL-safe alphabet). This is + * a digest, not a crypto primitive. + */ +export async function accessTokenHash(accessToken: string): Promise { + const bytes = new TextEncoder().encode(accessToken); + const digest = await crypto.subtle.digest('SHA-256', bytes); + return jose.base64url.encode(new Uint8Array(digest)); +} + +/** The persisted, JSON-serialisable form of a DPoP keypair (a private + public JWK pair). */ +export interface StoredDpopKeyPair { + readonly publicKey: JsonWebKey; + readonly privateKey: JsonWebKey; +} + +/** + * Generate a fresh DPoP keypair as **extractable** Web Crypto keys. + * + * NOTE: this keypair is exported as JWK to `chrome.storage` and re-imported on each + * service-worker wake-up — an MV3 service worker is killed/restarted aggressively, and + * a non-extractable key is LOST on suspension, breaking every subsequent refresh (CSS + * binds the refresh token to the original `jkt`). Extractability is therefore a + * deliberate, documented requirement of the service-worker context, not a weakness; the + * key never leaves the (page-unreachable) extension storage. The popup-page restore path + * uses the suite's non-extractable IndexedDB key instead. + */ +export async function generateDpopKeyPair(): Promise { + return crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: DPOP_CURVE }, true, [ + 'sign', + 'verify', + ]); +} + +/** Export a keypair to a JSON-serialisable JWK pair for `chrome.storage` persistence. */ +export async function exportDpopKeyPair(keyPair: CryptoKeyPair): Promise { + const publicKey = await crypto.subtle.exportKey('jwk', keyPair.publicKey); + const privateKey = await crypto.subtle.exportKey('jwk', keyPair.privateKey); + return { publicKey, privateKey }; +} + +/** Reconstruct a Web Crypto keypair from a {@link StoredDpopKeyPair}. */ +export async function importDpopKeyPair(stored: StoredDpopKeyPair): Promise { + const publicKey = await crypto.subtle.importKey( + 'jwk', + stored.publicKey, + { name: 'ECDSA', namedCurve: DPOP_CURVE }, + true, + ['verify'], + ); + const privateKey = await crypto.subtle.importKey( + 'jwk', + stored.privateKey, + { name: 'ECDSA', namedCurve: DPOP_CURVE }, + true, + ['sign'], + ); + return { publicKey, privateKey }; +} + +export interface DpopProofParams { + readonly keyPair: CryptoKeyPair; + readonly htm: string; + readonly htu: string; + /** Present iff this proof accompanies an access token (resource requests; some /token flows). */ + readonly accessToken?: string; + /** Server-supplied DPoP nonce (RFC 9449 §8) echoed back in the proof, if any. */ + readonly nonce?: string; +} + +/** + * Mint a single-use DPoP proof JWS. A fresh `jti` is generated per call, so every proof + * is unique; callers MUST NOT reuse a proof across requests. The header `jwk` carries + * ONLY the public components (kty/crv/x/y) — never the private scalar `d`. + */ +export async function createDpopProof(params: DpopProofParams): Promise { + const { keyPair, htm, htu, accessToken, nonce } = params; + + const publicJwk = (await crypto.subtle.exportKey('jwk', keyPair.publicKey)) as JsonWebKey; + const headerJwk = { + kty: publicJwk.kty, + crv: publicJwk.crv, + x: publicJwk.x, + y: publicJwk.y, + }; + + const payload: Record = { + htm: htm.toUpperCase(), + htu: canonicalHtu(htu), + jti: crypto.randomUUID(), + }; + if (accessToken !== undefined) { + payload['ath'] = await accessTokenHash(accessToken); + } + if (nonce !== undefined) { + payload['nonce'] = nonce; + } + + return new jose.SignJWT(payload) + .setProtectedHeader({ + typ: 'dpop+jwt', + alg: DPOP_ALG, + jwk: headerJwk as jose.JWK, + }) + .setIssuedAt() + .sign(keyPair.privateKey); +} diff --git a/apps/extension/src/background/core/origin-policy.ts b/apps/extension/src/background/core/origin-policy.ts new file mode 100644 index 000000000..41dd2e82d --- /dev/null +++ b/apps/extension/src/background/core/origin-policy.ts @@ -0,0 +1,145 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * The per-origin credential boundary — the security heart of a browser-level Solid + * auth proxy. + * + * The extension injects `window.solid.fetch` into EVERY page and proxies the call + * through the service worker, attaching the user's DPoP-bound access token. Without a + * boundary, a malicious page could call `solid.fetch("https://evil.example/collect")` + * and the extension would hand `evil.example` the user's pod token. That must be + * impossible. This module is the **fail-closed** gate, mirroring the boundary in + * `@jeswr/solid-elements`'s proactive-fetch (`computeAllowedOrigins`/`isOriginAllowed`): + * + * - The token is attached ONLY to an HTTPS origin in the allowed set. + * - The allowed set is the WebID's own origin ∪ the issuer's origin ∪ any explicit + * pod origins the user configured (a pod on a different host than the WebID is a + * valid Solid topology and must be listed). + * - CLEARTEXT GUARD: a non-`https:` origin is DROPPED, so a token never rides over + * plaintext — EXCEPT a loopback `http:` origin when `allowInsecureLoopback` is set + * (local dev CSS only). + * - Fail-closed everywhere: an unparseable URL is never allowed; an empty allowed set + * means the token is attached to NOTHING. + * + * Two further invariants enforced by the caller (the authenticated-fetch path), pinned + * here as pure helpers: the token is NEVER attached to the issuer's TOKEN endpoint via + * the resource path ({@link isTokenEndpoint}), and the proxy is re-entrancy guarded so a + * DPoP/discovery fetch the SW itself makes can never recurse into the authenticated path. + */ + +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']); + +/** Whether a host is a loopback host (the only place `http:` is tolerated, in dev). */ +export function isLoopbackHost(host: string): boolean { + return LOOPBACK_HOSTS.has(host.toLowerCase()); +} + +/** Parse a string to its URL `origin`, or `null` if it is not a usable URL. */ +function originOf(value: string): string | null { + try { + return new URL(value).origin; + } catch { + return null; + } +} + +/** + * Whether an origin is permitted to carry the token given the transport policy. + * `https:` always; `http:` only for a loopback host AND only when dev loopback is + * allowed. Everything else (ws:, file:, http: on a real host) is rejected. + */ +function transportAllowed(origin: string, allowInsecureLoopback: boolean): boolean { + let url: URL; + try { + url = new URL(origin); + } catch { + return false; + } + if (url.protocol === 'https:') return true; + if (url.protocol === 'http:') { + return allowInsecureLoopback && isLoopbackHost(url.hostname); + } + return false; +} + +export interface AllowedOriginsInputs { + /** The authenticated WebID (its origin is included unless disabled). */ + readonly webId?: string | null; + /** The issuer URL (its origin is included unless disabled). */ + readonly issuer?: string | null; + /** Explicit pod origins (any URL; compared by `origin`). */ + readonly podOrigins?: readonly string[]; + /** Include the WebID's origin. Default true. */ + readonly includeWebIdOrigin?: boolean; + /** Include the issuer's origin. Default true. */ + readonly includeIssuerOrigin?: boolean; + /** Allow `http:` for loopback hosts only (dev). Default false. */ + readonly allowInsecureLoopback?: boolean; +} + +/** + * The set of resource origins the session token may be attached to. PURE so the boundary + * is unit-tested. Applies the cleartext guard to every candidate; an empty result means + * the token is attached to NOTHING (strictly fail-closed). + */ +export function computeAllowedOrigins(inputs: AllowedOriginsInputs): ReadonlySet { + const { + webId, + issuer, + podOrigins = [], + includeWebIdOrigin = true, + includeIssuerOrigin = true, + allowInsecureLoopback = false, + } = inputs; + + const candidates: string[] = []; + if (includeWebIdOrigin && webId) { + const o = originOf(webId); + if (o) candidates.push(o); + } + if (includeIssuerOrigin && issuer) { + const o = originOf(issuer); + if (o) candidates.push(o); + } + for (const pod of podOrigins) { + const o = originOf(pod); + if (o) candidates.push(o); + } + + const allowed = new Set(); + for (const origin of candidates) { + if (transportAllowed(origin, allowInsecureLoopback)) { + allowed.add(origin); + } + } + return allowed; +} + +/** + * Whether a request URL targets an allowed origin (the per-request credential gate). + * PURE. Fail-closed: an unparseable URL, or an empty allowed set, is never allowed. + */ +export function isOriginAllowed(allowed: ReadonlySet, requestUrl: string): boolean { + const origin = originOf(requestUrl); + if (origin === null) return false; + return allowed.has(origin); +} + +/** + * Whether a request URL is the issuer's TOKEN endpoint. The user's access token must + * NEVER be attached to the token endpoint via the resource-fetch path (it would leak the + * resource token into a token-management request and could enable a confused-deputy + * exchange). Compared by full canonical URL (scheme+host+port+path), query/fragment + * ignored. PURE. + */ +export function isTokenEndpoint(requestUrl: string, tokenEndpoint: string | null): boolean { + if (!tokenEndpoint) return false; + let req: URL; + let tok: URL; + try { + req = new URL(requestUrl); + tok = new URL(tokenEndpoint); + } catch { + return false; + } + return req.origin === tok.origin && req.pathname === tok.pathname; +} diff --git a/apps/extension/src/background/core/www-authenticate.ts b/apps/extension/src/background/core/www-authenticate.ts new file mode 100644 index 000000000..13667881a --- /dev/null +++ b/apps/extension/src/background/core/www-authenticate.ts @@ -0,0 +1,126 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * `WWW-Authenticate` parsing + the RFC 9449 §8 DPoP-nonce-challenge classifier. + * + * A Solid resource server that requires a server-supplied DPoP nonce answers the first + * request with `401` + `WWW-Authenticate: DPoP …, error="use_dpop_nonce"` and a fresh + * `DPoP-Nonce` header. The client must re-mint its proof WITH that nonce and retry ONCE. + * This module is the conservative classifier for that case, mirroring + * `@jeswr/solid-elements`'s `isUseDpopNonceChallenge`/`parseWwwAuthenticate` so the + * extension behaves identically to the rest of the suite. + * + * Conservative on purpose: we treat a 401 as a nonce challenge ONLY when the server + * EXPLICITLY says the token was fine and only the nonce was missing + * (`error="use_dpop_nonce"` on a `DPoP` challenge). Any OTHER error (invalid_token, + * expired/revoked) returns false, so the caller force-refreshes the token instead of + * looping on a stale one even when the server ALSO rotated the nonce. + */ + +interface Challenge { + scheme: string; + params: Map; +} + +type Atom = { kind: 'word'; value: string } | { kind: 'quoted'; value: string } | { kind: 'eq' }; + +/** + * Tokenise a `WWW-Authenticate` header into atoms (a bare word, a quoted string, or a + * standalone `=`). The grammar (RFC 9110 §11.6.1) is comma-ambiguous, so we scan + * character-by-character; quoted strings (which may contain commas/`=`) are kept whole. + */ +function tokenize(header: string): Atom[] { + const atoms: Atom[] = []; + let i = 0; + const n = header.length; + while (i < n) { + const c = header[i]; + if (c === ' ' || c === '\t' || c === ',') { + i++; + continue; + } + if (c === '=') { + atoms.push({ kind: 'eq' }); + i++; + continue; + } + if (c === '"') { + i++; + let value = ''; + while (i < n && header[i] !== '"') { + if (header[i] === '\\' && i + 1 < n) { + value += header[i + 1]; + i += 2; + } else { + value += header[i]; + i++; + } + } + i++; // closing quote + atoms.push({ kind: 'quoted', value }); + continue; + } + let value = ''; + while (i < n && !' \t,="'.includes(header[i])) { + value += header[i]; + i++; + } + atoms.push({ kind: 'word', value }); + } + return atoms; +} + +/** + * Parse a `WWW-Authenticate` header into its individual challenges, each with its scheme + * and a quote-aware map of its top-level auth-params. PURE; odd input degrades safely. + */ +export function parseWwwAuthenticate(header: string): Challenge[] { + const atoms = tokenize(header); + const challenges: Challenge[] = []; + let current: Challenge | null = null; + let i = 0; + + const isAuthScheme = (a: Atom): boolean => a.kind === 'word' && /^[A-Za-z][\w-]*$/.test(a.value); + + while (i < atoms.length) { + const atom = atoms[i]; + // `word =` => a param; a bare `word` not followed by `=` starts a new challenge. + if (atom.kind === 'word') { + const next = atoms[i + 1]; + if (next && next.kind === 'eq') { + const valueAtom = atoms[i + 2]; + if (current && valueAtom && (valueAtom.kind === 'word' || valueAtom.kind === 'quoted')) { + current.params.set(atom.value.toLowerCase(), valueAtom.value); + i += 3; + continue; + } + // param with no current challenge / malformed — skip the `=`. + i += 2; + continue; + } + if (isAuthScheme(atom)) { + current = { scheme: atom.value, params: new Map() }; + challenges.push(current); + i += 1; + continue; + } + } + i += 1; + } + return challenges; +} + +/** + * Whether a 401 response is a PURE DPoP-nonce challenge (RFC 9449 §8): a `DPoP` + * challenge whose `error` param is exactly `use_dpop_nonce`. Conservative — see the + * module doc. PURE on the header value via {@link parseWwwAuthenticate}. + */ +export function isUseDpopNonceChallenge(response: Response): boolean { + const header = response.headers.get('www-authenticate'); + if (!header) return false; + for (const challenge of parseWwwAuthenticate(header)) { + if (challenge.scheme.toLowerCase() === 'dpop') { + if (challenge.params.get('error') === 'use_dpop_nonce') return true; + } + } + return false; +} diff --git a/apps/extension/test/dpop.test.ts b/apps/extension/test/dpop.test.ts new file mode 100644 index 000000000..d07cacc40 --- /dev/null +++ b/apps/extension/test/dpop.test.ts @@ -0,0 +1,115 @@ +// AUTHORED-BY Claude Opus 4.8 + +import * as jose from 'jose'; +import { describe, expect, it } from 'vitest'; +import { + accessTokenHash, + canonicalHtu, + createDpopProof, + DPOP_ALG, + exportDpopKeyPair, + generateDpopKeyPair, + importDpopKeyPair, +} from '../src/background/core/dpop'; + +describe('canonicalHtu', () => { + it('strips the query and fragment (RFC 9449 §4.2)', () => { + expect(canonicalHtu('https://pod.example/a/b.ttl?x=1#frag')).toBe( + 'https://pod.example/a/b.ttl', + ); + }); + + it('preserves scheme, host, port and path', () => { + expect(canonicalHtu('https://pod.example:8443/private/notes.ttl')).toBe( + 'https://pod.example:8443/private/notes.ttl', + ); + }); + + it('throws on a non-URL', () => { + expect(() => canonicalHtu('not a url')).toThrow(); + }); +}); + +describe('accessTokenHash', () => { + it('is the base64url SHA-256 of the ASCII token', async () => { + const token = 'abc123'; + const expected = jose.base64url.encode( + new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(token))), + ); + expect(await accessTokenHash(token)).toBe(expected); + }); +}); + +describe('createDpopProof', () => { + it('produces a verifiable JWS with the correct header and required claims', async () => { + const keyPair = await generateDpopKeyPair(); + const proof = await createDpopProof({ + keyPair, + htm: 'get', + htu: 'https://pod.example/x?q=1', + }); + + const { payload, protectedHeader } = await jose.jwtVerify(proof, keyPair.publicKey); + expect(protectedHeader.typ).toBe('dpop+jwt'); + expect(protectedHeader.alg).toBe(DPOP_ALG); + expect(payload.htm).toBe('GET'); // upper-cased + expect(payload.htu).toBe('https://pod.example/x'); // canonicalised + expect(typeof payload.jti).toBe('string'); + expect(typeof payload.iat).toBe('number'); + expect(payload.ath).toBeUndefined(); // no access token => no ath + expect(payload.nonce).toBeUndefined(); + }); + + it('embeds ONLY public JWK components in the header (never the private `d`)', async () => { + const keyPair = await generateDpopKeyPair(); + const proof = await createDpopProof({ keyPair, htm: 'GET', htu: 'https://pod.example/x' }); + const header = jose.decodeProtectedHeader(proof); + const jwk = header.jwk as Record; + expect(jwk).toMatchObject({ kty: 'EC', crv: 'P-256' }); + expect(jwk.x).toBeTypeOf('string'); + expect(jwk.y).toBeTypeOf('string'); + expect(jwk.d).toBeUndefined(); + }); + + it('includes ath when an access token is presented and nonce when supplied', async () => { + const keyPair = await generateDpopKeyPair(); + const proof = await createDpopProof({ + keyPair, + htm: 'POST', + htu: 'https://pod.example/x', + accessToken: 'tok', + nonce: 'srv-nonce', + }); + const { payload } = await jose.jwtVerify(proof, keyPair.publicKey); + expect(payload.ath).toBe(await accessTokenHash('tok')); + expect(payload.nonce).toBe('srv-nonce'); + }); + + it('mints a fresh jti every call (single-use proofs)', async () => { + const keyPair = await generateDpopKeyPair(); + const a = jose.decodeJwt( + await createDpopProof({ keyPair, htm: 'GET', htu: 'https://pod.example/x' }), + ); + const b = jose.decodeJwt( + await createDpopProof({ keyPair, htm: 'GET', htu: 'https://pod.example/x' }), + ); + expect(a.jti).not.toBe(b.jti); + }); +}); + +describe('keypair export/import round-trip (service-worker persistence)', () => { + it('re-imported key produces proofs verifiable against the original public key', async () => { + const keyPair = await generateDpopKeyPair(); + const stored = await exportDpopKeyPair(keyPair); + expect(stored.privateKey.d).toBeTypeOf('string'); // extractable private key persisted + const reimported = await importDpopKeyPair(stored); + + const proof = await createDpopProof({ + keyPair: reimported, + htm: 'GET', + htu: 'https://pod.example/x', + }); + // verify against the ORIGINAL public key — same jkt survives a SW restart. + await expect(jose.jwtVerify(proof, keyPair.publicKey)).resolves.toBeTruthy(); + }); +}); diff --git a/apps/extension/test/origin-policy.test.ts b/apps/extension/test/origin-policy.test.ts new file mode 100644 index 000000000..b28b67c78 --- /dev/null +++ b/apps/extension/test/origin-policy.test.ts @@ -0,0 +1,116 @@ +// AUTHORED-BY Claude Opus 4.8 +import { describe, expect, it } from 'vitest'; +import { + computeAllowedOrigins, + isLoopbackHost, + isOriginAllowed, + isTokenEndpoint, +} from '../src/background/core/origin-policy'; + +describe('computeAllowedOrigins', () => { + it('includes the WebID origin and issuer origin by default', () => { + const allowed = computeAllowedOrigins({ + webId: 'https://alice.pod.example/profile/card#me', + issuer: 'https://idp.example/', + }); + expect(allowed.has('https://alice.pod.example')).toBe(true); + expect(allowed.has('https://idp.example')).toBe(true); + }); + + it('includes explicit pod origins (pod on a different host than the WebID)', () => { + const allowed = computeAllowedOrigins({ + webId: 'https://alice.id.example/card#me', + podOrigins: ['https://storage.example/alice/'], + }); + expect(allowed.has('https://storage.example')).toBe(true); + }); + + it('DROPS a cleartext http origin (cleartext guard) by default', () => { + const allowed = computeAllowedOrigins({ + webId: 'http://alice.pod.example/card#me', + }); + expect(allowed.size).toBe(0); + }); + + it('allows http ONLY for loopback when allowInsecureLoopback is set', () => { + const allowed = computeAllowedOrigins({ + webId: 'http://localhost:3000/alice/card#me', + allowInsecureLoopback: true, + }); + expect(allowed.has('http://localhost:3000')).toBe(true); + + const rejected = computeAllowedOrigins({ + webId: 'http://evil.example/card#me', + allowInsecureLoopback: true, + }); + expect(rejected.size).toBe(0); // loopback-only, not any http + }); + + it('can exclude the WebID / issuer origins', () => { + const allowed = computeAllowedOrigins({ + webId: 'https://alice.pod.example/card#me', + issuer: 'https://idp.example/', + includeWebIdOrigin: false, + includeIssuerOrigin: false, + }); + expect(allowed.size).toBe(0); + }); + + it('yields an EMPTY set when nothing is supplied (fail-closed)', () => { + expect(computeAllowedOrigins({}).size).toBe(0); + }); +}); + +describe('isOriginAllowed — the per-request credential gate', () => { + const allowed = computeAllowedOrigins({ + webId: 'https://alice.pod.example/card#me', + issuer: 'https://idp.example/', + }); + + it('allows a request to an allowed origin', () => { + expect(isOriginAllowed(allowed, 'https://alice.pod.example/private/notes.ttl')).toBe(true); + }); + + it('FAILS CLOSED: rejects a foreign origin (the token-leak attack)', () => { + // The core security property: a malicious page calling + // solid.fetch("https://evil.example/collect") must NOT get the token. + expect(isOriginAllowed(allowed, 'https://evil.example/collect')).toBe(false); + }); + + it('rejects an unparseable URL', () => { + expect(isOriginAllowed(allowed, 'not a url')).toBe(false); + }); + + it('rejects everything when the allowed set is empty', () => { + expect(isOriginAllowed(new Set(), 'https://alice.pod.example/x')).toBe(false); + }); + + it('treats a port difference as a different origin', () => { + expect(isOriginAllowed(allowed, 'https://alice.pod.example:8443/x')).toBe(false); + }); +}); + +describe('isTokenEndpoint — never attach the resource token to /token', () => { + it('matches the token endpoint by origin + path, ignoring query', () => { + const ep = 'https://idp.example/oidc/token'; + expect(isTokenEndpoint('https://idp.example/oidc/token?x=1', ep)).toBe(true); + }); + + it('does not match a different path on the same origin', () => { + const ep = 'https://idp.example/oidc/token'; + expect(isTokenEndpoint('https://idp.example/oidc/userinfo', ep)).toBe(false); + }); + + it('returns false when no token endpoint is known', () => { + expect(isTokenEndpoint('https://idp.example/oidc/token', null)).toBe(false); + }); +}); + +describe('isLoopbackHost', () => { + it.each(['localhost', '127.0.0.1', '[::1]', '::1', 'LOCALHOST'])('treats %s as loopback', (h) => { + expect(isLoopbackHost(h)).toBe(true); + }); + it('treats a real host as non-loopback', () => { + expect(isLoopbackHost('evil.example')).toBe(false); + }); +}); diff --git a/apps/extension/test/www-authenticate.test.ts b/apps/extension/test/www-authenticate.test.ts new file mode 100644 index 000000000..4e64c6c84 --- /dev/null +++ b/apps/extension/test/www-authenticate.test.ts @@ -0,0 +1,58 @@ +// AUTHORED-BY Claude Opus 4.8 +import { describe, expect, it } from 'vitest'; +import { + isUseDpopNonceChallenge, + parseWwwAuthenticate, +} from '../src/background/core/www-authenticate'; + +function res(wwwAuth: string | null): Response { + const headers = new Headers(); + if (wwwAuth !== null) headers.set('www-authenticate', wwwAuth); + return new Response(null, { status: 401, headers }); +} + +describe('parseWwwAuthenticate', () => { + it('parses a single DPoP challenge with params', () => { + const out = parseWwwAuthenticate('DPoP error="use_dpop_nonce", error_description="x"'); + expect(out).toHaveLength(1); + expect(out[0].scheme).toBe('DPoP'); + expect(out[0].params.get('error')).toBe('use_dpop_nonce'); + expect(out[0].params.get('error_description')).toBe('x'); + }); + + it('parses multiple challenges (Bearer + DPoP)', () => { + const out = parseWwwAuthenticate('Bearer realm="solid", DPoP error="use_dpop_nonce"'); + expect(out.map((c) => c.scheme)).toEqual(['Bearer', 'DPoP']); + expect(out[1].params.get('error')).toBe('use_dpop_nonce'); + }); + + it('handles quoted values containing commas and equals', () => { + const out = parseWwwAuthenticate('DPoP error_description="a, b=c", error="use_dpop_nonce"'); + expect(out[0].params.get('error_description')).toBe('a, b=c'); + expect(out[0].params.get('error')).toBe('use_dpop_nonce'); + }); +}); + +describe('isUseDpopNonceChallenge — conservative RFC 9449 §8 classifier', () => { + it('true ONLY for a DPoP challenge with error=use_dpop_nonce', () => { + expect(isUseDpopNonceChallenge(res('DPoP error="use_dpop_nonce"'))).toBe(true); + }); + + it('false for invalid_token (force-refresh path, not a nonce retry)', () => { + expect(isUseDpopNonceChallenge(res('DPoP error="invalid_token"'))).toBe(false); + }); + + it('false for a Bearer challenge that merely mentions the nonce string', () => { + expect(isUseDpopNonceChallenge(res('Bearer error="use_dpop_nonce"'))).toBe(false); + }); + + it('false when there is no WWW-Authenticate header', () => { + expect(isUseDpopNonceChallenge(res(null))).toBe(false); + }); + + it('true when DPoP is the second challenge', () => { + expect(isUseDpopNonceChallenge(res('Bearer realm="x", DPoP error="use_dpop_nonce"'))).toBe( + true, + ); + }); +}); diff --git a/apps/extension/vitest.config.ts b/apps/extension/vitest.config.ts index 73fa48285..070a738a5 100644 --- a/apps/extension/vitest.config.ts +++ b/apps/extension/vitest.config.ts @@ -1,13 +1,15 @@ import { defineConfig } from 'vitest/config'; -// Unit tests run in jsdom so the security-critical, browser-shaped core modules -// (DPoP proof generation, per-origin token boundary, the silent-restore decision -// wiring) are testable WITHOUT a packed extension or a real Solid server. The +// The security-critical, browser-shaped core modules (DPoP proof generation, the +// per-origin token boundary, the silent-restore decision wiring) are testable WITHOUT +// a packed extension or a real Solid server. Default to `node` so jose signs with +// Node's spec-complete webcrypto CryptoKey (jsdom's crypto.subtle is incomplete); a +// DOM-needing test opts into jsdom with a `// @vitest-environment jsdom` docblock. The // chrome.* extension APIs are stubbed per-test; the e2e Playwright suite (against a // LOCAL CSS) covers the integrated extension. See e2e/playwright.config.ts. export default defineConfig({ test: { - environment: 'jsdom', + environment: 'node', include: ['test/**/*.test.ts'], exclude: ['node_modules/**', 'dist/**', 'e2e/**'], globals: false, From e01619940caced14da5709e38bf4cfa427a68513 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:01:48 +0100 Subject: [PATCH 09/50] =?UTF-8?q?feat(core):=20authenticated=20resource=20?= =?UTF-8?q?fetch=20(=C2=A78=20nonce=20retry)=20+=20RDF=20WebID=20resolver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core/authenticated-fetch.ts — the ONE place the access token is attached, behind every guard: origin gate (foreign origin => plain credential-free fetch, never the token), token-endpoint guard, page-supplied Authorization/DPoP header stripping (no injection), and a single RFC 9449 §8 nonce-challenge retry with the server DPoP-Nonce. 6 tests incl. the adversarial 'does NOT attach the token to a foreign origin' (the token-leak attack) and the header-injection case. core/webid.ts — issuer + display-profile resolution via N3.js (proper RDF, not regex over Turtle — the suite house rule). Returns ALL solid:oidcIssuer values; selectIssuer uses the single one directly, asks a chooser for several, throws NoIssuerError for none. Name/photo fall back foaf -> vcard. 8 tests. Model: claude-opus-4-8 Provenance: Opus 4.8 (Fable unavailable) — re-review/upgrade candidate Co-Authored-By: Claude Opus 4.8 --- .../background/core/authenticated-fetch.ts | 118 ++++++++++++++ apps/extension/src/background/core/webid.ts | 93 +++++++++++ .../test/authenticated-fetch.test.ts | 146 ++++++++++++++++++ apps/extension/test/webid.test.ts | 87 +++++++++++ 4 files changed, 444 insertions(+) create mode 100644 apps/extension/src/background/core/authenticated-fetch.ts create mode 100644 apps/extension/src/background/core/webid.ts create mode 100644 apps/extension/test/authenticated-fetch.test.ts create mode 100644 apps/extension/test/webid.test.ts diff --git a/apps/extension/src/background/core/authenticated-fetch.ts b/apps/extension/src/background/core/authenticated-fetch.ts new file mode 100644 index 000000000..7b2060a9e --- /dev/null +++ b/apps/extension/src/background/core/authenticated-fetch.ts @@ -0,0 +1,118 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * The authenticated resource fetch — DPoP-bound, origin-gated, fail-closed, with the + * RFC 9449 §8 nonce-challenge retry. This is the one place the user's access token is + * ever attached to an outbound request, so every guard lives here. + * + * Security invariants (all enforced unconditionally): + * 1. ORIGIN GATE. The token is attached ONLY when the request URL's origin is in the + * session's allowed set ({@link isOriginAllowed}). A request to any other origin is + * sent UNAUTHENTICATED (a plain fetch) — never with the token. This stops a + * malicious page exfiltrating the token via `solid.fetch("https://evil/")`. + * 2. TOKEN-ENDPOINT GUARD. The resource token is never attached to the issuer's /token + * endpoint ({@link isTokenEndpoint}) even if that origin is allowed. + * 3. NONCE RETRY (RFC 9449 §8). On a pure `use_dpop_nonce` 401, the proof is re-minted + * with the server `DPoP-Nonce` and the request retried ONCE. + * 4. SINGLE RETRY. Exactly one retry, so a misbehaving server cannot loop us. + * + * The re-entrancy guard (a fetch the SW itself issues for discovery/token/refresh must + * never recurse into THIS path) is the caller's responsibility: discovery/token use the + * raw `fetch`, never this function. This function only ever sends the access token to a + * vetted resource origin. + */ + +import { createDpopProof } from './dpop'; +import { isOriginAllowed, isTokenEndpoint } from './origin-policy'; +import { isUseDpopNonceChallenge } from './www-authenticate'; + +/** The minimum session shape the authenticated fetch needs. */ +export interface FetchSession { + readonly accessToken: string; + readonly dpopKeyPair: CryptoKeyPair; + readonly allowedOrigins: ReadonlySet; + readonly tokenEndpoint: string | null; +} + +export interface AuthenticatedFetchOptions { + readonly url: string; + readonly method: string; + readonly headers: Record; + readonly body?: BodyInit | null; + /** A cached server DPoP nonce to use on the first attempt, if known. */ + readonly nonce?: string; + /** Injected fetch (test seam). Defaults to the global. */ + readonly fetchImpl?: typeof fetch; +} + +export interface AuthenticatedFetchResult { + readonly response: Response; + /** The freshest server DPoP nonce observed (cache it for the next request). */ + readonly nonce?: string; + /** Whether the token was actually attached (false => sent as a plain, public fetch). */ + readonly authenticated: boolean; +} + +const HOP_BY_HOP = new Set(['authorization', 'dpop', 'host', 'content-length', 'connection']); + +/** Strip caller-supplied auth/transport headers so a page can't inject its own DPoP/Authorization. */ +function sanitizeHeaders(headers: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(headers)) { + if (!HOP_BY_HOP.has(k.toLowerCase())) out[k] = v; + } + return out; +} + +/** + * Perform an authenticated resource fetch under all the invariants above. If the request + * is not to an allowed origin (or targets the token endpoint), it is performed as a plain + * fetch with NO token — fail-open for reads to public/foreign resources, fail-CLOSED for + * the credential (the credential is simply never attached). + */ +export async function authenticatedFetch( + session: FetchSession, + options: AuthenticatedFetchOptions, +): Promise { + const { url, method, body } = options; + const fetchImpl = options.fetchImpl ?? fetch; + const safeHeaders = sanitizeHeaders(options.headers); + + const mayAttach = + isOriginAllowed(session.allowedOrigins, url) && !isTokenEndpoint(url, session.tokenEndpoint); + + if (!mayAttach) { + // Foreign / public / token-endpoint: a plain, credential-free fetch. + const response = await fetchImpl(url, { method, headers: safeHeaders, body }); + return { response, authenticated: false }; + } + + const send = async (nonce?: string): Promise => { + const proof = await createDpopProof({ + keyPair: session.dpopKeyPair, + htm: method, + htu: url, + accessToken: session.accessToken, + nonce, + }); + return fetchImpl(url, { + method, + headers: { + ...safeHeaders, + Authorization: `DPoP ${session.accessToken}`, + DPoP: proof, + }, + body, + }); + }; + + let response = await send(options.nonce); + let serverNonce = response.headers.get('dpop-nonce') ?? undefined; + + // RFC 9449 §8: one retry on a pure nonce challenge, using the server's fresh nonce. + if (response.status === 401 && isUseDpopNonceChallenge(response) && serverNonce) { + response = await send(serverNonce); + serverNonce = response.headers.get('dpop-nonce') ?? serverNonce; + } + + return { response, nonce: serverNonce, authenticated: true }; +} diff --git a/apps/extension/src/background/core/webid.ts b/apps/extension/src/background/core/webid.ts new file mode 100644 index 000000000..ce11441b9 --- /dev/null +++ b/apps/extension/src/background/core/webid.ts @@ -0,0 +1,93 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * WebID profile parsing — issuer resolution + display profile, via proper RDF (N3.js), + * never regex over Turtle. The house rule (suite-wide) is to parse RDF with N3/@solid + * libraries, not hand-rolled string matching; a profile can be in any Turtle shape + * (prefixed, full-IRI, multi-subject) that a regex would miss or mis-parse. + * + * `solid:oidcIssuer` (http://www.w3.org/ns/solid/terms#oidcIssuer) on the WebID subject + * is the OIDC issuer to log in against. A profile MAY advertise several; we return all so + * the caller can choose (single → use directly; many → the user picks; none → error). + */ + +import { DataFactory, Parser, Store } from 'n3'; + +const SOLID_OIDC_ISSUER = 'http://www.w3.org/ns/solid/terms#oidcIssuer'; +const FOAF_NAME = 'http://xmlns.com/foaf/0.1/name'; +const FOAF_IMG = 'http://xmlns.com/foaf/0.1/img'; +const VCARD_FN = 'http://www.w3.org/2006/vcard/ns#fn'; +const VCARD_PHOTO = 'http://www.w3.org/2006/vcard/ns#hasPhoto'; + +export interface WebIdProfile { + /** Every `solid:oidcIssuer` advertised on the WebID subject (deduped, in document order). */ + readonly issuers: string[]; + /** A human display name (foaf:name / vcard:fn), if any. */ + readonly name: string | null; + /** An avatar URL (foaf:img / vcard:hasPhoto), if any. */ + readonly photoUrl: string | null; +} + +/** Parse a WebID Turtle document into a {@link Store}, baselined to the document URL. */ +function parse(webId: string, turtle: string): Store { + const baseIRI = webId.split('#')[0]; + const parser = new Parser({ baseIRI }); + const store = new Store(); + store.addQuads(parser.parse(turtle)); + return store; +} + +function objectsFor(store: Store, subject: string, predicate: string): string[] { + const out: string[] = []; + for (const quad of store.getQuads( + DataFactory.namedNode(subject), + DataFactory.namedNode(predicate), + null, + null, + )) { + out.push(quad.object.value); + } + return out; +} + +/** + * Extract the issuer list + display profile for `webId` from its Turtle profile. PURE + * (no fetch). The issuer list is the deduped `solid:oidcIssuer` objects on the WebID + * subject; name/photo fall back foaf → vcard. + */ +export function parseWebIdProfile(webId: string, turtle: string): WebIdProfile { + const store = parse(webId, turtle); + + const issuers = [...new Set(objectsFor(store, webId, SOLID_OIDC_ISSUER))]; + + const name = + objectsFor(store, webId, FOAF_NAME)[0] ?? objectsFor(store, webId, VCARD_FN)[0] ?? null; + const photoUrl = + objectsFor(store, webId, FOAF_IMG)[0] ?? objectsFor(store, webId, VCARD_PHOTO)[0] ?? null; + + return { issuers, name, photoUrl }; +} + +export class NoIssuerError extends Error { + constructor(public readonly webId: string) { + super( + `No solid:oidcIssuer found in the WebID profile at ${webId}. The profile must declare a ` + + 'solid:oidcIssuer triple to be used for Solid login.', + ); + this.name = 'NoIssuerError'; + } +} + +/** + * Choose the single issuer to log in against. One issuer → it; several → the caller's + * `choose` callback (the user picks); none → {@link NoIssuerError}. PURE except the + * injected chooser. + */ +export async function selectIssuer( + profile: WebIdProfile, + webId: string, + choose: (issuers: string[]) => Promise, +): Promise { + if (profile.issuers.length === 0) throw new NoIssuerError(webId); + if (profile.issuers.length === 1) return profile.issuers[0]; + return choose(profile.issuers); +} diff --git a/apps/extension/test/authenticated-fetch.test.ts b/apps/extension/test/authenticated-fetch.test.ts new file mode 100644 index 000000000..e89cbe5b7 --- /dev/null +++ b/apps/extension/test/authenticated-fetch.test.ts @@ -0,0 +1,146 @@ +// AUTHORED-BY Claude Opus 4.8 + +import * as jose from 'jose'; +import { describe, expect, it, vi } from 'vitest'; +import { authenticatedFetch, type FetchSession } from '../src/background/core/authenticated-fetch'; +import { generateDpopKeyPair } from '../src/background/core/dpop'; + +async function makeSession(overrides: Partial = {}): Promise { + return { + accessToken: 'access-token-123', + dpopKeyPair: await generateDpopKeyPair(), + allowedOrigins: new Set(['https://alice.pod.example']), + tokenEndpoint: 'https://idp.example/token', + ...overrides, + }; +} + +function ok(headers: Record = {}): Response { + return new Response('body', { status: 200, headers }); +} + +describe('authenticatedFetch — credential boundary', () => { + it('attaches a DPoP-bound token for an allowed origin', async () => { + const session = await makeSession(); + const fetchImpl = vi.fn(async () => ok()); + const { authenticated } = await authenticatedFetch(session, { + url: 'https://alice.pod.example/private/notes.ttl', + method: 'GET', + headers: {}, + fetchImpl, + }); + + expect(authenticated).toBe(true); + const init = fetchImpl.mock.calls[0][1] as RequestInit; + const headers = init.headers as Record; + expect(headers.Authorization).toBe('DPoP access-token-123'); + expect(typeof headers.DPoP).toBe('string'); + // the proof carries the access-token hash + correct htu + const proof = jose.decodeJwt(headers.DPoP); + expect(proof.htu).toBe('https://alice.pod.example/private/notes.ttl'); + expect(proof.htm).toBe('GET'); + expect(proof.ath).toBeTypeOf('string'); + }); + + it('does NOT attach the token to a foreign origin (token-leak attack)', async () => { + const session = await makeSession(); + const fetchImpl = vi.fn(async () => ok()); + const { authenticated } = await authenticatedFetch(session, { + url: 'https://evil.example/collect', + method: 'POST', + headers: {}, + body: 'x', + fetchImpl, + }); + + expect(authenticated).toBe(false); + const init = fetchImpl.mock.calls[0][1] as RequestInit; + const headers = init.headers as Record; + expect(headers.Authorization).toBeUndefined(); + expect(headers.DPoP).toBeUndefined(); + }); + + it('does NOT attach the token to the issuer token endpoint even if its origin is allowed', async () => { + const session = await makeSession({ + allowedOrigins: new Set(['https://idp.example']), + tokenEndpoint: 'https://idp.example/token', + }); + const fetchImpl = vi.fn(async () => ok()); + const { authenticated } = await authenticatedFetch(session, { + url: 'https://idp.example/token', + method: 'POST', + headers: {}, + fetchImpl, + }); + expect(authenticated).toBe(false); + }); + + it('strips a page-supplied Authorization / DPoP header (no header injection)', async () => { + const session = await makeSession(); + const fetchImpl = vi.fn(async () => ok()); + await authenticatedFetch(session, { + url: 'https://alice.pod.example/x', + method: 'GET', + headers: { Authorization: 'Bearer attacker', DPoP: 'forged', 'X-Keep': 'yes' }, + fetchImpl, + }); + const headers = (fetchImpl.mock.calls[0][1] as RequestInit).headers as Record; + expect(headers.Authorization).toBe('DPoP access-token-123'); // ours, not the page's + expect(headers['X-Keep']).toBe('yes'); // unrelated header preserved + }); + + it('retries ONCE on an RFC 9449 §8 nonce challenge with the server nonce', async () => { + const session = await makeSession(); + let call = 0; + const fetchImpl = vi.fn(async () => { + call += 1; + if (call === 1) { + return new Response('nonce required', { + status: 401, + headers: { + 'www-authenticate': 'DPoP error="use_dpop_nonce"', + 'dpop-nonce': 'server-nonce-xyz', + }, + }); + } + return ok({ 'dpop-nonce': 'server-nonce-2' }); + }); + + const { response, nonce } = await authenticatedFetch(session, { + url: 'https://alice.pod.example/x', + method: 'GET', + headers: {}, + fetchImpl, + }); + + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(response.status).toBe(200); + expect(nonce).toBe('server-nonce-2'); + // the retry proof carried the server nonce + const retryHeaders = (fetchImpl.mock.calls[1][1] as RequestInit).headers as Record< + string, + string + >; + const proof = jose.decodeJwt(retryHeaders.DPoP); + expect(proof.nonce).toBe('server-nonce-xyz'); + }); + + it('does NOT retry on a non-nonce 401 (e.g. invalid_token)', async () => { + const session = await makeSession(); + const fetchImpl = vi.fn( + async () => + new Response('nope', { + status: 401, + headers: { 'www-authenticate': 'DPoP error="invalid_token"' }, + }), + ); + const { response } = await authenticatedFetch(session, { + url: 'https://alice.pod.example/x', + method: 'GET', + headers: {}, + fetchImpl, + }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(response.status).toBe(401); + }); +}); diff --git a/apps/extension/test/webid.test.ts b/apps/extension/test/webid.test.ts new file mode 100644 index 000000000..76f6c5d65 --- /dev/null +++ b/apps/extension/test/webid.test.ts @@ -0,0 +1,87 @@ +// AUTHORED-BY Claude Opus 4.8 +import { describe, expect, it } from 'vitest'; +import { NoIssuerError, parseWebIdProfile, selectIssuer } from '../src/background/core/webid'; + +const WEBID = 'https://alice.pod.example/profile/card#me'; + +const PROFILE = ` +@prefix solid: . +@prefix foaf: . +@prefix vcard: . +<#me> a foaf:Person ; + foaf:name "Alice Example" ; + foaf:img ; + solid:oidcIssuer . +`; + +describe('parseWebIdProfile', () => { + it('extracts the solid:oidcIssuer, name and photo from a prefixed profile', () => { + const p = parseWebIdProfile(WEBID, PROFILE); + expect(p.issuers).toEqual(['https://idp.example/']); + expect(p.name).toBe('Alice Example'); + expect(p.photoUrl).toBe('https://alice.pod.example/profile/photo.jpg'); + }); + + it('parses a full-IRI (non-prefixed) issuer triple', () => { + const turtle = `<${WEBID}> .`; + expect(parseWebIdProfile(WEBID, turtle).issuers).toEqual(['https://idp2.example/']); + }); + + it('returns ALL advertised issuers, deduped', () => { + const turtle = ` + @prefix solid: . + <#me> solid:oidcIssuer , , .`; + expect(parseWebIdProfile(WEBID, turtle).issuers).toEqual([ + 'https://a.example/', + 'https://b.example/', + ]); + }); + + it('falls back to vcard:fn / vcard:hasPhoto when foaf is absent', () => { + const turtle = ` + @prefix vcard: . + @prefix solid: . + <#me> vcard:fn "VC Alice" ; vcard:hasPhoto ; solid:oidcIssuer .`; + const p = parseWebIdProfile(WEBID, turtle); + expect(p.name).toBe('VC Alice'); + expect(p.photoUrl).toBe('https://x/p.png'); + }); + + it('returns no issuers / null name when the profile lacks them', () => { + const p = parseWebIdProfile(WEBID, '<#me> a .'); + expect(p.issuers).toEqual([]); + expect(p.name).toBeNull(); + expect(p.photoUrl).toBeNull(); + }); +}); + +describe('selectIssuer', () => { + const choose = async (issuers: string[]) => issuers[1]; + + it('uses the single issuer directly without calling choose', async () => { + const chooser = async () => { + throw new Error('should not be called'); + }; + const issuer = await selectIssuer( + { issuers: ['https://i/'], name: null, photoUrl: null }, + WEBID, + chooser, + ); + expect(issuer).toBe('https://i/'); + }); + + it('asks the chooser when several issuers are advertised', async () => { + const issuer = await selectIssuer( + { issuers: ['https://a/', 'https://b/'], name: null, photoUrl: null }, + WEBID, + choose, + ); + expect(issuer).toBe('https://b/'); + }); + + it('throws NoIssuerError when none are advertised', async () => { + await expect( + selectIssuer({ issuers: [], name: null, photoUrl: null }, WEBID, choose), + ).rejects.toBeInstanceOf(NoIssuerError); + }); +}); From c4aa537da0175597c60725b3308b0d7f242b152d Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:07:27 +0100 Subject: [PATCH 10/50] =?UTF-8?q?feat(background):=20rewrite=20the=20servi?= =?UTF-8?q?ce=20worker=20=E2=80=94=20session=20manager,=20auth-flow,=20act?= =?UTF-8?q?ion=20icon?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MV3 background is the sole holder of the access token and the orchestration layer: - session-store.ts (replaces session-database.ts) — chrome.storage.local-backed session (token + refresh token + DPoP keypair JWK), display profile, the logout-surviving recent-accounts list, per-origin client-id map, pod origins, and transient PKCE/state. chrome.storage is page-UNREACHABLE, so the refresh token is never page-reachable (the suite's secure-storage invariant); the page only ever sees the WebID. - auth-flow.ts — Solid-OIDC authorization-code + PKCE + DPoP via chrome.identity.launchWebAuthFlow (no window in a SW), reusing core/dpop for the token-exchange + refresh proofs. Published Client Identifier Document client_id with a dynamic-registration dev fallback; issuer transport guard (https / loopback http); OIDC discovery issuer-match check; refresh reuses the SAME keypair (jkt binding). - service-worker.ts — message router (login/logout/fetch/get-state/set-client-id), proactive 30s-before-expiry refresh, authenticated fetch behind the core credential boundary with a per-origin nonce cache, RAW-fetch re-entrancy discipline, state broadcast, and onInstalled pin-nudge flag. - action-icon.ts — the pinned-toolbar avatar: OffscreenCanvas render of the profile photo (circular) or coloured initials (via @jeswr/solid-elements initials), + a green status badge, fail-soft to the bundled icon. - shared/messages.ts — the JSON-serialisable 4-context message protocol (page never receives a token). Model: claude-opus-4-8 Provenance: Opus 4.8 (Fable unavailable) — re-review/upgrade candidate Co-Authored-By: Claude Opus 4.8 --- apps/extension/src/background/action-icon.ts | 122 +++++ apps/extension/src/background/auth-flow.ts | 431 +++++++++--------- apps/extension/src/background/core/dpop.ts | 4 +- .../src/background/service-worker.ts | 369 ++++++++------- .../src/background/session-database.ts | 125 ----- .../extension/src/background/session-store.ts | 152 ++++++ apps/extension/src/shared/messages.ts | 90 ++++ .../test/authenticated-fetch.test.ts | 20 +- 8 files changed, 796 insertions(+), 517 deletions(-) create mode 100644 apps/extension/src/background/action-icon.ts delete mode 100644 apps/extension/src/background/session-database.ts create mode 100644 apps/extension/src/background/session-store.ts create mode 100644 apps/extension/src/shared/messages.ts diff --git a/apps/extension/src/background/action-icon.ts b/apps/extension/src/background/action-icon.ts new file mode 100644 index 000000000..a19ea0a0c --- /dev/null +++ b/apps/extension/src/background/action-icon.ts @@ -0,0 +1,122 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * The pinned top-right toolbar identity: the `chrome.action` icon becomes the signed-in + * WebID's avatar (the profile photo, or coloured initials), plus a status badge. Rendered + * off-DOM with `OffscreenCanvas` (a service worker has no `document`), then handed to + * `chrome.action.setIcon` as `ImageData`. + * + * Fail-soft: any failure (no canvas, an un-fetchable/cross-origin photo) falls back to + * initials, and ultimately to the bundled default icon — the toolbar entry is never left + * broken. The badge is a tiny dot indicating signed-in (green) / signed-out (cleared). + */ + +import { initials } from '@jeswr/solid-elements'; + +const SIZES = [16, 32, 48, 128] as const; + +/** A deterministic accent colour derived from a string (stable per WebID). */ +function colorFor(seed: string): string { + let hash = 0; + for (let i = 0; i < seed.length; i++) { + hash = (hash * 31 + seed.charCodeAt(i)) >>> 0; + } + const hue = hash % 360; + return `hsl(${hue} 62% 48%)`; +} + +function drawInitials(size: number, name: string, seed: string): ImageData { + const canvas = new OffscreenCanvas(size, size); + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('No 2d context'); + ctx.fillStyle = colorFor(seed); + // a filled rounded square + const r = size * 0.22; + ctx.beginPath(); + ctx.roundRect(0, 0, size, size, r); + ctx.fill(); + ctx.fillStyle = '#ffffff'; + ctx.font = `${Math.round(size * 0.46)}px sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(initials(name) || 'S', size / 2, size / 2 + size * 0.02); + return ctx.getImageData(0, 0, size, size); +} + +async function drawPhoto(size: number, photoUrl: string): Promise { + const response = await fetch(photoUrl); + if (!response.ok) throw new Error(`photo ${response.status}`); + const blob = await response.blob(); + const bitmap = await createImageBitmap(blob); + const canvas = new OffscreenCanvas(size, size); + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('No 2d context'); + // circular crop + ctx.save(); + ctx.beginPath(); + ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2); + ctx.clip(); + // cover-fit + const scale = Math.max(size / bitmap.width, size / bitmap.height); + const w = bitmap.width * scale; + const h = bitmap.height * scale; + ctx.drawImage(bitmap, (size - w) / 2, (size - h) / 2, w, h); + ctx.restore(); + bitmap.close(); + return ctx.getImageData(0, 0, size, size); +} + +/** + * Set the toolbar action icon to the signed-in user's avatar (photo → initials fallback) + * and a green status dot. Best-effort: failures leave the previous/default icon intact. + */ +export async function setSignedInIcon(profile: { + webId: string; + name: string | null; + photoUrl: string | null; +}): Promise { + const label = profile.name ?? profile.webId; + const imageData: Record = {}; + + for (const size of SIZES) { + try { + imageData[size] = profile.photoUrl + ? await drawPhoto(size, profile.photoUrl) + : drawInitials(size, label, profile.webId); + } catch { + // photo fetch/decoder failed for this size — fall back to initials. + try { + imageData[size] = drawInitials(size, label, profile.webId); + } catch { + // canvas unavailable entirely — leave the default bundled icon. + return; + } + } + } + + try { + await chrome.action.setIcon({ imageData }); + await chrome.action.setBadgeBackgroundColor({ color: '#2e9e57' }); + await chrome.action.setBadgeText({ text: ' ' }); + await chrome.action.setTitle({ title: `Signed in as ${label}` }); + } catch { + // ignore — icon update is cosmetic. + } +} + +/** Reset the action icon + badge to the signed-out (default) state. */ +export async function setSignedOutIcon(): Promise { + try { + await chrome.action.setIcon({ + path: { + 16: 'icons/icon16.png', + 32: 'icons/icon32.png', + 48: 'icons/icon48.png', + 128: 'icons/icon128.png', + }, + }); + await chrome.action.setBadgeText({ text: '' }); + await chrome.action.setTitle({ title: 'Sign in to Solid' }); + } catch { + // ignore. + } +} diff --git a/apps/extension/src/background/auth-flow.ts b/apps/extension/src/background/auth-flow.ts index 7891ea37b..fb0190339 100644 --- a/apps/extension/src/background/auth-flow.ts +++ b/apps/extension/src/background/auth-flow.ts @@ -1,12 +1,47 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * The Solid-OIDC authorization-code + PKCE + DPoP login, adapted for the MV3 service + * worker (no `window`): the interactive redirect is driven by + * `chrome.identity.launchWebAuthFlow`, which opens the IdP in a managed window and + * resolves with the `https://.chromiumapp.org/...` callback URL. The DPoP + * proofs (token exchange + refresh) go through the shared core/dpop module so the proof + * discipline matches the rest of the suite (RFC 9449). + * + * Login uses a PUBLISHED Solid-OIDC Client Identifier Document URL as the `client_id` + * (so the consent screen shows a stable name and `token_endpoint_auth_method=none` + * public-client behaviour is correct), falling back to dynamic client registration only + * when no Client ID Document is configured (dev). A page may supply its OWN Client ID + * Document via `window.solid.setClientId(...)` to identify itself to the pod. + */ + import * as jose from 'jose'; import { - saveAuthParams, - loadAuthParams, - clearAuthParams, + createDpopProof, exportDpopKeyPair, - saveSession, + generateDpopKeyPair, + importDpopKeyPair, +} from './core/dpop'; +import { isLoopbackHost } from './core/origin-policy'; +import { parseWebIdProfile, selectIssuer } from './core/webid'; +import { + type AuthParams, + clearAuthParams, + loadAuthParams, type StoredSession, -} from './session-database'; + saveAuthParams, + saveSession, +} from './session-store'; + +/** + * The extension's own published Client Identifier Document URL. A real deployment serves + * a JSON-LD Client ID Document (with this extension's `chromiumapp.org` redirect URI) at a + * stable HTTPS URL and pins it here; until then the value is overridable at runtime via + * `chrome.storage` (the e2e harness injects a localhost Client ID Document). When unset, + * the flow falls back to dynamic client registration. + */ +const DEFAULT_CLIENT_ID = ''; + +const SCOPE = 'openid webid offline_access'; interface OidcConfig { authorization_endpoint: string; @@ -15,58 +50,46 @@ interface OidcConfig { issuer: string; } -async function fetchOidcConfig(idpUrl: string): Promise { - const wellKnown = idpUrl.replace(/\/$/, '') + '/.well-known/openid-configuration'; +/** The `https:` everywhere / `http:` loopback-only transport guard (RFC 8252 §8.3). */ +function assertIssuerTransport(issuer: string): void { + const url = new URL(issuer); + if (url.protocol === 'https:') return; + if (url.protocol === 'http:' && isLoopbackHost(url.hostname)) return; + throw new Error(`Refusing to use insecure issuer transport: ${issuer}`); +} + +async function fetchOidcConfig(issuer: string): Promise { + assertIssuerTransport(issuer); + const wellKnown = `${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`; const response = await fetch(wellKnown); if (!response.ok) { - throw new Error(`Failed to fetch OIDC configuration from ${wellKnown}: ${response.status}`); + throw new Error(`Failed to fetch OIDC configuration (${response.status}) from ${wellKnown}`); + } + const config = (await response.json()) as OidcConfig; + // OIDC Discovery §4.3: the returned issuer must equal the requested one. + if (config.issuer.replace(/\/$/, '') !== issuer.replace(/\/$/, '')) { + throw new Error(`Issuer mismatch: requested ${issuer}, discovery returned ${config.issuer}`); } - return response.json(); + return config; } -// The extension's dereferenceable Client ID Document URL. -// This URL must resolve to a JSON-LD document with the extension's redirect_uri. -// In production, this would be hosted by the extension developer. -const EXTENSION_CLIENT_ID = 'http://localhost:8080/client-id'; - +/** The fixed redirect URI this extension is registered with (chrome.identity). */ function getRedirectUri(): string { return chrome.identity.getRedirectURL('callback'); } -async function resolveIssuerFromWebId(webId: string): Promise { - // Fetch the WebID profile document as Turtle - const response = await fetch(webId, { - headers: { Accept: 'text/turtle' }, - }); +async function fetchProfile(webId: string): Promise { + const response = await fetch(webId, { headers: { Accept: 'text/turtle' } }); if (!response.ok) { - throw new Error(`Failed to fetch WebID profile from ${webId}: ${response.status}`); + throw new Error(`Failed to fetch WebID profile (${response.status}) from ${webId}`); } - const turtle = await response.text(); - - // Parse solid:oidcIssuer from the Turtle document - // Matches both full URI and prefixed forms - const issuerPatterns = [ - /solid:oidcIssuer\s+<([^>]+)>/, - /http:\/\/www\.w3\.org\/ns\/solid\/terms#oidcIssuer>\s+<([^>]+)>/, - ]; - - for (const pattern of issuerPatterns) { - const match = turtle.match(pattern); - if (match) { - return match[1]; - } - } - - throw new Error( - `No solid:oidcIssuer found in WebID profile at ${webId}. ` + - 'Make sure your WebID profile contains a solid:oidcIssuer triple.' - ); + return response.text(); } async function dynamicClientRegistration( registrationEndpoint: string, - redirectUri: string -): Promise<{ client_id: string; client_secret?: string }> { + redirectUri: string, +): Promise { const response = await fetch(registrationEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -74,262 +97,220 @@ async function dynamicClientRegistration( application_type: 'web', redirect_uris: [redirectUri], token_endpoint_auth_method: 'none', - id_token_signed_response_alg: 'ES256', grant_types: ['authorization_code', 'refresh_token'], - scope: 'openid webid offline_access', + response_types: ['code'], + scope: SCOPE, client_name: 'Solid Browser Extension', }), }); if (!response.ok) { - const errorBody = await response.text(); - throw new Error(`Dynamic client registration failed: ${response.status} ${errorBody}`); + throw new Error(`Dynamic client registration failed (${response.status})`); } - return response.json(); + const body = (await response.json()) as { client_id: string }; + return body.client_id; } -function generateCodeVerifier(): string { - const array = new Uint8Array(32); +function randomB64Url(bytes: number): string { + const array = new Uint8Array(bytes); crypto.getRandomValues(array); return jose.base64url.encode(array); } -async function generateCodeChallenge(verifier: string): Promise { - const encoder = new TextEncoder(); - const data = encoder.encode(verifier); - const hash = await crypto.subtle.digest('SHA-256', data); - return jose.base64url.encode(new Uint8Array(hash)); +async function pkceChallenge(verifier: string): Promise { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)); + return jose.base64url.encode(new Uint8Array(digest)); } -function generateState(): string { - const array = new Uint8Array(16); - crypto.getRandomValues(array); - return jose.base64url.encode(array); +export interface InitiateLoginOptions { + readonly webId: string; + /** A Client Identifier Document URL to log in as (page-supplied or the extension's own). */ + readonly clientId?: string; + /** Resolve a multi-issuer ambiguity (the user picks). Defaults to the first issuer. */ + readonly chooseIssuer?: (issuers: string[]) => Promise; } -export async function initiateLogin(webId: string, staticClientId?: string): Promise { - const idpUrl = await resolveIssuerFromWebId(webId); - const oidcConfig = await fetchOidcConfig(idpUrl); +/** + * Run the interactive authorization-code (DPoP) login for a WebID and persist the + * resulting session. Returns the WebID + display profile parsed from the same fetch. + */ +export async function initiateLogin( + options: InitiateLoginOptions, +): Promise<{ session: StoredSession; name: string | null; photoUrl: string | null }> { + const { webId } = options; + const turtle = await fetchProfile(webId); + const profile = parseWebIdProfile(webId, turtle); + const issuer = await selectIssuer( + profile, + webId, + options.chooseIssuer ?? (async (issuers) => issuers[0]), + ); + + const config = await fetchOidcConfig(issuer); const redirectUri = getRedirectUri(); - // Use the provided client ID, or the extension's own dereferenceable client identifier - const clientId = staticClientId || EXTENSION_CLIENT_ID; + let clientId = options.clientId || DEFAULT_CLIENT_ID; + if (!clientId) { + if (!config.registration_endpoint) { + throw new Error( + 'No client identifier configured and the issuer offers no dynamic registration endpoint.', + ); + } + clientId = await dynamicClientRegistration(config.registration_endpoint, redirectUri); + } - // Generate PKCE - const codeVerifier = generateCodeVerifier(); - const codeChallenge = await generateCodeChallenge(codeVerifier); - const state = generateState(); + const codeVerifier = randomB64Url(32); + const codeChallenge = await pkceChallenge(codeVerifier); + const state = randomB64Url(16); - // Store auth params for token exchange - await saveAuthParams({ + const params: AuthParams = { codeVerifier, state, clientId, - tokenEndpoint: oidcConfig.token_endpoint, - issuer: oidcConfig.issuer, + issuer: config.issuer, + tokenEndpoint: config.token_endpoint, redirectUri, - }); + }; + await saveAuthParams(params); - // Build authorization URL - const params = new URLSearchParams({ + const authUrl = new URL(config.authorization_endpoint); + authUrl.search = new URLSearchParams({ response_type: 'code', client_id: clientId, redirect_uri: redirectUri, - scope: 'openid webid offline_access', + scope: SCOPE, state, code_challenge: codeChallenge, code_challenge_method: 'S256', prompt: 'consent', - }); - - const authUrl = `${oidcConfig.authorization_endpoint}?${params.toString()}`; + }).toString(); - // Launch the auth flow using chrome.identity const callbackUrl = await new Promise((resolve, reject) => { - chrome.identity.launchWebAuthFlow( - { url: authUrl, interactive: true }, - (responseUrl) => { - if (chrome.runtime.lastError) { - reject(new Error(chrome.runtime.lastError.message)); - } else if (!responseUrl) { - reject(new Error('No response URL from auth flow')); - } else { - resolve(responseUrl); - } - } - ); + chrome.identity.launchWebAuthFlow({ url: authUrl.toString(), interactive: true }, (url) => { + if (chrome.runtime.lastError) reject(new Error(chrome.runtime.lastError.message)); + else if (!url) reject(new Error('Authorization was cancelled.')); + else resolve(url); + }); }); - // Extract code, state, iss from callback URL - const callbackParams = new URL(callbackUrl).searchParams; - const code = callbackParams.get('code'); - const returnedState = callbackParams.get('state'); - const iss = callbackParams.get('iss'); - - if (!code || !returnedState) { - const error = callbackParams.get('error'); - const errorDesc = callbackParams.get('error_description'); - throw new Error(`Auth failed: ${error} - ${errorDesc}`); - } - - // Handle the redirect (exchange code for tokens) - return handleRedirect(code, returnedState, iss || oidcConfig.issuer); + const session = await completeLogin(callbackUrl); + return { session, name: profile.name, photoUrl: profile.photoUrl }; } -async function createDpopProof( - keyPair: CryptoKeyPair, - method: string, - url: string, - nonce?: string -): Promise { - const publicJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey); - const header: Record = { - alg: 'ES256', - typ: 'dpop+jwt', - jwk: { - kty: publicJwk.kty, - crv: publicJwk.crv, - x: publicJwk.x, - y: publicJwk.y, - }, - }; +/** Exchange the authorization code (from the callback URL) for a DPoP-bound token set. */ +async function completeLogin(callbackUrl: string): Promise { + const callback = new URL(callbackUrl).searchParams; + const code = callback.get('code'); + const returnedState = callback.get('state'); - const payload: Record = { - jti: crypto.randomUUID(), - htm: method, - htu: url, - iat: Math.floor(Date.now() / 1000), - }; - if (nonce) { - payload.nonce = nonce; - } - - const privateKey = await crypto.subtle.importKey( - 'jwk', - await crypto.subtle.exportKey('jwk', keyPair.privateKey), - { name: 'ECDSA', namedCurve: 'P-256' }, - false, - ['sign'] - ); + const params = await loadAuthParams(); + if (!params) throw new Error('Login flow not initiated (no auth params).'); - return new jose.SignJWT(payload as jose.JWTPayload) - .setProtectedHeader(header as jose.JWTHeaderParameters) - .sign(privateKey); -} - -export async function handleRedirect( - code: string, - state: string, - iss: string -): Promise { - const authParams = await loadAuthParams(); - if (!authParams) { - throw new Error('No auth params found — login flow not initiated'); - } - - // Validate state - if (state !== authParams.state) { - throw new Error('State mismatch — possible CSRF attack'); + if (!code) { + const error = callback.get('error') ?? 'unknown'; + throw new Error(`Authorization failed: ${error} - ${callback.get('error_description') ?? ''}`); } - - // Validate issuer - if (iss !== authParams.issuer) { - throw new Error('Issuer mismatch'); + if (returnedState !== params.state) { + throw new Error('State mismatch — possible CSRF; aborting login.'); } - // Generate DPoP key pair (extractable for persistence) - const keyPair = await crypto.subtle.generateKey( - { name: 'ECDSA', namedCurve: 'P-256' }, - true, - ['sign', 'verify'] - ); - - const tokenEndpoint = authParams.tokenEndpoint as string; - const dpopProof = await createDpopProof(keyPair, 'POST', tokenEndpoint); + const keyPair = await generateDpopKeyPair(); + const dpopProof = await createDpopProof({ + keyPair, + htm: 'POST', + htu: params.tokenEndpoint, + }); - // Exchange code for tokens - const tokenResponse = await fetch(tokenEndpoint, { + const response = await fetch(params.tokenEndpoint, { method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - DPoP: dpopProof, - }, + headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: dpopProof }, body: new URLSearchParams({ grant_type: 'authorization_code', code, - redirect_uri: authParams.redirectUri as string, - client_id: authParams.clientId as string, - code_verifier: authParams.codeVerifier as string, + redirect_uri: params.redirectUri, + client_id: params.clientId, + code_verifier: params.codeVerifier, }), }); - if (!tokenResponse.ok) { - const errorBody = await tokenResponse.text(); - throw new Error(`Token exchange failed: ${tokenResponse.status} ${errorBody}`); - } - - const tokens = await tokenResponse.json(); - - // Decode the ID token to extract WebID - const idTokenPayload = jose.decodeJwt(tokens.id_token); - const webId = (idTokenPayload.webid as string) || (idTokenPayload.sub as string); - if (!webId) { - throw new Error('No WebID found in ID token'); + if (!response.ok) { + throw new Error(`Token exchange failed (${response.status}).`); } + const tokens = (await response.json()) as { + access_token: string; + refresh_token?: string; + id_token: string; + expires_in?: number; + }; - const storedKeyPair = await exportDpopKeyPair(keyPair); + const idToken = jose.decodeJwt(tokens.id_token); + const webId = (idToken.webid as string) || (idToken.sub as string); + if (!webId) throw new Error('No WebID in the ID token.'); const session: StoredSession = { webId, accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - idToken: tokens.id_token, - dpopKeyPair: storedKeyPair, - tokenEndpoint, - issuer: authParams.issuer as string, - clientId: authParams.clientId as string, + refreshToken: tokens.refresh_token ?? null, + dpopKeyPair: await exportDpopKeyPair(keyPair), + issuer: params.issuer, + tokenEndpoint: params.tokenEndpoint, + clientId: params.clientId, expiresAt: Date.now() + (tokens.expires_in ?? 600) * 1000, }; await saveSession(session); await clearAuthParams(); - return session; } -export async function refreshAccessToken( - session: StoredSession, - keyPair: CryptoKeyPair -): Promise { - const dpopProof = await createDpopProof(keyPair, 'POST', session.tokenEndpoint); - - const response = await fetch(session.tokenEndpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - DPoP: dpopProof, - }, - body: new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: session.refreshToken, - client_id: session.clientId, - }), - }); +/** + * Refresh an expiring session via the DPoP-bound refresh-token grant, reusing the SAME + * keypair (CSS binds the refresh token to the original `jkt`, so a regenerated key fails). + * One retry on a server DPoP-nonce challenge. + */ +export async function refreshSession(session: StoredSession): Promise { + if (!session.refreshToken) throw new Error('No refresh token; re-login required.'); + const keyPair = await importDpopKeyPair(session.dpopKeyPair); + + const grant = async (nonce?: string): Promise => { + const proof = await createDpopProof({ + keyPair, + htm: 'POST', + htu: session.tokenEndpoint, + nonce, + }); + return fetch(session.tokenEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: proof }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: session.refreshToken as string, + client_id: session.clientId, + scope: SCOPE, + }), + }); + }; + let response = await grant(); + if (response.status === 400 || response.status === 401) { + const nonce = response.headers.get('dpop-nonce'); + if (nonce) response = await grant(nonce); + } if (!response.ok) { - throw new Error(`Token refresh failed: ${response.status}`); + throw new Error(`Token refresh failed (${response.status}).`); } - const tokens = await response.json(); + const tokens = (await response.json()) as { + access_token: string; + refresh_token?: string; + expires_in?: number; + }; - const updatedSession: StoredSession = { + const updated: StoredSession = { ...session, accessToken: tokens.access_token, refreshToken: tokens.refresh_token ?? session.refreshToken, expiresAt: Date.now() + (tokens.expires_in ?? 600) * 1000, }; - - await saveSession(updatedSession); - return updatedSession; + await saveSession(updated); + return updated; } - -export { createDpopProof }; diff --git a/apps/extension/src/background/core/dpop.ts b/apps/extension/src/background/core/dpop.ts index 304db0849..c3609f84d 100644 --- a/apps/extension/src/background/core/dpop.ts +++ b/apps/extension/src/background/core/dpop.ts @@ -135,10 +135,10 @@ export async function createDpopProof(params: DpopProofParams): Promise jti: crypto.randomUUID(), }; if (accessToken !== undefined) { - payload['ath'] = await accessTokenHash(accessToken); + payload.ath = await accessTokenHash(accessToken); } if (nonce !== undefined) { - payload['nonce'] = nonce; + payload.nonce = nonce; } return new jose.SignJWT(payload) diff --git a/apps/extension/src/background/service-worker.ts b/apps/extension/src/background/service-worker.ts index b8692e583..7aaeafed6 100644 --- a/apps/extension/src/background/service-worker.ts +++ b/apps/extension/src/background/service-worker.ts @@ -1,140 +1,141 @@ -import { initiateLogin, refreshAccessToken, createDpopProof } from './auth-flow'; +// AUTHORED-BY Claude Opus 4.8 +/** + * The MV3 background service worker — the session manager + message router + the one + * holder of the access token. It: + * - restores/refreshes the session (DPoP refresh grant, proactive 30s-before-expiry), + * - routes the page/popup message protocol (login / logout / fetch / get-state / + * set-client-id), + * - performs every authenticated fetch behind the core credential boundary + * (origin-gated, token-endpoint-guarded, §8 nonce retry), and + * - drives the toolbar action icon (the signed-in avatar) + state broadcasts. + * + * RE-ENTRANCY: discovery / token / refresh / profile fetches use the RAW `fetch`, never + * the authenticatedFetch path — so a SW-internal request can never recurse into the + * token-attaching path, and the access token is only ever sent to a vetted resource + * origin. + * + * The MV3 worker is killed aggressively; all durable state lives in chrome.storage and is + * re-hydrated on wake (the in-memory caches below are best-effort accelerators only). + */ + +import type { + ActionResult, + FetchRequest, + FetchResponse, + LoginRequest, + SessionState, + SetClientIdRequest, + WorkerRequest, +} from '../shared/messages'; +import { setSignedInIcon, setSignedOutIcon } from './action-icon'; +import { initiateLogin, refreshSession } from './auth-flow'; +import { authenticatedFetch, type FetchSession } from './core/authenticated-fetch'; +import { importDpopKeyPair } from './core/dpop'; +import { computeAllowedOrigins } from './core/origin-policy'; +import { parseWebIdProfile } from './core/webid'; import { - loadSession, - clearSession, clearProfile, - importDpopKeyPair, - saveClientId, + clearSession, loadClientIds, - saveProfile, + loadPodOrigins, loadProfile, - loadPastProfiles, + loadRecentAccounts, + loadSession, type StoredSession, -} from './session-database'; -import { Parser } from 'n3'; -import { Agent, WebIdDataset } from '@solid/object'; -import { DataFactory } from 'n3'; + saveClientId, + saveProfile, +} from './session-store'; -let currentSession: StoredSession | null = null; -let currentKeyPair: CryptoKeyPair | null = null; +const ALLOW_INSECURE_LOOPBACK = true; // dev CSS over http://localhost; harmless for https issuers. +const EXPIRY_BUFFER_MS = 30_000; -async function ensureSession(): Promise<{ session: StoredSession; keyPair: CryptoKeyPair } | null> { - if (!currentSession) { - currentSession = await loadSession(); - } - if (!currentSession) return null; +/** Best-effort in-memory caches (re-hydrated from storage on SW wake). */ +let cachedSession: StoredSession | null = null; +let cachedKeyPair: CryptoKeyPair | null = null; +/** A cached server DPoP nonce per resource origin, to skip the first §8 round-trip. */ +const nonceByOrigin = new Map(); - if (!currentKeyPair) { - currentKeyPair = await importDpopKeyPair(currentSession.dpopKeyPair); - } +/** Load + refresh-if-needed the active session and its (imported) keypair. */ +async function ensureSession(): Promise<{ session: StoredSession; keyPair: CryptoKeyPair } | null> { + if (!cachedSession) cachedSession = await loadSession(); + if (!cachedSession) return null; - // Refresh token if expired or about to expire (30s buffer) - if (currentSession.expiresAt < Date.now() + 30_000) { + if (cachedSession.expiresAt < Date.now() + EXPIRY_BUFFER_MS) { try { - currentSession = await refreshAccessToken(currentSession, currentKeyPair); + cachedSession = await refreshSession(cachedSession); + cachedKeyPair = null; } catch { - currentSession = null; - currentKeyPair = null; - await clearSession(); + await teardownSession(); return null; } } - return { session: currentSession, keyPair: currentKeyPair }; + if (!cachedKeyPair) cachedKeyPair = await importDpopKeyPair(cachedSession.dpopKeyPair); + return { session: cachedSession, keyPair: cachedKeyPair }; } -function parseProfileFromTurtle(webId: string, turtle: string): { name: string | null; photoUrl: string | null } { - const parser = new Parser({ baseIRI: webId.split('#')[0] }); - const store = new (require('n3').Store)(); - store.addQuads(parser.parse(turtle)); +async function teardownSession(): Promise { + cachedSession = null; + cachedKeyPair = null; + nonceByOrigin.clear(); + await Promise.all([clearSession(), clearProfile()]); + await setSignedOutIcon(); +} - const dataset = new WebIdDataset(store, DataFactory); - const agent = dataset.mainSubject; +/** Build the per-request fetch session (the credential boundary) from the stored session. */ +async function buildFetchSession( + session: StoredSession, + keyPair: CryptoKeyPair, +): Promise { + const podOrigins = await loadPodOrigins(); + const allowedOrigins = computeAllowedOrigins({ + webId: session.webId, + issuer: session.issuer, + podOrigins, + allowInsecureLoopback: ALLOW_INSECURE_LOOPBACK, + }); return { - name: agent?.name ?? null, - photoUrl: agent?.photoUrl ?? null, + accessToken: session.accessToken, + dpopKeyPair: keyPair, + allowedOrigins, + tokenEndpoint: session.tokenEndpoint, }; } -async function fetchAndStoreProfile(webId: string): Promise { +function originOf(url: string): string | null { try { - const response = await fetch(webId, { - headers: { Accept: 'text/turtle' }, - }); - if (!response.ok) return; - const turtle = await response.text(); - const { name, photoUrl } = parseProfileFromTurtle(webId, turtle); - await saveProfile({ webId, name, photoUrl, turtle }); + return new URL(url).origin; } catch { - // Profile fetch is best-effort + return null; } } -function broadcastStateChange(webId: string | null, profileTurtle?: string): void { - chrome.tabs.query({}, (tabs) => { - for (const tab of tabs) { - if (tab.id) { - chrome.tabs.sendMessage(tab.id, { - type: 'SOLID_STATE_CHANGED', - webId, - profileTurtle: profileTurtle ?? null, - }).catch(() => {}); - } - } - }); -} - -async function handleAuthFetch( - message: { - requestId: string; - url: string; - method: string; - headers: Record; - body: string | null; - } -): Promise<{ - requestId: string; - status?: number; - statusText?: string; - headers?: Record; - body?: string; - error?: string; -}> { +async function handleFetch(message: FetchRequest): Promise { const ctx = await ensureSession(); - if (!ctx) { - return { requestId: message.requestId, error: 'Not authenticated' }; - } - - const { session, keyPair } = ctx; + if (!ctx) return { requestId: message.requestId, error: 'Not authenticated' }; try { - const dpopProof = await createDpopProof(keyPair, message.method, message.url); - - const fetchHeaders: Record = { - ...message.headers, - Authorization: `DPoP ${session.accessToken}`, - DPoP: dpopProof, - }; - - const response = await fetch(message.url, { + const fetchSession = await buildFetchSession(ctx.session, ctx.keyPair); + const origin = originOf(message.url); + const result = await authenticatedFetch(fetchSession, { + url: message.url, method: message.method, - headers: fetchHeaders, + headers: message.headers, body: message.body, + nonce: origin ? nonceByOrigin.get(origin) : undefined, }); + if (origin && result.nonce) nonceByOrigin.set(origin, result.nonce); - const responseHeaders: Record = {}; - response.headers.forEach((value, key) => { - responseHeaders[key] = value; + const headers: Record = {}; + result.response.headers.forEach((value, key) => { + headers[key] = value; }); - - const body = await response.text(); - return { requestId: message.requestId, - status: response.status, - statusText: response.statusText, - headers: responseHeaders, - body, + status: result.response.status, + statusText: result.response.statusText, + headers, + body: await result.response.text(), }; } catch (err) { return { @@ -144,76 +145,128 @@ async function handleAuthFetch( } } -chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { - if (message.type === 'SOLID_LOGIN') { - const loginWithClientId = async () => { - const clientIds = await loadClientIds(); - const staticClientId = message.clientId || clientIds[message.origin] || undefined; - return initiateLogin(message.webId, staticClientId); - }; - loginWithClientId() - .then(async (session) => { - currentSession = session; - currentKeyPair = null; - await fetchAndStoreProfile(session.webId); - const profile = await loadProfile(); - broadcastStateChange(session.webId, profile?.turtle); - sendResponse({ ok: true, webId: session.webId }); - }) - .catch((err) => { - sendResponse({ error: err.message }); - }); - return true; +/** Best-effort: fetch + parse the WebID profile (name/photo) and persist it. */ +async function refreshProfile(webId: string): Promise { + try { + const response = await fetch(webId, { headers: { Accept: 'text/turtle' } }); + if (!response.ok) return; + const { name, photoUrl } = parseWebIdProfile(webId, await response.text()); + await saveProfile({ webId, name, photoUrl }); + await setSignedInIcon({ webId, name, photoUrl }); + } catch { + // cosmetic — leave whatever profile/icon we have. } +} - if (message.type === 'SOLID_FETCH_REQUEST') { - handleAuthFetch(message).then(sendResponse); - return true; - } +function broadcastStateChange(webId: string | null): void { + chrome.tabs.query({}, (tabs) => { + for (const tab of tabs) { + if (tab.id !== undefined) { + chrome.tabs.sendMessage(tab.id, { type: 'SOLID_STATE_CHANGED', webId }).catch(() => {}); + } + } + }); + // Also notify the popup if open (runtime broadcast). + chrome.runtime.sendMessage({ type: 'SOLID_STATE_CHANGED', webId }).catch(() => {}); +} - if (message.type === 'SOLID_GET_STATE') { - (async () => { - const ctx = await ensureSession(); - const profile = await loadProfile(); - const pastProfiles = await loadPastProfiles(); - sendResponse({ - webId: ctx?.session.webId ?? null, - isActive: ctx !== null, - profileTurtle: profile?.turtle ?? null, - profileName: profile?.name ?? null, - profilePhotoUrl: profile?.photoUrl ?? null, - pastProfiles, - }); - })(); - return true; - } +async function handleLogin(message: LoginRequest): Promise { + try { + const clientIds = await loadClientIds(); + const clientId = + message.clientId || (message.origin ? clientIds[message.origin] : undefined) || undefined; - if (message.type === 'SOLID_SET_CLIENT_ID') { - saveClientId(message.origin, message.clientId) - .then(() => sendResponse({ ok: true })) - .catch((err) => sendResponse({ error: err.message })); - return true; + const { session, name, photoUrl } = await initiateLogin({ + webId: message.webId, + clientId, + }); + cachedSession = session; + cachedKeyPair = null; + await saveProfile({ webId: session.webId, name, photoUrl }); + await setSignedInIcon({ webId: session.webId, name, photoUrl }); + // best-effort fresher profile from the post-auth WebID (handles webid != input). + void refreshProfile(session.webId); + broadcastStateChange(session.webId); + return { ok: true, webId: session.webId }; + } catch (err) { + return { error: err instanceof Error ? err.message : 'Login failed' }; } +} + +async function handleGetState(): Promise { + const ctx = await ensureSession(); + const profile = await loadProfile(); + const recentAccounts = await loadRecentAccounts(); + return { + webId: ctx?.session.webId ?? null, + isActive: ctx !== null, + name: profile?.name ?? null, + photoUrl: profile?.photoUrl ?? null, + recentAccounts, + }; +} - if (message.type === 'SOLID_LOGOUT') { - currentSession = null; - currentKeyPair = null; - Promise.all([clearSession(), clearProfile()]) - .then(() => { - broadcastStateChange(null); - sendResponse({ ok: true }); - }) - .catch((err) => sendResponse({ error: err.message })); - return true; +async function handleSetClientId(message: SetClientIdRequest): Promise { + try { + // Validate it parses + is https (or loopback). A client-id is a dereferenceable URL. + const url = new URL(message.clientId); + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + throw new Error('Client identifier must be an http(s) URL.'); + } + await saveClientId(message.origin, message.clientId); + return { ok: true }; + } catch (err) { + return { error: err instanceof Error ? err.message : 'Invalid client identifier' }; } +} - return false; +chrome.runtime.onMessage.addListener( + (message: WorkerRequest, _sender, sendResponse: (r: unknown) => void) => { + switch (message.type) { + case 'SOLID_FETCH_REQUEST': + handleFetch(message).then(sendResponse); + return true; + case 'SOLID_LOGIN': + handleLogin(message).then(sendResponse); + return true; + case 'SOLID_LOGOUT': + teardownSession() + .then(() => { + broadcastStateChange(null); + sendResponse({ ok: true }); + }) + .catch((err) => sendResponse({ error: String(err) })); + return true; + case 'SOLID_GET_STATE': + handleGetState().then(sendResponse); + return true; + case 'SOLID_SET_CLIENT_ID': + handleSetClientId(message).then(sendResponse); + return true; + default: + return false; + } + }, +); + +/** First-run onboarding: prompt the user to PIN the toolbar action (extensions can't self-pin). */ +chrome.runtime.onInstalled.addListener((details) => { + if (details.reason === 'install') { + chrome.storage.local.set({ 'solid:show-pin-nudge': true }).catch(() => {}); + } }); -// Restore session on service worker startup +// Re-hydrate the session + icon on every SW wake. ensureSession().then(async (ctx) => { if (ctx) { const profile = await loadProfile(); - broadcastStateChange(ctx.session.webId, profile?.turtle); + await setSignedInIcon({ + webId: ctx.session.webId, + name: profile?.name ?? null, + photoUrl: profile?.photoUrl ?? null, + }); + broadcastStateChange(ctx.session.webId); + } else { + await setSignedOutIcon(); } }); diff --git a/apps/extension/src/background/session-database.ts b/apps/extension/src/background/session-database.ts deleted file mode 100644 index 2939df9ce..000000000 --- a/apps/extension/src/background/session-database.ts +++ /dev/null @@ -1,125 +0,0 @@ -const STORAGE_KEY = 'solid-session'; - -export interface StoredSession { - webId: string; - accessToken: string; - refreshToken: string; - idToken: string; - dpopKeyPair: { - publicKey: JsonWebKey; - privateKey: JsonWebKey; - }; - tokenEndpoint: string; - issuer: string; - clientId: string; - expiresAt: number; -} - -export interface ClientIdMapping { - [origin: string]: string; -} - -export async function loadSession(): Promise { - const result = await chrome.storage.local.get(STORAGE_KEY); - return result[STORAGE_KEY] ?? null; -} - -export async function saveSession(session: StoredSession): Promise { - await chrome.storage.local.set({ [STORAGE_KEY]: session }); -} - -export async function clearSession(): Promise { - await chrome.storage.local.remove(STORAGE_KEY); -} - -export interface StoredProfile { - webId: string; - name: string | null; - photoUrl: string | null; - turtle: string; -} - -export async function saveProfile(profile: StoredProfile): Promise { - await chrome.storage.local.set({ 'solid-profile': profile }); - // Also add to past profiles - const past = await loadPastProfiles(); - const existing = past.findIndex((p) => p.webId === profile.webId); - if (existing >= 0) { - past[existing] = { webId: profile.webId, name: profile.name, photoUrl: profile.photoUrl }; - } else { - past.unshift({ webId: profile.webId, name: profile.name, photoUrl: profile.photoUrl }); - } - await chrome.storage.local.set({ 'solid-past-profiles': past }); -} - -export async function loadProfile(): Promise { - const result = await chrome.storage.local.get('solid-profile'); - return result['solid-profile'] ?? null; -} - -export async function clearProfile(): Promise { - await chrome.storage.local.remove('solid-profile'); -} - -export interface PastProfile { - webId: string; - name: string | null; - photoUrl: string | null; -} - -export async function loadPastProfiles(): Promise { - const result = await chrome.storage.local.get('solid-past-profiles'); - return result['solid-past-profiles'] ?? []; -} - -export async function loadClientIds(): Promise { - const result = await chrome.storage.local.get('solid-client-ids'); - return result['solid-client-ids'] ?? {}; -} - -export async function saveClientId(origin: string, clientId: string): Promise { - const mapping = await loadClientIds(); - mapping[origin] = clientId; - await chrome.storage.local.set({ 'solid-client-ids': mapping }); -} - -export async function loadAuthParams(): Promise | null> { - const result = await chrome.storage.local.get('solid-auth-params'); - return result['solid-auth-params'] ?? null; -} - -export async function saveAuthParams(params: Record): Promise { - await chrome.storage.local.set({ 'solid-auth-params': params }); -} - -export async function clearAuthParams(): Promise { - await chrome.storage.local.remove('solid-auth-params'); -} - -export async function exportDpopKeyPair( - keyPair: CryptoKeyPair -): Promise<{ publicKey: JsonWebKey; privateKey: JsonWebKey }> { - const publicKey = await crypto.subtle.exportKey('jwk', keyPair.publicKey); - const privateKey = await crypto.subtle.exportKey('jwk', keyPair.privateKey); - return { publicKey, privateKey }; -} - -export async function importDpopKeyPair( - stored: { publicKey: JsonWebKey; privateKey: JsonWebKey } -): Promise { - const publicKey = await crypto.subtle.importKey( - 'jwk', - stored.publicKey, - { name: 'ECDSA', namedCurve: 'P-256' }, - true, - ['verify'] - ); - const privateKey = await crypto.subtle.importKey( - 'jwk', - stored.privateKey, - { name: 'ECDSA', namedCurve: 'P-256' }, - true, - ['sign'] - ); - return { publicKey, privateKey }; -} diff --git a/apps/extension/src/background/session-store.ts b/apps/extension/src/background/session-store.ts new file mode 100644 index 000000000..1b9b848ad --- /dev/null +++ b/apps/extension/src/background/session-store.ts @@ -0,0 +1,152 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * The extension's durable auth state, backed by `chrome.storage.local`. + * + * What lives here, and why this store and not the page: + * - The active session (access + refresh token, the DPoP keypair as JWK, issuer/token + * endpoint, expiry). `chrome.storage.local` is reachable ONLY from the extension's own + * contexts (service worker / popup) — NEVER from a web page or the injected MAIN-world + * script. The refresh token therefore stays page-unreachable, satisfying the suite's + * "DPoP refresh token in extension-appropriate secure storage (not page-reachable)" + * invariant. The MAIN-world `window.solid` only ever sees the WebID + a proxied fetch. + * - The display profile (name + avatar) for the popup + the action icon. + * - The credential-free recent-accounts list (WebID + display, survives logout) for the + * returning-user affordance — the suite's recent-accounts UX. + * - Per-origin client-id mappings + extra pod origins (the credential boundary inputs). + * + * The refresh token is never logged; only the WebID/display is ever surfaced to a page. + */ + +import type { StoredDpopKeyPair } from './core/dpop'; + +const KEYS = { + session: 'solid:session', + profile: 'solid:profile', + recentAccounts: 'solid:recent-accounts', + clientIds: 'solid:client-ids', + podOrigins: 'solid:pod-origins', + authParams: 'solid:auth-params', +} as const; + +/** The persisted active session. The DPoP keypair is stored as JWK (see core/dpop). */ +export interface StoredSession { + readonly webId: string; + readonly accessToken: string; + readonly refreshToken: string | null; + readonly dpopKeyPair: StoredDpopKeyPair; + readonly issuer: string; + readonly tokenEndpoint: string; + readonly clientId: string; + readonly expiresAt: number; +} + +/** The credential-free display profile shown in the popup + the action icon. */ +export interface StoredProfile { + readonly webId: string; + readonly name: string | null; + readonly photoUrl: string | null; +} + +/** A previously-used account, for the returning-user affordance (no credential). */ +export interface RecentAccount { + readonly webId: string; + readonly name: string | null; + readonly photoUrl: string | null; +} + +async function get(key: string): Promise { + const result = await chrome.storage.local.get(key); + return (result[key] as T) ?? null; +} + +async function set(key: string, value: unknown): Promise { + await chrome.storage.local.set({ [key]: value }); +} + +// --- Active session ------------------------------------------------------------------- + +export function loadSession(): Promise { + return get(KEYS.session); +} + +export function saveSession(session: StoredSession): Promise { + return set(KEYS.session, session); +} + +export async function clearSession(): Promise { + await chrome.storage.local.remove(KEYS.session); +} + +// --- Display profile + recent accounts ------------------------------------------------ + +export function loadProfile(): Promise { + return get(KEYS.profile); +} + +export async function saveProfile(profile: StoredProfile): Promise { + await set(KEYS.profile, profile); + await rememberAccount(profile); +} + +export async function clearProfile(): Promise { + await chrome.storage.local.remove(KEYS.profile); +} + +/** The recent-accounts list (most-recent-first, deduped by WebID). Survives logout. */ +export async function loadRecentAccounts(): Promise { + return (await get(KEYS.recentAccounts)) ?? []; +} + +/** Promote/insert an account at the front of the recent list (logout-surviving memory). */ +async function rememberAccount(profile: StoredProfile): Promise { + const list = await loadRecentAccounts(); + const next: RecentAccount[] = [ + { webId: profile.webId, name: profile.name, photoUrl: profile.photoUrl }, + ...list.filter((a) => a.webId !== profile.webId), + ].slice(0, 8); + await set(KEYS.recentAccounts, next); +} + +// --- Per-origin client IDs + pod origins (credential-boundary inputs) ------------------ + +export async function loadClientIds(): Promise> { + return (await get>(KEYS.clientIds)) ?? {}; +} + +export async function saveClientId(origin: string, clientId: string): Promise { + const map = await loadClientIds(); + map[origin] = clientId; + await set(KEYS.clientIds, map); +} + +/** User-configured pod origins (pods served from a host other than the WebID origin). */ +export async function loadPodOrigins(): Promise { + return (await get(KEYS.podOrigins)) ?? []; +} + +export async function savePodOrigins(origins: string[]): Promise { + await set(KEYS.podOrigins, origins); +} + +// --- Transient auth params (PKCE/state across the redirect) ---------------------------- + +export interface AuthParams { + readonly codeVerifier: string; + readonly state: string; + readonly clientId: string; + readonly issuer: string; + readonly tokenEndpoint: string; + readonly redirectUri: string; +} + +export function loadAuthParams(): Promise { + return get(KEYS.authParams); +} + +export function saveAuthParams(params: AuthParams): Promise { + return set(KEYS.authParams, params); +} + +export async function clearAuthParams(): Promise { + await chrome.storage.local.remove(KEYS.authParams); +} diff --git a/apps/extension/src/shared/messages.ts b/apps/extension/src/shared/messages.ts new file mode 100644 index 000000000..d29d3188e --- /dev/null +++ b/apps/extension/src/shared/messages.ts @@ -0,0 +1,90 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * The message protocol shared across the four contexts (MAIN-world inject, ISOLATED + * content script, service worker, popup). All payloads MUST be JSON-serialisable — + * `window.postMessage` (page ↔ content) and `chrome.runtime` (content/popup ↔ worker) + * both structured-clone, and Response bodies are serialised as text (Solid resources are + * predominantly text/RDF; binary support is a documented future extension). + * + * The injected `window.solid` NEVER receives a token — only the WebID + a proxied fetch + * result. The credential boundary is enforced entirely in the service worker. + */ + +/** A namespacing tag on every page↔content postMessage so we ignore unrelated messages. */ +export const CHANNEL = 'solid-browser-ext' as const; + +// --- Requests (page/popup -> worker) -------------------------------------------------- + +export interface FetchRequest { + readonly type: 'SOLID_FETCH_REQUEST'; + readonly requestId: string; + readonly url: string; + readonly method: string; + readonly headers: Record; + readonly body: string | null; +} + +export interface LoginRequest { + readonly type: 'SOLID_LOGIN'; + readonly webId: string; + /** The requesting page's origin (for per-origin client-id resolution). */ + readonly origin?: string; + /** A page-supplied Client Identifier Document URL. */ + readonly clientId?: string; +} + +export interface LogoutRequest { + readonly type: 'SOLID_LOGOUT'; +} + +export interface GetStateRequest { + readonly type: 'SOLID_GET_STATE'; +} + +export interface SetClientIdRequest { + readonly type: 'SOLID_SET_CLIENT_ID'; + readonly origin: string; + readonly clientId: string; +} + +export type WorkerRequest = + | FetchRequest + | LoginRequest + | LogoutRequest + | GetStateRequest + | SetClientIdRequest; + +// --- Responses (worker -> caller) ----------------------------------------------------- + +export interface FetchResponse { + readonly requestId: string; + readonly status?: number; + readonly statusText?: string; + readonly headers?: Record; + readonly body?: string; + readonly error?: string; +} + +export interface SessionState { + readonly webId: string | null; + readonly isActive: boolean; + readonly name: string | null; + readonly photoUrl: string | null; + readonly recentAccounts: ReadonlyArray<{ + webId: string; + name: string | null; + photoUrl: string | null; + }>; +} + +export interface ActionResult { + readonly ok?: boolean; + readonly webId?: string; + readonly error?: string; +} + +/** Broadcast from the worker to all tabs/popup when auth state changes. */ +export interface StateChanged { + readonly type: 'SOLID_STATE_CHANGED'; + readonly webId: string | null; +} diff --git a/apps/extension/test/authenticated-fetch.test.ts b/apps/extension/test/authenticated-fetch.test.ts index e89cbe5b7..a077d775f 100644 --- a/apps/extension/test/authenticated-fetch.test.ts +++ b/apps/extension/test/authenticated-fetch.test.ts @@ -1,7 +1,7 @@ // AUTHORED-BY Claude Opus 4.8 import * as jose from 'jose'; -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, type Mock, vi } from 'vitest'; import { authenticatedFetch, type FetchSession } from '../src/background/core/authenticated-fetch'; import { generateDpopKeyPair } from '../src/background/core/dpop'; @@ -19,10 +19,16 @@ function ok(headers: Record = {}): Response { return new Response('body', { status: 200, headers }); } +/** A fetch mock typed so `.mock.calls[i][1]` is the RequestInit (the credential carrier). */ +type FetchMock = Mock<(input: RequestInfo | URL, init?: RequestInit) => Promise>; +function mockFetch(impl: (url: string, init?: RequestInit) => Promise): FetchMock { + return vi.fn((input: RequestInfo | URL, init?: RequestInit) => impl(input.toString(), init)); +} + describe('authenticatedFetch — credential boundary', () => { it('attaches a DPoP-bound token for an allowed origin', async () => { const session = await makeSession(); - const fetchImpl = vi.fn(async () => ok()); + const fetchImpl = mockFetch(async () => ok()); const { authenticated } = await authenticatedFetch(session, { url: 'https://alice.pod.example/private/notes.ttl', method: 'GET', @@ -44,7 +50,7 @@ describe('authenticatedFetch — credential boundary', () => { it('does NOT attach the token to a foreign origin (token-leak attack)', async () => { const session = await makeSession(); - const fetchImpl = vi.fn(async () => ok()); + const fetchImpl = mockFetch(async () => ok()); const { authenticated } = await authenticatedFetch(session, { url: 'https://evil.example/collect', method: 'POST', @@ -65,7 +71,7 @@ describe('authenticatedFetch — credential boundary', () => { allowedOrigins: new Set(['https://idp.example']), tokenEndpoint: 'https://idp.example/token', }); - const fetchImpl = vi.fn(async () => ok()); + const fetchImpl = mockFetch(async () => ok()); const { authenticated } = await authenticatedFetch(session, { url: 'https://idp.example/token', method: 'POST', @@ -77,7 +83,7 @@ describe('authenticatedFetch — credential boundary', () => { it('strips a page-supplied Authorization / DPoP header (no header injection)', async () => { const session = await makeSession(); - const fetchImpl = vi.fn(async () => ok()); + const fetchImpl = mockFetch(async () => ok()); await authenticatedFetch(session, { url: 'https://alice.pod.example/x', method: 'GET', @@ -92,7 +98,7 @@ describe('authenticatedFetch — credential boundary', () => { it('retries ONCE on an RFC 9449 §8 nonce challenge with the server nonce', async () => { const session = await makeSession(); let call = 0; - const fetchImpl = vi.fn(async () => { + const fetchImpl = mockFetch(async () => { call += 1; if (call === 1) { return new Response('nonce required', { @@ -127,7 +133,7 @@ describe('authenticatedFetch — credential boundary', () => { it('does NOT retry on a non-nonce 401 (e.g. invalid_token)', async () => { const session = await makeSession(); - const fetchImpl = vi.fn( + const fetchImpl = mockFetch( async () => new Response('nope', { status: 401, From dbbb681d0d096c77edcd32593de5adbd3619ad20 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:14:42 +0100 Subject: [PATCH 11/50] feat(ui): window.solid inject + content bridge + @jeswr/solid-elements popup; MV3 wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page-facing + UI layer on the new message protocol: - inject.ts (MAIN world) — window.solid = { webId, fetch, setClientId, login, logout }. Holds NO credential; everything is a postMessage to the content bridge. The access-management SEAM (requestAccess?) is declared but throws "not implemented" so it is feature-detectable and addable later WITHOUT a breaking change — access management is the separate design-first track, deliberately out of scope here. - content-script.ts (ISOLATED world) — the trust boundary: stamps the REAL page origin (read here, not page-supplied) onto login + set-client-id so a page can't impersonate another origin's client-id mapping; relays fetch + state. - popup — the account UI built with @jeswr/solid-elements (jeswr-account-menu + jeswr-theme-toggle) for Pod-Manager-consistent look + dark mode; recent-accounts affordance, pod shortcut, a "restoring" state, and the first-run PIN nudge (extensions can't self-pin). Signed-out uses a native WebID form driving the worker login (the reactive-auth LoginController that jeswr-login-panel needs is SPA-popup-shaped; a chrome.identity-backed controller is a clean follow-up). popup.css declares the app-shell OKLCH tokens (light :root + .dark) so the chrome themes WITH the components; the filled button style is scoped to #login-form so it can't leak onto the shell controls. - manifest — drop the now-unused redirect web_accessible_resource + activeTab (chrome.identity handles the redirect internally); keep storage + identity + . v0.2.0. - build — webpack.config.cjs (the repo is ESM) + tsconfig.build.json (emit on); drop the redirect entry. The bundle carries NO node: imports (browser-safe core verified). Model: claude-opus-4-8 Provenance: Opus 4.8 (Fable unavailable) — re-review/upgrade candidate Co-Authored-By: Claude Opus 4.8 --- apps/extension/package.json | 4 +- apps/extension/src/content/content-script.ts | 162 ++++---- apps/extension/src/inject/inject.ts | 227 +++++------ apps/extension/src/manifest.json | 24 +- apps/extension/src/popup/popup.css | 361 +++++++++--------- apps/extension/src/popup/popup.html | 94 +++-- apps/extension/src/popup/popup.ts | 256 ++++++------- apps/extension/src/redirect/redirect.html | 45 --- apps/extension/src/redirect/redirect.ts | 36 -- apps/extension/tsconfig.build.json | 9 + .../{webpack.config.js => webpack.config.cjs} | 12 +- 11 files changed, 567 insertions(+), 663 deletions(-) delete mode 100644 apps/extension/src/redirect/redirect.html delete mode 100644 apps/extension/src/redirect/redirect.ts create mode 100644 apps/extension/tsconfig.build.json rename apps/extension/{webpack.config.js => webpack.config.cjs} (70%) diff --git a/apps/extension/package.json b/apps/extension/package.json index ad4664671..17c876577 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -12,8 +12,8 @@ "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", - "build": "webpack", - "build:dev": "webpack --mode development --watch", + "build": "webpack --config webpack.config.cjs", + "build:dev": "webpack --config webpack.config.cjs --mode development --watch", "gate": "npm run lint && npm run typecheck && npm test && npm run build", "test:e2e": "npm run build && playwright test --config e2e/playwright.config.ts", "clean": "rm -rf dist" diff --git a/apps/extension/src/content/content-script.ts b/apps/extension/src/content/content-script.ts index 4cee0e615..c5ede95a7 100644 --- a/apps/extension/src/content/content-script.ts +++ b/apps/extension/src/content/content-script.ts @@ -1,90 +1,104 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * The ISOLATED-world bridge between the page (MAIN-world `window.solid`) and the service + * worker. It is the trust boundary: the page can only ASK; this script decides what to + * forward and stamps the page's REAL origin (read here, not page-supplied) onto + * origin-sensitive messages (login, set-client-id) so a page cannot impersonate another + * origin's client-id mapping. + */ + export {}; -const SOLID_EXT_PREFIX = 'solid-browser-ext'; +const CHANNEL = 'solid-browser-ext'; + +interface PageMessage { + channel: string; + dir: string; + type: string; + requestId?: string; + actionId?: string; + url?: string; + method?: string; + headers?: Record; + body?: string | null; + webId?: string; + clientId?: string; +} + +function toPage(message: Record): void { + window.postMessage({ channel: CHANNEL, dir: 'to-page', ...message }, window.location.origin); +} -// Page -> Background: relay messages from MAIN world to service worker window.addEventListener('message', (event) => { + // Only same-window, our-channel, page->content messages. if (event.source !== window) return; - if (event.data?.source !== SOLID_EXT_PREFIX) return; - - const { type } = event.data; + const data = event.data as PageMessage | undefined; + if (!data || data.channel !== CHANNEL || data.dir !== 'to-content') return; - if (type === 'SOLID_FETCH_REQUEST') { - chrome.runtime.sendMessage({ - type: 'SOLID_FETCH_REQUEST', - requestId: event.data.requestId, - url: event.data.url, - method: event.data.method, - headers: event.data.headers, - body: event.data.body, - }, (response) => { - window.postMessage({ - source: SOLID_EXT_PREFIX, - type: 'SOLID_FETCH_RESPONSE', - ...response, - }, '*'); - }); - } + switch (data.type) { + case 'SOLID_FETCH_REQUEST': + chrome.runtime.sendMessage( + { + type: 'SOLID_FETCH_REQUEST', + requestId: data.requestId, + url: data.url, + method: data.method, + headers: data.headers ?? {}, + body: data.body ?? null, + }, + (response) => { + toPage({ type: 'SOLID_FETCH_RESPONSE', ...(response ?? { error: 'No response' }) }); + }, + ); + break; - if (type === 'SOLID_GET_STATE') { - chrome.runtime.sendMessage({ type: 'SOLID_GET_STATE' }, (response) => { - window.postMessage({ - source: SOLID_EXT_PREFIX, - type: 'SOLID_STATE_UPDATE', - webId: response?.webId ?? null, - profileTurtle: response?.profileTurtle ?? null, - }, '*'); - }); - } + case 'SOLID_GET_STATE': + chrome.runtime.sendMessage({ type: 'SOLID_GET_STATE' }, (response) => { + toPage({ type: 'SOLID_STATE_UPDATE', webId: response?.webId ?? null }); + }); + break; - if (type === 'SOLID_SET_CLIENT_ID') { - chrome.runtime.sendMessage({ - type: 'SOLID_SET_CLIENT_ID', - origin: event.data.origin, - clientId: event.data.clientId, - }); - } + case 'SOLID_SET_CLIENT_ID': + // Stamp the REAL page origin; the page cannot set it for another origin. + chrome.runtime.sendMessage({ + type: 'SOLID_SET_CLIENT_ID', + origin: window.location.origin, + clientId: data.clientId, + }); + break; - if (type === 'SOLID_LOGIN') { - chrome.runtime.sendMessage({ - type: 'SOLID_LOGIN', - actionId: event.data.actionId, - webId: event.data.webId, - origin: event.data.origin, - clientId: event.data.clientId, - }, (response) => { - window.postMessage({ - source: SOLID_EXT_PREFIX, - type: 'SOLID_ACTION_RESPONSE', - actionId: event.data.actionId, - error: response?.error ?? null, - }, '*'); - }); - } + case 'SOLID_LOGIN': + chrome.runtime.sendMessage( + { + type: 'SOLID_LOGIN', + webId: data.webId, + origin: window.location.origin, + }, + (response) => { + toPage({ + type: 'SOLID_ACTION_RESPONSE', + actionId: data.actionId, + error: response?.error ?? null, + }); + }, + ); + break; - if (type === 'SOLID_LOGOUT') { - chrome.runtime.sendMessage({ - type: 'SOLID_LOGOUT', - actionId: event.data.actionId, - }, (response) => { - window.postMessage({ - source: SOLID_EXT_PREFIX, - type: 'SOLID_ACTION_RESPONSE', - actionId: event.data.actionId, - error: response?.error ?? null, - }, '*'); - }); + case 'SOLID_LOGOUT': + chrome.runtime.sendMessage({ type: 'SOLID_LOGOUT' }, (response) => { + toPage({ + type: 'SOLID_ACTION_RESPONSE', + actionId: data.actionId, + error: response?.error ?? null, + }); + }); + break; } }); -// Background -> Page: listen for state broadcasts from service worker +// Relay worker -> page state broadcasts. chrome.runtime.onMessage.addListener((message) => { - if (message.type === 'SOLID_STATE_CHANGED') { - window.postMessage({ - source: SOLID_EXT_PREFIX, - type: 'SOLID_STATE_UPDATE', - webId: message.webId, - profileTurtle: message.profileTurtle ?? null, - }, '*'); + if (message?.type === 'SOLID_STATE_CHANGED') { + toPage({ type: 'SOLID_STATE_UPDATE', webId: message.webId ?? null }); } }); diff --git a/apps/extension/src/inject/inject.ts b/apps/extension/src/inject/inject.ts index 5a780f3c3..b8b790681 100644 --- a/apps/extension/src/inject/inject.ts +++ b/apps/extension/src/inject/inject.ts @@ -1,145 +1,118 @@ -export {}; +// AUTHORED-BY Claude Opus 4.8 +/** + * The MAIN-world injection: defines `window.solid` on every page. This script runs in the + * page's own JS world (so the page can call it), but it holds NO credential — every + * privileged operation is a `postMessage` to the ISOLATED content script, which relays to + * the service worker (the sole token holder). The page only ever sees the WebID + a + * proxied fetch Response. The credential boundary is enforced in the worker, not here. + */ -import { Parser, Store, DataFactory } from 'n3'; -import { Agent, WebIdDataset } from '@solid/object'; +export {}; -const SOLID_EXT_PREFIX = 'solid-browser-ext'; +const CHANNEL = 'solid-browser-ext'; +/** + * The `window.solid` API surface. Deliberately small + stable so the access-management + * track (a later `requestAccess(...)` method — see the SEAM below) slots in without a + * breaking change. + */ interface SolidExtension { + /** The authenticated user's WebID, or null when signed out. */ readonly webId: string | null; - readonly profile: Agent | null; + /** A DPoP-authenticated fetch, proxied through the extension (origin-gated, fail-closed). */ fetch(input: RequestInfo | URL, init?: RequestInit): Promise; + /** Declare this origin's Client Identifier Document URL so the pod sees the page's identity. */ setClientId(clientId: string): void; + /** Start the interactive login for a WebID (same as the popup). */ login(webId: string): Promise; + /** Clear the session (app-local logout). */ logout(): Promise; -} -const pendingRequests = new Map void; - reject: (reason: Error) => void; -}>(); + // --- ACCESS-MANAGEMENT SEAM (NOT IMPLEMENTED) ------------------------------------- + // The access-request JS API (requestAccess / consent UI / queued-request handling) is + // a SEPARATE, design-first track and is intentionally out of scope for this core. The + // method is declared here so adding it later is non-breaking, but calling it throws so + // a consumer can feature-detect. Do NOT wire access management onto this stub without + // the access-management design. + requestAccess?(request: unknown): Promise; +} -const pendingActions = new Map void; - reject: (reason: Error) => void; -}>(); +type Pending = { resolve: (v: T) => void; reject: (e: Error) => void }; +const pendingFetches = new Map>(); +const pendingActions = new Map>(); let currentWebId: string | null = null; -let currentProfile: Agent | null = null; - -function buildProfile(webId: string, turtle: string): Agent | null { - try { - const parser = new Parser({ baseIRI: webId.split('#')[0] }); - const store = new Store(); - store.addQuads(parser.parse(turtle)); - const dataset = new WebIdDataset(store, DataFactory); - return dataset.mainSubject ?? null; - } catch { - return null; - } -} window.addEventListener('message', (event) => { if (event.source !== window) return; - if (event.data?.source !== SOLID_EXT_PREFIX) return; - - const { type } = event.data; - - if (type === 'SOLID_FETCH_RESPONSE') { - const pending = pendingRequests.get(event.data.requestId); - if (!pending) return; - pendingRequests.delete(event.data.requestId); - - if (event.data.error) { - pending.reject(new Error(event.data.error)); - } else { - pending.resolve(new Response(event.data.body, { - status: event.data.status, - statusText: event.data.statusText, - headers: new Headers(event.data.headers), - })); + const data = event.data; + if (!data || data.channel !== CHANNEL || data.dir !== 'to-page') return; + + switch (data.type) { + case 'SOLID_FETCH_RESPONSE': { + const pending = pendingFetches.get(data.requestId); + if (!pending) return; + pendingFetches.delete(data.requestId); + if (data.error) { + pending.reject(new Error(data.error)); + } else { + pending.resolve( + new Response(data.body ?? null, { + status: data.status, + statusText: data.statusText, + headers: new Headers(data.headers ?? {}), + }), + ); + } + break; } - } - - if (type === 'SOLID_STATE_UPDATE') { - currentWebId = event.data.webId ?? null; - if (currentWebId && event.data.profileTurtle) { - currentProfile = buildProfile(currentWebId, event.data.profileTurtle); - } else { - currentProfile = null; - } - } - - if (type === 'SOLID_ACTION_RESPONSE') { - const pending = pendingActions.get(event.data.actionId); - if (!pending) return; - pendingActions.delete(event.data.actionId); - - if (event.data.error) { - pending.reject(new Error(event.data.error)); - } else { - pending.resolve(); + case 'SOLID_ACTION_RESPONSE': { + const pending = pendingActions.get(data.actionId); + if (!pending) return; + pendingActions.delete(data.actionId); + if (data.error) pending.reject(new Error(data.error)); + else pending.resolve(); + break; } + case 'SOLID_STATE_UPDATE': + currentWebId = data.webId ?? null; + break; } }); +function postToContent(message: Record): void { + window.postMessage({ channel: CHANNEL, dir: 'to-content', ...message }, window.location.origin); +} + function solidFetch(input: RequestInfo | URL, init?: RequestInit): Promise { return new Promise((resolve, reject) => { const requestId = crypto.randomUUID(); - pendingRequests.set(requestId, { resolve, reject }); - - window.postMessage({ - source: SOLID_EXT_PREFIX, + pendingFetches.set(requestId, { resolve, reject }); + let body: string | null = null; + if (init?.body != null) { + if (typeof init.body !== 'string') { + pendingFetches.delete(requestId); + reject(new Error('window.solid.fetch currently supports only string request bodies.')); + return; + } + body = init.body; + } + postToContent({ type: 'SOLID_FETCH_REQUEST', requestId, url: input.toString(), - method: init?.method || 'GET', - headers: init?.headers - ? Object.fromEntries(new Headers(init.headers).entries()) - : {}, - body: init?.body ?? null, - }, '*'); - }); -} - -let currentClientId: string | undefined; - -function setClientId(clientId: string): void { - currentClientId = clientId; - window.postMessage({ - source: SOLID_EXT_PREFIX, - type: 'SOLID_SET_CLIENT_ID', - origin: window.location.origin, - clientId, - }, '*'); -} - -function login(webId: string): Promise { - return new Promise((resolve, reject) => { - const actionId = crypto.randomUUID(); - pendingActions.set(actionId, { resolve, reject }); - - window.postMessage({ - source: SOLID_EXT_PREFIX, - type: 'SOLID_LOGIN', - actionId, - webId, - origin: window.location.origin, - clientId: currentClientId, - }, '*'); + method: init?.method ?? 'GET', + headers: init?.headers ? Object.fromEntries(new Headers(init.headers).entries()) : {}, + body, + }); }); } -function logout(): Promise { +function action(type: string, extra: Record = {}): Promise { return new Promise((resolve, reject) => { const actionId = crypto.randomUUID(); pendingActions.set(actionId, { resolve, reject }); - - window.postMessage({ - source: SOLID_EXT_PREFIX, - type: 'SOLID_LOGOUT', - actionId, - }, '*'); + postToContent({ type, actionId, ...extra }); }); } @@ -147,23 +120,27 @@ const solid: SolidExtension = { get webId() { return currentWebId; }, - get profile() { - return currentProfile; - }, fetch: solidFetch, - setClientId, - login, - logout, + setClientId(clientId: string) { + postToContent({ type: 'SOLID_SET_CLIENT_ID', clientId }); + }, + login(webId: string) { + return action('SOLID_LOGIN', { webId }); + }, + logout() { + return action('SOLID_LOGOUT'); + }, + requestAccess() { + return Promise.reject( + new Error( + 'window.solid.requestAccess is not implemented: access management is a separate, ' + + 'design-first track and is not part of the core extension.', + ), + ); + }, }; -Object.defineProperty(window, 'solid', { - value: solid, - writable: false, - configurable: false, -}); +Object.defineProperty(window, 'solid', { value: Object.freeze(solid), configurable: false }); -// Request current session state on load -window.postMessage({ - source: SOLID_EXT_PREFIX, - type: 'SOLID_GET_STATE', -}, '*'); +// Ask the worker for the current session state on load (populates window.solid.webId). +postToContent({ type: 'SOLID_GET_STATE' }); diff --git a/apps/extension/src/manifest.json b/apps/extension/src/manifest.json index 654501219..590d08702 100644 --- a/apps/extension/src/manifest.json +++ b/apps/extension/src/manifest.json @@ -1,16 +1,10 @@ { "manifest_version": 3, - "name": "Solid Browser Extension", - "version": "0.1.0", - "description": "Authenticate with Solid at the browser level", - "permissions": [ - "storage", - "activeTab", - "identity" - ], - "host_permissions": [ - "" - ], + "name": "Solid", + "version": "0.2.0", + "description": "Sign in to Solid at the browser level — injects window.solid (DPoP-authenticated fetch) and a pinned account menu.", + "permissions": ["storage", "identity"], + "host_permissions": [""], "background": { "service_worker": "background/service-worker.js", "type": "module" @@ -23,7 +17,7 @@ }, "action": { "default_popup": "popup/popup.html", - "default_title": "Solid Login", + "default_title": "Sign in to Solid", "default_icon": { "16": "icons/icon16.png", "32": "icons/icon32.png", @@ -44,11 +38,5 @@ "run_at": "document_start", "world": "ISOLATED" } - ], - "web_accessible_resources": [ - { - "resources": ["redirect/redirect.html"], - "matches": [""] - } ] } diff --git a/apps/extension/src/popup/popup.css b/apps/extension/src/popup/popup.css index cd4748e19..e57d1bef5 100644 --- a/apps/extension/src/popup/popup.css +++ b/apps/extension/src/popup/popup.css @@ -1,260 +1,257 @@ +/* AUTHORED-BY Claude Opus 4.8 */ +/* + * Popup theme. The @jeswr/solid-elements web components inherit the app-shell OKLCH + * tokens (--background/--foreground/--primary/...) through the shadow boundary, flipped + * by the `.dark` class jeswr-theme-toggle sets on . We declare the SAME token set + * here (light :root + .dark) so the popup's own chrome themes in lockstep with the + * components. These mirror app-shell's light/dark values so the look matches Pod Manager. + */ + :root { - --solid-purple: #7C4DFF; - --solid-purple-dark: #651FFF; - --solid-purple-light: #B388FF; - --text-primary: #1a1a2e; - --text-secondary: #555; - --bg: #ffffff; - --border: #ddd; - --error: #d32f2f; + --background: oklch(0.992 0.003 210); + --foreground: oklch(0.21 0.022 235); + --border: oklch(0.91 0.012 220); + --muted-foreground: oklch(0.5 0.018 230); + --primary: oklch(0.52 0.094 205); + --primary-foreground: oklch(0.99 0.005 200); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.21 0.022 235); + --destructive: oklch(0.55 0.2 27); + --accent: oklch(0.94 0.03 195); + --accent-foreground: oklch(0.31 0.05 215); + --ring: oklch(0.52 0.094 205); + --radius: 0.7rem; + color-scheme: light; +} + +html.dark { + --background: oklch(0.18 0.02 240); + --foreground: oklch(0.95 0.01 220); + --border: oklch(0.32 0.02 235); + --muted-foreground: oklch(0.68 0.02 230); + --primary: oklch(0.7 0.12 200); + --primary-foreground: oklch(0.16 0.02 240); + --popover: oklch(0.22 0.02 240); + --popover-foreground: oklch(0.95 0.01 220); + --destructive: oklch(0.62 0.2 27); + --accent: oklch(0.3 0.04 215); + --accent-foreground: oklch(0.9 0.03 200); + --ring: oklch(0.7 0.12 200); + color-scheme: dark; } * { - margin: 0; - padding: 0; box-sizing: border-box; } body { - width: 320px; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - background: var(--bg); - color: var(--text-primary); + margin: 0; + width: 340px; + background: var(--background); + color: var(--foreground); + font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, + sans-serif; + font-size: 14px; } .container { - padding: 24px 20px; + padding: 14px 16px 18px; } -.header { +.topbar { display: flex; align-items: center; - gap: 12px; - margin-bottom: 20px; + justify-content: space-between; + margin-bottom: 12px; +} + +.brand { + display: flex; + align-items: center; + gap: 8px; } .logo { - width: 36px; - height: 36px; + width: 26px; + height: 26px; } -.header h1 { - font-size: 20px; - font-weight: 700; - color: var(--solid-purple); +.brand-name { + font-weight: 600; + font-size: 15px; } .subtitle { - color: var(--text-secondary); - font-size: 13px; - margin-bottom: 16px; + color: var(--muted-foreground); + margin: 0 0 12px; } -label { - display: block; +.section-label { font-size: 12px; font-weight: 600; - color: var(--text-secondary); - margin-bottom: 6px; + color: var(--muted-foreground); text-transform: uppercase; - letter-spacing: 0.5px; + letter-spacing: 0.04em; + margin: 4px 0 6px; } -input[type="url"] { - width: 100%; - padding: 10px 12px; - border: 1.5px solid var(--border); - border-radius: 8px; - font-size: 14px; - outline: none; - transition: border-color 0.2s; +/* The signed-out WebID form. Scoped so the bare element styles below never leak + * onto the @jeswr/solid-elements controls (the suite's CSS-leak guard). */ +#login-form { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 10px; } -input[type="url"]:focus { - border-color: var(--solid-purple); +#login-form label { + font-size: 12px; + color: var(--muted-foreground); } -button { - width: 100%; - padding: 10px; - border: none; - border-radius: 8px; +#login-form input { + padding: 9px 10px; + border: 1px solid var(--border); + border-radius: calc(var(--radius) - 0.25rem); + background: var(--popover); + color: var(--foreground); font-size: 14px; - font-weight: 600; - cursor: pointer; - transition: background-color 0.2s; -} - -#login-btn { - margin-top: 12px; - background: var(--solid-purple); - color: white; -} - -#login-btn:hover { - background: var(--solid-purple-dark); } -#logout-btn { - margin-top: 16px; - background: transparent; - color: var(--solid-purple); - border: 1.5px solid var(--solid-purple); +#login-form input:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 1px; } -#logout-btn:hover { - background: var(--solid-purple); - color: white; -} - -.error { - color: var(--error); - font-size: 12px; - margin-top: 8px; -} - -.spinner { - width: 32px; - height: 32px; - border: 3px solid var(--solid-purple-light); - border-top-color: var(--solid-purple); - border-radius: 50%; - animation: spin 0.8s linear infinite; - margin: 20px auto; -} - -@keyframes spin { - to { transform: rotate(360deg); } +#login-form button.primary { + margin-top: 4px; + padding: 9px 12px; + border: none; + border-radius: calc(var(--radius) - 0.25rem); + background: var(--primary); + color: var(--primary-foreground); + font-weight: 600; + font-size: 14px; + cursor: pointer; } -#loading-view { - text-align: center; - color: var(--text-secondary); +#login-form button.primary:hover { + filter: brightness(1.05); } -/* Profile card (logged-in state) */ - -.profile-card { +.account-item { display: flex; align-items: center; - gap: 12px; - background: #f5f0ff; - padding: 14px; - border-radius: 10px; - border: 1px solid var(--solid-purple-light); + gap: 10px; + width: 100%; + padding: 8px; + border: 1px solid var(--border); + border-radius: calc(var(--radius) - 0.2rem); + background: var(--popover); + color: var(--foreground); + cursor: pointer; + margin-bottom: 6px; + text-align: left; } -.profile-photo { - width: 44px; - height: 44px; - border-radius: 50%; - object-fit: cover; - flex-shrink: 0; +.account-item:hover { + background: var(--accent); + color: var(--accent-foreground); } -.profile-initials { - width: 44px; - height: 44px; - border-radius: 50%; - background: var(--solid-purple); - color: white; +.account-avatar { + display: inline-flex; align-items: center; justify-content: center; - font-size: 18px; - font-weight: 700; - flex-shrink: 0; -} - -.profile-initials:not([hidden]) { - display: flex; + width: 30px; + height: 30px; + border-radius: 50%; + background: var(--primary); + color: var(--primary-foreground); + font-weight: 600; + overflow: hidden; + flex: 0 0 auto; } -.profile-info { - min-width: 0; +.account-avatar img { + width: 100%; + height: 100%; + object-fit: cover; } -.profile-name { - font-size: 16px; - font-weight: 600; - color: var(--text-primary); - white-space: nowrap; +.account-name { + font-size: 13px; overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; } -/* Past profiles list */ - -.past-profiles-label { - font-size: 12px; - font-weight: 600; - color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: 0.5px; - margin-bottom: 8px; +.error { + color: var(--destructive); + margin: 8px 0 0; + font-size: 13px; } -.past-profile-item { +.shortcuts { + margin-top: 12px; display: flex; - align-items: center; - gap: 10px; - padding: 8px 10px; - border-radius: 8px; - cursor: pointer; - transition: background-color 0.15s; - border: none; - background: none; - width: 100%; - text-align: left; -} - -.past-profile-item:hover { - background: #f5f0ff; + flex-direction: column; + gap: 6px; } -.past-profile-photo { - width: 32px; - height: 32px; - border-radius: 50%; - object-fit: cover; - flex-shrink: 0; +.shortcut { + color: var(--primary); + text-decoration: none; + font-size: 13px; } -.past-profile-initials { - width: 32px; - height: 32px; - border-radius: 50%; - background: var(--solid-purple-light); - color: white; - align-items: center; - justify-content: center; - font-size: 13px; - font-weight: 700; - flex-shrink: 0; +.shortcut:hover { + text-decoration: underline; } -.past-profile-initials:not([hidden]) { +.pin-nudge { display: flex; + flex-direction: column; + gap: 6px; + padding: 10px 12px; + margin-bottom: 12px; + background: var(--accent); + color: var(--accent-foreground); + border-radius: calc(var(--radius) - 0.2rem); + font-size: 12.5px; } -.past-profile-name { - font-size: 14px; - font-weight: 500; - color: var(--text-primary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; +.link-btn { + align-self: flex-end; + background: none; + border: none; + color: var(--primary); + font-weight: 600; + cursor: pointer; + padding: 0; + font-size: 12.5px; } -.add-account-icon { - background: var(--border); - color: var(--text-secondary); - font-size: 18px; +#busy { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + padding: 22px 0; + color: var(--muted-foreground); } -.add-account-btn .past-profile-name { - color: var(--text-secondary); +.spinner { + width: 24px; + height: 24px; + border: 3px solid var(--border); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 0.8s linear infinite; } -#webid-form-container { - margin-top: 12px; +@keyframes spin { + to { + transform: rotate(360deg); + } } diff --git a/apps/extension/src/popup/popup.html b/apps/extension/src/popup/popup.html index 41e7b6295..2c2d38a4c 100644 --- a/apps/extension/src/popup/popup.html +++ b/apps/extension/src/popup/popup.html @@ -1,55 +1,69 @@ - - - Solid Login - + + + Solid +
-
- -

Solid

+
+
+ + Solid +
+ +
+ + + -
-

Sign in with your Solid identity

-
- - -
+ + - + + - + + +
- + diff --git a/apps/extension/src/popup/popup.ts b/apps/extension/src/popup/popup.ts index b3b01fa04..9919d4bc6 100644 --- a/apps/extension/src/popup/popup.ts +++ b/apps/extension/src/popup/popup.ts @@ -1,175 +1,157 @@ -const loginView = document.getElementById('login-view')!; -const loadingView = document.getElementById('loading-view')!; -const loggedInView = document.getElementById('logged-in-view')!; -const loginForm = document.getElementById('login-form') as HTMLFormElement; -const webidInput = document.getElementById('webid-input') as HTMLInputElement; -const webidFormContainer = document.getElementById('webid-form-container')!; -const loginError = document.getElementById('login-error')!; -const logoutBtn = document.getElementById('logout-btn')!; -const profilePhoto = document.getElementById('profile-photo') as HTMLImageElement; -const profileInitials = document.getElementById('profile-initials')!; -const profileName = document.getElementById('profile-name')!; -const pastProfilesContainer = document.getElementById('past-profiles')!; - -interface PastProfile { - webId: string; - name: string | null; - photoUrl: string | null; +// AUTHORED-BY Claude Opus 4.8 +/** + * The popup IS the account UI — sign in / out, switch WebID, pod shortcuts, theme — + * built with @jeswr/solid-elements (`jeswr-account-menu` + `jeswr-theme-toggle`) so it is + * visually consistent with Pod Manager and the rest of the suite. The popup does NOT run + * its own auth: it talks to the service worker (the sole token holder) over the message + * protocol and reflects state. + * + * Why not `jeswr-login-panel` for the signed-out form: that element drives the + * @solid/reactive-authentication popup/redirect flow (a `LoginController`), which is the + * normal-SPA login. The extension's login runs through `chrome.identity.launchWebAuthFlow` + * in the worker instead, so the signed-out state uses a small native WebID form that asks + * the worker to log in; the account-menu + theme-toggle web components give it the shared + * look. (Adopting `jeswr-login-panel` would require a chrome.identity-backed + * LoginController — a clean follow-up.) + */ + +// Side-effect import: registers , , etc. +import '@jeswr/solid-elements'; +import type { SessionState } from '../shared/messages'; + +const el = (id: string): T => document.getElementById(id) as T; + +const signedOut = el('signed-out'); +const busy = el('busy'); +const busyText = el('busy-text'); +const signedIn = el('signed-in'); +const loginForm = el('login-form'); +const webidInput = el('webid-input'); +const loginError = el('login-error'); +const recentAccounts = el('recent-accounts'); +const accountMenu = el('account-menu'); +const shortcutProfile = el('shortcut-profile'); +const pinNudge = el('pin-nudge'); + +type View = 'signed-out' | 'busy' | 'signed-in'; +function show(view: View): void { + signedOut.hidden = view !== 'signed-out'; + busy.hidden = view !== 'busy'; + signedIn.hidden = view !== 'signed-in'; } -function showView(view: 'login' | 'loading' | 'logged-in') { - loginView.hidden = view !== 'login'; - loadingView.hidden = view !== 'loading'; - loggedInView.hidden = view !== 'logged-in'; +function send(message: unknown): Promise { + return new Promise((resolve) => chrome.runtime.sendMessage(message, resolve)); } -function getInitials(name: string | null): string { - if (name) { - return name.split(/\s+/).map(w => w[0]).slice(0, 2).join('').toUpperCase(); - } - return 'S'; +function showError(message: string): void { + loginError.textContent = message; + loginError.hidden = false; } -function renderAvatar( - photoEl: HTMLImageElement, - initialsEl: HTMLElement, - name: string | null, - photoUrl: string | null, -) { - if (photoUrl) { - photoEl.src = photoUrl; - photoEl.alt = name || ''; - photoEl.hidden = false; - initialsEl.hidden = true; +function renderSignedIn(state: SessionState): void { + accountMenu.setAttribute('web-id', state.webId ?? ''); + accountMenu.setAttribute('name', state.name ?? ''); + if (state.photoUrl) accountMenu.setAttribute('avatar-url', state.photoUrl); + else accountMenu.removeAttribute('avatar-url'); + + // Pod shortcut: the user's profile document (drop the fragment). + if (state.webId) { + shortcutProfile.href = state.webId.split('#')[0]; + shortcutProfile.hidden = false; } else { - photoEl.hidden = true; - initialsEl.hidden = false; - initialsEl.textContent = getInitials(name); + shortcutProfile.hidden = true; } + show('signed-in'); } -function showLoggedInProfile(name: string | null, photoUrl: string | null) { - profileName.textContent = name || 'Solid User'; - renderAvatar(profilePhoto, profileInitials, name, photoUrl); - showView('logged-in'); -} - -function renderPastProfiles(profiles: PastProfile[]) { - pastProfilesContainer.innerHTML = ''; - - if (profiles.length === 0) { - // No past profiles — show the WebID form directly - webidFormContainer.hidden = false; - return; - } - - // Hide the form by default when there are past profiles - webidFormContainer.hidden = true; +function renderRecentAccounts(state: SessionState): void { + recentAccounts.replaceChildren(); + if (state.recentAccounts.length === 0) return; const label = document.createElement('div'); - label.className = 'past-profiles-label'; + label.className = 'section-label'; label.textContent = 'Recent accounts'; - pastProfilesContainer.appendChild(label); + recentAccounts.appendChild(label); - for (const p of profiles) { + for (const account of state.recentAccounts) { const btn = document.createElement('button'); - btn.className = 'past-profile-item'; btn.type = 'button'; - - const displayName = p.name || 'Solid User'; - const initials = getInitials(p.name); - - if (p.photoUrl) { - btn.innerHTML = ` - -
${escapeHtml(displayName)}
`; + btn.className = 'account-item'; + + const avatar = document.createElement('span'); + avatar.className = 'account-avatar'; + if (account.photoUrl) { + const img = document.createElement('img'); + img.src = account.photoUrl; + img.alt = ''; + avatar.appendChild(img); } else { - btn.innerHTML = ` -
${escapeHtml(initials)}
-
${escapeHtml(displayName)}
`; + avatar.textContent = (account.name ?? account.webId).charAt(0).toUpperCase(); } - btn.addEventListener('click', () => { - doLogin(p.webId); - }); + const name = document.createElement('span'); + name.className = 'account-name'; + name.textContent = account.name ?? account.webId; - pastProfilesContainer.appendChild(btn); + btn.append(avatar, name); + btn.addEventListener('click', () => doLogin(account.webId)); + recentAccounts.appendChild(btn); } - - // "Add account" button - const addBtn = document.createElement('button'); - addBtn.className = 'past-profile-item add-account-btn'; - addBtn.type = 'button'; - addBtn.innerHTML = ` - -
Add account
`; - addBtn.addEventListener('click', () => { - webidFormContainer.hidden = false; - webidInput.focus(); - }); - pastProfilesContainer.appendChild(addBtn); } -function escapeHtml(str: string): string { - const div = document.createElement('div'); - div.textContent = str; - return div.innerHTML; -} - -function doLogin(webId: string) { +async function doLogin(webId: string): Promise { loginError.hidden = true; - showView('loading'); - - chrome.runtime.sendMessage( - { type: 'SOLID_LOGIN', webId }, - (response) => { - if (response?.error) { - loginError.textContent = response.error; - loginError.hidden = false; - showView('login'); - } - } - ); + busyText.textContent = 'Signing in…'; + show('busy'); + const result = await send<{ ok?: boolean; webId?: string; error?: string }>({ + type: 'SOLID_LOGIN', + webId, + }); + if (result?.error) { + showError(result.error); + await refresh(); + } + // Success path is driven by the SOLID_STATE_CHANGED broadcast -> refresh(). } -// Check current session state on popup open -chrome.runtime.sendMessage({ type: 'SOLID_GET_STATE' }, (response) => { - if (response?.webId) { - showLoggedInProfile(response.profileName, response.profilePhotoUrl); +async function refresh(): Promise { + const state = await send({ type: 'SOLID_GET_STATE' }); + if (state?.isActive && state.webId) { + renderSignedIn(state); } else { - renderPastProfiles(response?.pastProfiles ?? []); - showView('login'); + renderRecentAccounts(state); + show('signed-out'); } -}); +} + +// --- Wiring --------------------------------------------------------------------------- loginForm.addEventListener('submit', (e) => { e.preventDefault(); const webId = webidInput.value.trim(); - if (!webId) return; - doLogin(webId); + if (webId) void doLogin(webId); }); -logoutBtn.addEventListener('click', () => { - chrome.runtime.sendMessage({ type: 'SOLID_LOGOUT' }, () => { - chrome.runtime.sendMessage({ type: 'SOLID_GET_STATE' }, (response) => { - renderPastProfiles(response?.pastProfiles ?? []); - showView('login'); - }); - }); +// The account-menu emits `sign-out`. +accountMenu.addEventListener('sign-out', () => { + void send({ type: 'SOLID_LOGOUT' }).then(() => refresh()); }); -// Listen for state changes while popup is open +// React to background state broadcasts while the popup is open. chrome.runtime.onMessage.addListener((message) => { - if (message.type === 'SOLID_STATE_CHANGED') { - if (message.webId) { - chrome.runtime.sendMessage({ type: 'SOLID_GET_STATE' }, (response) => { - showLoggedInProfile(response.profileName, response.profilePhotoUrl); - }); - } else { - chrome.runtime.sendMessage({ type: 'SOLID_GET_STATE' }, (response) => { - renderPastProfiles(response?.pastProfiles ?? []); - showView('login'); - }); - } - } + if (message?.type === 'SOLID_STATE_CHANGED') void refresh(); +}); + +// First-run pin nudge (extensions can't self-pin). +chrome.storage.local.get('solid:show-pin-nudge').then((result) => { + if (result['solid:show-pin-nudge']) pinNudge.hidden = false; +}); +el('pin-nudge-dismiss').addEventListener('click', () => { + pinNudge.hidden = true; + void chrome.storage.local.remove('solid:show-pin-nudge'); }); + +// Initial paint: show "Restoring" while we read state. +show('busy'); +void refresh(); diff --git a/apps/extension/src/redirect/redirect.html b/apps/extension/src/redirect/redirect.html deleted file mode 100644 index 9e1a36c65..000000000 --- a/apps/extension/src/redirect/redirect.html +++ /dev/null @@ -1,45 +0,0 @@ - - - - - Solid - Redirecting - - - -
-
-

Processing login...

- -
- - - diff --git a/apps/extension/src/redirect/redirect.ts b/apps/extension/src/redirect/redirect.ts deleted file mode 100644 index 28dd2264e..000000000 --- a/apps/extension/src/redirect/redirect.ts +++ /dev/null @@ -1,36 +0,0 @@ -const params = new URLSearchParams(window.location.search); -const code = params.get('code'); -const state = params.get('state'); -const iss = params.get('iss'); -const statusEl = document.getElementById('status')!; -const errorEl = document.getElementById('error')!; -const spinnerEl = document.getElementById('spinner')!; - -if (code && state && iss) { - chrome.runtime.sendMessage( - { - type: 'SOLID_HANDLE_REDIRECT', - code, - state, - iss, - }, - (response) => { - spinnerEl.style.display = 'none'; - if (response?.error) { - statusEl.textContent = 'Login failed'; - errorEl.textContent = response.error; - errorEl.hidden = false; - } else { - statusEl.textContent = 'Logged in successfully!'; - statusEl.className = 'success'; - // Close this tab after a brief delay - setTimeout(() => window.close(), 1500); - } - } - ); -} else { - spinnerEl.style.display = 'none'; - statusEl.textContent = 'Invalid redirect'; - errorEl.textContent = 'Missing required parameters (code, state, or iss).'; - errorEl.hidden = false; -} diff --git a/apps/extension/tsconfig.build.json b/apps/extension/tsconfig.build.json new file mode 100644 index 000000000..ac8afaf0f --- /dev/null +++ b/apps/extension/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": false + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "e2e", "test"] +} diff --git a/apps/extension/webpack.config.js b/apps/extension/webpack.config.cjs similarity index 70% rename from apps/extension/webpack.config.js rename to apps/extension/webpack.config.cjs index a94a15c48..cb91f7028 100644 --- a/apps/extension/webpack.config.js +++ b/apps/extension/webpack.config.cjs @@ -1,6 +1,9 @@ -const path = require('path'); +const path = require('node:path'); const CopyPlugin = require('copy-webpack-plugin'); +// MV3 bundle: one entry per context. ts-loader compiles with tsconfig.build.json (which +// flips off the typecheck-only `noEmit`). The popup is loaded as an ES module +// ( + + diff --git a/apps/extension/src/sidepanel/sidepanel.ts b/apps/extension/src/sidepanel/sidepanel.ts new file mode 100644 index 000000000..f289388c0 --- /dev/null +++ b/apps/extension/src/sidepanel/sidepanel.ts @@ -0,0 +1,21 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * The MV3 side panel — a PERSISTENT account surface (vs the ephemeral toolbar popup). + * Chrome keeps the side panel open as the user navigates and switches tabs, so the + * signed-in account / signed-out login stays a glance away without re-opening a popup. + * + * It renders the IDENTICAL surface as the popup — `` signed out, + * `` + pod shortcuts signed in — by REUSING the popup's account + * surface ({@link mountAccountSurface}) driven by the SAME `MessageBridgeLoginController` + * (the token-free, service-worker-backed bridge). No auth runs here: the service worker + * is the sole token holder; this panel only reflects state and proxies actions over the + * existing `chrome.runtime` message protocol — exactly like the popup. The popup is + * unchanged and keeps working; both share the one controller seam. + */ + +// Side-effect import: registers , , , etc. +import '@jeswr/solid-elements'; +import { mountAccountSurface, resolveAccountSurfaceElements } from '../popup/account-surface'; + +// Wire the shared account surface (busy → signed-in / signed-out, all the auth invariants). +mountAccountSurface(resolveAccountSurfaceElements()); diff --git a/apps/extension/test/client-id.test.ts b/apps/extension/test/client-id.test.ts new file mode 100644 index 000000000..734bfd673 --- /dev/null +++ b/apps/extension/test/client-id.test.ts @@ -0,0 +1,144 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * Tests for the static Solid-OIDC Client Identifier Document + its in-code mirror. + * + * The published Client ID Document (`public/clientid.jsonld`, copied into the build) lets + * the extension log in with a STABLE named identity instead of throwaway dynamic + * registration. These assert: + * (1) the shipped JSON has every member Solid-OIDC requires, with the right values; + * (2) the in-code mirror CLIENT_ID_DOCUMENT agrees byte-for-byte with the shipped JSON + * (so the asset can never silently drift from what the flow expects); + * (3) the document's redirect_uri shape matches auth-flow.ts's chromiumapp.org callback; + * (4) the reachability probe is fail-closed: the shipped placeholder URL is treated as + * unreachable so initiateLogin() falls back to dynamic client registration. + */ + +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { + CLIENT_ID_DOCUMENT, + CLIENT_ID_SCOPE, + isPlaceholderClientId, + isPublishedClientIdReachable, + PUBLISHED_CLIENT_ID_URL, + SOLID_OIDC_CONTEXT, +} from '../src/background/client-id'; + +const shippedJson = JSON.parse( + readFileSync(fileURLToPath(new URL('../public/clientid.jsonld', import.meta.url)), 'utf8'), +) as Record; + +describe('the shipped Client Identifier Document (public/clientid.jsonld)', () => { + it('declares the Solid-OIDC @context', () => { + expect(shippedJson['@context']).toBe(SOLID_OIDC_CONTEXT); + expect(SOLID_OIDC_CONTEXT).toBe('https://www.w3.org/ns/solid/oidc-context.jsonld'); + }); + + it('has the spec-required members for a public-client app', () => { + expect(typeof shippedJson.client_id).toBe('string'); + expect(shippedJson.client_name).toBe('Solid Browser Extension'); + expect(shippedJson.scope).toBe(CLIENT_ID_SCOPE); + expect(shippedJson.scope).toBe('openid webid offline_access'); + // A browser-extension app is a PUBLIC client → no client secret → auth method "none". + expect(shippedJson.token_endpoint_auth_method).toBe('none'); + expect(shippedJson.grant_types).toEqual(['authorization_code', 'refresh_token']); + expect(shippedJson.response_types).toEqual(['code']); + }); + + it('redirect_uris is the chromiumapp.org callback shape (matches auth-flow getRedirectURL("callback"))', () => { + const uris = shippedJson.redirect_uris as string[]; + expect(Array.isArray(uris)).toBe(true); + expect(uris).toHaveLength(1); + // chrome.identity.getRedirectURL('callback') => https://.chromiumapp.org/callback + expect(uris[0]).toMatch(/^https:\/\/[^/]+\.chromiumapp\.org\/callback$/); + }); + + it('client_id self-references the published URL (spec: the doc client_id MUST equal its URL)', () => { + // Both are the placeholder today — the maintainer sets them to the real hosted URL. + expect(shippedJson.client_id).toBe(PUBLISHED_CLIENT_ID_URL); + }); + + it('the in-code mirror CLIENT_ID_DOCUMENT agrees byte-for-byte with the shipped JSON', () => { + // The single source of truth check: if either side changes, this fails loudly. + expect(JSON.parse(JSON.stringify(CLIENT_ID_DOCUMENT))).toEqual(shippedJson); + }); +}); + +describe('PUBLISHED_CLIENT_ID_URL placeholder (needs:user — hosting)', () => { + it('is a clearly-marked, non-real placeholder so the maintainer must fill it in', () => { + expect(PUBLISHED_CLIENT_ID_URL).toContain('REPLACE-ME'); + expect(isPlaceholderClientId(PUBLISHED_CLIENT_ID_URL)).toBe(true); + // It IS at least a well-formed https: URL (so wiring/validation paths are exercised). + expect(() => new URL(PUBLISHED_CLIENT_ID_URL)).not.toThrow(); + expect(new URL(PUBLISHED_CLIENT_ID_URL).protocol).toBe('https:'); + }); +}); + +describe('isPublishedClientIdReachable — fail-closed fallback to dynamic registration', () => { + it('returns false for the placeholder WITHOUT making a network call', async () => { + let called = false; + const fetchSpy = (async () => { + called = true; + return new Response('{}'); + }) as unknown as typeof fetch; + expect(await isPublishedClientIdReachable(PUBLISHED_CLIENT_ID_URL, fetchSpy)).toBe(false); + expect(called).toBe(false); // short-circuited on the placeholder sentinel + }); + + it('returns false for an empty URL', async () => { + expect(await isPublishedClientIdReachable('')).toBe(false); + }); + + it('returns false for a non-HTTPS URL (the published doc must be HTTPS)', async () => { + const fetchSpy = (async () => new Response('{}')) as unknown as typeof fetch; + expect(await isPublishedClientIdReachable('http://example.com/cid.jsonld', fetchSpy)).toBe( + false, + ); + }); + + it('returns false on a non-2xx response', async () => { + const fetchSpy = (async () => + new Response('not found', { status: 404 })) as unknown as typeof fetch; + expect(await isPublishedClientIdReachable('https://real.example/cid.jsonld', fetchSpy)).toBe( + false, + ); + }); + + it('returns false when the doc client_id does NOT self-reference its URL', async () => { + const fetchSpy = (async () => + new Response(JSON.stringify({ client_id: 'https://other.example/x' }), { + status: 200, + })) as unknown as typeof fetch; + expect(await isPublishedClientIdReachable('https://real.example/cid.jsonld', fetchSpy)).toBe( + false, + ); + }); + + it('returns false on a network error (never throws)', async () => { + const fetchSpy = (async () => { + throw new Error('network down'); + }) as unknown as typeof fetch; + await expect( + isPublishedClientIdReachable('https://real.example/cid.jsonld', fetchSpy), + ).resolves.toBe(false); + }); + + it('returns TRUE for a reachable, self-referencing https doc', async () => { + const url = 'https://real.example/cid.jsonld'; + const fetchSpy = (async () => + new Response(JSON.stringify({ client_id: url, client_name: 'X' }), { + status: 200, + })) as unknown as typeof fetch; + expect(await isPublishedClientIdReachable(url, fetchSpy)).toBe(true); + }); + + it('tolerates a trailing-slash difference in the self-reference', async () => { + const url = 'https://real.example/cid.jsonld/'; + const fetchSpy = (async () => + new Response(JSON.stringify({ client_id: 'https://real.example/cid.jsonld' }), { + status: 200, + })) as unknown as typeof fetch; + expect(await isPublishedClientIdReachable(url, fetchSpy)).toBe(true); + }); +}); diff --git a/apps/extension/test/side-panel.test.ts b/apps/extension/test/side-panel.test.ts new file mode 100644 index 000000000..7a88a2553 --- /dev/null +++ b/apps/extension/test/side-panel.test.ts @@ -0,0 +1,139 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * Tests for the service-worker side-panel wiring (background/side-panel.ts). + * + * The toolbar ACTION keeps its existing popup; the PERSISTENT side panel is opened via a + * right-click "Open Solid side panel" action-menu entry. These assert: + * (1) registerSidePanel() creates exactly that menu entry (on the action context); + * (2) clicking it opens the side panel for the clicked window — in the click handler + * (the user-gesture turn chrome.sidePanel.open() requires); + * (3) an unrelated menu click is ignored; + * (4) it is a no-op (never throws) when chrome.sidePanel / chrome.contextMenus are absent. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { registerSidePanel } from '../src/background/side-panel'; + +type MenuClickListener = ( + info: { menuItemId: string }, + tab?: { id?: number; windowId?: number }, +) => void; + +interface CreatedMenu { + id?: string; + title?: string; + contexts?: string[]; +} + +let createdMenus: CreatedMenu[]; +let menuClickListeners: MenuClickListener[]; +let removeAllCalls: number; +let openCalls: Array<{ windowId?: number; tabId?: number }>; + +function installChrome(opts: { sidePanel?: boolean; contextMenus?: boolean } = {}): void { + const { sidePanel = true, contextMenus = true } = opts; + createdMenus = []; + menuClickListeners = []; + removeAllCalls = 0; + openCalls = []; + + const chromeStub: Record = { + runtime: { + onInstalled: { addListener: (_fn: () => void) => {} }, + }, + }; + if (contextMenus) { + chromeStub.contextMenus = { + removeAll: () => { + removeAllCalls += 1; + return Promise.resolve(); + }, + create: (menu: CreatedMenu) => { + createdMenus.push(menu); + }, + onClicked: { + addListener: (fn: MenuClickListener) => { + menuClickListeners.push(fn); + }, + }, + }; + } + if (sidePanel) { + chromeStub.sidePanel = { + open: (opts2: { windowId?: number; tabId?: number }) => { + openCalls.push(opts2); + return Promise.resolve(); + }, + }; + } + vi.stubGlobal('chrome', chromeStub); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('registerSidePanel — context-menu entry', () => { + beforeEach(() => installChrome()); + + it('creates a single "Open Solid side panel" entry on the action context (idempotent removeAll first)', async () => { + registerSidePanel(); + // createSidePanelMenu is async (removeAll → create); let it settle. + await Promise.resolve(); + await Promise.resolve(); + expect(removeAllCalls).toBeGreaterThanOrEqual(1); + expect(createdMenus).toHaveLength(1); + expect(createdMenus[0].title).toBe('Open Solid side panel'); + expect(createdMenus[0].contexts).toEqual(['action']); + expect(typeof createdMenus[0].id).toBe('string'); + }); + + it('registers a click listener', () => { + registerSidePanel(); + expect(menuClickListeners).toHaveLength(1); + }); +}); + +describe('the click handler opens the side panel', () => { + beforeEach(() => installChrome()); + + it('opens for the clicked window when the menu entry is clicked', async () => { + registerSidePanel(); + await Promise.resolve(); + const menuId = createdMenus[0].id as string; + menuClickListeners[0]({ menuItemId: menuId }, { id: 7, windowId: 42 }); + expect(openCalls).toEqual([{ windowId: 42 }]); + }); + + it('falls back to the tab id when no windowId is present', async () => { + registerSidePanel(); + await Promise.resolve(); + const menuId = createdMenus[0].id as string; + menuClickListeners[0]({ menuItemId: menuId }, { id: 7 }); + expect(openCalls).toEqual([{ tabId: 7 }]); + }); + + it('IGNORES an unrelated menu click (does not open)', async () => { + registerSidePanel(); + await Promise.resolve(); + menuClickListeners[0]({ menuItemId: 'some-other-menu' }, { windowId: 42 }); + expect(openCalls).toHaveLength(0); + }); +}); + +describe('graceful absence (older Chromium / test)', () => { + it('is a no-op and never throws when chrome.contextMenus is absent', () => { + installChrome({ contextMenus: false }); + expect(() => registerSidePanel()).not.toThrow(); + }); + + it('does not open and never throws when chrome.sidePanel is absent', async () => { + installChrome({ sidePanel: false }); + expect(() => registerSidePanel()).not.toThrow(); + await Promise.resolve(); + expect(menuClickListeners).toHaveLength(1); + // Clicking the entry must not throw even though sidePanel.open is missing. + const menuId = createdMenus[0].id as string; + expect(() => menuClickListeners[0]({ menuItemId: menuId }, { windowId: 1 })).not.toThrow(); + }); +}); diff --git a/apps/extension/webpack.config.cjs b/apps/extension/webpack.config.cjs index cb91f7028..4d1a0bda1 100644 --- a/apps/extension/webpack.config.cjs +++ b/apps/extension/webpack.config.cjs @@ -10,6 +10,7 @@ module.exports = { entry: { 'background/service-worker': './src/background/service-worker.ts', 'popup/popup': './src/popup/popup.ts', + 'sidepanel/sidepanel': './src/sidepanel/sidepanel.ts', 'content/content-script': './src/content/content-script.ts', 'inject/inject': './src/inject/inject.ts', }, @@ -39,7 +40,13 @@ module.exports = { { from: 'src/manifest.json', to: 'manifest.json' }, { from: 'src/popup/popup.html', to: 'popup/popup.html' }, { from: 'src/popup/popup.css', to: 'popup/popup.css' }, + { from: 'src/sidepanel/sidepanel.html', to: 'sidepanel/sidepanel.html' }, + { from: 'src/sidepanel/sidepanel.css', to: 'sidepanel/sidepanel.css' }, { from: 'src/icons', to: 'icons' }, + // The static Solid-OIDC Client Identifier Document (see client-id.ts). Shipped so a + // build can be hosted with the doc alongside; the in-flow client_id points at the + // eventual HOSTED URL (PUBLISHED_CLIENT_ID_URL), which is a needs:user placeholder. + { from: 'public/clientid.jsonld', to: 'clientid.jsonld' }, ], }), ], From a5cea07cc5a3c8d314e7a01ade29d6f10e4ce1c3 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Sun, 21 Jun 2026 01:03:17 +0100 Subject: [PATCH 25/50] fix(sidepanel): targeted context-menu remove + account-surface listener cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address two roborev findings on the side-panel/client-id work: - side-panel.ts (Medium): createSidePanelMenu() called chrome.contextMenus.removeAll(), which deletes EVERY context-menu item the extension owns — clobbering any other/future menu. Now removes ONLY the side-panel id via chrome.contextMenus.remove(OPEN_SIDE_PANEL_MENU_ID, cb) (callback swallows the "not found" lastError on first run), still idempotent across SW restarts. - account-surface.ts (Low): the chrome.runtime.onMessage listener was never removed — harmless for the ephemeral popup, but the long-lived side panel would accumulate duplicate listeners + refresh() calls on remount. mountAccountSurface() now returns { controller, cleanup }: cleanup() detaches every listener it added (login/session-change/sign-out/onMessage) and is generation-fenced + idempotent. Added a WeakMap double-mount guard keyed on the host document. sidepanel.ts keeps the handle and calls cleanup() on `pagehide`; the popup ignores the return (short-lived). Tests: side-panel test asserts remove(own-id) and NEVER removeAll(); new account-surface-cleanup test asserts the listener detaches, cleanup is idempotent, and remount is guarded. Both genuinely fail without the fix (verified). Gate green: lint + typecheck + 133 tests + webpack build. Model: claude-opus-4-8 Provenance: Opus 4.8 (Fable unavailable) — re-review/upgrade candidate Co-Authored-By: Claude Opus 4.8 --- apps/extension/src/background/side-panel.ts | 25 +++- apps/extension/src/popup/account-surface.ts | 87 +++++++++-- apps/extension/src/sidepanel/sidepanel.ts | 9 +- .../test/account-surface-cleanup.test.ts | 136 ++++++++++++++++++ apps/extension/test/side-panel.test.ts | 25 +++- 5 files changed, 263 insertions(+), 19 deletions(-) create mode 100644 apps/extension/test/account-surface-cleanup.test.ts diff --git a/apps/extension/src/background/side-panel.ts b/apps/extension/src/background/side-panel.ts index d10327165..973c332ae 100644 --- a/apps/extension/src/background/side-panel.ts +++ b/apps/extension/src/background/side-panel.ts @@ -19,12 +19,33 @@ const OPEN_SIDE_PANEL_MENU_ID = 'solid-open-side-panel'; +/** + * Remove ONLY our own side-panel menu entry (if present), ignoring the "item not found" + * error so first-create is fine. We must NOT use `contextMenus.removeAll()` here: that would + * delete EVERY context-menu item the extension owns, clobbering any other/future menu. + */ +function removeSidePanelMenu(): Promise { + return new Promise((resolve) => { + if (!chrome.contextMenus?.remove) { + resolve(); + return; + } + // The callback form lets us swallow chrome.runtime.lastError (the "Cannot find menu item" + // error on first run / after a SW restart cleared the registry) without it surfacing. + chrome.contextMenus.remove(OPEN_SIDE_PANEL_MENU_ID, () => { + void chrome.runtime?.lastError; // read-and-discard so it isn't reported + resolve(); + }); + }); +} + /** Create the "Open Solid side panel" context-menu entry on the action icon. */ async function createSidePanelMenu(): Promise { if (!chrome.contextMenus?.create) return; try { - // Remove-then-create is idempotent across SW restarts / reinstalls (a duplicate id throws). - await chrome.contextMenus.removeAll(); + // Remove-OUR-id-then-create is idempotent across SW restarts / reinstalls (a duplicate id + // throws) WITHOUT clobbering any other menu item — removeAll() would regress those. + await removeSidePanelMenu(); chrome.contextMenus.create({ id: OPEN_SIDE_PANEL_MENU_ID, title: 'Open Solid side panel', diff --git a/apps/extension/src/popup/account-surface.ts b/apps/extension/src/popup/account-surface.ts index 80bacdb2e..e1813db56 100644 --- a/apps/extension/src/popup/account-surface.ts +++ b/apps/extension/src/popup/account-surface.ts @@ -51,6 +51,25 @@ export interface AccountSurfaceElements { type View = 'signed-out' | 'busy' | 'signed-in'; +/** + * What {@link mountAccountSurface} returns: the live bridge controller (so callers/tests can + * inspect it) plus a `cleanup` that detaches every listener this mount added (notably the + * long-lived `chrome.runtime.onMessage` listener). The ephemeral popup can ignore the result; + * the persistent side panel MUST call `cleanup()` on teardown, otherwise a remount accumulates + * duplicate `onMessage` listeners (and duplicate refresh() calls) on the long-lived document. + */ +export interface MountedAccountSurface { + readonly controller: MessageBridgeLoginController; + readonly cleanup: () => void; +} + +/** + * A weak guard against double-mounting the SAME document: each mount tags its host document so a + * second mount (e.g. an accidental re-import in the long-lived side panel) is a no-op that just + * returns the existing mount's handle rather than stacking a second set of listeners. + */ +const MOUNTED = new WeakMap(); + /** Resolve the standard surface elements from a root document by their shared ids. */ export function resolveAccountSurfaceElements(doc: Document = document): AccountSurfaceElements { const byId = (id: string): T => doc.getElementById(id) as T; @@ -71,12 +90,23 @@ function send(message: unknown): Promise { /** * Wire the shared account surface against `els` and kick off the initial paint * ("Restoring…" → state read → signed-in / signed-out). Returns the live controller - * instance (the bridge) so callers/tests can inspect it. Idempotent per element set: - * call once per surface (popup.ts and sidepanel.ts each call it on their own DOM). + * instance (the bridge) plus a `cleanup` that detaches every listener added here (so the + * long-lived side panel can tear down without leaking the `chrome.runtime.onMessage` + * listener). Guarded against double-mounting the same document: a second call on a document + * already mounted returns the existing handle without stacking another set of listeners. + * + * The ephemeral popup may ignore the return (it is torn down with its document); the + * persistent side panel MUST call `cleanup()` on teardown / before any remount. */ -export function mountAccountSurface(els: AccountSurfaceElements): MessageBridgeLoginController { +export function mountAccountSurface(els: AccountSurfaceElements): MountedAccountSurface { const { signedOut, busy, signedIn, loginPanel, accountMenu, shortcutProfile } = els; + // Double-mount guard: if THIS document is already mounted, do not stack a second set of + // listeners (the long-lived side panel's failure mode) — return the existing handle. + const hostDoc = loginPanel.ownerDocument; + const existing = MOUNTED.get(hostDoc); + if (existing) return existing; + // Disable the panel's OWN silent-restore-on-connect via the PROPERTY (not a markup // attribute — `auto-restore="false"` reads ambiguous next to Lit's boolean-attribute // trap). This surface OWNS restore: the initial paint shows "Restoring…" and calls @@ -149,34 +179,65 @@ export function mountAccountSurface(els: AccountSurfaceElements): MessageBridgeL // --- Wiring ------------------------------------------------------------------------- + // Track every detach so cleanup() removes ALL listeners this mount added (the long-lived + // side panel relies on this; the ephemeral popup may ignore it). + const detachers: Array<() => void> = []; + // The panel drives login via the bridge (chrome.identity flow in the worker). - loginPanel.addEventListener('login', (e) => { + const onLogin = (e: Event): void => { const detail = (e as CustomEvent).detail; if (detail?.webId) void refresh(); - }); - loginPanel.addEventListener('session-change', (e) => { + }; + loginPanel.addEventListener('login', onLogin); + detachers.push(() => loginPanel.removeEventListener('login', onLogin)); + + const onSessionChange = (e: Event): void => { const detail = (e as CustomEvent).detail; // A logged-IN change → confirm with the worker and render signed-in. A logged-OUT // change (incl. the one emitted during the showSignedOut() swap) only needs the // signed-out section visible — NOT another swap (which would re-emit and recurse). if (detail?.loggedIn) void refresh(); else show('signed-out'); - }); + }; + loginPanel.addEventListener('session-change', onSessionChange); + detachers.push(() => loginPanel.removeEventListener('session-change', onSessionChange)); // The account-menu emits `sign-out` → tear down in the worker, then re-render. - accountMenu.addEventListener('sign-out', () => { + const onSignOut = (): void => { void send({ type: 'SOLID_LOGOUT' }).then(() => refresh()); - }); + }; + accountMenu.addEventListener('sign-out', onSignOut); + detachers.push(() => accountMenu.removeEventListener('sign-out', onSignOut)); // React to background state broadcasts while the surface is open (a page logs in, or the - // worker's token expires/logs out): re-read state and re-render. - chrome.runtime.onMessage.addListener((message) => { + // worker's token expires/logs out): re-read state and re-render. This is the LONG-LIVED + // listener: the side panel persists across navigation, so a remount without cleanup would + // accumulate duplicate onMessage listeners (and duplicate refresh() calls). + const onRuntimeMessage = (message: { type?: string }): void => { if (message?.type === 'SOLID_STATE_CHANGED') void refresh(); - }); + }; + chrome.runtime.onMessage.addListener(onRuntimeMessage); + detachers.push(() => chrome.runtime?.onMessage?.removeListener?.(onRuntimeMessage)); // Initial paint: show "Restoring" while we read state. show('busy'); void refresh(); - return bridge; + let cleaned = false; + const cleanup = (): void => { + if (cleaned) return; // idempotent + cleaned = true; + refreshGeneration++; // fence: any in-flight refresh resolving after teardown is a no-op + for (const detach of detachers) detach(); + MOUNTED.delete(hostDoc); + }; + + const mounted: MountedAccountSurface = { + get controller() { + return bridge; + }, + cleanup, + }; + MOUNTED.set(hostDoc, mounted); + return mounted; } diff --git a/apps/extension/src/sidepanel/sidepanel.ts b/apps/extension/src/sidepanel/sidepanel.ts index f289388c0..e4a622359 100644 --- a/apps/extension/src/sidepanel/sidepanel.ts +++ b/apps/extension/src/sidepanel/sidepanel.ts @@ -18,4 +18,11 @@ import '@jeswr/solid-elements'; import { mountAccountSurface, resolveAccountSurfaceElements } from '../popup/account-surface'; // Wire the shared account surface (busy → signed-in / signed-out, all the auth invariants). -mountAccountSurface(resolveAccountSurfaceElements()); +// The side panel is LONG-LIVED (persists across navigation), so — unlike the ephemeral popup — +// we keep the cleanup handle and detach the listeners on teardown. Without this, the long-lived +// `chrome.runtime.onMessage` listener (and its refresh() calls) would leak / accumulate across +// any remount. (mountAccountSurface also guards against double-mounting the same document.) +const { cleanup } = mountAccountSurface(resolveAccountSurfaceElements()); + +// `pagehide` fires when the panel document is torn down (closed / navigated away); detach there. +globalThis.addEventListener?.('pagehide', cleanup, { once: true }); diff --git a/apps/extension/test/account-surface-cleanup.test.ts b/apps/extension/test/account-surface-cleanup.test.ts new file mode 100644 index 000000000..ee60cde1e --- /dev/null +++ b/apps/extension/test/account-surface-cleanup.test.ts @@ -0,0 +1,136 @@ +// @vitest-environment jsdom +// AUTHORED-BY Claude Opus 4.8 +/** + * Listener-lifecycle for the shared account surface (account-surface.ts) — the roborev finding + * that the long-lived side panel must NOT leak / accumulate `chrome.runtime.onMessage` listeners. + * + * The popup is ephemeral (torn down with its document) so it may ignore the return value, but + * the side panel is LONG-LIVED, so mountAccountSurface(): + * (1) returns a `cleanup()` that removes the onMessage listener it added; + * (2) guards against double-mounting the SAME document (a second mount stacks no listeners). + * + * These run on the real custom elements (jsdom + @jeswr/solid-elements) so they exercise the + * real wiring path, not a stub. + */ + +import '@jeswr/solid-elements'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + type MountedAccountSurface, + mountAccountSurface, + resolveAccountSurfaceElements, +} from '../src/popup/account-surface'; + +let onMessageListeners: Array<(message: { type?: string }) => void>; + +/** + * Mount the surface AND track the handle so afterEach can tear it down. The mount guard is keyed + * on the (shared, per-file) jsdom `document`, so a test that left a live mount would otherwise + * leak the guard entry into the next test — tracking + cleanup keeps the tests independent. + */ +const liveMounts: MountedAccountSurface[] = []; +function mount(): MountedAccountSurface { + const handle = mountAccountSurface(resolveAccountSurfaceElements()); + liveMounts.push(handle); + return handle; +} + +/** Build the surface markup exactly as popup.html / sidepanel.html do. */ +function mountSurfaceDom(): void { + document.body.innerHTML = ` + + + + `; +} + +/** A chrome stub whose onMessage add/removeListener are observable. */ +function installChrome(): void { + onMessageListeners = []; + vi.stubGlobal('chrome', { + runtime: { + lastError: undefined as { message?: string } | undefined, + sendMessage: (message: { type?: string }, callback: (response: unknown) => void) => { + if (message?.type === 'SOLID_GET_STATE') { + callback({ + webId: null, + isActive: false, + name: null, + photoUrl: null, + recentAccounts: [], + }); + } else { + callback({ ok: true }); + } + }, + onMessage: { + addListener: (fn: (message: { type?: string }) => void) => { + onMessageListeners.push(fn); + }, + removeListener: (fn: (message: { type?: string }) => void) => { + onMessageListeners = onMessageListeners.filter((l) => l !== fn); + }, + }, + }, + }); +} + +beforeEach(() => { + mountSurfaceDom(); + installChrome(); +}); + +afterEach(() => { + // Tear down any still-live mount so its guard entry doesn't leak into the next test. + for (const m of liveMounts) m.cleanup(); + liveMounts.length = 0; + vi.unstubAllGlobals(); + document.body.innerHTML = ''; +}); + +describe('mountAccountSurface — listener lifecycle (long-lived side panel)', () => { + it('registers exactly one onMessage listener on mount', () => { + mount(); + expect(onMessageListeners).toHaveLength(1); + }); + + it('cleanup() removes the onMessage listener it added', () => { + const { cleanup } = mount(); + expect(onMessageListeners).toHaveLength(1); + cleanup(); + expect(onMessageListeners).toHaveLength(0); + }); + + it('cleanup() is idempotent (a second call does not throw / under-remove)', () => { + const { cleanup } = mount(); + cleanup(); + expect(() => cleanup()).not.toThrow(); + expect(onMessageListeners).toHaveLength(0); + }); + + it('guards a double-mount on the SAME document — no second listener is stacked', () => { + const first = mount(); + const second = mount(); + // Still exactly one listener; the second call returned the existing handle. + expect(onMessageListeners).toHaveLength(1); + expect(second).toBe(first); + }); + + it('after cleanup() a fresh mount works again (re-registers a single listener)', () => { + const { cleanup } = mount(); + cleanup(); + expect(onMessageListeners).toHaveLength(0); + mount(); + expect(onMessageListeners).toHaveLength(1); + }); + + it('exposes the live bridge controller via .controller', () => { + const { controller } = mount(); + expect(controller).toBeDefined(); + }); +}); diff --git a/apps/extension/test/side-panel.test.ts b/apps/extension/test/side-panel.test.ts index 7a88a2553..3089c09db 100644 --- a/apps/extension/test/side-panel.test.ts +++ b/apps/extension/test/side-panel.test.ts @@ -28,6 +28,7 @@ interface CreatedMenu { let createdMenus: CreatedMenu[]; let menuClickListeners: MenuClickListener[]; let removeAllCalls: number; +let removedIds: string[]; let openCalls: Array<{ windowId?: number; tabId?: number }>; function installChrome(opts: { sidePanel?: boolean; contextMenus?: boolean } = {}): void { @@ -35,19 +36,27 @@ function installChrome(opts: { sidePanel?: boolean; contextMenus?: boolean } = { createdMenus = []; menuClickListeners = []; removeAllCalls = 0; + removedIds = []; openCalls = []; const chromeStub: Record = { runtime: { + lastError: undefined as { message?: string } | undefined, onInstalled: { addListener: (_fn: () => void) => {} }, }, }; if (contextMenus) { chromeStub.contextMenus = { + // removeAll MUST NOT be used by the production code (it would clobber other menus); we + // keep the stub so a regression to removeAll() is observable via removeAllCalls. removeAll: () => { removeAllCalls += 1; return Promise.resolve(); }, + remove: (id: string, callback?: () => void) => { + removedIds.push(id); + callback?.(); + }, create: (menu: CreatedMenu) => { createdMenus.push(menu); }, @@ -76,18 +85,28 @@ afterEach(() => { describe('registerSidePanel — context-menu entry', () => { beforeEach(() => installChrome()); - it('creates a single "Open Solid side panel" entry on the action context (idempotent removeAll first)', async () => { + it('creates a single "Open Solid side panel" entry on the action context (idempotent remove-OWN-id first)', async () => { registerSidePanel(); - // createSidePanelMenu is async (removeAll → create); let it settle. + // createSidePanelMenu is async (remove → create); let it settle. await Promise.resolve(); await Promise.resolve(); - expect(removeAllCalls).toBeGreaterThanOrEqual(1); expect(createdMenus).toHaveLength(1); expect(createdMenus[0].title).toBe('Open Solid side panel'); expect(createdMenus[0].contexts).toEqual(['action']); expect(typeof createdMenus[0].id).toBe('string'); }); + it('removes ONLY its own menu id before re-creating — never removeAll() (would clobber other menus)', async () => { + registerSidePanel(); + await Promise.resolve(); + await Promise.resolve(); + // The fix: a targeted remove of exactly the side-panel id, NOT a blanket removeAll(). + expect(removeAllCalls).toBe(0); + expect(removedIds).toHaveLength(1); + // The removed id is the same id that is then (re-)created — idempotent for just our item. + expect(removedIds[0]).toBe(createdMenus[0].id); + }); + it('registers a click listener', () => { registerSidePanel(); expect(menuClickListeners).toHaveLength(1); From ae16e6e53c26e5ba63d4d9ce542e61a2566223cf Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Sun, 21 Jun 2026 15:50:34 +0100 Subject: [PATCH 26/50] docs(design): shared-replica routing architecture (one extension-owned pod replica across origins + browser-side default-deny access control) Design record from the PSS-agent judge-panel design pass (3 routing mechanisms + adversarial security incl. cross-app leak). Recommends: MAIN-world fetch patch -> isolated bridge -> SW (NOT declarativeNetRequest, which cannot route/sign DPoP); SW default-deny per-requesting-origin gate FIRST on read/cache-hit/write; (WebID,pod-origin,grant-scope) cache key; shared replica gated behind Phase-0 security + the tt6 data-class consent runtime. Ships single-user owner-only mode first. Model: claude-opus-4-8 Co-Authored-By: Claude Opus 4.8 --- .../design/shared-replica-architecture.md | 406 ++++++++++++++++++ 1 file changed, 406 insertions(+) create mode 100644 apps/extension/docs/design/shared-replica-architecture.md diff --git a/apps/extension/docs/design/shared-replica-architecture.md b/apps/extension/docs/design/shared-replica-architecture.md new file mode 100644 index 000000000..58c5b8d40 --- /dev/null +++ b/apps/extension/docs/design/shared-replica-architecture.md @@ -0,0 +1,406 @@ +# Shared-Replica Routing for the Solid Browser Extension + +> **Status:** Design proposal (research + design record). Authored by the PSS agent +> (Claude Opus 4.8). Target repo: `jeswr/solid-browser-extension` (PUBLIC, MV3). +> Couples to `full-solid-ecosystem/docs/design/access-management-solid-lws.md` (tt6). +> **This is an experimental design; the extension is under active development — not a +> production, live, or supported component.** + +## 0. The problem in one paragraph + +The extension already brings Solid sign-in to the browser: it injects `window.solid` +(a DPoP-authenticated `fetch` + the WebID), holds the DPoP key + tokens ONLY in the +service worker (SW), and proxies every pod request through the SW behind a fail-closed +TARGET-origin credential gate. The refinement this doc designs is the **offline-first** +layer. The naive approach — give every Solid app a `@jeswr/solid-offline` service worker +— **duplicates the same pod data N times across N apps**, because browser Cache/IndexedDB +are partitioned by the visiting app's top-level site. We instead make the **extension own +ONE live, authenticated local replica per (user, pod), in the extension's own +`chrome-extension://` storage (NOT partitioned by the visited app's origin), SHARED across +every app, origin, and window.** The SW is the sole egress (sole DPoP-key holder) and the +sole offline-cache owner; WebSocketChannel2023 invalidation runs ONCE centrally. The +consequence is that **per-origin access control becomes load-bearing**: one shared replica +means any app could otherwise read the whole pod, so the extension MUST enforce which app +may read/write which data at the browser boundary — the runtime home for the tt6 data-class +consent model. + +## 1. Per-origin duplication — the problem this removes + +Verified against Chrome's [Storage and cookies](https://developer.chrome.com/docs/extensions/develop/concepts/storage-and-cookies) +and [Storage Partitioning](https://developer.chrome.com/en/docs/privacy-sandbox/storage-partitioning/) +docs: + +- A normal web page's `CacheStorage` + `IndexedDB` are **partitioned by the top-level + site**. App A at `a.example` and App B at `b.example` each get their OWN partition. If + both register `solid-offline`, the user's pod `alice.pod/finance/…` is cached **twice** — + once per app — and revalidated twice, with two WebSocket subscriptions. With N apps, N + copies + N sockets. +- The **`chrome-extension://` origin is a single top-level partition** shared across + the SW, popup, side panel, and offscreen documents, and is **NOT partitioned by the + visited site**. One physical store, reachable from one place. + +So holding the replica in the extension's own storage collapses N copies to ONE, N sockets +to ONE, and N revalidation pipelines to ONE — and makes cross-app consistency automatic +(App B sees App A's confirmed write because they read the same bytes). + +## 2. The single shared extension-owned replica + +### 2.1 Storage layout + +ONE replica per `(WebID, pod-origin)` in the extension SW's own storage, with +`unlimitedStorage`: + +- **Bytes** → `CacheStorage` (survives SW restarts, available in the SW context), keyed by + a canonical synthetic `(podResourceUrl, varyKey)` Request — reusing `@jeswr/solid-offline` + `cache-coherence.ts` (`putCanonicalBytes` / `CANONICAL_TURTLE_VARY_KEY`) so there is one + byte copy per resource, not per content-type-variant-per-app. +- **Metadata** (ETag, fetchedAt, max-age, variant set, the OWNING grant-scope) → `IndexedDB` + via `@jeswr/solid-offline` `MetadataStore` (`metadata-store.ts`). +- **Session + DPoP key + grant table** → `chrome.storage.local` (page-unreachable; already + the home of the keypair today). +- **DPoP nonce cache** → MOVED from the in-memory `nonceByOrigin` Map (service-worker.ts:59) + to IndexedDB, so a cold SW wake does not pay the §8 nonce round-trip on every first request. + +**LOAD-BEARING CACHE-KEY FIX (review finding).** `@jeswr/solid-offline` `scope.ts` states +verbatim that its WebID hash is "only a *namespacing discriminator*, NOT a security +boundary: the real boundary is the browser's origin isolation." In the extension partition +there IS no origin boundary underneath. So the cache key MUST include the active **WebID** +(and the grant-scope, §4), and every read MUST verify the active-WebID namespace, never just +the ETag — otherwise a same-URL+same-ETag collision across two users on one device (a shared +or public resource, or a re-provisioned WebID) serves user A's bytes to user B. + +### 2.2 Consistency model + +Read-from-cache + always-revalidate (never-authoritative stale-while-revalidate), reusing +`@jeswr/solid-offline` `swr.ts`. A cache value is provisional until a conditional +`If-None-Match` GET returns 304/200. Because every app reads the same replica: + +- App B sees App A's write the instant the write-through completes AND the metadata ETag + updates and the change is broadcast (see §2.3 for the MV3 caveat on broadcast atomicity). +- A single in-SW single-flight (the existing `SingleFlight`) coalesces concurrent reads of + the same URL so N apps loading the same resource cause ONE egress. +- The replica is **never authoritative**: the real pod is the source of truth; on any + ETag/cache mismatch the SW revalidates against the pod before serving (mirrors the PSS + S3/QLever "cache is never authoritative" invariant). + +### 2.3 Write path + +A write (PUT/POST/PATCH/DELETE) from any app routes through the same patch→bridge→SW path. +The SW is the single write funnel. **Phase-1 = synchronous WRITE-THROUGH, not +optimistic** (a shared replica makes a bad optimistic write visible to every other app, so +writes must be authoritative-on-success): + +1. **Access-control check FIRST** — does this requesting origin hold a *write* grant for this + target/data-class? Default-deny (§4). +2. The SW issues the DPoP-signed write to the real pod, carrying `If-Match:` + when the app supplied none (cross-app lost-update guard — App B cannot silently clobber + App A). +3. On 2xx: update the shared replica bytes + metadata ETag **from the pod's response** (not + the app's optimistic copy) and broadcast `{url, event:'updated', etag}` to every tab. + On 412: surface the conflict verbatim to the caller (same contract as a direct pod write) + and revalidate. On 5xx/offline: **FAIL the write back to the app** (no silent queue in v1). +4. The optimistic-mutation UX invariant is delivered at the **app layer** (the app paints + immediately, shows Saving/Saved) over a write-through that confirms; the replica stays + coherent. + +**MV3 write-visibility caveat (review finding).** The SW can be suspended AFTER the pod 2xx +but BEFORE step 3 persists+broadcasts. The write is not lost (the pod has it) but cross-app +visibility is not atomic with the write; other apps see it on next revalidation / WS frame. +Convergence is eventual, not instant — never-authoritative-revalidate keeps it *correct*. + +**Offline write QUEUE is an explicit Phase-3 deferral** (matches tt6 + solid-offline both +deferring offline writes): a durable per-resource outbox replayed on reconnect must (a) be +keyed by the unforgeable requesting origin, (b) **re-check the grant at replay time** (a +grant may have been revoked between enqueue and replay), and (c) surface a 412 on replay, +never auto-merge. Do not ship until that conflict+revocation path is built. + +## 3. The three routing mechanisms compared + +| Mechanism | Can carry per-request DPoP at egress? | Can serve a replica body? | Boot-race? | Blocks cross-app leak? | Verdict | +|---|---|---|---|---|---| +| **MAIN-world global-fetch patch → ISOLATED bridge → SW** (RECOMMENDED) | **Yes** — the SW signs each proof; key never leaves SW | **Yes** — SW returns a serialized Response | Patch is racy + bypassable (verified), so it is **transparency only, zero security weight** | **Only if** the SW enforces a per-requesting-origin default-deny gate (the graft below) | **Recommended** | +| **declarativeNetRequest redirects the pod request to the SW** | **No** — DNR has no JS callback, cannot carry/sign a per-request proof | **No** — DNR cannot synthesize/modify a response body; redirect drops method+body | Race-free (installed before page scripts) but unusable as a data path | n/a — cannot route at all | **Infeasible as router** (3 verified blockers) | +| **DNR as a fail-closed bypass backstop** (defense-in-depth) | n/a | n/a | Race-free | **No** — cannot cleanly exempt the SW's own egress while blocking page bypass | **Best-effort observer only, never relied on** | + +**Verified facts behind the table** (Chrome for Developers, June 2026): + +- MAIN-world `document_start` content scripts are **NOT guaranteed to run before the page's + own inline scripts** (inline scripts are parser-blocking; src scripts are not) — so a page + can capture pristine `fetch` first. ([content-scripts manifest doc](https://developer.chrome.com/docs/extensions/reference/manifest/content-scripts), [chromium-extensions thread](https://groups.google.com/a/chromium.org/g/chromium-extensions/c/eVZd_vOIryc)) +- `declarativeNetRequest` **cannot modify response bodies** (headers only), a redirect target + must be a static web-accessible resource (no JS callback), and **it DOES affect `fetch()` + made in a service worker** — so the extension SW's own legitimate pod egress is itself + subject to a pod-host DNR rule. The only way to exempt it (`excludedInitiatorDomains` / + tabId -1) **also stops the rule matching website-service-worker-initiated requests**, so a + malicious page can bypass the backstop via its own page SW. ([declarativeNetRequest API](https://developer.chrome.com/docs/extensions/reference/api/declarativeNetRequest)) The backstop is therefore NOT reliably fail-closed; the real guarantee must not depend on intercepting page traffic. + +## 4. Security findings + +### 4.1 Cross-app data leak — the headline (CRITICAL as code stands today) + +All three adversarial reviews landed the same break against the ACTUAL code: + +- `service-worker.ts` `handleFetch(message)` consumes `message.url/method/headers/body` and + **ignores `_sender`** (line 253). There is no per-requesting-origin authorization. +- `content-script.ts` forwards the fetch with **no origin stamp** (lines 39-53); it stamps + `window.location.origin` only for `SOLID_LOGIN` / `SOLID_SET_CLIENT_ID`. +- The only gate (`origin-policy.ts` `isOriginAllowed`) is about the **TARGET pod** ("don't + send the token to evil.com"), not the **requesting app**. +- `inject.ts` `requestAccess()` **throws "not implemented"** (lines 138-145). +- The cache key has **no app/origin dimension**; `manifest.json` injects `window.solid` on + ``. + +So **today any origin can call `window.solid.fetch(podUrl)` and read/write the whole pod.** +That is tolerable in the current single-user product (no shared replica, no per-app promise), +but it is FATAL the instant a shared replica exists. **`crossAppLeakBlocked = false` as +written.** This is a pre-existing exposure worth a tracked security bead on +`solid-browser-extension` regardless of the shared-replica work. + +### 4.2 Key custody — the strongest property (PRESERVED, do not touch) + +Refuted by all reviewers. The DPoP keypair lives only as JWK in `chrome.storage.local`, +reachable only from extension contexts; `createDpopProof` (`dpop.ts`) exports ONLY the +public components (kty/crv/x/y, never `d`); the page receives only a WebID + a reconstructed +Response; page-supplied `Authorization`/`DPoP` headers are stripped (`authenticated-fetch.ts` +`HOP_BY_HOP`). Residual LOW: the key is generated **extractable** (an MV3 necessity — a +non-extractable key is lost on SW suspension, breaking the jkt-bound refresh) and co-located +with the refresh token in `chrome.storage.local`, so any extension-context compromise +exfiltrates both from one store. Not page-reachable; a concentrated single point worth noting. + +### 4.3 Other verified findings (graft the mitigation into the recommendation) + +- **Forge the access-control anchor.** `sender.origin` is renderer-spoofable; the + content-script stamp is read in the same renderer. Mitigation: default-deny + SW-sole- + enforcement so a forged origin only reaches THAT origin's grants; require BOTH anchors to + agree; never trust a page/bridge-supplied origin FIELD — read `chrome.runtime` `sender`. +- **Boot-race / page-SW / XHR bypass.** Fail-safe for the credential (a bypassed request is + unauthenticated → pod 401), but can reach PUBLIC pod data. Mitigation: server-side WAC is + the ultimate boundary; the SW gate + the (best-effort) DNR observer are layers, not the + guarantee. +- **Cross-USER stale bytes.** `logout.ts` purge is async/best-effort and MV3-interruptible; + the WebID-omitted cache key collides. Mitigation: WebID in the key + **synchronous-before- + serve purge** (block a new session's reads until the prior identity's stores are fully + dropped). +- **Refresh-token rotation across SW suspension.** A mid-refresh kill can burn the rotation- + bound refresh token (DoS). Mitigation: persist the rotated token atomically BEFORE use; the + existing `refreshGate` single-flight is in-memory and must be re-derivable on wake. +- **resolveClass widening (tt6 §6.2).** A pod-controlled Type Index / SHACL shape can widen a + data-class grant. Mitigation: SHACL-validate at resolution; pin the exact shape IRI at + consent; read-only Phase-1; write-by-class pins explicit targets. +- **Foreign-origin fetch ambient cookies.** `window.solid.fetch('https://tracker/')` is run + by the SW as a plain fetch carrying extension-context ambient cookies (CSP-free tracking + amplification). Mitigation: same-origin-or-deny policy for foreign SW fetches. +- **PII broadcast.** The WebID (and any pod-origin allowlist) is pushed to every script on + ``. Mitigation: scope presence/WebID to apps that opt in. + +## 5. Recommended architecture (implementable) + +### 5.1 Routing + +PRIMARY = **MAIN-world global-fetch patch → ISOLATED bridge → SW**, a generalization of the +existing `window.solid.fetch` proxy: + +- In `inject.ts`, in addition to defining `window.solid`, install a global wrapper anchored + on the captured pristine fetch: + `const real = window.fetch; window.fetch = (i,init) => isPodUrl(i) ? solidFetch(i,init) : real(i,init)`. + Reuse the `@jeswr/solid-elements` `installProactiveAuthFetch` discipline (snapshot the + pristine fetch, build once, re-assert ownership, never re-read a possibly-patched global), + but its `base` transport is the postMessage→SW bridge, NOT a page-side authenticated fetch. +- `isPodUrl` diverts only known pod origins; everything else hits the untouched native fetch + (no proxy tax, no breakage of unrelated requests). Do NOT broadcast the full pod-origin + list to the page (PII); use a minimal predicate or route everything and let the SW gate + decide. +- **Register the MAIN-world script programmatically from the SW** + (`chrome.scripting.registerContentScripts({world:'MAIN', runAt:'document_start', + persistAcrossSessions:true})`) — the most reliable MAIN injection (the MetaMask + `window.ethereum` pattern). +- **The patch is best-effort transparency with ZERO security weight.** It is racy and + bypassable (verified). A missed/bypassed request is a plain unauthenticated fetch (pod 401, + fail-safe for the credential) and the server's own WAC governs what it returns. +- DNR is retained ONLY as a best-effort defense-in-depth observer/blocker; it is NEVER the + router and NEVER relied on as fail-closed (its self-egress exclusion hole is verified). + +### 5.2 Replica + key in the SW + +As §2: bytes in `CacheStorage`, metadata in `IndexedDB`, key+session+grants in +`chrome.storage.local`, nonce cache moved to IndexedDB; cache key includes WebID + grant- +scope; everything re-hydratable on SW wake (no load-bearing in-memory state). + +### 5.3 Egress DPoP (preserve verbatim — strongest property) + +The SW is the SOLE egress and SOLE key holder. Per-request signing via the existing +`createDpopProof` (Web Crypto + jose; ES256; public-only `jwk` header; `htm/htu/iat/jti`; +`ath` = base64url(SHA-256(token)); §8 nonce echoed on challenge), one §8 nonce retry with a +per-origin nonce cache, exactly-one retry. Proactive refresh ~30s before expiry, single- +flighted via `refreshGate`, same keypair reused so the pod `jkt` binding holds. Refresh-token +persistence + the fail-closed silent-restore decision reuse `@jeswr/solid-session-restore`. +The page only ever receives a WebID + a proxied Response. (Note: the live PSS RS runs +`allowMissingAth` compat, so end-to-end token-binding at the RS is weaker than the proof +shape implies — orthogonal to the extension but relevant to the PoP story.) + +### 5.4 Per-origin default-deny access control + the tt6 data-class hook + +THE load-bearing graft. Enforced in the SW, BEFORE any cache hit or egress, on every read, +**every cache hit**, and every write: + +1. **Forward + enforce the true requesting origin on EVERY fetch.** Change the + `SOLID_FETCH_REQUEST` relay to stamp `window.location.origin` (as it already does for + login), AND have `handleFetch` read `chrome.runtime` `sender.origin` (browser-attested). + Both must agree. **Never** trust a page-supplied origin field. **Fail-closed on + opaque/null origins** (sandboxed iframe, `srcdoc`, `about:blank`, `data:`) — never a + shared "null" bucket. +2. **Default-deny.** Resolve `(origin, target-URL, method) → grant decision`. No grant ⇒ the + SW returns a 403-equivalent WITHOUT touching the replica or the pod (so the shared replica + cannot serve cached private bytes to a non-consented app). NOT sent unauthenticated. +3. **Re-check the gate on cache HITS, not just egress** — the central authenticated + revalidation lands bytes in one replica that multiple un-equal-trust origins can read, so + the gate must mediate every read of the shared bucket. Partition the cache by grant-scope + so a missing grant cannot even reach a shared body. +4. **tt6 data-class hook = the policy source.** The gate consults a per-origin grant store + whose grants are tt6 `accm:`/SAI `AccessGrant` records resolved to concrete targets via + the tt6 ladder (`accm:resolvesTo`: SAI `interop:DataRegistration.registeredShapeTree` → + Type-Index `solid:instanceContainer` → **SHACL-validate**). "This app may read finance + data" compiles to "origin X may read {container set Y}". Consent UI renders in EXTENSION + chrome (popup/side-panel), unspoofable by the requesting origin; the grant is bound to the + content-script-stamped+sender-attested true origin. The `window.solid.requestAccess` seam + (today a throwing stub) is wired to `requestAccess`/`listGrants`/`revokeGrant`/ + `resolveClass`. +5. **Read-only Phase-1 grants**; write-by-class pins exact targets (tt6 §2.3 — never let an + app-influenced class definition broaden its write surface). **Revocation must invalidate + already-served/replica bytes + any queued writes, not just future fetches.** + +### 5.5 Offline-first + WS invalidation (the inversion) + +Reuse `@jeswr/solid-offline` modules INVERTED for the authenticated extension SW: + +- Verbatim: `swr.ts` (never-authoritative SWR), `cache-coherence.ts` (synthetic-key keying), + `metadata-store.ts`, `invalidation.ts` (WS-frame → revalidate-or-purge with the ETag self- + write short-circuit). +- **The inversion:** `solid-offline`'s SW is UNAUTHENTICATED by design — `notifications.ts` + states verbatim "THE WEBSOCKET LIVES IN THE PAGE (decision 5), NEVER THE SW … the SW only + invalidates." In the EXTENSION the SW IS the authenticated owner, so (a) the injected + `fetch` port given to swr/invalidation is the AUTHENTICATED DPoP egress (private resources + revalidate, not 401), and (b) the WebSocketChannel2023 subscription lives in the EXTENSION + SW — ONE socket per pod container for ALL apps. This re-opens `solid-offline`'s threat model + (authenticated subscribe POST + one-socket-per-pod fan-out) and **needs its own audit before + reuse** — flagged, not assumed safe. Discovery + subscribe use the suite + `solid-notifications` path; the notification `state` field === the resource ETag (the join + invalidation already exploits). A central invalidation broadcasts to every tab so all apps + re-render. + +### 5.6 MV3 persistence + +- The MV3 SW is terminated after ~30s idle (verified) — durable state must survive: bytes + (Cache), metadata + nonce (IndexedDB), key+session+grants (`chrome.storage.local`). On + every wake re-open handles + rebuild in-memory nonce/single-flight lazily. +- The WS does NOT survive suspension. A live WS extends SW lifetime (Chrome 116+: WS + send/receive resets the idle timer — verified), but Chrome still reclaims on memory + pressure. On every wake run `resyncSweep` (conditional-revalidate the warmed set) then + re-subscribe; a `chrome.alarms` heartbeat (min 30s) re-asserts subscriptions + sweeps. + Reads are never-authoritative, so a momentarily-missed invalidation is one stale-then- + revalidated read, never a wrong write. + +### 5.7 Trustworthy presence signal + +- Presence = `window.solid` present + `window.solid.webId` + a `solid:ready` CustomEvent + + a frozen capability handshake `{present:true, version, capabilities:['fetch', + 'globalFetchPatch','accessControl','sharedReplica']}`. `window.solid` is installed with + `Object.defineProperty(window,'solid',{configurable:false})` (already done) so a later page + script cannot replace it. +- **Presence is a UX/cooperation signal, NOT a security boundary** — a page can pre-define a + fake `window.solid` (boot race) to lie to its own scripts, but gains no cross-origin trust + and no data (the SW gate is the boundary; a forged presence cannot mint a DPoP proof). The + only TRUE presence proof is capability: a successful DPoP-authed pod read the page did not + supply a token for. +- Apps use presence to skip their own login and hide their own profile chrome; the pinned + toolbar avatar is the user-facing signal. **Scope the WebID broadcast to opt-in apps**, not + every script on `` (PII). + +## 6. Coupling to tt6 (access-management) — what ships first + +- **Ships BEFORE access-mgmt lands — owner-only single-user default.** The extension's strong, + already-built single-user mode: ONE user, the existing per-user pod-origin allowlist as the + grant store, the SW as sole key holder + egress, the DPoP egress preserved. In this mode + there is no cross-app boundary to enforce because there is effectively one trust principal + (the owner) and a coarse per-pod allowlist — which is exactly why the **shared replica must + NOT be turned on in this mode** (a coarse per-pod-origin grant is per-USER, not per-APP, so + it provides NO cross-app isolation). Ship offline-first per-app-cache OR single-user only + until the gate lands. +- **What the browser-side enforcement needs from tt6 (the access-mgmt design):** the + `client→consent→grant` RUNTIME — a per-origin grant store keyed by `(requesting-origin → + data-class → mode → expiry)`, resolved to concrete targets via `accm:resolvesTo` (SAI + DataRegistration → Type Index → SHACL-validate). The extension is tt6's browser-side + enforcement point: it authors SAI/ACL grants as ordinary authenticated writes (no server + change for read grants) and gates every routed request against the resolved grant set. +- **What tt6 sequences AFTER (CORE-PSS, maintainer-gated):** the server-native Authorization + Agent + request-inbox (LDN) + class→target resolver + WAC/ACP materialisation for the + seamless, scalable flow. The extension can do client-side class→target resolution today; + server help is wanted for materialization at scale. **tt6 itself sequences the extension + consent path LAST and flags the confused-deputy mislabelling attack as open** — so the + shared replica is gated behind the per-app/per-data-class grant store, not the coarse store. + +## 7. Phased build plan + +The non-negotiable ordering: **build the boundary, prove it adversarially, THEN turn on the +shared replica.** + +- **Phase 0 — Hardening prerequisite (security-critical; build + prove FIRST).** Forward the + true requesting origin on every `SOLID_FETCH_REQUEST` (content-script stamp + `sender` + cross-check); add a SW default-deny per-origin gate as the FIRST check on read/cache-hit/ + write; fail-closed on opaque/null origins; add the same-origin-or-deny foreign-fetch policy; + stop broadcasting the pod-origin allowlist + scope the WebID to opt-in. Adversarial tests: + forged `sender.origin` → DENY, whole-pod read by an ungranted origin → DENY, app-A requests + app-B-only resource → DENY, opaque-origin → DENY, race the boot patch → fail-safe. Land this + on the single-user product BEFORE any replica work. File the pre-existing `` + full-pod-exposure security bead. +- **Phase 1 — Shared-replica + routing + egress-DPoP core (behind the Phase-0 gate).** The + global-fetch patch (programmatic MAIN injection); the `(WebID, pod-origin, grant-scope)`- + keyed replica in `CacheStorage`+`IndexedDB`; nonce cache → IndexedDB; synchronous-before- + serve logout purge + atomic rotated-refresh-token persistence; preserve the DPoP egress + verbatim. Read-from-cache+revalidate; synchronous write-through. Re-check the gate on cache + hits. +- **Phase 2 — Per-origin access-control enforcement + tt6 data-class hook.** Wire + `window.solid.requestAccess`/`listGrants`/`revokeGrant`/`resolveClass` to the tt6 grant + store; consent UI in extension chrome bound to the true origin; SHACL-validate `resolveClass`; + read-only class grants; revocation invalidates served/replica bytes. +- **Phase 3 — `@jeswr/solid-offline` central sync.** WebSocketChannel2023 subscription in the + SW (one per pod container), authenticated revalidation, central invalidation broadcast, + `resyncSweep` + `chrome.alarms` heartbeat on wake. **Re-audit `solid-offline`'s + notifications/invalidation under the inverted (authenticated-SW) threat model before reuse.** + (Offline write QUEUE is deferred past v1 — see §2.3.) +- **Phase 4 — Consolidate the 10 apps onto `` with hide-when-extension- + present.** Standardise the suite apps (7 pod-apps + PM + solid-issues + app-store) on the + Lit `@jeswr/solid-elements` `` (exists; `webid` attribute), feature- + detecting `window.solid` + the `solid:ready` capability handshake to suppress the app's own + login/profile chrome and defer to the extension identity. Add a `hide-when-extension-present` + capability to the element. Update the `create-solid-app` template to standardise the check. +- **Phase 5 — Playwright-with-extension tests vs a LOCAL Solid server.** E2E with the + unpacked extension loaded (`--load-extension`, persistent context), pointed at a **local + CSS / local `prod-solid-server` (`docker compose up`), never the live deploy** (house rule). + Cover: cross-app read+write denial (two app origins, one pod), forged-origin denial, cross- + user purge race, boot-race fail-safe, single-replica-shared-across-origins, offline-load- + from-cache, central WS invalidation. Use the `playwright-best-practices` / + `solid-test-infrastructure` skills. + +## 8. What needs CORE-PSS server changes (maintainer approval, separate) + +The tt6 server-native Authorization Agent (request-inbox LDN endpoint, class→target +resolver, WAC/ACP materialisation) is new security-critical surface on `prod-solid-server` +(`src/access-requests`, touching `src/authz`). It goes through auth-specialist + +storage-specialist + the conformance harness + roborev, and needs maintainer approval. The +extension's shared-replica + per-origin gate + client-side grant authoring need **no** PSS +server change; they work against any spec-conformant Solid server today, with server-side WAC +as the ultimate enforcement backstop. + +## Appendix — Sources (verified June 2026) + +- [Chrome: content scripts manifest (MAIN-world / document_start)](https://developer.chrome.com/docs/extensions/reference/manifest/content-scripts) +- [chromium-extensions: MV3 MAIN-world injection ordering](https://groups.google.com/a/chromium.org/g/chromium-extensions/c/eVZd_vOIryc) +- [Chrome: declarativeNetRequest API (body/redirect/SW-fetch limits)](https://developer.chrome.com/docs/extensions/reference/api/declarativeNetRequest) +- [Chrome: Storage and cookies (extension origin not site-partitioned)](https://developer.chrome.com/docs/extensions/develop/concepts/storage-and-cookies) +- [Chrome: Storage Partitioning](https://developer.chrome.com/en/docs/privacy-sandbox/storage-partitioning/) +- [Chrome: service worker lifecycle (~30s idle; Chrome 116 WS keepalive)](https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/lifecycle) +- [RFC 9449 DPoP](https://www.rfc-editor.org/rfc/rfc9449.html) · [RFC 9068](https://www.rfc-editor.org/rfc/rfc9068.html) +- In-repo: `solid-browser-extension/src/{inject/inject.ts,content/content-script.ts,background/service-worker.ts,background/core/{origin-policy.ts,authenticated-fetch.ts,dpop.ts}}` +- In-repo: `solid-offline/src/{scope.ts,notifications.ts,logout.ts,swr.ts,cache-coherence.ts,metadata-store.ts,invalidation.ts}` · `solid-elements/src/{auth/proactive-fetch.ts,components/account-menu.ts}` +- tt6: `full-solid-ecosystem/docs/design/access-management-solid-lws.md` \ No newline at end of file From 3aec8dda2405cafd5e7bd7b49bb56fc970232389 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Sun, 21 Jun 2026 16:15:34 +0100 Subject: [PATCH 27/50] feat(auth): Phase-0 SW default-deny per-requesting-origin gate + close full-pod over-exposure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security-critical hardening on the existing single-user owner product, BEFORE any shared-replica work (design §Phase 0). Makes the per-origin credential boundary fail-closed + browser-attested and stops over-broadcasting identity. - New `src/background/core/requesting-origin.ts`: the pure, exhaustively-tested gate. `resolveRequestingOrigin` requires the browser-attested `sender` origin (or origin of `sender.url`) and the content-script `window.location.origin` stamp to AGREE — a forged stamp only ever denies. `decideRequestingOrigin` runs the full fail-closed decision: dual-origin agreement → default-deny per-origin grant → same-origin-or-deny foreign fetch. Opaque/null/`about:blank`/`data:`/`file:`/`srcdoc` → DENY (no shared "null" bucket). - Wired into `service-worker.ts` `handleFetch` as the FIRST check before any pod egress or cache read: the cheap dual-origin/opaque half runs before `ensureSession()` (so a forged/opaque origin can't even cause a refresh side effect), then the grant + foreign- fetch half. Grant set is re-read from durable storage every request → cold-wake/boot-race is fail-CLOSED (empty store denies all). - Grant store in `session-store.ts` (`solid:granted-origins`): default-deny; the owner opts an app in by a deliberate browser-attested action (login / setClientId) FROM that origin; session credential (pod/WebID/issuer) origins are auto-granted requesters. - Close the PII over-exposure: `SOLID_GET_STATE` returns the WebID only to a verified, granted page (extension popup/side-panel get full state); `broadcastStateChange` reveals the WebID only to granted tabs. The pod-origin allowlist was never page-exposed. - `content-script.ts` now stamps the real origin on `SOLID_FETCH_REQUEST` + `SOLID_GET_STATE` (as it already did for login/set-client-id). `setClientId` is stored/granted under the VERIFIED origin only. - `docs/manifest-permissions.md`: documents why `` host/content-script scope is required (arbitrary user-chosen pods) and that the SW gates are the real boundary. - DPoP egress + key-in-SW custody unchanged. 53 new adversarial tests (forged sender, un-granted whole-pod read, cross-app A→B, opaque origin, boot-race, legit owner path, login-grants-origin, PII scoping); full gate green (lint+typecheck+186 tests+build). Phase 0 ONLY — the shared replica / offline layer is NOT built. Model: claude-opus-4-8 Provenance: Opus 4.8 (Fable unavailable) — re-review/upgrade candidate Co-Authored-By: Claude Opus 4.8 --- apps/extension/docs/MODEL-PROVENANCE.md | 2 + apps/extension/docs/manifest-permissions.md | 57 +++ .../src/background/core/requesting-origin.ts | 215 ++++++++++ .../src/background/service-worker.ts | 242 +++++++++-- .../extension/src/background/session-store.ts | 43 ++ apps/extension/src/content/content-script.ts | 13 +- apps/extension/src/shared/messages.ts | 13 + apps/extension/test/requesting-origin.test.ts | 312 ++++++++++++++ .../test/service-worker-gate.test.ts | 394 ++++++++++++++++++ 9 files changed, 1258 insertions(+), 33 deletions(-) create mode 100644 apps/extension/docs/manifest-permissions.md create mode 100644 apps/extension/src/background/core/requesting-origin.ts create mode 100644 apps/extension/test/requesting-origin.test.ts create mode 100644 apps/extension/test/service-worker-gate.test.ts diff --git a/apps/extension/docs/MODEL-PROVENANCE.md b/apps/extension/docs/MODEL-PROVENANCE.md index 139fb8979..fb56caf2d 100644 --- a/apps/extension/docs/MODEL-PROVENANCE.md +++ b/apps/extension/docs/MODEL-PROVENANCE.md @@ -20,3 +20,5 @@ be targeted for re-review / upgrade when Fable returns. | MV3 side panel (`src/sidepanel/*`, `src/background/side-panel.ts`) + shared `src/popup/account-surface.ts` (popup/side-panel surface extraction) | `feat/sidepanel-and-clientid` | re-review/upgrade candidate | | Static Client ID Document (`public/clientid.jsonld`, `src/background/client-id.ts`) + `auth-flow.ts` published-client-id wiring | `feat/sidepanel-and-clientid` | published-URL hosting = needs:user; re-review/upgrade candidate | | `test/{client-id,side-panel}.test.ts` (+21 cases) | `feat/sidepanel-and-clientid` | re-review/upgrade candidate | +| Phase-0 per-requesting-origin gate (`src/background/core/requesting-origin.ts`) + SW wiring (`service-worker.ts` gate/PII-scoping, `session-store.ts` grant store, `content-script.ts` origin stamp, `shared/messages.ts`) | `phase-0-origin-gate` | SECURITY-CRITICAL — re-review/upgrade candidate | +| `test/{requesting-origin,service-worker-gate}.test.ts` (+53 adversarial cases) + `docs/manifest-permissions.md` | `phase-0-origin-gate` | re-review/upgrade candidate | diff --git a/apps/extension/docs/manifest-permissions.md b/apps/extension/docs/manifest-permissions.md new file mode 100644 index 000000000..fa9f5599c --- /dev/null +++ b/apps/extension/docs/manifest-permissions.md @@ -0,0 +1,57 @@ +# Manifest permissions — rationale (Phase 0) + +> AUTHORED-BY Claude Opus 4.8 + +`manifest.json` cannot carry comments, so this file is the durable record of WHY each +permission and host-permission is requested, and what the Phase-0 hardening reviewed. +Reviewed against the shared-replica design `docs/design/shared-replica-architecture.md` +§4.3 ("PII broadcast", "Foreign-origin fetch") and §5.7. + +## `permissions` + +| Permission | Why it is required | Could it be narrowed? | +|---|---|---| +| `storage` | The SOLE home of the DPoP keypair, session tokens, profile, recent accounts, per-origin client-ids, and the **granted-origins grant store** (`chrome.storage.local`, page-unreachable). | No — load-bearing for the credential boundary. | +| `identity` | `chrome.identity.launchWebAuthFlow` drives the Solid-OIDC auth-code + PKCE redirect (a service worker has no `window`). | No. | +| `sidePanel` | The persistent side-panel account surface. | No. | +| `contextMenus` | The right-click "Open Solid side panel" entry. | No. | + +No `tabs` permission is requested: `broadcastStateChange` reads tab URLs via the existing +`` host permission (host access implies tab-URL visibility), and the WebID it +sends is scoped per-tab to GRANTED origins only (a non-granted tab receives `webId: null`). + +## `host_permissions: [""]` + +**Required, and genuinely broad — documented rather than removed.** The service worker is +the sole egress: it makes cross-origin `fetch`es to **arbitrary, user-chosen** Solid pod +origins, IdP issuer origins, token endpoints, and WebID profile documents (discovery, token +exchange, refresh, profile, and every authenticated resource read/write). A Solid pod can be +hosted on **any** origin the owner picks, so the set of origins the SW must reach is +unbounded at install time. Without `` host permission these credentialed +cross-origin fetches would be subject to extension CORS restrictions and could not reliably +read response bodies/headers, breaking the core auth flow against arbitrary pods. + +This breadth is NOT the security boundary. The boundary is the SW-enforced, fail-closed +gates, all of which run regardless of host permissions: + +- **Target-origin credential gate** (`origin-policy.ts`): the access token is attached ONLY + to the WebID / issuer / configured-pod origins — never a foreign origin, never `/token`, + never over cleartext (loopback dev excepted). A fetch to any other origin is a plain, + token-free fetch. +- **Per-requesting-origin default-deny gate** (`requesting-origin.ts`, Phase 0): the calling + app's origin must be GRANTED (an explicit per-origin grant, or a session credential/pod + origin) before any pod egress or cache read. The requesting origin is the **browser- + attested** `sender` cross-checked against the page stamp; opaque/`null`/mismatched → DENY. +- **Same-origin-or-deny foreign-fetch policy** (Phase 0): a request to a non-credential + ("foreign") origin is allowed only when it targets the requesting app's OWN origin, so the + extension cannot be used as an ambient-cookie proxy to arbitrary third parties. + +## `content_scripts` matches: `[""]` + +**Required.** `window.solid` (the cooperation surface) must be available on any page that +might be a Solid app — the owner chooses which apps to use, so the match set is unbounded. +Presence of `window.solid` is a UX/cooperation signal, NOT a security boundary (design +§5.7): the credential never leaves the SW, and the per-requesting-origin gate denies any +non-granted origin even though the script is injected everywhere. Identity (the WebID) is +NOT exposed to a page merely because the script is injected — `SOLID_GET_STATE` returns the +WebID only to a verified, granted origin. diff --git a/apps/extension/src/background/core/requesting-origin.ts b/apps/extension/src/background/core/requesting-origin.ts new file mode 100644 index 000000000..212502069 --- /dev/null +++ b/apps/extension/src/background/core/requesting-origin.ts @@ -0,0 +1,215 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * The per-REQUESTING-origin access gate — the browser-side credential boundary that decides + * WHICH page (app) origin is allowed to drive `window.solid.fetch`, before any pod egress + * OR any cache hit is served. + * + * This is distinct from `origin-policy.ts`, which gates the TARGET pod ("don't send the + * token to evil.com"). THIS module gates the REQUESTER ("is the app at https://a.example + * allowed to read/write this pod at all?"). Both run on every fetch; this one runs FIRST. + * + * THREAT MODEL (design §4.3 "Forge the access-control anchor"): + * - `sender.origin` is the browser-ATTESTED origin of the content script that relayed the + * message. It is set by Chrome, not the page, so it cannot be forged from page JS. + * - The content script ALSO stamps `window.location.origin` (the page-supplied value). + * A compromised renderer could lie about this, so it is NEVER trusted alone. + * - Therefore we require BOTH anchors to AGREE and use the browser-attested value as the + * authority. A forged stamp only ever DENIES (mismatch) — it can never widen access. + * + * FAIL-CLOSED everywhere: + * - An opaque / `null` / missing origin (sandboxed `srcdoc` iframe, `about:blank`, + * `data:`, `file:`, `javascript:`) → DENY. We never bucket these into a shared "null" + * grant. (A `blob:` URL is NOT opaque — it inherits its creator's real origin, which the + * browser fixes and a page cannot forge across origins, so it resolves normally.) + * - A `sender` with no `origin` but a usable `url` → we derive the origin from the URL and + * STILL require it to match the stamp; an underivable origin → DENY. + * - Default-deny: an origin that is not in the granted set → DENY, without touching the + * pod or the cache. + */ + +/** Parse a string to its URL `origin`, or `null` if it is not a usable URL. */ +function originOf(value: string | null | undefined): string | null { + if (!value) return null; + try { + const o = new URL(value).origin; + // An opaque origin parses to the literal string "null" (`about:blank`, `data:`, `file:`, + // `javascript:`); a sandboxed `srcdoc` frame reports "null" too (which then throws here). + // Treat the opaque sentinel as no-origin (fail-closed), never a shared bucket. + if (o === 'null' || o === '') return null; + return o; + } catch { + return null; + } +} + +/** + * The browser-attested sender identity, as Chrome populates it on a `chrome.runtime` + * message. `origin` is present for content-script senders on a real http(s) page; `url` is + * the document URL. Both can be absent for an opaque context. We treat this object as the + * SOLE authority for the requesting origin. + */ +export interface MessageSender { + readonly origin?: string; + readonly url?: string; +} + +/** + * Resolve the TRUE requesting origin from the browser-attested `sender` and the + * page-supplied stamp, requiring them to AGREE. Returns the canonical origin string when + * both agree on a usable (non-opaque) origin, else `null` (DENY). + * + * Authority order: + * 1. The browser-attested origin is `sender.origin`, falling back to the origin OF + * `sender.url` (Chrome omits `origin` for some sender types but always has `url`). + * This value is set by the browser and cannot be forged by page JS. + * 2. The page-supplied `stampedOrigin` is the value the content script read from + * `window.location.origin` and forwarded. It is advisory only. + * 3. BOTH must resolve to the SAME non-opaque origin. Any mismatch, or either being + * opaque/absent, → `null`. + * + * PURE so the boundary is exhaustively unit-tested. + */ +export function resolveRequestingOrigin( + sender: MessageSender | null | undefined, + stampedOrigin: string | null | undefined, +): string | null { + // Browser-attested origin: prefer sender.origin, else derive from sender.url. + const attested = originOf(sender?.origin) ?? originOf(sender?.url); + if (attested === null) return null; // opaque / missing attested origin → DENY. + + // The page-supplied stamp must be present and parse to a usable origin. + const stamped = originOf(stampedOrigin); + if (stamped === null) return null; // missing / opaque stamp → DENY. + + // Dual-origin agreement: the page-supplied value must match the browser truth EXACTLY. + if (attested !== stamped) return null; + + return attested; +} + +/** + * The default-deny per-requesting-origin gate. `granted` is the set of app origins the user + * has authorised to drive the extension. An origin not in the set is DENIED — no pod egress, + * no cache read. PURE; fail-closed on a `null` requesting origin or an empty granted set. + */ +export function isRequestingOriginGranted( + granted: ReadonlySet, + requestingOrigin: string | null, +): boolean { + if (requestingOrigin === null) return false; // opaque/unresolved → DENY. + return granted.has(requestingOrigin); +} + +export interface GrantedOriginInputs { + /** App origins the owner explicitly granted (login / setClientId / consent). */ + readonly explicitGrants?: readonly string[]; + /** The active session's credential origins (WebID/issuer/pod) — apps served from the */ + /** pod itself are owner-trusted requesters and need no separate grant. */ + readonly credentialOrigins?: ReadonlySet; +} + +/** + * The full set of REQUESTING origins permitted to drive the extension, for the single-user + * owner model: the explicit per-origin grants ∪ the session's credential (pod/WebID/issuer) + * origins. Opaque / non-http(s) candidates are dropped (fail-closed). PURE. + * + * An empty result (no session, no grants) means default-deny for EVERY origin. + */ +export function computeGrantedOrigins(inputs: GrantedOriginInputs): ReadonlySet { + const granted = new Set(); + for (const o of inputs.credentialOrigins ?? []) { + const c = originOf(o); + if (c) granted.add(c); + } + for (const g of inputs.explicitGrants ?? []) { + const c = originOf(g); + if (c) granted.add(c); + } + return granted; +} + +/** + * The same-origin-or-deny foreign-fetch policy (design §4.3 "Foreign-origin fetch ambient + * cookies"). A request whose TARGET is NOT one of the session's pod/credential origins (a + * "foreign" fetch — `window.solid.fetch('https://tracker/')`) is only permitted when its + * target origin equals the REQUESTING app's own origin. This stops the extension being used + * as a CSP-free, ambient-cookie-carrying proxy to arbitrary third parties: an app may reach + * its OWN origin through the proxy, but not weaponise it against unrelated hosts. + * + * A request to a credential (pod) origin is NOT foreign and is governed by the per-origin + * grant + the target-origin credential gate instead; this helper only decides foreign ones. + * + * PURE; fail-closed: an unresolved requesting/target origin → DENY. + */ +export function isForeignFetchAllowed(requestingOrigin: string | null, targetUrl: string): boolean { + if (requestingOrigin === null) return false; + const target = originOf(targetUrl); + if (target === null) return false; + return target === requestingOrigin; +} + +/** Why a request was denied (for an actionable error + auditability). `null` reason = allow. */ +export type DenyReason = + | 'forbidden-origin' // dual-origin disagreement / opaque / missing + | 'origin-not-granted' // default-deny: the app origin holds no grant + | 'cross-origin-foreign'; // a foreign-target fetch that isn't same-origin + +export interface GateDecision { + /** The resolved, verified requesting origin when allowed; `null` when denied. */ + readonly requestingOrigin: string | null; + /** `null` ⇒ ALLOW; otherwise the deny reason. */ + readonly deny: DenyReason | null; +} + +export interface GateInputs { + /** App origins the owner explicitly granted. */ + readonly explicitGrants?: readonly string[]; + /** The active session's credential (pod/WebID/issuer) origins. */ + readonly credentialOrigins?: ReadonlySet; +} + +/** + * The COMPLETE per-requesting-origin gate decision, as ONE pure function so the whole + * boundary is exhaustively unit-testable in isolation from `chrome.*`. Runs the three checks + * in fail-closed order: + * + * 1. Dual-origin agreement (browser-attested sender ∧ page stamp) — forged/opaque → DENY. + * 2. Default-deny per-origin grant (explicit grants ∪ session credential origins). + * 3. Same-origin-or-deny for a foreign (non-credential) target. + * + * The service worker calls this BEFORE any cache read or pod egress, on every fetch (and, in + * a later phase, on every cache hit), passing the storage-loaded grant inputs. + */ +export function decideRequestingOrigin( + sender: MessageSender | null | undefined, + stampedOrigin: string | null | undefined, + requestUrl: string, + inputs: GateInputs, +): GateDecision { + const requestingOrigin = resolveRequestingOrigin(sender, stampedOrigin); + if (requestingOrigin === null) return { requestingOrigin: null, deny: 'forbidden-origin' }; + + const granted = computeGrantedOrigins({ + explicitGrants: inputs.explicitGrants, + credentialOrigins: inputs.credentialOrigins, + }); + if (!isRequestingOriginGranted(granted, requestingOrigin)) { + return { requestingOrigin: null, deny: 'origin-not-granted' }; + } + + // A credential (pod) target is governed by the target-origin credential gate downstream; + // only a NON-credential ("foreign") target is subject to same-origin-or-deny here. + const targetIsCredential = isOriginAllowedSet(inputs.credentialOrigins, requestUrl); + if (!targetIsCredential && !isForeignFetchAllowed(requestingOrigin, requestUrl)) { + return { requestingOrigin: null, deny: 'cross-origin-foreign' }; + } + + return { requestingOrigin, deny: null }; +} + +/** Whether a request URL's origin is in a set (fail-closed on a missing set / bad URL). */ +function isOriginAllowedSet(allowed: ReadonlySet | undefined, requestUrl: string): boolean { + if (!allowed) return false; + const o = originOf(requestUrl); + return o !== null && allowed.has(o); +} diff --git a/apps/extension/src/background/service-worker.ts b/apps/extension/src/background/service-worker.ts index 771f1fad9..0846babc3 100644 --- a/apps/extension/src/background/service-worker.ts +++ b/apps/extension/src/background/service-worker.ts @@ -22,6 +22,7 @@ import type { ActionResult, FetchRequest, FetchResponse, + GetStateRequest, LoginRequest, SessionState, SetClientIdRequest, @@ -32,13 +33,22 @@ import { initiateLogin, refreshSession } from './auth-flow'; import { authenticatedFetch, type FetchSession } from './core/authenticated-fetch'; import { importDpopKeyPair } from './core/dpop'; import { computeAllowedOrigins, isValidClientIdUrl } from './core/origin-policy'; +import { + computeGrantedOrigins, + decideRequestingOrigin, + isRequestingOriginGranted, + type MessageSender, + resolveRequestingOrigin, +} from './core/requesting-origin'; import { filterResponseHeaders } from './core/response-headers'; import { SingleFlight } from './core/single-flight'; import { parseWebIdProfile } from './core/webid'; import { clearProfile, clearSession, + grantOrigin, loadClientIds, + loadGrantedOrigins, loadPodOrigins, loadProfile, loadRecentAccounts, @@ -135,12 +145,83 @@ function originOf(url: string): string | null { } } -async function handleFetch(message: FetchRequest): Promise { +const DENY_MESSAGE = { + 'forbidden-origin': 'Forbidden origin', + 'origin-not-granted': 'Origin not granted access', + 'cross-origin-foreign': 'Cross-origin fetch denied', +} as const; + +/** + * The grant + foreign-fetch half of the per-requesting-origin gate, run BEFORE any pod + * egress or cache read (the dual-origin/opaque half already ran in {@link handleFetch}). It + * re-reads the grant store and re-runs the FULL decision (idempotent on the same inputs) so + * the single audited `decideRequestingOrigin` is the one boundary. Fail-closed on DENY. + * + * NOTE: the per-origin gate is applied to EVERY fetch up-front. Once a shared replica / + * cache exists (Phase 1) this same gate must be the first check on a cache hit too — it is + * deliberately separated here so a future cached-response path cannot bypass it. + */ +async function gateRequestingOrigin( + message: FetchRequest, + requestingOrigin: string, + sender: MessageSender | undefined, + allowedTargetOrigins: ReadonlySet, +): Promise<{ requestingOrigin: string } | { error: FetchResponse }> { + // The grant set is re-read from durable storage on EVERY request (no load-bearing + // in-memory gate state), so a cold SW wake is fail-CLOSED: if nothing has loaded yet the + // grant set is empty and every origin is denied — never a serve-while-loading window. + const explicitGrants = await loadGrantedOrigins(); + const decision = decideRequestingOrigin(sender, message.stampedOrigin, message.url, { + explicitGrants, + credentialOrigins: allowedTargetOrigins, + }); + // Defence-in-depth: the re-resolved origin must match the one resolved earlier in this + // request (it always will for identical inputs); a divergence is treated as DENY. + if (decision.deny !== null || decision.requestingOrigin !== requestingOrigin) { + return { + error: { + requestId: message.requestId, + status: 403, + error: decision.deny ? DENY_MESSAGE[decision.deny] : DENY_MESSAGE['forbidden-origin'], + }, + }; + } + return { requestingOrigin: decision.requestingOrigin }; +} + +async function handleFetch( + message: FetchRequest, + sender: MessageSender | undefined, +): Promise { + // FIRST, before ANY session work (which may trigger a token refresh): the cheap, + // session-independent half of the gate — dual-origin agreement + opaque/null rejection. A + // forged/opaque origin is denied here so it cannot even cause a session-refresh side + // effect (refresh-token amplification). The browser-attested sender is the authority. + const requestingOrigin = resolveRequestingOrigin(sender, message.stampedOrigin); + if (requestingOrigin === null) { + return { + requestId: message.requestId, + status: 403, + error: DENY_MESSAGE['forbidden-origin'], + }; + } + const ctx = await ensureSession(); if (!ctx) return { requestId: message.requestId, error: 'Not authenticated' }; try { const fetchSession = await buildFetchSession(ctx.session, ctx.keyPair); + + // THEN the grant + foreign-fetch half (needs the session's credential origins): the + // per-requesting-origin default-deny gate, BEFORE any pod egress or cache read. + const gate = await gateRequestingOrigin( + message, + requestingOrigin, + sender, + fetchSession.allowedOrigins, + ); + if ('error' in gate) return gate.error; + const origin = originOf(message.url); const result = await authenticatedFetch(fetchSession, { url: message.url, @@ -182,20 +263,53 @@ async function refreshProfile(webId: string): Promise { } } -function broadcastStateChange(webId: string | null): void { - chrome.tabs.query({}, (tabs) => { - for (const tab of tabs) { - if (tab.id !== undefined) { - chrome.tabs.sendMessage(tab.id, { type: 'SOLID_STATE_CHANGED', webId }).catch(() => {}); - } +/** + * Broadcast an auth-state change. The WebID (PII) is sent to a tab ONLY when that tab's + * origin holds a grant — a non-granted page is told the WebID is `null` (it learns nothing + * about who is signed in), closing the design §4.3 PII-broadcast over-exposure. A logout + * (`webId === null`) is sent to every tab so granted apps clear their state. The popup/side + * panel (extension contexts) always get the real value via the runtime broadcast. + */ +async function broadcastStateChange(webId: string | null): Promise { + let granted: ReadonlySet = new Set(); + if (webId !== null) { + const ctx = await ensureSession(); + if (ctx) { + const explicitGrants = await loadGrantedOrigins(); + granted = computeGrantedOrigins({ + explicitGrants, + credentialOrigins: await sessionCredentialOrigins(ctx.session, ctx.keyPair), + }); } - }); - // Also notify the popup if open (runtime broadcast). + } + + const tabs = await chrome.tabs.query({}); + for (const tab of tabs) { + if (tab.id === undefined) continue; + const tabOrigin = tab.url ? originOf(tab.url) : null; + // A signed-in WebID is only revealed to a granted tab; everyone else (and every tab on + // logout) gets null. Fail-closed: an unparseable tab URL never receives the WebID. + const tabWebId = webId !== null && tabOrigin !== null && granted.has(tabOrigin) ? webId : null; + chrome.tabs + .sendMessage(tab.id, { type: 'SOLID_STATE_CHANGED', webId: tabWebId }) + .catch(() => {}); + } + // The popup/side panel are trusted extension contexts — give them the real value. chrome.runtime.sendMessage({ type: 'SOLID_STATE_CHANGED', webId }).catch(() => {}); } -async function handleLogin(message: LoginRequest): Promise { +async function handleLogin( + message: LoginRequest, + sender: MessageSender | undefined, +): Promise { try { + // A login driven FROM a web page is a deliberate owner opt-in: grant that app origin so + // its subsequent window.solid.fetch calls pass the per-origin gate. Verify the origin + // via the same dual-origin agreement (browser-attested ∧ page stamp) and never trust the + // page-supplied field alone. A popup/side-panel login has no web sender → nothing granted. + const requestingOrigin = resolveRequestingOrigin(sender, message.origin); + if (requestingOrigin !== null) await grantOrigin(requestingOrigin); + const clientIds = await loadClientIds(); const candidate = message.clientId || (message.origin ? clientIds[message.origin] : undefined) || undefined; @@ -214,27 +328,86 @@ async function handleLogin(message: LoginRequest): Promise { await setSignedInIcon({ webId: session.webId, name, photoUrl }); // best-effort fresher profile from the post-auth WebID (handles webid != input). void refreshProfile(session.webId); - broadcastStateChange(session.webId); + await broadcastStateChange(session.webId); return { ok: true, webId: session.webId }; } catch (err) { return { error: err instanceof Error ? err.message : 'Login failed' }; } } -async function handleGetState(): Promise { +/** + * Whether a message sender is one of the extension's OWN trusted contexts (popup, side + * panel, options) rather than a web page. Chrome stamps these with the extension's own + * origin/URL; a web content script is stamped with the page origin. The owner UI is fully + * trusted; a web page is not. Fail-closed: an unknown/opaque sender is treated as NOT the + * extension (a web page), so it gets the scoped (PII-free) view. + */ +function isExtensionContext(sender: MessageSender | undefined): boolean { + const extensionOrigin = `chrome-extension://${chrome.runtime.id}`; + if (sender?.origin && sender.origin === extensionOrigin) return true; + if (sender?.url?.startsWith(`${extensionOrigin}/`)) return true; + return false; +} + +/** + * Answer `SOLID_GET_STATE`. The extension's OWN contexts (popup/side panel) get the full + * session view. A WEB PAGE gets identity (WebID + profile) ONLY if its verified+granted + * origin holds a grant — otherwise the PII is withheld (design §4.3 "PII broadcast"): a page + * the owner never opted in learns nothing about who is signed in. + */ +async function handleGetState( + message: GetStateRequest, + sender: MessageSender | undefined, +): Promise { const ctx = await ensureSession(); const profile = await loadProfile(); - const recentAccounts = await loadRecentAccounts(); + + if (isExtensionContext(sender)) { + const recentAccounts = await loadRecentAccounts(); + return { + webId: ctx?.session.webId ?? null, + isActive: ctx !== null, + name: profile?.name ?? null, + photoUrl: profile?.photoUrl ?? null, + recentAccounts, + }; + } + + // Web page: scope identity exposure to opt-in/granted origins only. + const requestingOrigin = resolveRequestingOrigin(sender, message.stampedOrigin); + let mayExpose = false; + if (requestingOrigin !== null && ctx) { + const explicitGrants = await loadGrantedOrigins(); + const granted = computeGrantedOrigins({ + explicitGrants, + credentialOrigins: await sessionCredentialOrigins(ctx.session, ctx.keyPair), + }); + mayExpose = isRequestingOriginGranted(granted, requestingOrigin); + } + return { - webId: ctx?.session.webId ?? null, - isActive: ctx !== null, - name: profile?.name ?? null, - photoUrl: profile?.photoUrl ?? null, - recentAccounts, + webId: mayExpose ? (ctx?.session.webId ?? null) : null, + isActive: mayExpose && ctx !== null, + name: mayExpose ? (profile?.name ?? null) : null, + photoUrl: mayExpose ? (profile?.photoUrl ?? null) : null, + // Never leak the cross-pod recent-accounts list to a web page. + recentAccounts: [], }; } -async function handleSetClientId(message: SetClientIdRequest): Promise { +/** The session's credential (pod/WebID/issuer) origins — the auto-granted requester set. */ +async function sessionCredentialOrigins( + session: StoredSession, + keyPair: CryptoKeyPair, +): Promise> { + const fetchSession = await buildFetchSession(session, keyPair); + return fetchSession.allowedOrigins; +} + +async function handleSetClientId( + message: SetClientIdRequest, + sender: MessageSender | undefined, +): Promise { try { // A Client Identifier Document is a dereferenceable URL. Require HTTPS so it cannot be // tampered with in transit; allow http: ONLY for a loopback host (dev CSS). A remote @@ -242,7 +415,16 @@ async function handleSetClientId(message: SetClientIdRequest): Promise void) => { + (message: WorkerRequest, sender, sendResponse: (r: unknown) => void) => { switch (message.type) { case 'SOLID_FETCH_REQUEST': - handleFetch(message).then(sendResponse); + // Pass the browser-ATTESTED sender (origin/url set by Chrome, not the page) so the + // per-requesting-origin gate can cross-check it against the page-supplied stamp. + handleFetch(message, sender).then(sendResponse); return true; case 'SOLID_LOGIN': - handleLogin(message).then(sendResponse); + handleLogin(message, sender).then(sendResponse); return true; case 'SOLID_LOGOUT': teardownSession() - .then(() => { - broadcastStateChange(null); - sendResponse({ ok: true }); - }) + .then(() => broadcastStateChange(null)) + .then(() => sendResponse({ ok: true })) .catch((err) => sendResponse({ error: String(err) })); return true; case 'SOLID_GET_STATE': - handleGetState().then(sendResponse); + handleGetState(message, sender).then(sendResponse); return true; case 'SOLID_SET_CLIENT_ID': - handleSetClientId(message).then(sendResponse); + handleSetClientId(message, sender).then(sendResponse); return true; default: return false; @@ -298,7 +480,7 @@ ensureSession().then(async (ctx) => { name: profile?.name ?? null, photoUrl: profile?.photoUrl ?? null, }); - broadcastStateChange(ctx.session.webId); + await broadcastStateChange(ctx.session.webId); } else { await setSignedOutIcon(); } diff --git a/apps/extension/src/background/session-store.ts b/apps/extension/src/background/session-store.ts index 1b9b848ad..03550d1a5 100644 --- a/apps/extension/src/background/session-store.ts +++ b/apps/extension/src/background/session-store.ts @@ -25,6 +25,7 @@ const KEYS = { recentAccounts: 'solid:recent-accounts', clientIds: 'solid:client-ids', podOrigins: 'solid:pod-origins', + grantedOrigins: 'solid:granted-origins', authParams: 'solid:auth-params', } as const; @@ -128,6 +129,48 @@ export async function savePodOrigins(origins: string[]): Promise { await set(KEYS.podOrigins, origins); } +// --- Granted requesting origins (the per-REQUESTING-origin access gate) ---------------- +// +// The set of APP origins the owner has authorised to drive `window.solid.fetch`. This is +// the Phase-0 grant store: default-deny means an origin not in this set (and not a +// credential/pod origin) cannot read or write the pod through the extension. The owner +// opts an app in by a deliberate, browser-attested action FROM that app's origin (login / +// setClientId); the extension's own popup/side-panel flows do not add a web origin. +// +// NOTE: this store holds only origin strings (no credential). It is the single-user +// owner-only grant model; the per-data-class grant store (tt6) is a later phase. + +/** The app origins explicitly granted access (most-recent-first not required; a set). */ +export async function loadGrantedOrigins(): Promise { + return (await get(KEYS.grantedOrigins)) ?? []; +} + +/** + * Grant an app origin access (idempotent). Only a well-formed http(s) origin is stored; + * an opaque/invalid value is ignored (fail-closed — we never persist a "null" grant). + */ +export async function grantOrigin(origin: string): Promise { + let canonical: string; + try { + const u = new URL(origin); + if (u.protocol !== 'https:' && u.protocol !== 'http:') return; + canonical = u.origin; + if (canonical === 'null' || canonical === '') return; + } catch { + return; + } + const list = await loadGrantedOrigins(); + if (list.includes(canonical)) return; + await set(KEYS.grantedOrigins, [...list, canonical]); +} + +/** Revoke an app origin's access. */ +export async function revokeOrigin(origin: string): Promise { + const list = await loadGrantedOrigins(); + const next = list.filter((o) => o !== origin); + if (next.length !== list.length) await set(KEYS.grantedOrigins, next); +} + // --- Transient auth params (PKCE/state across the redirect) ---------------------------- export interface AuthParams { diff --git a/apps/extension/src/content/content-script.ts b/apps/extension/src/content/content-script.ts index 01a279ffa..0b3d77513 100644 --- a/apps/extension/src/content/content-script.ts +++ b/apps/extension/src/content/content-script.ts @@ -45,6 +45,10 @@ window.addEventListener('message', (event) => { method: data.method, headers: data.headers ?? {}, body: data.body ?? null, + // Stamp the REAL page origin (read HERE in the ISOLATED world, not page-supplied) + // so the worker's per-requesting-origin gate can cross-check it against the + // browser-attested sender.origin. The worker trusts the browser value, not this. + stampedOrigin: window.location.origin, }, (response) => { toPage({ type: 'SOLID_FETCH_RESPONSE', ...(response ?? { error: 'No response' }) }); @@ -53,9 +57,12 @@ window.addEventListener('message', (event) => { break; case 'SOLID_GET_STATE': - chrome.runtime.sendMessage({ type: 'SOLID_GET_STATE' }, (response) => { - toPage({ type: 'SOLID_STATE_UPDATE', webId: response?.webId ?? null }); - }); + chrome.runtime.sendMessage( + { type: 'SOLID_GET_STATE', stampedOrigin: window.location.origin }, + (response) => { + toPage({ type: 'SOLID_STATE_UPDATE', webId: response?.webId ?? null }); + }, + ); break; case 'SOLID_SET_CLIENT_ID': diff --git a/apps/extension/src/shared/messages.ts b/apps/extension/src/shared/messages.ts index 24f68670b..84e2703d2 100644 --- a/apps/extension/src/shared/messages.ts +++ b/apps/extension/src/shared/messages.ts @@ -22,6 +22,13 @@ export interface FetchRequest { readonly method: string; readonly headers: Record; readonly body: string | null; + /** + * The page-supplied requesting origin (`window.location.origin`), stamped by the content + * script. ADVISORY ONLY — the worker cross-checks it against the browser-attested + * `sender.origin` and trusts the browser value. A mismatch is denied; this is never the + * sole authority. (Per design §4.3 "Forge the access-control anchor".) + */ + readonly stampedOrigin?: string; } export interface LoginRequest { @@ -39,6 +46,12 @@ export interface LogoutRequest { export interface GetStateRequest { readonly type: 'SOLID_GET_STATE'; + /** + * The page-supplied requesting origin, stamped by the content script. ADVISORY ONLY — + * cross-checked against the browser-attested `sender` so the worker only exposes the WebID + * (PII) to a verified, granted page origin. Absent for the extension's own contexts. + */ + readonly stampedOrigin?: string; } export interface SetClientIdRequest { diff --git a/apps/extension/test/requesting-origin.test.ts b/apps/extension/test/requesting-origin.test.ts new file mode 100644 index 000000000..f94671b71 --- /dev/null +++ b/apps/extension/test/requesting-origin.test.ts @@ -0,0 +1,312 @@ +// AUTHORED-BY Claude Opus 4.8 +// +// Adversarial unit tests for the per-REQUESTING-origin gate — the Phase-0 browser-side +// access boundary. These prove the load-bearing security properties as PURE functions: +// - dual-origin agreement (browser-attested sender ∧ page stamp), forged stamp → DENY +// - fail-closed on opaque / null / missing / mismatched origins +// - default-deny per-origin gate +// - same-origin-or-deny foreign-fetch policy +// - the granted-origin set computation (credential origins ∪ explicit grants) + +import { describe, expect, it } from 'vitest'; +import { + computeGrantedOrigins, + decideRequestingOrigin, + isForeignFetchAllowed, + isRequestingOriginGranted, + resolveRequestingOrigin, +} from '../src/background/core/requesting-origin'; + +describe('resolveRequestingOrigin — dual-origin agreement', () => { + it('resolves when the browser-attested sender.origin agrees with the page stamp', () => { + const o = resolveRequestingOrigin({ origin: 'https://app.example' }, 'https://app.example'); + expect(o).toBe('https://app.example'); + }); + + it('derives the attested origin from sender.url when sender.origin is absent', () => { + const o = resolveRequestingOrigin( + { url: 'https://app.example/page.html?x=1' }, + 'https://app.example', + ); + expect(o).toBe('https://app.example'); + }); + + it('DENIES a FORGED stamp that disagrees with the browser-attested origin', () => { + // A compromised renderer stamps a victim origin, but the browser attests the real one. + const o = resolveRequestingOrigin( + { origin: 'https://attacker.example' }, + 'https://victim.example', + ); + expect(o).toBeNull(); + }); + + it('DENIES when the stamp claims a credential origin but the sender is the attacker', () => { + const o = resolveRequestingOrigin( + { origin: 'https://attacker.example' }, + 'https://alice.pod.example', + ); + expect(o).toBeNull(); + }); + + it('DENIES a missing browser-attested origin (no origin, no url) — fail-closed', () => { + expect(resolveRequestingOrigin({}, 'https://app.example')).toBeNull(); + expect(resolveRequestingOrigin(undefined, 'https://app.example')).toBeNull(); + expect(resolveRequestingOrigin(null, 'https://app.example')).toBeNull(); + }); + + it('DENIES a missing page stamp even when the sender is attested — fail-closed', () => { + expect(resolveRequestingOrigin({ origin: 'https://app.example' }, undefined)).toBeNull(); + expect(resolveRequestingOrigin({ origin: 'https://app.example' }, null)).toBeNull(); + expect(resolveRequestingOrigin({ origin: 'https://app.example' }, '')).toBeNull(); + }); + + // These all yield an OPAQUE origin (the literal "null" sentinel, or unparseable). They must + // NEVER resolve to a usable origin and must NEVER share a "null" bucket. (Verified against + // Node's URL parser: about:blank / data: / file: / javascript: → origin "null"; the literal + // string "null" — what a sandboxed `srcdoc` iframe reports as location.origin — throws.) + it.each([ + ['about:blank', 'about:blank'], + ['data: URL', 'data:text/html,'], + ['file: URL', 'file:///etc/passwd'], + ['javascript: URL', 'javascript:alert(1)'], + ['srcdoc literal null', 'null'], + ])('DENIES an opaque %s stamp/sender (no shared null bucket)', (_label, opaque) => { + // opaque on BOTH sides + expect(resolveRequestingOrigin({ origin: opaque }, opaque)).toBeNull(); + // opaque attested, real stamp + expect(resolveRequestingOrigin({ url: opaque }, 'https://app.example')).toBeNull(); + // real attested, opaque stamp + expect(resolveRequestingOrigin({ origin: 'https://app.example' }, opaque)).toBeNull(); + }); + + it('a blob: URL legitimately inherits its creator origin (allowed when both agree)', () => { + // A blob URL cannot be forged across origins — the browser fixes its embedded origin to + // the creating context, so `sender` + stamp still agree on the real origin. This is the + // app reading through its own blob document, which is the genuine origin and is allowed. + expect( + resolveRequestingOrigin({ url: 'blob:https://app.example/uuid' }, 'https://app.example'), + ).toBe('https://app.example'); + // But a blob whose creator is the attacker cannot impersonate a victim stamp → DENY. + expect( + resolveRequestingOrigin( + { url: 'blob:https://attacker.example/uuid' }, + 'https://victim.example', + ), + ).toBeNull(); + }); + + it('treats a port difference as a mismatch (different origin) → DENY', () => { + expect( + resolveRequestingOrigin({ origin: 'https://app.example:8443' }, 'https://app.example'), + ).toBeNull(); + }); + + it('treats a scheme difference as a mismatch → DENY', () => { + expect( + resolveRequestingOrigin({ origin: 'http://app.example' }, 'https://app.example'), + ).toBeNull(); + }); + + it('DENIES an unparseable stamp', () => { + expect(resolveRequestingOrigin({ origin: 'https://app.example' }, 'not a url')).toBeNull(); + }); +}); + +describe('isRequestingOriginGranted — default-deny', () => { + const granted = new Set(['https://app.example', 'https://alice.pod.example']); + + it('allows a granted origin', () => { + expect(isRequestingOriginGranted(granted, 'https://app.example')).toBe(true); + }); + + it('DENIES an un-granted origin (default-deny)', () => { + expect(isRequestingOriginGranted(granted, 'https://other.example')).toBe(false); + }); + + it('DENIES a null requesting origin (opaque/unresolved) — fail-closed', () => { + expect(isRequestingOriginGranted(granted, null)).toBe(false); + }); + + it('DENIES everything when the granted set is empty (no session, no grants)', () => { + expect(isRequestingOriginGranted(new Set(), 'https://app.example')).toBe(false); + expect(isRequestingOriginGranted(new Set(), null)).toBe(false); + }); +}); + +describe('isForeignFetchAllowed — same-origin-or-deny', () => { + it('allows an app to fetch its OWN origin through the proxy', () => { + expect(isForeignFetchAllowed('https://app.example', 'https://app.example/data.json')).toBe( + true, + ); + }); + + it('DENIES an app fetching an unrelated third-party origin (ambient-cookie proxy abuse)', () => { + expect(isForeignFetchAllowed('https://app.example', 'https://tracker.example/collect')).toBe( + false, + ); + }); + + it('DENIES a null requesting origin', () => { + expect(isForeignFetchAllowed(null, 'https://app.example/x')).toBe(false); + }); + + it('DENIES an unparseable target', () => { + expect(isForeignFetchAllowed('https://app.example', 'not a url')).toBe(false); + }); + + it('treats a port/scheme difference as a different (denied) origin', () => { + expect(isForeignFetchAllowed('https://app.example', 'https://app.example:9000/x')).toBe(false); + expect(isForeignFetchAllowed('https://app.example', 'http://app.example/x')).toBe(false); + }); +}); + +describe('computeGrantedOrigins — credential origins ∪ explicit grants', () => { + it('unions the session credential origins and explicit grants', () => { + const granted = computeGrantedOrigins({ + credentialOrigins: new Set(['https://alice.pod.example', 'https://idp.example']), + explicitGrants: ['https://pm.example', 'https://issues.example'], + }); + expect(granted.has('https://alice.pod.example')).toBe(true); + expect(granted.has('https://idp.example')).toBe(true); + expect(granted.has('https://pm.example')).toBe(true); + expect(granted.has('https://issues.example')).toBe(true); + }); + + it('drops opaque / non-URL candidates (fail-closed)', () => { + const granted = computeGrantedOrigins({ + credentialOrigins: new Set(['null', '']), + explicitGrants: ['about:blank', 'not a url', 'https://ok.example'], + }); + expect(granted.has('null')).toBe(false); + expect(granted.has('https://ok.example')).toBe(true); + expect(granted.size).toBe(1); + }); + + it('yields an EMPTY set with no inputs (default-deny for every origin)', () => { + expect(computeGrantedOrigins({}).size).toBe(0); + }); + + it('canonicalises a credential URL down to its origin', () => { + const granted = computeGrantedOrigins({ + explicitGrants: ['https://app.example/some/path?q=1#f'], + }); + expect(granted.has('https://app.example')).toBe(true); + expect(granted.size).toBe(1); + }); +}); + +describe('decideRequestingOrigin — the COMPLETE gate (the adversarial scenarios)', () => { + // A granted app reading its OWN pod through the extension. + const credentialOrigins = new Set(['https://alice.pod.example', 'https://idp.example']); + const explicitGrants = ['https://pm.example']; + const inputs = { credentialOrigins, explicitGrants }; + + it('ALLOWS a granted app reading a credential (pod) resource', () => { + const d = decideRequestingOrigin( + { origin: 'https://pm.example' }, + 'https://pm.example', + 'https://alice.pod.example/private/notes.ttl', + inputs, + ); + expect(d.deny).toBeNull(); + expect(d.requestingOrigin).toBe('https://pm.example'); + }); + + it('ALLOWS an app served from the pod origin itself (credential origin auto-granted)', () => { + const d = decideRequestingOrigin( + { origin: 'https://alice.pod.example' }, + 'https://alice.pod.example', + 'https://alice.pod.example/private/notes.ttl', + inputs, + ); + expect(d.deny).toBeNull(); + }); + + it('DENIES a FORGED sender.origin (renderer spoofs a granted origin)', () => { + // The attacker's renderer stamps a granted origin, but the browser attests the attacker. + const d = decideRequestingOrigin( + { origin: 'https://attacker.example' }, + 'https://pm.example', + 'https://alice.pod.example/private/notes.ttl', + inputs, + ); + expect(d.deny).toBe('forbidden-origin'); + expect(d.requestingOrigin).toBeNull(); + }); + + it('DENIES an UN-GRANTED origin attempting a whole-pod read (default-deny)', () => { + const d = decideRequestingOrigin( + { origin: 'https://evil.example' }, + 'https://evil.example', + 'https://alice.pod.example/', // the whole pod root + inputs, + ); + expect(d.deny).toBe('origin-not-granted'); + expect(d.requestingOrigin).toBeNull(); + }); + + it('DENIES app-A reading app-B-only data when A is not granted (cross-app isolation)', () => { + // appB is granted; appA is not. appA tries to read the pod resource appB uses. + const withB = { credentialOrigins, explicitGrants: ['https://appB.example'] }; + const d = decideRequestingOrigin( + { origin: 'https://appA.example' }, + 'https://appA.example', + 'https://alice.pod.example/appB-data/secret.ttl', + withB, + ); + expect(d.deny).toBe('origin-not-granted'); + }); + + it('DENIES an opaque/null requesting origin (fail-safe)', () => { + const d = decideRequestingOrigin( + { origin: 'null' }, + 'null', + 'https://alice.pod.example/x', + inputs, + ); + expect(d.deny).toBe('forbidden-origin'); + }); + + it('FAIL-CLOSED on a boot/SW-restart race: empty grant inputs deny everything', () => { + // On a cold wake the SW re-reads storage; if nothing is loaded the grant set is empty. + // A request that WOULD be allowed once loaded must be DENIED while unloaded — never served. + const cold = decideRequestingOrigin( + { origin: 'https://pm.example' }, + 'https://pm.example', + 'https://alice.pod.example/private/notes.ttl', + {}, // no credentialOrigins, no explicitGrants — nothing loaded yet + ); + expect(cold.deny).toBe('origin-not-granted'); + expect(cold.requestingOrigin).toBeNull(); + }); + + it('DENIES a granted app fetching an unrelated FOREIGN third party (ambient-cookie proxy)', () => { + const d = decideRequestingOrigin( + { origin: 'https://pm.example' }, + 'https://pm.example', + 'https://tracker.example/collect', // foreign, not a credential origin, not same-origin + inputs, + ); + expect(d.deny).toBe('cross-origin-foreign'); + }); + + it('ALLOWS a granted app fetching its OWN origin (same-origin foreign fetch)', () => { + const d = decideRequestingOrigin( + { origin: 'https://pm.example' }, + 'https://pm.example', + 'https://pm.example/api/local.json', // same origin as the requester + inputs, + ); + expect(d.deny).toBeNull(); + }); + + it('DENIES when the stamp is missing even with a valid attested+granted sender', () => { + const d = decideRequestingOrigin( + { origin: 'https://pm.example' }, + undefined, + 'https://alice.pod.example/x', + inputs, + ); + expect(d.deny).toBe('forbidden-origin'); + }); +}); diff --git a/apps/extension/test/service-worker-gate.test.ts b/apps/extension/test/service-worker-gate.test.ts new file mode 100644 index 000000000..d3cc80d4f --- /dev/null +++ b/apps/extension/test/service-worker-gate.test.ts @@ -0,0 +1,394 @@ +// AUTHORED-BY Claude Opus 4.8 +// +// INTEGRATION tests for the Phase-0 per-requesting-origin gate AS WIRED INTO the service +// worker. These drive the REAL `chrome.runtime.onMessage` listener the worker registers, +// with an in-memory `chrome.storage.local` and a captured `fetch`, proving the wiring — not +// just the pure gate logic (covered in requesting-origin.test.ts): +// +// - a forged/mismatched sender.origin vs the page stamp → DENY, NO pod egress +// - an un-granted origin attempting a whole-pod read → DENY, NO egress +// - app-A requesting app-B-only data (A un-granted) → DENY +// - opaque/null origin → DENY (fail-safe) +// - a boot/SW-restart race (grant store not yet populated) → fail-CLOSED +// - the legitimate single-user owner path (granted origin) → SUCCEEDS with egress +// - login from an app origin GRANTS that origin (the opt-in), unlocking subsequent fetches +// - SOLID_GET_STATE withholds the WebID (PII) from a non-granted page, exposes to granted + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { exportDpopKeyPair, generateDpopKeyPair } from '../src/background/core/dpop'; +import type { StoredSession } from '../src/background/session-store'; + +// --- An in-memory chrome.* stub + a captured fetch ------------------------------------ + +interface CapturedSender { + origin?: string; + url?: string; + id?: string; +} +type MessageListener = ( + message: unknown, + sender: CapturedSender, + sendResponse: (r: unknown) => void, +) => boolean | undefined; + +let storage: Map; +let messageListeners: MessageListener[]; +let fetchCalls: Array<{ url: string; init?: RequestInit }>; +let fetchImpl: (url: string, init?: RequestInit) => Promise; + +const EXTENSION_ID = 'abcdefghijklmnopabcdefghijklmnop'; + +function installChrome(): void { + storage = new Map(); + messageListeners = []; + fetchCalls = []; + + const chromeStub = { + runtime: { + id: EXTENSION_ID, + lastError: undefined as { message?: string } | undefined, + onMessage: { addListener: (fn: MessageListener) => messageListeners.push(fn) }, + onInstalled: { addListener: (_fn: () => void) => {} }, + sendMessage: (_m: unknown, _cb?: (r: unknown) => void) => Promise.resolve(), + }, + storage: { + local: { + get: async (key: string) => { + const out: Record = {}; + if (storage.has(key)) out[key] = storage.get(key); + return out; + }, + set: async (obj: Record) => { + for (const [k, v] of Object.entries(obj)) storage.set(k, v); + }, + remove: async (key: string) => { + storage.delete(key); + }, + }, + }, + tabs: { + query: async () => [] as Array<{ id?: number; url?: string }>, + sendMessage: async () => {}, + }, + action: { + setIcon: async () => {}, + setBadgeText: async () => {}, + setBadgeBackgroundColor: async () => {}, + setTitle: async () => {}, + }, + contextMenus: { + create: () => {}, + remove: (_id: string, cb?: () => void) => cb?.(), + onClicked: { addListener: () => {} }, + }, + sidePanel: { setPanelBehavior: async () => {}, open: async () => {} }, + identity: { launchWebAuthFlow: async () => '' }, + }; + (globalThis as unknown as { chrome: unknown }).chrome = chromeStub; + + // A captured global fetch: default deny-with-401 so an UNGATED egress is observable. + fetchImpl = async () => new Response('ok', { status: 200 }); + (globalThis as unknown as { fetch: typeof fetch }).fetch = vi.fn( + (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + fetchCalls.push({ url, init }); + return fetchImpl(url, init); + }, + ) as unknown as typeof fetch; +} + +/** Drive the worker's registered SOLID_FETCH_REQUEST listener and await its response. */ +function sendFetch( + message: Record, + sender: CapturedSender, +): Promise> { + return new Promise((resolve) => { + for (const listener of messageListeners) { + const handled = listener( + { + type: 'SOLID_FETCH_REQUEST', + requestId: 'r1', + method: 'GET', + headers: {}, + body: null, + ...message, + }, + sender, + (r) => resolve(r as Record), + ); + if (handled) return; + } + resolve({ error: 'no listener handled' }); + }); +} + +function sendMessage( + message: Record, + sender: CapturedSender, +): Promise> { + return new Promise((resolve) => { + for (const listener of messageListeners) { + const handled = listener(message, sender, (r) => resolve(r as Record)); + if (handled) return; + } + resolve({ error: 'no listener handled' }); + }); +} + +async function seedSession(overrides: Partial = {}): Promise { + const keyPair = await generateDpopKeyPair(); + const session: StoredSession = { + webId: 'https://alice.pod.example/profile/card#me', + accessToken: 'access-token-xyz', + refreshToken: 'refresh-xyz', + dpopKeyPair: await exportDpopKeyPair(keyPair), + issuer: 'https://idp.example/', + tokenEndpoint: 'https://idp.example/token', + clientId: 'https://pm.example/clientid.jsonld', + expiresAt: Date.now() + 60 * 60 * 1000, // far in the future: no refresh needed + ...overrides, + }; + storage.set('solid:session', session); +} + +/** Fresh module + chrome per test (the worker registers listeners + state at import). */ +async function loadWorker(): Promise { + vi.resetModules(); + await import('../src/background/service-worker'); + // Let the import-time ensureSession()/icon promise settle. + await new Promise((r) => setTimeout(r, 0)); +} + +beforeEach(() => { + installChrome(); +}); + +describe('service-worker per-requesting-origin gate (wired)', () => { + it('the legitimate owner path: a GRANTED app reads its pod → SUCCEEDS with egress', async () => { + await seedSession(); + storage.set('solid:granted-origins', ['https://pm.example']); + await loadWorker(); + + const res = await sendFetch( + { url: 'https://alice.pod.example/private/notes.ttl', stampedOrigin: 'https://pm.example' }, + { origin: 'https://pm.example', url: 'https://pm.example/app.html' }, + ); + + expect(res.status).toBe(200); + // The token-bearing egress to the pod actually happened. + const podCall = fetchCalls.find((c) => c.url.startsWith('https://alice.pod.example')); + expect(podCall).toBeDefined(); + const headers = podCall?.init?.headers as Record; + expect(headers.Authorization).toBe('DPoP access-token-xyz'); + }); + + it('a credential-origin app (served FROM the pod) reads without a separate grant', async () => { + await seedSession(); + // no explicit grants — the pod origin is auto-granted as a requester. + await loadWorker(); + const res = await sendFetch( + { url: 'https://alice.pod.example/x.ttl', stampedOrigin: 'https://alice.pod.example' }, + { origin: 'https://alice.pod.example', url: 'https://alice.pod.example/app' }, + ); + expect(res.status).toBe(200); + }); + + it('DENIES a FORGED sender.origin (renderer spoofs a granted origin) — NO egress', async () => { + await seedSession(); + storage.set('solid:granted-origins', ['https://pm.example']); + await loadWorker(); + + const res = await sendFetch( + // The page stamps the granted origin, but the browser attests the attacker's. + { url: 'https://alice.pod.example/private/notes.ttl', stampedOrigin: 'https://pm.example' }, + { origin: 'https://attacker.example', url: 'https://attacker.example/x' }, + ); + + expect(res.status).toBe(403); + expect(res.error).toBe('Forbidden origin'); + // CRITICAL: no request to the pod was ever made. + expect(fetchCalls.some((c) => c.url.startsWith('https://alice.pod.example'))).toBe(false); + }); + + it('DENIES an UN-GRANTED origin attempting a whole-pod read — NO egress', async () => { + await seedSession(); + storage.set('solid:granted-origins', ['https://pm.example']); + await loadWorker(); + + const res = await sendFetch( + { url: 'https://alice.pod.example/', stampedOrigin: 'https://evil.example' }, + { origin: 'https://evil.example', url: 'https://evil.example/x' }, + ); + + expect(res.status).toBe(403); + expect(res.error).toBe('Origin not granted access'); + expect(fetchCalls.some((c) => c.url.startsWith('https://alice.pod.example'))).toBe(false); + }); + + it('DENIES app-A reading app-B-only data when A is not granted — NO egress', async () => { + await seedSession(); + storage.set('solid:granted-origins', ['https://appB.example']); // only B + await loadWorker(); + + const res = await sendFetch( + { + url: 'https://alice.pod.example/appB-data/secret.ttl', + stampedOrigin: 'https://appA.example', + }, + { origin: 'https://appA.example', url: 'https://appA.example/x' }, + ); + + expect(res.status).toBe(403); + expect(fetchCalls.some((c) => c.url.startsWith('https://alice.pod.example'))).toBe(false); + }); + + it('DENIES an opaque/null requesting origin (fail-safe) — NO egress', async () => { + await seedSession(); + storage.set('solid:granted-origins', ['https://pm.example']); + await loadWorker(); + + const res = await sendFetch( + { url: 'https://alice.pod.example/x', stampedOrigin: 'null' }, + { origin: 'null', url: 'about:blank' }, + ); + + expect(res.status).toBe(403); + expect(res.error).toBe('Forbidden origin'); + expect(fetchCalls.some((c) => c.url.startsWith('https://alice.pod.example'))).toBe(false); + }); + + it('FAIL-CLOSED on a boot/SW-restart race: a request that arrives before the grant store is populated is DENIED', async () => { + // Session exists, but the granted-origins store is EMPTY (e.g. a cold wake before the + // owner has opted any web app in). pm.example is NOT a credential origin, so default-deny. + await seedSession(); + // No 'solid:granted-origins' key set at all. + await loadWorker(); + + const res = await sendFetch( + { url: 'https://alice.pod.example/private/notes.ttl', stampedOrigin: 'https://pm.example' }, + { origin: 'https://pm.example', url: 'https://pm.example/x' }, + ); + + expect(res.status).toBe(403); + expect(res.error).toBe('Origin not granted access'); + expect(fetchCalls.some((c) => c.url.startsWith('https://alice.pod.example'))).toBe(false); + }); + + it('DENIES a granted app proxying to an unrelated FOREIGN third party — NO egress to it', async () => { + await seedSession(); + storage.set('solid:granted-origins', ['https://pm.example']); + await loadWorker(); + + const res = await sendFetch( + { url: 'https://tracker.example/collect', stampedOrigin: 'https://pm.example' }, + { origin: 'https://pm.example', url: 'https://pm.example/x' }, + ); + + expect(res.status).toBe(403); + expect(res.error).toBe('Cross-origin fetch denied'); + expect(fetchCalls.some((c) => c.url.startsWith('https://tracker.example'))).toBe(false); + }); + + it('returns "Not authenticated" with NO gate egress when there is no session', async () => { + // No session seeded. + await loadWorker(); + const res = await sendFetch( + { url: 'https://alice.pod.example/x', stampedOrigin: 'https://pm.example' }, + { origin: 'https://pm.example', url: 'https://pm.example/x' }, + ); + expect(res.error).toBe('Not authenticated'); + expect(fetchCalls.some((c) => c.url.startsWith('https://alice.pod.example'))).toBe(false); + }); +}); + +describe('login GRANTS the requesting origin (the opt-in)', () => { + it('persists the verified origin to the grant store on a page-driven login', async () => { + // We do not exercise the real OIDC flow (it needs the IdP); we assert the grant write, + // which runs BEFORE initiateLogin. A login failure still leaves the grant recorded. + await loadWorker(); + await sendMessage( + { + type: 'SOLID_LOGIN', + webId: 'https://alice.pod.example/card#me', + origin: 'https://newapp.example', + }, + { origin: 'https://newapp.example', url: 'https://newapp.example/x' }, + ); + const grants = (storage.get('solid:granted-origins') as string[]) ?? []; + expect(grants).toContain('https://newapp.example'); + }); + + it('does NOT grant a FORGED login origin (sender ≠ stamp)', async () => { + await loadWorker(); + await sendMessage( + { + type: 'SOLID_LOGIN', + webId: 'https://alice.pod.example/card#me', + origin: 'https://victim.example', // stamp claims the victim + }, + { origin: 'https://attacker.example', url: 'https://attacker.example/x' }, // real sender + ); + const grants = (storage.get('solid:granted-origins') as string[]) ?? []; + expect(grants).not.toContain('https://victim.example'); + expect(grants).not.toContain('https://attacker.example'); + }); +}); + +describe('SOLID_GET_STATE scopes the WebID (PII) to granted origins', () => { + it('WITHHOLDS the WebID from a non-granted web page', async () => { + await seedSession(); + storage.set('solid:granted-origins', ['https://pm.example']); + await loadWorker(); + + const res = await sendMessage( + { type: 'SOLID_GET_STATE', stampedOrigin: 'https://random.example' }, + { origin: 'https://random.example', url: 'https://random.example/x' }, + ); + expect(res.webId).toBeNull(); + expect(res.isActive).toBe(false); + expect(res.recentAccounts).toEqual([]); + }); + + it('EXPOSES the WebID to a granted web page', async () => { + await seedSession(); + storage.set('solid:granted-origins', ['https://pm.example']); + await loadWorker(); + + const res = await sendMessage( + { type: 'SOLID_GET_STATE', stampedOrigin: 'https://pm.example' }, + { origin: 'https://pm.example', url: 'https://pm.example/x' }, + ); + expect(res.webId).toBe('https://alice.pod.example/profile/card#me'); + expect(res.isActive).toBe(true); + }); + + it('gives the EXTENSION popup (own context) the full state incl. recent accounts', async () => { + await seedSession(); + storage.set('solid:recent-accounts', [ + { webId: 'https://alice.pod.example/card#me', name: 'Alice', photoUrl: null }, + ]); + await loadWorker(); + + const res = await sendMessage( + { type: 'SOLID_GET_STATE' }, // popup sends no stampedOrigin + { + origin: `chrome-extension://${EXTENSION_ID}`, + url: `chrome-extension://${EXTENSION_ID}/popup/popup.html`, + }, + ); + expect(res.webId).toBe('https://alice.pod.example/profile/card#me'); + expect((res.recentAccounts as unknown[]).length).toBe(1); + }); + + it('WITHHOLDS the WebID from a FORGED state request (sender ≠ stamp)', async () => { + await seedSession(); + storage.set('solid:granted-origins', ['https://pm.example']); + await loadWorker(); + + const res = await sendMessage( + { type: 'SOLID_GET_STATE', stampedOrigin: 'https://pm.example' }, // claims granted + { origin: 'https://attacker.example', url: 'https://attacker.example/x' }, // real sender + ); + expect(res.webId).toBeNull(); + }); +}); From 20a3c8573cfc09cf34ca9979b0907f1931ef13e0 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Sun, 21 Jun 2026 16:27:25 +0100 Subject: [PATCH 28/50] =?UTF-8?q?fix(auth):=20close=202=20HIGH=20Phase-0?= =?UTF-8?q?=20gate=20holes=20=E2=80=94=20opaque=20sender.origin=20fail-ope?= =?UTF-8?q?n=20+=20grant-before-login?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit High #1 (requesting-origin.ts): a PRESENT-but-opaque sender.origin (Chrome stamps the literal string "null" for sandboxed/opaque frames) used to fall back to sender.url, laundering an opaque sender with a normal https url + matching stamp into a TRUSTED origin (opaque-boundary fail-OPEN). Now classifySenderOrigin distinguishes ABSENT (undefined/null/empty → may derive from sender.url) from PRESENT-but-OPAQUE ("null"/unparseable/non-http(s) → DENY immediately, no url fallback). Legit cases preserved: real https sender.origin, absent-origin url fallback, blob:-resolves-to-creator-origin. High #2 (service-worker.ts handleLogin): grantOrigin was called BEFORE initiateLogin, so any page could send SOLID_LOGIN, get its origin persisted as granted even if the auth flow cancelled/failed, then ride an existing session via SOLID_FETCH_REQUEST (privilege escalation). The verified requesting origin is now resolved BEFORE login (to bind the flow) but grantOrigin is moved to the success path — a cancelled/failed login leaves the grant store unchanged. Adversarial tests added (205 total, all green): present-opaque sender.origin denies with no url fallback (pure + wired, no egress); absent-origin url fallback still resolves; failed/cancelled login persists no grant and a subsequent fetch is denied even when a session exists; successful login grants + fetch succeeds; forged login origin not granted even on success. Gate: lint + typecheck + 205 tests + build all green. Model: claude-opus-4-8 Provenance: Opus 4.8 (Fable unavailable) — re-review/upgrade candidate Co-Authored-By: Claude Opus 4.8 --- .../src/background/core/requesting-origin.ts | 42 ++++- .../src/background/service-worker.ts | 19 ++- apps/extension/test/requesting-origin.test.ts | 85 ++++++++++ .../test/service-worker-gate.test.ts | 155 +++++++++++++++++- 4 files changed, 284 insertions(+), 17 deletions(-) diff --git a/apps/extension/src/background/core/requesting-origin.ts b/apps/extension/src/background/core/requesting-origin.ts index 212502069..1214dd896 100644 --- a/apps/extension/src/background/core/requesting-origin.ts +++ b/apps/extension/src/background/core/requesting-origin.ts @@ -36,12 +36,33 @@ function originOf(value: string | null | undefined): string | null { // `javascript:`); a sandboxed `srcdoc` frame reports "null" too (which then throws here). // Treat the opaque sentinel as no-origin (fail-closed), never a shared bucket. if (o === 'null' || o === '') return null; + // Only http(s) origins are usable requester anchors. A non-http(s) scheme (e.g. `ftp:`, + // `ws:`, a custom protocol) is treated as no-origin (fail-closed) so it can never be a + // trusted requester. + if (!o.startsWith('https://') && !o.startsWith('http://')) return null; return o; } catch { return null; } } +/** + * Distinguish a sender.origin that is genuinely ABSENT (undefined / null / empty string — Chrome + * omits `origin` for some sender types) from one that is PRESENT-but-OPAQUE. Chrome sets + * `sender.origin` to the literal string `"null"` for sandboxed / opaque frames; a present opaque + * value must DENY immediately (never fall back to `sender.url`), so that an opaque sender with a + * normal http(s) `sender.url` cannot be laundered into a trusted origin. Absent → caller may + * derive from `sender.url`. + * + * Returns `'absent'`, `'opaque'`, or the resolved (usable, http(s)) origin string. + */ +function classifySenderOrigin(origin: string | null | undefined): 'absent' | 'opaque' | string { + if (origin === undefined || origin === null || origin === '') return 'absent'; + const resolved = originOf(origin); + if (resolved === null) return 'opaque'; // present but opaque / unparseable / non-http(s) → DENY + return resolved; +} + /** * The browser-attested sender identity, as Chrome populates it on a `chrome.runtime` * message. `origin` is present for content-script senders on a real http(s) page; `url` is @@ -59,9 +80,13 @@ export interface MessageSender { * both agree on a usable (non-opaque) origin, else `null` (DENY). * * Authority order: - * 1. The browser-attested origin is `sender.origin`, falling back to the origin OF - * `sender.url` (Chrome omits `origin` for some sender types but always has `url`). - * This value is set by the browser and cannot be forged by page JS. + * 1. The browser-attested origin is `sender.origin`. We fall back to the origin OF + * `sender.url` ONLY when `sender.origin` is genuinely ABSENT (Chrome omits `origin` for + * some sender types but always has `url`). If `sender.origin` is PRESENT and OPAQUE + * (Chrome stamps the literal string `"null"` for sandboxed / opaque frames), we DENY + * IMMEDIATELY and do NOT consult `sender.url` — otherwise an opaque sender with a normal + * http(s) `sender.url` would be laundered into a trusted origin (the opaque-boundary + * fail-OPEN). This value is set by the browser and cannot be forged by page JS. * 2. The page-supplied `stampedOrigin` is the value the content script read from * `window.location.origin` and forwarded. It is advisory only. * 3. BOTH must resolve to the SAME non-opaque origin. Any mismatch, or either being @@ -73,9 +98,14 @@ export function resolveRequestingOrigin( sender: MessageSender | null | undefined, stampedOrigin: string | null | undefined, ): string | null { - // Browser-attested origin: prefer sender.origin, else derive from sender.url. - const attested = originOf(sender?.origin) ?? originOf(sender?.url); - if (attested === null) return null; // opaque / missing attested origin → DENY. + // Browser-attested origin. CRITICAL fail-closed boundary: a PRESENT sender.origin is + // authoritative — if it is opaque ("null"/unparseable/non-http(s)) we DENY and must NOT fall + // back to sender.url (which could be a normal https URL for an opaque/sandboxed frame). Only a + // genuinely ABSENT sender.origin lets us derive the attested origin from sender.url. + const senderOriginClass = classifySenderOrigin(sender?.origin); + if (senderOriginClass === 'opaque') return null; // present but opaque → DENY, no url fallback. + const attested = senderOriginClass === 'absent' ? originOf(sender?.url) : senderOriginClass; + if (attested === null) return null; // absent origin AND no usable url → DENY. // The page-supplied stamp must be present and parse to a usable origin. const stamped = originOf(stampedOrigin); diff --git a/apps/extension/src/background/service-worker.ts b/apps/extension/src/background/service-worker.ts index 0846babc3..e836d4033 100644 --- a/apps/extension/src/background/service-worker.ts +++ b/apps/extension/src/background/service-worker.ts @@ -303,12 +303,17 @@ async function handleLogin( sender: MessageSender | undefined, ): Promise { try { - // A login driven FROM a web page is a deliberate owner opt-in: grant that app origin so - // its subsequent window.solid.fetch calls pass the per-origin gate. Verify the origin - // via the same dual-origin agreement (browser-attested ∧ page stamp) and never trust the - // page-supplied field alone. A popup/side-panel login has no web sender → nothing granted. + // A login driven FROM a web page is a deliberate owner opt-in. Resolve + REMEMBER the + // verified requesting origin now (to bind the flow), but do NOT grant it yet — verify the + // origin via the same dual-origin agreement (browser-attested ∧ page stamp) and never trust + // the page-supplied field alone. A popup/side-panel login has no web sender → nothing to grant. + // + // SECURITY (privilege-escalation guard): the grant is persisted ONLY AFTER initiateLogin + // succeeds (the success path below). Granting BEFORE login would let any page send + // SOLID_LOGIN, have its origin persisted as granted even if the auth flow is cancelled or + // fails, and then — if a session already existed — read the existing credentials via + // SOLID_FETCH_REQUEST. A failed/cancelled login must leave the grant store UNCHANGED. const requestingOrigin = resolveRequestingOrigin(sender, message.origin); - if (requestingOrigin !== null) await grantOrigin(requestingOrigin); const clientIds = await loadClientIds(); const candidate = @@ -322,6 +327,9 @@ async function handleLogin( webId: message.webId, clientId, }); + // Login succeeded (owner-approved): NOW grant the verified requesting origin so its + // subsequent window.solid.fetch calls pass the per-origin gate. + if (requestingOrigin !== null) await grantOrigin(requestingOrigin); cachedSession = session; cachedKeyPair = null; await saveProfile({ webId: session.webId, name, photoUrl }); @@ -331,6 +339,7 @@ async function handleLogin( await broadcastStateChange(session.webId); return { ok: true, webId: session.webId }; } catch (err) { + // A cancelled / failed login persists NO grant — the grant store is untouched above. return { error: err instanceof Error ? err.message : 'Login failed' }; } } diff --git a/apps/extension/test/requesting-origin.test.ts b/apps/extension/test/requesting-origin.test.ts index f94671b71..c7a32bf50 100644 --- a/apps/extension/test/requesting-origin.test.ts +++ b/apps/extension/test/requesting-origin.test.ts @@ -112,6 +112,91 @@ describe('resolveRequestingOrigin — dual-origin agreement', () => { }); }); +describe('resolveRequestingOrigin — PRESENT-but-OPAQUE sender.origin must NOT fall back to sender.url (High #1)', () => { + // THE BUG (High #1): Chrome sets sender.origin to the literal string "null" for an opaque / + // sandboxed frame. The old code fell back to sender.url whenever sender.origin was falsy OR + // opaque, so an opaque sender with a NORMAL https sender.url + a matching stamp was laundered + // into a TRUSTED origin — a fail-OPEN of the opaque boundary. The fix: a PRESENT opaque + // sender.origin DENIES immediately and never consults sender.url. + + it('DENIES sender.origin === "null" (string) even with a valid https sender.url + matching stamp', () => { + // The exploit: opaque sender, but a believable https url + stamp that agree. + expect( + resolveRequestingOrigin( + { origin: 'null', url: 'https://app.example/page.html' }, + 'https://app.example', + ), + ).toBeNull(); + }); + + it('DENIES an opaque sender.origin even when sender.url, stamp AND the url-origin all agree', () => { + // Belt-and-braces: every page-controllable signal points at a real origin, but the + // browser-attested sender.origin is opaque → DENY, no url fallback. + expect( + resolveRequestingOrigin( + { origin: 'null', url: 'https://victim.example/' }, + 'https://victim.example', + ), + ).toBeNull(); + }); + + it.each([ + ['data:', 'data:text/html,'], + ['about:blank', 'about:blank'], + ['file:', 'file:///etc/passwd'], + ['javascript:', 'javascript:alert(1)'], + ['unparseable garbage', 'not a url at all'], + ['non-http scheme ftp:', 'ftp://app.example/'], + ['non-http scheme ws:', 'ws://app.example/'], + ])('DENIES a PRESENT but opaque/unparseable/non-http(s) sender.origin (%s) with a valid https url', (_label, opaqueOrigin) => { + expect( + resolveRequestingOrigin( + { origin: opaqueOrigin, url: 'https://app.example/page.html' }, + 'https://app.example', + ), + ).toBeNull(); + }); + + it('STILL resolves when sender.origin is genuinely ABSENT and sender.url + stamp agree (legit fallback preserved)', () => { + // undefined origin → derive from url (the legitimate Chrome-omits-origin case). + expect( + resolveRequestingOrigin({ url: 'https://app.example/page.html?x=1' }, 'https://app.example'), + ).toBe('https://app.example'); + }); + + it.each([ + ['undefined origin', undefined], + ['null origin', null], + ['empty-string origin', ''], + ])('treats %s as ABSENT → derives from sender.url (legit fallback preserved)', (_label, origin) => { + expect( + resolveRequestingOrigin( + { origin: origin as string | undefined, url: 'https://app.example/p.html' }, + 'https://app.example', + ), + ).toBe('https://app.example'); + }); + + it('a PRESENT real https sender.origin is used as the authority (the normal case) and ignores a divergent url', () => { + // sender.origin present and real: it is authoritative; sender.url is NOT consulted, so even a + // divergent url cannot widen access — agreement is checked against sender.origin + stamp. + expect( + resolveRequestingOrigin( + { origin: 'https://app.example', url: 'https://elsewhere.example/x' }, + 'https://app.example', + ), + ).toBe('https://app.example'); + }); + + it('the blob:-resolves-to-creator-origin case still works (sender.origin absent, blob url)', () => { + // A blob: URL is NOT opaque — it carries its creator origin, which the browser fixes. With + // sender.origin absent, the fallback derives the creator origin from the blob url. + expect( + resolveRequestingOrigin({ url: 'blob:https://app.example/uuid' }, 'https://app.example'), + ).toBe('https://app.example'); + }); +}); + describe('isRequestingOriginGranted — default-deny', () => { const granted = new Set(['https://app.example', 'https://alice.pod.example']); diff --git a/apps/extension/test/service-worker-gate.test.ts b/apps/extension/test/service-worker-gate.test.ts index d3cc80d4f..9b4bd7da8 100644 --- a/apps/extension/test/service-worker-gate.test.ts +++ b/apps/extension/test/service-worker-gate.test.ts @@ -289,6 +289,38 @@ describe('service-worker per-requesting-origin gate (wired)', () => { expect(fetchCalls.some((c) => c.url.startsWith('https://tracker.example'))).toBe(false); }); + it('DENIES a PRESENT-but-OPAQUE sender.origin ("null") with a normal https sender.url — NO egress (High #1)', async () => { + // The High #1 exploit wired through the SW: an opaque/sandboxed frame whose sender.origin is + // the string "null" but whose sender.url is a normal https URL that matches the stamp. The + // old fail-open would have laundered it into the granted origin and served credentials. + await seedSession(); + storage.set('solid:granted-origins', ['https://app.example']); + await loadWorker(); + + const res = await sendFetch( + { url: 'https://alice.pod.example/private/notes.ttl', stampedOrigin: 'https://app.example' }, + { origin: 'null', url: 'https://app.example/page.html' }, + ); + + expect(res.status).toBe(403); + expect(res.error).toBe('Forbidden origin'); + expect(fetchCalls.some((c) => c.url.startsWith('https://alice.pod.example'))).toBe(false); + }); + + it('STILL allows a sender with ABSENT origin but a valid https url + matching stamp (legit fallback)', async () => { + await seedSession(); + storage.set('solid:granted-origins', ['https://app.example']); + await loadWorker(); + + const res = await sendFetch( + { url: 'https://alice.pod.example/private/notes.ttl', stampedOrigin: 'https://app.example' }, + { url: 'https://app.example/page.html' }, // no origin field → ABSENT → derive from url + ); + + expect(res.status).toBe(200); + expect(fetchCalls.some((c) => c.url.startsWith('https://alice.pod.example'))).toBe(true); + }); + it('returns "Not authenticated" with NO gate egress when there is no session', async () => { // No session seeded. await loadWorker(); @@ -301,12 +333,91 @@ describe('service-worker per-requesting-origin gate (wired)', () => { }); }); -describe('login GRANTS the requesting origin (the opt-in)', () => { - it('persists the verified origin to the grant store on a page-driven login', async () => { - // We do not exercise the real OIDC flow (it needs the IdP); we assert the grant write, - // which runs BEFORE initiateLogin. A login failure still leaves the grant recorded. +describe('login GRANTS the requesting origin ONLY on success (privilege-escalation guard)', () => { + // High #2: the grant must be persisted ONLY AFTER a successful, owner-approved login — never + // before initiateLogin. In these tests the real OIDC flow has no IdP, so initiateLogin THROWS; + // therefore a SOLID_LOGIN here represents a CANCELLED/FAILED login and must leave the grant + // store UNCHANGED. The success path is covered separately by mocking initiateLogin. + + it('does NOT grant the requesting origin when initiateLogin fails/cancels', async () => { await loadWorker(); - await sendMessage( + const res = await sendMessage( + { + type: 'SOLID_LOGIN', + webId: 'https://alice.pod.example/card#me', + origin: 'https://newapp.example', + }, + { origin: 'https://newapp.example', url: 'https://newapp.example/x' }, + ); + // The login failed (no IdP) ... + expect(res.error).toBeDefined(); + // ... so NO grant was persisted (the privilege-escalation fix). + const grants = (storage.get('solid:granted-origins') as string[]) ?? []; + expect(grants).not.toContain('https://newapp.example'); + }); + + it('a FAILED/CANCELLED login leaves the grant store UNCHANGED and a later fetch from that origin is DENIED even when a session EXISTS', async () => { + // The attack High #2 closes: a session already exists for the user; a hostile page sends + // SOLID_LOGIN (which it cancels/fails), then tries SOLID_FETCH_REQUEST to ride the existing + // credentials. The cancelled login must NOT have granted the page. + await seedSession(); // a live session already exists + // No grant for the hostile origin. + await loadWorker(); + + // Step 1: the hostile page drives a login that fails (no IdP). + const loginRes = await sendMessage( + { + type: 'SOLID_LOGIN', + webId: 'https://alice.pod.example/card#me', + origin: 'https://hostile.example', + }, + { origin: 'https://hostile.example', url: 'https://hostile.example/x' }, + ); + expect(loginRes.error).toBeDefined(); + const grants = (storage.get('solid:granted-origins') as string[]) ?? []; + expect(grants).not.toContain('https://hostile.example'); + + // The failing OIDC flow itself may make network calls (discovery/profile) on the raw fetch; + // those are NOT the gated egress we're asserting about. Reset the captured calls so the next + // assertion concerns ONLY the credential-attaching SOLID_FETCH_REQUEST egress. + fetchCalls.length = 0; + + // Step 2: the hostile page now tries to read the existing session's pod. DENIED — no grant, + // so NO credential-attaching egress to the pod from the gated fetch path. + const fetchRes = await sendFetch( + { + url: 'https://alice.pod.example/private/notes.ttl', + stampedOrigin: 'https://hostile.example', + }, + { origin: 'https://hostile.example', url: 'https://hostile.example/x' }, + ); + expect(fetchRes.status).toBe(403); + expect(fetchRes.error).toBe('Origin not granted access'); + expect(fetchCalls.some((c) => c.url.startsWith('https://alice.pod.example'))).toBe(false); + }); + + it('a SUCCESSFUL login GRANTS the verified origin and a subsequent fetch SUCCEEDS', async () => { + // Mock initiateLogin to succeed, returning a fresh session, so we exercise the success path + // where the grant SHOULD now be persisted. + const keyPair = await generateDpopKeyPair(); + const exported = await exportDpopKeyPair(keyPair); + const session: StoredSession = { + webId: 'https://alice.pod.example/profile/card#me', + accessToken: 'access-token-xyz', + refreshToken: 'refresh-xyz', + dpopKeyPair: exported, + issuer: 'https://idp.example/', + tokenEndpoint: 'https://idp.example/token', + clientId: 'https://newapp.example/clientid.jsonld', + expiresAt: Date.now() + 60 * 60 * 1000, + }; + vi.doMock('../src/background/auth-flow', () => ({ + initiateLogin: vi.fn(async () => ({ session, name: 'Alice', photoUrl: null })), + refreshSession: vi.fn(async (s: StoredSession) => s), + })); + await loadWorker(); + + const loginRes = await sendMessage( { type: 'SOLID_LOGIN', webId: 'https://alice.pod.example/card#me', @@ -314,12 +425,42 @@ describe('login GRANTS the requesting origin (the opt-in)', () => { }, { origin: 'https://newapp.example', url: 'https://newapp.example/x' }, ); + expect(loginRes.ok).toBe(true); const grants = (storage.get('solid:granted-origins') as string[]) ?? []; expect(grants).toContain('https://newapp.example'); + + // The now-granted origin can read its pod. + const fetchRes = await sendFetch( + { + url: 'https://alice.pod.example/private/notes.ttl', + stampedOrigin: 'https://newapp.example', + }, + { origin: 'https://newapp.example', url: 'https://newapp.example/app.html' }, + ); + expect(fetchRes.status).toBe(200); + expect(fetchCalls.some((c) => c.url.startsWith('https://alice.pod.example'))).toBe(true); + vi.doUnmock('../src/background/auth-flow'); }); - it('does NOT grant a FORGED login origin (sender ≠ stamp)', async () => { + it('does NOT grant a FORGED login origin even on success (sender ≠ stamp)', async () => { + const keyPair = await generateDpopKeyPair(); + const exported = await exportDpopKeyPair(keyPair); + const session: StoredSession = { + webId: 'https://alice.pod.example/profile/card#me', + accessToken: 'access-token-xyz', + refreshToken: 'refresh-xyz', + dpopKeyPair: exported, + issuer: 'https://idp.example/', + tokenEndpoint: 'https://idp.example/token', + clientId: 'https://newapp.example/clientid.jsonld', + expiresAt: Date.now() + 60 * 60 * 1000, + }; + vi.doMock('../src/background/auth-flow', () => ({ + initiateLogin: vi.fn(async () => ({ session, name: 'Alice', photoUrl: null })), + refreshSession: vi.fn(async (s: StoredSession) => s), + })); await loadWorker(); + await sendMessage( { type: 'SOLID_LOGIN', @@ -329,8 +470,10 @@ describe('login GRANTS the requesting origin (the opt-in)', () => { { origin: 'https://attacker.example', url: 'https://attacker.example/x' }, // real sender ); const grants = (storage.get('solid:granted-origins') as string[]) ?? []; + // The forged origin resolves to null → no grant for victim OR attacker, even on success. expect(grants).not.toContain('https://victim.example'); expect(grants).not.toContain('https://attacker.example'); + vi.doUnmock('../src/background/auth-flow'); }); }); From 45422c473e05cd35de1deea98842177aa10fb38a Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Sun, 21 Jun 2026 17:05:10 +0100 Subject: [PATCH 29/50] =?UTF-8?q?feat(replica):=20Phase=201=20=E2=80=94=20?= =?UTF-8?q?single=20extension-owned=20shared=20pod=20replica=20+=20page?= =?UTF-8?q?=E2=86=92SW=20routing=20+=20egress=20DPoP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of the shared-replica architecture (docs/design/shared-replica-architecture.md §2/§3/§5, "Phase 1"), under the single-user owner-only default and BEHIND the Phase-0 gate. Builds the five Phase-1 pieces; Phases 2-4 (per-app/data-class consent, central WS sync, app consolidation) are NOT built. 1. MAIN-world global-fetch patch → ISOLATED bridge → SW routing (inject.ts global `fetch` wrapper anchored on the pristine fetch + programmatic `chrome.scripting.registerContentScripts` world:MAIN run_at:document_start persistAcrossSessions). BEST-EFFORT TRANSPARENCY, ZERO security weight — every routed request still goes through the Phase-0 gate as the FIRST check. 2. ONE shared replica per (WebID, pod-origin) in the extension's own storage: bytes → CacheStorage, metadata → IndexedDB (replica-db.ts), keyed on a SECURITY-SCOPED synthetic key that embeds the active WebID + grant-scope (replica-key.ts — the design's LOAD-BEARING CACHE-KEY FIX; solid-offline's scope hash is NOT a security boundary in the single extension partition). Read-from-cache + revalidate (never-authoritative SWR); synchronous write-through with If-Match (replica.ts). 3. Re-check the Phase-0 gate on EVERY cache HIT — the gate is an injected predicate the replica awaits as the FIRST step on every read AND write; no code path serves a cached body before the gate resolves to ALLOW. 4. DPoP nonce cache moved from the in-memory Map to IndexedDB (survives SW restart); synchronous-before-serve logout purge (replica bytes + metadata + nonces dropped before any further serve); atomic rotated-refresh-token persistence + the refreshGate single-flight PRESERVED. The DPoP egress + key-in-SW custody are PRESERVED VERBATIM (authenticated-fetch.ts and dpop.ts unchanged). 5. Single-user owner-only: serves only the Phase-0 granted origins; no cross-app/data-class logic (window.solid.requestAccess remains the throwing stub). Adversarial tests (34 new, all 205 Phase-0 tests still green): shared replica serves the same bytes across two granted origins (no dup); a cache HIT still enforces the origin gate (un-granted origin DENIED on a warm cache, no egress); write-through with If-Match; synchronous logout purge; SW-restart nonce restore from IDB; cross-user cache-key guard (same URL+ETag, different WebID → not served). Model: claude-opus-4-8 Provenance: Opus 4.8 (Fable unavailable) — re-review/upgrade candidate Co-Authored-By: Claude Opus 4.8 --- .../src/background/core/replica-db.ts | 163 ++++++ .../src/background/core/replica-key.ts | 140 ++++++ apps/extension/src/background/core/replica.ts | 350 +++++++++++++ .../src/background/global-fetch-register.ts | 65 +++ .../src/background/service-worker.ts | 142 +++++- apps/extension/src/inject/inject.ts | 64 +++ apps/extension/src/manifest.json | 2 +- apps/extension/test/replica-key.test.ts | 102 ++++ apps/extension/test/replica.test.ts | 462 ++++++++++++++++++ .../test/service-worker-gate.test.ts | 71 +++ .../test/service-worker-replica.test.ts | 443 +++++++++++++++++ 11 files changed, 1984 insertions(+), 20 deletions(-) create mode 100644 apps/extension/src/background/core/replica-db.ts create mode 100644 apps/extension/src/background/core/replica-key.ts create mode 100644 apps/extension/src/background/core/replica.ts create mode 100644 apps/extension/src/background/global-fetch-register.ts create mode 100644 apps/extension/test/replica-key.test.ts create mode 100644 apps/extension/test/replica.test.ts create mode 100644 apps/extension/test/service-worker-replica.test.ts diff --git a/apps/extension/src/background/core/replica-db.ts b/apps/extension/src/background/core/replica-db.ts new file mode 100644 index 000000000..8ee877049 --- /dev/null +++ b/apps/extension/src/background/core/replica-db.ts @@ -0,0 +1,163 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * The IndexedDB backing for the shared extension-owned replica: + * - the per-entry METADATA store (ETag / fetchedAt / contentType / status / scope) — the + * client analogue of QLever, the 1:1 companion of the bytes in the Cache API. Modelled + * on `@jeswr/solid-offline` `metadata-store.ts` (a thin promise-handle over one object + * store) but EXTENDED so the security-scoped composite key (which embeds WebID + + * grant-scope, see replica-key.ts) is the primary key, and so the WebID is a queryable + * column for the synchronous logout purge. + * - the DPoP NONCE store — moved off the in-memory `Map` so a cold SW wake does not pay the + * RFC 9449 §8 nonce round-trip on its first request (design §2.1 last bullet, §5.2). + * + * ONE IndexedDB database (`solid-ext-replica`) holds both object stores, so the extension + * opens a single handle. All durable state lives here / in the Cache API / in + * `chrome.storage.local`; nothing in this module is load-bearing in-memory (the open handle + * is a re-derivable accelerator), keeping the MV3 "stateless on wake" invariant. + */ + +/** The metadata record persisted per replica entry — the 1:1 companion of the bytes. */ +export interface ReplicaMetadata { + /** Composite primary key = the security-scoped `computeReplicaKey` string. */ + readonly key: string; + /** The active WebID this entry belongs to (a queryable column for the logout purge). */ + readonly webId: string; + /** The grant-scope (verified requesting origin) this entry was cached under. */ + readonly grantScope: string; + /** The real resource URL (without the synthetic key wrapper). */ + readonly resourceUrl: string; + /** The variant discriminator (canonical Accept). */ + readonly varyKey: string; + /** The response ETag — drives the conditional `If-None-Match` revalidation. */ + readonly etag?: string; + /** The response Content-Type. */ + readonly contentType?: string; + /** HTTP status of the cached response. */ + readonly status: number; + /** Epoch ms the entry was last confirmed fresh (set on 200, touched on 304). */ + readonly fetchedAt: number; +} + +const DB_NAME = 'solid-ext-replica'; +const DB_VERSION = 1; +const META_STORE = 'replica-metadata'; +const NONCE_STORE = 'dpop-nonces'; +const META_BY_WEBID = 'byWebId'; + +function promisify(req: IDBRequest): Promise { + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +function txDone(tx: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); +} + +/** + * Open (or upgrade) the replica DB. The caller may inject an `indexedDB` factory (the test + * suite injects `fake-indexeddb`); defaults to the global (present in the SW context). + */ +export function openReplicaDb(factory: IDBFactory = globalThis.indexedDB): Promise { + if (!factory) { + return Promise.reject(new Error('[solid-ext] no IndexedDB available in this context')); + } + return new Promise((resolve, reject) => { + const req = factory.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(META_STORE)) { + const os = db.createObjectStore(META_STORE, { keyPath: 'key' }); + // The logout purge drops exactly one identity's rows — index the WebID column. + os.createIndex(META_BY_WEBID, 'webId', { unique: false }); + } + if (!db.objectStoreNames.contains(NONCE_STORE)) { + // Nonce store keyed by resource origin → the freshest server DPoP nonce string. + db.createObjectStore(NONCE_STORE); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +/** A promise-based handle over the replica's two IndexedDB stores. */ +export class ReplicaDb { + private constructor(private readonly db: IDBDatabase) {} + + static async open(factory?: IDBFactory): Promise { + return new ReplicaDb(await openReplicaDb(factory)); + } + + // --- Metadata ------------------------------------------------------------------------ + + async getMetadata(key: string): Promise { + const tx = this.db.transaction(META_STORE, 'readonly'); + return promisify(tx.objectStore(META_STORE).get(key)); + } + + async putMetadata(record: ReplicaMetadata): Promise { + const tx = this.db.transaction(META_STORE, 'readwrite'); + await promisify(tx.objectStore(META_STORE).put(record)); + await txDone(tx); + } + + async deleteMetadata(key: string): Promise { + const tx = this.db.transaction(META_STORE, 'readwrite'); + await promisify(tx.objectStore(META_STORE).delete(key)); + await txDone(tx); + } + + /** Every metadata row for a WebID (the rows a logout purge must drop + their byte keys). */ + async getMetadataByWebId(webId: string): Promise { + const tx = this.db.transaction(META_STORE, 'readonly'); + const index = tx.objectStore(META_STORE).index(META_BY_WEBID); + return promisify(index.getAll(webId)); + } + + /** Delete every metadata row for a WebID (the metadata half of the logout purge). */ + async deleteMetadataByWebId(webId: string): Promise { + const tx = this.db.transaction(META_STORE, 'readwrite'); + const store = tx.objectStore(META_STORE); + const index = store.index(META_BY_WEBID); + const keys = await promisify(index.getAllKeys(webId)); + for (const k of keys) await promisify(store.delete(k)); + await txDone(tx); + } + + /** Drop ALL replica metadata (a full reset — e.g. a logout with an unknown prior WebID). */ + async clearMetadata(): Promise { + const tx = this.db.transaction(META_STORE, 'readwrite'); + await promisify(tx.objectStore(META_STORE).clear()); + await txDone(tx); + } + + // --- DPoP nonce cache (survives SW restart) ------------------------------------------ + + async getNonce(origin: string): Promise { + const tx = this.db.transaction(NONCE_STORE, 'readonly'); + return promisify(tx.objectStore(NONCE_STORE).get(origin)); + } + + async setNonce(origin: string, nonce: string): Promise { + const tx = this.db.transaction(NONCE_STORE, 'readwrite'); + await promisify(tx.objectStore(NONCE_STORE).put(nonce, origin)); + await txDone(tx); + } + + /** Clear every cached nonce (part of the logout purge — a new identity must not reuse them). */ + async clearNonces(): Promise { + const tx = this.db.transaction(NONCE_STORE, 'readwrite'); + await promisify(tx.objectStore(NONCE_STORE).clear()); + await txDone(tx); + } + + close(): void { + this.db.close(); + } +} diff --git a/apps/extension/src/background/core/replica-key.ts b/apps/extension/src/background/core/replica-key.ts new file mode 100644 index 000000000..4a0605ae7 --- /dev/null +++ b/apps/extension/src/background/core/replica-key.ts @@ -0,0 +1,140 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * The SHARED-REPLICA cache key — the SECURITY-CRITICAL identity dimension of the + * extension-owned offline replica (design §2.1 "LOAD-BEARING CACHE-KEY FIX"). + * + * ──────────────────────────────────────────────────────────────────────────── + * WHY THIS IS A SECURITY BOUNDARY HERE (and is NOT in `@jeswr/solid-offline`): + * + * `@jeswr/solid-offline` (`scope.ts`) keys its byte/metadata stores on a hash of the + * WebID, but states VERBATIM that the hash is "only a *namespacing discriminator*, NOT a + * security boundary: the real boundary is the browser's origin isolation." That holds in a + * normal web page, where each top-level site gets its OWN partitioned Cache/IndexedDB. It + * does NOT hold in the extension: the `chrome-extension://` origin is a SINGLE shared + * partition (design §1) — there is no origin boundary underneath. ONE physical store holds + * EVERY app's, EVERY user's bytes. + * + * Therefore in the extension the cache key MUST itself be the boundary: + * - it embeds the active WebID, so a same-URL+same-ETag collision across two users on one + * device (a shared/public resource, or a re-provisioned WebID) can NEVER serve user A's + * bytes to user B (design §2.1); + * - it embeds the GRANT-SCOPE (the requesting app's verified, granted origin), so a body + * landed in the shared replica by one granted origin lives in that origin's partition — + * a missing grant cannot even REACH a shared body (design §5.4 point 3: "Partition the + * cache by grant-scope so a missing grant cannot even reach a shared body"). + * + * Single-user owner-only default (this Phase): there is ONE user, and the grant-scope is the + * verified requesting origin from the Phase-0 gate. The shared replica's value here is the + * SINGLE physical store (one byte store reachable from one place, no N-per-app duplication, + * one revalidation pipeline) keyed by the WebID+url+varyKey dimension, with the grant-scope + * PARTITIONING access. (Per-data-class grant scoping that lets several apps share one + * grant-scope is Phase 2; here grant-scope === the requesting origin.) + * ──────────────────────────────────────────────────────────────────────────── + * + * The byte cache (Cache API) and the metadata store (IndexedDB) are keyed TOGETHER on a + * single synthetic, collision-free Request whose URL encodes the FULL + * `(webId, grantScope, resourceUrl, varyKey)` composite — the same synthetic-key discipline + * `@jeswr/solid-offline` `cache-policy.ts#keyRequest` uses (one canonical key, no + * header-driven `Vary` divergence), EXTENDED with the two security dimensions above. + * + * PURE: no `caches` / `indexedDB` / `fetch` / SW globals, so the whole key derivation is + * exhaustively unit-testable. + */ + +/** Canonical Accept used to normalise RDF reads to a single cache variant (per solid-offline). */ +export const CANONICAL_RDF_ACCEPT = 'text/turtle'; + +/** A fresh sentinel origin for the synthetic key — nothing the server sees can collide with it. */ +export const REPLICA_KEY_ORIGIN = 'https://solid-extension-replica.invalid/'; + +/** RDF media types collapsed to {@link CANONICAL_RDF_ACCEPT} so all RDF variants share one entry. */ +const RDF_ACCEPT_HINTS = [ + 'text/turtle', + 'application/ld+json', + 'application/n-triples', + 'application/n-quads', + 'application/trig', + 'application/rdf+xml', +]; + +/** + * Compute the canonical Accept header for keying + revalidation. An RDF (or `* /*`) Accept + * collapses to `text/turtle` so JSON-LD and Turtle reads of the same resource share ONE byte + * copy (the de-duplication the shared replica exists to deliver); a non-RDF Accept (image, + * html) keys on what was actually requested. Mirrors solid-offline `cache-policy.ts`. + */ +export function canonicalAccept(accept: string | null | undefined): string { + if (!accept) return CANONICAL_RDF_ACCEPT; + const lower = accept.toLowerCase(); + if (lower.includes('*/*')) return CANONICAL_RDF_ACCEPT; + if (RDF_ACCEPT_HINTS.some((t) => lower.includes(t))) return CANONICAL_RDF_ACCEPT; + return accept.split(',')[0]?.trim() ?? CANONICAL_RDF_ACCEPT; +} + +/** + * The variant discriminator for a read. The shared replica normalises every read to the + * canonical Accept (Phase 1 caches RDF + opaque bytes by request Accept); a richer + * `Vary`-driven key is a later refinement. Deliberately SMALL + deterministic. + */ +export function computeVaryKey(accept: string | null | undefined): string { + return `accept=${canonicalAccept(accept)}`; +} + +/** + * The identity tuple that scopes a replica entry. BOTH fields are part of the security + * boundary (see the module note): `webId` prevents cross-USER leakage; `grantScope` (the + * verified, granted requesting origin) prevents a non-granted origin reaching a shared body. + */ +export interface ReplicaScope { + /** The active session's WebID. `null` ⇒ no session ⇒ no replica entry may be keyed. */ + readonly webId: string | null; + /** The verified+granted requesting origin (Phase-0 gate output). `null` ⇒ DENY. */ + readonly grantScope: string | null; +} + +/** + * The delimiter joining the key's four components: a NUL byte (`U+0000`). A NUL can NEVER + * appear in a WebID / origin / http(s) URL / the canonical-Accept varyKey (all are printable + * once the URL parser has normalised them), so the join is UNAMBIGUOUS and INJECTIVE across + * the whole tuple — no value can bleed from one component into another to forge a colliding + * key, even for an adversary who can influence a component's contents. This is STRICTLY + * STRONGER than the `${url} ${varyKey}` space join `@jeswr/solid-offline` uses (which is safe + * only because its inputs never contain a raw space); here the key IS a security boundary, so + * we pick a separator that is impossible in any input. + */ +const KEY_DELIMITER = '\u0000'; + +/** + * The composite primary key string for the metadata store: the four security+coherence + * dimensions, space-delimited. Distinct tuples ALWAYS produce distinct keys (injective — see + * {@link KEY_DELIMITER}). + * + * Returns `null` (un-keyable ⇒ the caller must NOT cache) when either security dimension is + * absent — fail-closed: an entry can never be written/read without a WebID AND a grant-scope. + */ +export function computeReplicaKey( + scope: ReplicaScope, + resourceUrl: string, + varyKey: string, +): string | null { + if (scope.webId === null || scope.grantScope === null) return null; + return [scope.webId, scope.grantScope, resourceUrl, varyKey].join(KEY_DELIMITER); +} + +/** + * The CANONICAL synthetic Cache-API key Request for a replica entry. The full composite key + * (which embeds WebID + grant-scope) is percent-encoded into ONE opaque path segment on a + * sentinel origin — injective, and nothing the server ever returns can collide with it. The + * byte cache and the metadata store are thus keyed 1:1 on the SAME security-scoped identity. + * + * Returns `null` when the entry is un-keyable (no WebID / no grant-scope) — fail-closed. + */ +export function replicaKeyRequest( + scope: ReplicaScope, + resourceUrl: string, + varyKey: string, +): Request | null { + const key = computeReplicaKey(scope, resourceUrl, varyKey); + if (key === null) return null; + return new Request(`${REPLICA_KEY_ORIGIN}${encodeURIComponent(key)}`, { method: 'GET' }); +} diff --git a/apps/extension/src/background/core/replica.ts b/apps/extension/src/background/core/replica.ts new file mode 100644 index 000000000..50db15cfc --- /dev/null +++ b/apps/extension/src/background/core/replica.ts @@ -0,0 +1,350 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * The single, extension-owned SHARED REPLICA (design §2, §5.2). + * + * ONE replica per `(WebID, pod-origin)` in the EXTENSION's own storage (NOT partitioned by + * the visited app's site, unlike a page service worker): bytes → Cache API, metadata → + * IndexedDB (replica-db.ts). Both are keyed on the SECURITY-SCOPED synthetic key + * (replica-key.ts) so the cache key itself is the cross-user / cross-grant boundary. + * + * Consistency = read-from-cache + ALWAYS-revalidate, never-authoritative + * stale-while-revalidate (the `@jeswr/solid-offline` `swr.ts` discipline, the PSS "cache is + * never authoritative" invariant): a cached body is provisional; a conditional + * `If-None-Match` GET confirms (304) or replaces (200) it. Writes are SYNCHRONOUS + * WRITE-THROUGH (design §2.3, Phase-1 is write-through NOT optimistic): the pod is written + * first with `If-Match`, and only a 2xx updates the shared replica from the POD's response. + * + * ──────────────────────────────────────────────────────────────────────────── + * THE TWO LOAD-BEARING SECURITY PROPERTIES this module structurally enforces: + * + * (1) GATE-ON-EVERY-READ, INCLUDING A CACHE HIT (design §5.4 point 3). The gate is an + * INJECTED predicate that this module calls as the FIRST thing on read AND on write — + * there is no code path that returns a cached body without first awaiting `gate()`. A + * cache HIT is gated identically to an egress, so a body landed in the shared replica + * by one granted origin can never be served to a non-granted origin off a warm cache. + * + * (2) SECURITY-SCOPED KEY. Every Cache/metadata access goes through `replicaKeyRequest` / + * `computeReplicaKey`, which embed the WebID + grant-scope. A read for (webId=B) can + * never `match()` bytes written for (webId=A) even at the same URL+ETag. + * ──────────────────────────────────────────────────────────────────────────── + * + * Pure I/O ports are injected (the gate, the authenticated egress, the Cache, the DB) so the + * whole orchestration is unit-testable without a packed extension or a live pod. + */ + +import type { ReplicaMetadata } from './replica-db'; +import { + CANONICAL_RDF_ACCEPT, + computeReplicaKey, + computeVaryKey, + type ReplicaScope, + replicaKeyRequest, +} from './replica-key'; + +/** + * The metadata-store surface the replica depends on (dependency inversion so the + * security-critical orchestration is unit-testable with an in-memory double, while the live + * SW passes the IndexedDB-backed `ReplicaDb`). A subset of `ReplicaDb`'s methods. + */ +export interface ReplicaMetaStore { + getMetadata(key: string): Promise; + putMetadata(record: ReplicaMetadata): Promise; + deleteMetadata(key: string): Promise; + getMetadataByWebId(webId: string): Promise; + deleteMetadataByWebId(webId: string): Promise; + clearMetadata(): Promise; + clearNonces(): Promise; +} + +/** A served replica result — a real `Response` plus whether it came from the warm cache. */ +export interface ReplicaResult { + readonly response: Response; + readonly fromCache: boolean; + /** The freshest server DPoP nonce observed on an egress (cache the next call), if any. */ + readonly nonce?: string; +} + +/** The authenticated-egress port — the SW's sole DPoP egress (authenticated-fetch.ts). */ +export interface EgressPort { + /** + * Perform the authenticated egress for a request. `extraHeaders` carries the conditional + * (`If-None-Match` / `If-Match`) header the replica adds. Returns the live Response + the + * freshest server nonce. + */ + fetch( + url: string, + method: string, + headers: Record, + body: string | null, + nonce: string | undefined, + ): Promise<{ response: Response; nonce?: string }>; +} + +/** + * The injected, fail-closed access gate — re-run as the FIRST step on EVERY read and write + * (the gate-on-cache-hit invariant). Resolves to the verified grant-scope (the granted + * requesting origin) on ALLOW, or an error Response on DENY. The replica NEVER touches the + * cache or the pod before this resolves to ALLOW. + */ +export type ReplicaGate = () => Promise<{ grantScope: string } | { deny: Response }>; + +/** The minimal Cache-API surface the replica uses (the SW's own named cache). */ +export interface ReplicaByteCache { + match(request: Request, options?: CacheQueryOptions): Promise; + put(request: Request, response: Response): Promise; + delete(request: Request, options?: CacheQueryOptions): Promise; +} + +/** + * We key the byte cache on our own synthetic canonical Request, so a stored response's own + * `Vary` must not cause the Cache API to re-apply header matching. Pass `ignoreVary` on every + * match/delete (the solid-offline `IGNORE_VARY` discipline). + */ +const IGNORE_VARY: CacheQueryOptions = { ignoreVary: true }; + +/** Build the request headers the egress should carry, with the conditional header merged in. */ +function withConditional( + headers: Record, + conditional: { name: string; value: string } | null, +): Record { + if (conditional === null) return headers; + return { ...headers, [conditional.name]: conditional.value }; +} + +/** Reconstruct a servable Response from cached bytes + the stored metadata's status/headers. */ +function responseFromCache(bytes: Response, meta: ReplicaMetadata): Response { + const headers = new Headers(bytes.headers); + if (meta.etag && !headers.has('etag')) headers.set('etag', meta.etag); + if (meta.contentType && !headers.has('content-type')) + headers.set('content-type', meta.contentType); + return new Response(bytes.body, { status: meta.status, headers }); +} + +export interface ReplicaConfig { + readonly gate: ReplicaGate; + readonly egress: EgressPort; + readonly cache: ReplicaByteCache; + readonly db: ReplicaMetaStore; + /** The active session's WebID (the security key dimension). */ + readonly webId: string | null; +} + +/** + * The shared replica. Constructed per-request from the SW with the active session's WebID + + * the injected ports; cheap (the ports are re-derivable on every wake). + */ +export class SharedReplica { + constructor(private readonly cfg: ReplicaConfig) {} + + /** + * Read a resource through the replica (GET/HEAD): gate FIRST (incl. on a cache hit), then + * serve-from-cache + revalidate (never-authoritative SWR). Returns the gate's DENY Response + * untouched when access is refused — without touching the cache or the pod. + */ + async read( + url: string, + method: string, + headers: Record, + nonce: string | undefined, + ): Promise { + // (1) GATE FIRST — applies to a cache HIT exactly as to an egress. No cached body is ever + // returned before this resolves to ALLOW. + const decision = await this.cfg.gate(); + if ('deny' in decision) return { deny: decision.deny }; + + const scope: ReplicaScope = { webId: this.cfg.webId, grantScope: decision.grantScope }; + const varyKey = computeVaryKey(headers.Accept ?? headers.accept); + const keyReq = replicaKeyRequest(scope, url, varyKey); + const metaKey = computeReplicaKey(scope, url, varyKey); + + // No WebID / no grant-scope ⇒ un-keyable ⇒ never cache: a plain authenticated egress. + if (keyReq === null || metaKey === null) { + const { response, nonce: n } = await this.cfg.egress.fetch(url, method, headers, null, nonce); + return { response, fromCache: false, nonce: n }; + } + + const cachedBytes = await this.cfg.cache.match(keyReq, IGNORE_VARY); + const meta = await this.cfg.db.getMetadata(metaKey); + + // Conditional revalidation: carry the cached ETag so a 304 confirms the provisional body. + const conditional = + cachedBytes && meta?.etag ? { name: 'If-None-Match', value: meta.etag } : null; + const reqHeaders = withConditional(headers, conditional); + + const { response, nonce: serverNonce } = await this.cfg.egress.fetch( + url, + method, + reqHeaders, + null, + nonce, + ); + + // 304 Not Modified: the provisional cached body is confirmed fresh — serve it, touch it. + if (response.status === 304 && cachedBytes && meta) { + await this.cfg.db.putMetadata({ ...meta, fetchedAt: Date.now() }); + return { + response: responseFromCache(cachedBytes.clone(), meta), + fromCache: true, + nonce: serverNonce, + }; + } + + // 2xx: replace the shared replica bytes + metadata FROM the pod's response (never-stale). + if (response.status >= 200 && response.status < 300 && method.toUpperCase() === 'GET') { + const stored = await this.store(scope, url, varyKey, metaKey, keyReq, response); + return { response: stored, fromCache: false, nonce: serverNonce }; + } + + // Any other status (incl. a now-403/404 — access changed, or not-found): do NOT serve a + // stale cached body; surface the live response. Drop any now-invalid cached entry. + if (cachedBytes || meta) { + await this.invalidate(metaKey, keyReq); + } + return { response, fromCache: false, nonce: serverNonce }; + } + + /** + * Write a resource through the replica (PUT/POST/PATCH/DELETE): gate FIRST, then SYNCHRONOUS + * WRITE-THROUGH — the pod is written with `If-Match` (the cross-app lost-update guard when + * the caller supplied none), and only a 2xx updates the shared replica FROM the pod's + * response. A 412 / 5xx is surfaced verbatim (no silent queue in Phase 1). + */ + async write( + url: string, + method: string, + headers: Record, + body: string | null, + nonce: string | undefined, + ): Promise { + const decision = await this.cfg.gate(); + if ('deny' in decision) return { deny: decision.deny }; + + const scope: ReplicaScope = { webId: this.cfg.webId, grantScope: decision.grantScope }; + const varyKey = computeVaryKey(CANONICAL_RDF_ACCEPT); + const keyReq = replicaKeyRequest(scope, url, varyKey); + const metaKey = computeReplicaKey(scope, url, varyKey); + + // Lost-update guard: if the caller supplied no precondition AND we hold a cached ETag, + // attach `If-Match` so App B cannot silently clobber App A's confirmed write. + let reqHeaders = headers; + const hasPrecondition = + 'if-match' in lowerKeys(headers) || 'if-none-match' in lowerKeys(headers); + if (!hasPrecondition && metaKey !== null) { + const meta = await this.cfg.db.getMetadata(metaKey); + if (meta?.etag) reqHeaders = withConditional(headers, { name: 'If-Match', value: meta.etag }); + } + + const { response, nonce: serverNonce } = await this.cfg.egress.fetch( + url, + method, + reqHeaders, + body, + nonce, + ); + + // On a 2xx write, the cached copy is stale. A DELETE drops the entry; a PUT/PATCH/POST + // re-fetches lazily on the next read (we do not trust the write response body as the new + // GET representation). Either way invalidate the shared replica entry for this URL. + if (response.status >= 200 && response.status < 300 && keyReq !== null && metaKey !== null) { + await this.invalidate(metaKey, keyReq); + } + + return { response, fromCache: false, nonce: serverNonce }; + } + + /** Store a 2xx GET response's bytes + metadata under the security-scoped key. */ + private async store( + scope: ReplicaScope, + url: string, + varyKey: string, + metaKey: string, + keyReq: Request, + response: Response, + ): Promise { + // Clone so the caller still gets a live body; store the clone UN-mutated under the key. + await this.cfg.cache.put(keyReq, response.clone()); + const record: ReplicaMetadata = { + key: metaKey, + webId: scope.webId as string, + grantScope: scope.grantScope as string, + resourceUrl: url, + varyKey, + etag: response.headers.get('etag') ?? undefined, + contentType: response.headers.get('content-type') ?? undefined, + status: response.status, + fetchedAt: Date.now(), + }; + await this.cfg.db.putMetadata(record); + return response; + } + + /** Drop one entry's bytes + metadata (a no-leak partial-write sweep). */ + private async invalidate(metaKey: string, keyReq: Request): Promise { + await this.cfg.cache.delete(keyReq, IGNORE_VARY).catch(() => false); + await this.cfg.db.deleteMetadata(metaKey).catch(() => {}); + } +} + +/** Lower-cased header-name set for case-insensitive precondition detection. */ +function lowerKeys(headers: Record): Record { + const out: Record = {}; + for (const k of Object.keys(headers)) out[k.toLowerCase()] = true; + return out; +} + +/** + * SYNCHRONOUS-BEFORE-SERVE logout purge (design §4.3 "Cross-USER stale bytes", §5.4 point 5): + * delete the prior identity's replica bytes + metadata + cached nonces BEFORE any new + * session's read can be served. The SW awaits this in its logout handler, so a returning / + * different user never reads a single departed-identity byte (the WebID-in-key makes a + * cross-user collision impossible, and this purge removes the prior rows entirely). + * + * `webId === null` (a logout where the prior WebID is unknown / a full reset) drops EVERYTHING + * — fail-closed: when in doubt, purge all, never leave a row behind. + */ +export async function purgeReplica( + cache: ReplicaByteCache, + db: ReplicaMetaStore, + webId: string | null, + cacheKeysForWebId?: () => Promise, +): Promise<{ bytesDropped: number; metaDropped: number }> { + let metaDropped = 0; + let bytesDropped = 0; + + if (webId !== null) { + // Drop exactly this identity's rows + their byte entries. + const rows = await db.getMetadataByWebId(webId); + for (const row of rows) { + const keyReq = new Request( + // Reconstruct the exact synthetic key from the stored scope (injective — see replica-key.ts). + replicaKeyUrlFor(row), + ); + if (await cache.delete(keyReq, IGNORE_VARY).catch(() => false)) bytesDropped++; + } + await db.deleteMetadataByWebId(webId); + metaDropped = rows.length; + } else { + // Unknown prior identity → purge ALL replica state (fail-closed, no row survives). + if (cacheKeysForWebId) { + for (const keyReq of await cacheKeysForWebId()) { + if (await cache.delete(keyReq, IGNORE_VARY).catch(() => false)) bytesDropped++; + } + } + await db.clearMetadata(); + } + + // Nonces are always cleared on logout (a new identity must never reuse a prior nonce). + await db.clearNonces(); + return { bytesDropped, metaDropped }; +} + +/** Reconstruct the synthetic Cache key URL for a stored metadata row (the byte half of purge). */ +function replicaKeyUrlFor(row: ReplicaMetadata): string { + const keyReq = replicaKeyRequest( + { webId: row.webId, grantScope: row.grantScope }, + row.resourceUrl, + row.varyKey, + ); + // A stored row always has both scope dimensions, so this is never null; assert for the type. + return (keyReq as Request).url; +} diff --git a/apps/extension/src/background/global-fetch-register.ts b/apps/extension/src/background/global-fetch-register.ts new file mode 100644 index 000000000..e182855da --- /dev/null +++ b/apps/extension/src/background/global-fetch-register.ts @@ -0,0 +1,65 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * Programmatic registration of the MAIN-world injection (design §5.1). + * + * The manifest already declares the `inject.js` MAIN-world content script. This module ALSO + * registers it via `chrome.scripting.registerContentScripts({ world: 'MAIN', runAt: + * 'document_start', persistAcrossSessions: true })` — the most reliable MAIN-world injection + * (the MetaMask `window.ethereum` pattern). The two are belt-and-braces: the dynamic + * registration is idempotent (it removes a stale prior registration first), survives across + * browser sessions, and gives a single code path to evolve the injection without a manifest + * edit. + * + * SECURITY NOTE: the injected script is BEST-EFFORT TRANSPARENCY with ZERO security weight + * (design §5.1) — it is racy + bypassable, and the SW gate remains the sole boundary on every + * routed request. Registration failing (older Chrome, missing `scripting` permission) is + * therefore NON-FATAL: the manifest-declared content script still provides the path, and even + * with NO injection at all the security posture is unchanged (a plain fetch hits the pod and + * gets a 401 / WAC decision). So this function swallows errors and never throws into SW boot. + */ + +const SCRIPT_ID = 'solid-main-world-inject'; +const INJECT_JS = 'inject/inject.js'; + +/** The chrome.scripting surface we touch (typed minimally so a stub can satisfy it in tests). */ +interface ScriptingApi { + getRegisteredContentScripts(filter?: { ids?: string[] }): Promise>; + registerContentScripts(scripts: chrome.scripting.RegisteredContentScript[]): Promise; + unregisterContentScripts(filter?: { ids?: string[] }): Promise; +} + +/** + * Register (idempotently) the MAIN-world global-fetch-patch injection. Removes any stale prior + * registration of the same id first so a reload/upgrade does not error on a duplicate id. + * Best-effort: any failure is swallowed (the manifest content script + the SW gate stand). + */ +export async function registerGlobalFetchInjection( + scripting: ScriptingApi | undefined = (globalThis as { chrome?: { scripting?: ScriptingApi } }) + .chrome?.scripting, +): Promise { + if (!scripting?.registerContentScripts) return false; + try { + const existing = await scripting + .getRegisteredContentScripts({ ids: [SCRIPT_ID] }) + .catch(() => [] as Array<{ id: string }>); + if (existing.some((s) => s.id === SCRIPT_ID)) { + await scripting.unregisterContentScripts({ ids: [SCRIPT_ID] }).catch(() => {}); + } + await scripting.registerContentScripts([ + { + id: SCRIPT_ID, + js: [INJECT_JS], + matches: [''], + runAt: 'document_start', + world: 'MAIN', + persistAcrossSessions: true, + allFrames: false, + }, + ]); + return true; + } catch { + // Non-fatal — the manifest-declared MAIN-world script still injects, and the SW gate is + // the sole security boundary regardless of whether the patch ran (design §5.1). + return false; + } +} diff --git a/apps/extension/src/background/service-worker.ts b/apps/extension/src/background/service-worker.ts index e836d4033..ce238ba84 100644 --- a/apps/extension/src/background/service-worker.ts +++ b/apps/extension/src/background/service-worker.ts @@ -33,6 +33,13 @@ import { initiateLogin, refreshSession } from './auth-flow'; import { authenticatedFetch, type FetchSession } from './core/authenticated-fetch'; import { importDpopKeyPair } from './core/dpop'; import { computeAllowedOrigins, isValidClientIdUrl } from './core/origin-policy'; +import { + type EgressPort, + purgeReplica, + type ReplicaByteCache, + SharedReplica, +} from './core/replica'; +import { ReplicaDb } from './core/replica-db'; import { computeGrantedOrigins, decideRequestingOrigin, @@ -43,6 +50,7 @@ import { import { filterResponseHeaders } from './core/response-headers'; import { SingleFlight } from './core/single-flight'; import { parseWebIdProfile } from './core/webid'; +import { registerGlobalFetchInjection } from './global-fetch-register'; import { clearProfile, clearSession, @@ -62,11 +70,31 @@ import { registerSidePanel } from './side-panel'; const ALLOW_INSECURE_LOOPBACK = true; // dev CSS over http://localhost; harmless for https issuers. const EXPIRY_BUFFER_MS = 30_000; +/** The Cache API bucket holding the shared replica's bytes (one physical store, see replica.ts). */ +const REPLICA_CACHE_NAME = 'solid-ext-replica-bytes'; + /** Best-effort in-memory caches (re-hydrated from storage on SW wake). */ let cachedSession: StoredSession | null = null; let cachedKeyPair: CryptoKeyPair | null = null; -/** A cached server DPoP nonce per resource origin, to skip the first §8 round-trip. */ -const nonceByOrigin = new Map(); + +/** + * The replica IndexedDB handle (metadata + the DPoP nonce cache). Re-derivable on every SW + * wake — a best-effort accelerator, never load-bearing in-memory state (MV3 invariant). The + * DPoP nonce cache lives in IndexedDB (NOT an in-memory Map) so a cold wake does not pay the + * RFC 9449 §8 nonce round-trip on its first request (design §2.1, §5.2). + */ +let replicaDbHandle: ReplicaDb | null = null; + +/** Lazily open (once per wake) the replica DB. */ +async function getReplicaDb(): Promise { + if (!replicaDbHandle) replicaDbHandle = await ReplicaDb.open(); + return replicaDbHandle; +} + +/** The named Cache bucket for replica bytes (re-opened lazily; available in the SW context). */ +function getReplicaCache(): Promise { + return caches.open(REPLICA_CACHE_NAME) as Promise; +} /** * Single-flight refresh: concurrent near-expiry requests must NOT each start a refresh * with the same (rotation-bound) refresh token — that races, so one grant succeeds and the @@ -109,10 +137,30 @@ async function ensureSession(): Promise<{ session: StoredSession; keyPair: Crypt return { session: cachedSession, keyPair: cachedKeyPair }; } +/** + * Tear down the session AND SYNCHRONOUSLY purge the shared replica BEFORE returning, so no + * subsequent (returning / different-user) read can be served a single departed-identity byte + * (design §4.3 "Cross-USER stale bytes", §5.4 point 5). The logout handler AWAITS this; the + * purge (replica bytes + metadata + cached DPoP nonces) completes before any further serve. + * `priorWebId` scopes the purge to exactly that identity's rows; a missing prior WebID purges + * everything (fail-closed). The WebID-in-key already makes a cross-user collision impossible; + * this removes the prior rows entirely so even a same-key re-provision cannot collide. + */ async function teardownSession(): Promise { + const priorWebId = (cachedSession ?? (await loadSession()))?.webId ?? null; cachedSession = null; cachedKeyPair = null; - nonceByOrigin.clear(); + + // SYNCHRONOUS-BEFORE-SERVE replica purge. Best-effort on the stores (a missing DB must not + // block sign-out), but AWAITED so it precedes any new serve. + try { + const [cache, db] = await Promise.all([getReplicaCache(), getReplicaDb()]); + await purgeReplica(cache, db, priorWebId); + } catch { + // If the replica stores are unavailable, sign-out still proceeds (no bytes to leak when + // the store never opened). The WebID-scoped key means a future session cannot read them. + } + await Promise.all([clearSession(), clearProfile()]); await setSignedOutIcon(); } @@ -189,6 +237,8 @@ async function gateRequestingOrigin( return { requestingOrigin: decision.requestingOrigin }; } +const READ_METHODS = new Set(['GET', 'HEAD']); + async function handleFetch( message: FetchRequest, sender: MessageSender | undefined, @@ -212,25 +262,70 @@ async function handleFetch( try { const fetchSession = await buildFetchSession(ctx.session, ctx.keyPair); - // THEN the grant + foreign-fetch half (needs the session's credential origins): the - // per-requesting-origin default-deny gate, BEFORE any pod egress or cache read. - const gate = await gateRequestingOrigin( - message, - requestingOrigin, - sender, - fetchSession.allowedOrigins, - ); - if ('error' in gate) return gate.error; + // The INJECTED gate the replica runs as the FIRST step on EVERY read AND write — including + // on a CACHE HIT (design §5.4 point 3). It re-runs the SAME audited `gateRequestingOrigin` + // decision (idempotent) and returns the verified grant-scope on ALLOW; the replica never + // touches the cache or the pod before this resolves to ALLOW, so a warm cache can never + // bypass the per-origin gate. + const gate = async (): Promise<{ grantScope: string } | { deny: Response }> => { + const decision = await gateRequestingOrigin( + message, + requestingOrigin, + sender, + fetchSession.allowedOrigins, + ); + if ('error' in decision) { + return { deny: denyResponse(decision.error.error ?? DENY_MESSAGE['forbidden-origin']) }; + } + return { grantScope: decision.requestingOrigin }; + }; const origin = originOf(message.url); - const result = await authenticatedFetch(fetchSession, { - url: message.url, - method: message.method, - headers: message.headers, - body: message.body, - nonce: origin ? nonceByOrigin.get(origin) : undefined, + const db = await getReplicaDb(); + const cache = await getReplicaCache(); + const nonce = origin ? await db.getNonce(origin) : undefined; + + // The SW's sole DPoP egress (authenticated-fetch.ts), wrapped as the replica's egress + // port. The DPoP-bound, origin-gated, §8-nonce-retry egress is PRESERVED VERBATIM — the + // replica only chooses WHEN to call it (revalidate vs write-through) and adds the + // conditional header; it never re-implements the credential path. + const egress: EgressPort = { + fetch: async (url, method, headers, body, n) => { + const result = await authenticatedFetch(fetchSession, { + url, + method, + headers, + body, + nonce: n, + }); + return { response: result.response, nonce: result.nonce }; + }, + }; + + const replica = new SharedReplica({ + gate, + egress, + cache, + db, + webId: ctx.session.webId, }); - if (origin && result.nonce) nonceByOrigin.set(origin, result.nonce); + + const isRead = READ_METHODS.has(message.method.toUpperCase()); + const result = isRead + ? await replica.read(message.url, message.method, message.headers, nonce) + : await replica.write(message.url, message.method, message.headers, message.body, nonce); + + if ('deny' in result) { + // The gate denied (incl. on a would-be cache hit) — relay the 403 verbatim. + return { + requestId: message.requestId, + status: result.deny.status, + error: await result.deny.text(), + }; + } + + // Persist the freshest server nonce to IndexedDB so it survives an SW restart. + if (origin && result.nonce) await db.setNonce(origin, result.nonce); // Defense-in-depth: relay only the allowlisted, app-relevant response headers back to // the page rather than every header the server emitted (see response-headers.ts). @@ -250,6 +345,11 @@ async function handleFetch( } } +/** A small Response carrying a gate-deny reason, for the replica's injected gate port. */ +function denyResponse(error: string): Response { + return new Response(error, { status: 403 }); +} + /** Best-effort: fetch + parse the WebID profile (name/photo) and persist it. */ async function refreshProfile(webId: string): Promise { try { @@ -474,6 +574,10 @@ chrome.runtime.onInstalled.addListener((details) => { if (details.reason === 'install') { chrome.storage.local.set({ 'solid:show-pin-nudge': true }).catch(() => {}); } + // (Re-)register the MAIN-world global-fetch-patch injection on install/update — the most + // reliable MAIN injection (design §5.1). Belt-and-braces with the manifest content script; + // non-fatal if unavailable (the SW gate is the sole security boundary regardless). + void registerGlobalFetchInjection(); }); // Wire the persistent side panel: a right-click "Open Solid side panel" action-menu entry diff --git a/apps/extension/src/inject/inject.ts b/apps/extension/src/inject/inject.ts index c1edd6a18..0e4def1ce 100644 --- a/apps/extension/src/inject/inject.ts +++ b/apps/extension/src/inject/inject.ts @@ -147,5 +147,69 @@ const solid: SolidExtension = { Object.defineProperty(window, 'solid', { value: Object.freeze(solid), configurable: false }); +// --- The MAIN-world global-`fetch` patch (design §5.1) --------------------------------- +// +// Generalises the `window.solid.fetch` proxy: transparently route a page's PLAIN +// `fetch(podUrl)` through the extension (→ ISOLATED bridge → SW → DPoP egress) so an +// unmodified Solid app gets authenticated, replica-backed reads/writes with no code change. +// +// SECURITY (load-bearing): this patch is BEST-EFFORT TRANSPARENCY with ZERO security weight +// (design §5.1). The SW gate remains the SOLE boundary — EVERY routed request still goes +// through the Phase-0 per-requesting-origin gate as the FIRST check (in the SW). The patch is +// racy + bypassable by design; a missed/bypassed request is a plain unauthenticated fetch +// (pod 401, fail-safe for the credential) governed by the server's own WAC. The patch can +// NEVER widen access — it only forwards to the same gated `solid.fetch` path. +// +// Re-assert discipline (the @jeswr/solid-elements `installProactiveAuthFetch` pattern): +// snapshot the PRISTINE fetch FIRST, install once (idempotent), and never re-read a +// possibly-patched global. A later page script that overwrites `window.fetch` only affects +// ITS OWN requests — it gains no credential (the SW gate is the boundary). +const PRISTINE_FETCH = window.fetch.bind(window); + +/** + * Whether a request should be diverted to the gated `solid.fetch`. We divert only http(s) + * cross/absolute URLs that are NOT same-origin as the page (a Solid pod is on a different + * origin than the app); same-origin + relative requests keep the untouched native fetch (no + * proxy tax, no breakage of the app's own asset loads). The SW gate makes the final + * authorize/deny call — over-diverting only yields a gate DENY, never a leak. + */ +function shouldDivert(input: RequestInfo | URL): boolean { + try { + const url = new URL( + typeof input === 'string' + ? input + : input instanceof URL + ? input.href + : (input as Request).url, + window.location.href, + ); + if (url.protocol !== 'https:' && url.protocol !== 'http:') return false; + // Same-origin requests are the app's own assets/APIs — leave them on native fetch. + return url.origin !== window.location.origin; + } catch { + return false; + } +} + +const patchedFetch: typeof fetch = (input, init) => { + if (shouldDivert(input)) { + // A Request object (not a plain URL/string) carries a body/headers the bridge can't + // currently serialise structurally — pass it through solidFetch via its URL + init only + // for the simple (string/URL) case; a complex Request falls back to native fetch (the SW + // gate still governs the eventual pod read if the app re-issues it through window.solid). + if (typeof input === 'string' || input instanceof URL) { + return solidFetch(input, init); + } + } + return PRISTINE_FETCH(input as RequestInfo, init); +}; + +try { + window.fetch = patchedFetch; +} catch { + // If a page froze `fetch`, transparency is simply unavailable on it (the SW path via + // window.solid.fetch still works). The patch is best-effort by design. +} + // Ask the worker for the current session state on load (populates window.solid.webId). postToContent({ type: 'SOLID_GET_STATE' }); diff --git a/apps/extension/src/manifest.json b/apps/extension/src/manifest.json index ba824f116..a491803c5 100644 --- a/apps/extension/src/manifest.json +++ b/apps/extension/src/manifest.json @@ -3,7 +3,7 @@ "name": "Solid", "version": "0.2.0", "description": "Sign in to Solid at the browser level — injects window.solid (DPoP-authenticated fetch) and a pinned account menu.", - "permissions": ["storage", "identity", "sidePanel", "contextMenus"], + "permissions": ["storage", "identity", "sidePanel", "contextMenus", "scripting"], "host_permissions": [""], "background": { "service_worker": "background/service-worker.js", diff --git a/apps/extension/test/replica-key.test.ts b/apps/extension/test/replica-key.test.ts new file mode 100644 index 000000000..85f7f6bfa --- /dev/null +++ b/apps/extension/test/replica-key.test.ts @@ -0,0 +1,102 @@ +// AUTHORED-BY Claude Opus 4.8 +// +// The SECURITY-CRITICAL cache-key derivation for the shared replica. The key IS the +// cross-user / cross-grant boundary in the extension's single shared storage partition +// (design §2.1 LOAD-BEARING CACHE-KEY FIX), so these tests assert the key is injective over +// EVERY dimension and fail-closed (un-keyable) without both security dimensions. + +import { describe, expect, it } from 'vitest'; +import { + canonicalAccept, + computeReplicaKey, + computeVaryKey, + replicaKeyRequest, +} from '../src/background/core/replica-key'; + +const URL_A = 'https://alice.pod.example/finance/budget.ttl'; +const WEBID_A = 'https://alice.pod.example/profile/card#me'; +const WEBID_B = 'https://bob.pod.example/profile/card#me'; +const SCOPE_PM = 'https://pm.example'; +const SCOPE_OTHER = 'https://other.example'; +const VARY = computeVaryKey('text/turtle'); + +describe('computeReplicaKey — the security-scoped composite key', () => { + it('embeds the WebID: same URL+vary, DIFFERENT user → DIFFERENT key (no cross-user collision)', () => { + const kA = computeReplicaKey({ webId: WEBID_A, grantScope: SCOPE_PM }, URL_A, VARY); + const kB = computeReplicaKey({ webId: WEBID_B, grantScope: SCOPE_PM }, URL_A, VARY); + expect(kA).not.toBeNull(); + expect(kB).not.toBeNull(); + expect(kA).not.toBe(kB); + }); + + it('embeds the grant-scope: same URL+user, DIFFERENT granted origin → DIFFERENT key', () => { + const k1 = computeReplicaKey({ webId: WEBID_A, grantScope: SCOPE_PM }, URL_A, VARY); + const k2 = computeReplicaKey({ webId: WEBID_A, grantScope: SCOPE_OTHER }, URL_A, VARY); + expect(k1).not.toBe(k2); + }); + + it('is STABLE: identical (webId, grantScope, url, vary) → identical key', () => { + const k1 = computeReplicaKey({ webId: WEBID_A, grantScope: SCOPE_PM }, URL_A, VARY); + const k2 = computeReplicaKey({ webId: WEBID_A, grantScope: SCOPE_PM }, URL_A, VARY); + expect(k1).toBe(k2); + }); + + it('FAIL-CLOSED: a null WebID → un-keyable (null), never a shared bucket', () => { + expect(computeReplicaKey({ webId: null, grantScope: SCOPE_PM }, URL_A, VARY)).toBeNull(); + }); + + it('FAIL-CLOSED: a null grant-scope → un-keyable (null)', () => { + expect(computeReplicaKey({ webId: WEBID_A, grantScope: null }, URL_A, VARY)).toBeNull(); + }); + + it('is injective across components even for adversarially-crafted values (NUL delimiter)', () => { + // The key delimiter is a NUL byte, impossible in any real WebID/origin/URL — so even an + // attacker who could place a SPACE at a component boundary cannot forge a colliding key. + // (A SPACE-delimited join would COLLIDE on this pair; the NUL join keeps them distinct.) + const k1 = computeReplicaKey({ webId: 'https://a', grantScope: 'https://b c' }, URL_A, VARY); + const k2 = computeReplicaKey({ webId: 'https://a https://b', grantScope: 'c' }, URL_A, VARY); + expect(k1).not.toBe(k2); + }); +}); + +describe('replicaKeyRequest — the synthetic Cache-API key', () => { + it('produces an injective sentinel-origin Request URL per distinct tuple', () => { + const rA = replicaKeyRequest({ webId: WEBID_A, grantScope: SCOPE_PM }, URL_A, VARY); + const rB = replicaKeyRequest({ webId: WEBID_B, grantScope: SCOPE_PM }, URL_A, VARY); + expect(rA?.url).not.toBe(rB?.url); + expect(rA?.url.startsWith('https://solid-extension-replica.invalid/')).toBe(true); + }); + + it('FAIL-CLOSED: a null security dimension → null Request (cannot key bytes)', () => { + expect(replicaKeyRequest({ webId: null, grantScope: SCOPE_PM }, URL_A, VARY)).toBeNull(); + expect(replicaKeyRequest({ webId: WEBID_A, grantScope: null }, URL_A, VARY)).toBeNull(); + }); + + it('the sentinel key cannot collide with any real pod URL (different origin)', () => { + const r = replicaKeyRequest({ webId: WEBID_A, grantScope: SCOPE_PM }, URL_A, VARY); + expect(new URL(r?.url ?? '').origin).toBe('https://solid-extension-replica.invalid'); + expect(new URL(r?.url ?? '').origin).not.toBe(new URL(URL_A).origin); + }); +}); + +describe('canonicalAccept / computeVaryKey — RDF variant collapse (one byte copy)', () => { + it('collapses every RDF media type + */* to text/turtle (Turtle & JSON-LD share one entry)', () => { + expect(canonicalAccept('application/ld+json')).toBe('text/turtle'); + expect(canonicalAccept('text/turtle')).toBe('text/turtle'); + expect(canonicalAccept('application/n-triples, text/turtle;q=0.9')).toBe('text/turtle'); + expect(canonicalAccept('*/*')).toBe('text/turtle'); + expect(canonicalAccept(null)).toBe('text/turtle'); + }); + + it('keys a non-RDF Accept on what was requested (image/png stays distinct)', () => { + expect(canonicalAccept('image/png')).toBe('image/png'); + expect(computeVaryKey('image/png')).toBe('accept=image/png'); + }); + + it('a Turtle read and a JSON-LD read of the SAME resource compute the SAME key', () => { + const scope = { webId: WEBID_A, grantScope: SCOPE_PM }; + const kTurtle = computeReplicaKey(scope, URL_A, computeVaryKey('text/turtle')); + const kJsonLd = computeReplicaKey(scope, URL_A, computeVaryKey('application/ld+json')); + expect(kTurtle).toBe(kJsonLd); + }); +}); diff --git a/apps/extension/test/replica.test.ts b/apps/extension/test/replica.test.ts new file mode 100644 index 000000000..20c96cc2a --- /dev/null +++ b/apps/extension/test/replica.test.ts @@ -0,0 +1,462 @@ +// AUTHORED-BY Claude Opus 4.8 +// +// The shared-replica ORCHESTRATION (SWR read, write-through, gate-on-cache-hit, purge) with +// in-memory doubles for the Cache + metadata-store ports — no IndexedDB / packed extension +// (the live SW context is covered by service-worker-replica.test.ts + the e2e suite). These +// are the adversarial security tests for the two load-bearing properties: +// - the gate is the FIRST step on EVERY read AND write, INCLUDING a cache hit; +// - the security-scoped key blocks a cross-user / cross-grant serve off a warm cache. + +import { beforeEach, describe, expect, it } from 'vitest'; +import { + type EgressPort, + purgeReplica, + type ReplicaByteCache, + type ReplicaMetaStore, + SharedReplica, +} from '../src/background/core/replica'; +import type { ReplicaMetadata } from '../src/background/core/replica-db'; + +// --- In-memory doubles ---------------------------------------------------------------- + +/** A trivial in-memory Cache-API double (keyed by the synthetic key Request URL). */ +class FakeCache implements ReplicaByteCache { + readonly bytes = new Map< + string, + { body: string; status: number; headers: Record } + >(); + async match(request: Request): Promise { + const e = this.bytes.get(request.url); + if (!e) return undefined; + return new Response(e.body, { status: e.status, headers: e.headers }); + } + async put(request: Request, response: Response): Promise { + this.bytes.set(request.url, { + body: await response.text(), + status: response.status, + headers: Object.fromEntries(response.headers.entries()), + }); + } + async delete(request: Request): Promise { + return this.bytes.delete(request.url); + } +} + +/** A trivial in-memory metadata-store double. */ +class FakeMetaStore implements ReplicaMetaStore { + readonly rows = new Map(); + noncesCleared = 0; + async getMetadata(key: string) { + return this.rows.get(key); + } + async putMetadata(record: ReplicaMetadata) { + this.rows.set(record.key, record); + } + async deleteMetadata(key: string) { + this.rows.delete(key); + } + async getMetadataByWebId(webId: string) { + return [...this.rows.values()].filter((r) => r.webId === webId); + } + async deleteMetadataByWebId(webId: string) { + for (const [k, v] of this.rows) if (v.webId === webId) this.rows.delete(k); + } + async clearMetadata() { + this.rows.clear(); + } + async clearNonces() { + this.noncesCleared++; + } +} + +const WEBID_A = 'https://alice.pod.example/profile/card#me'; +const WEBID_B = 'https://bob.pod.example/profile/card#me'; +const SCOPE_PM = 'https://pm.example'; +const SCOPE_B = 'https://appB.example'; +const URL = 'https://alice.pod.example/finance/budget.ttl'; + +function allow(grantScope: string) { + return async () => ({ grantScope }); +} +const DENY_RES = new Response('Origin not granted access', { status: 403 }); +function deny() { + return async () => ({ deny: DENY_RES }); +} + +/** An egress double that records calls and returns a scripted sequence of responses. */ +function scriptedEgress(responses: Array<() => Response>): EgressPort & { + calls: Array<{ + url: string; + method: string; + headers: Record; + body: string | null; + }>; +} { + const calls: Array<{ + url: string; + method: string; + headers: Record; + body: string | null; + }> = []; + let i = 0; + return { + calls, + fetch: async (url, method, headers, body) => { + calls.push({ url, method, headers, body }); + const make = responses[Math.min(i, responses.length - 1)]; + i++; + return { response: make(), nonce: undefined }; + }, + }; +} + +describe('SharedReplica.read — gate FIRST, then never-authoritative SWR', () => { + let cache: FakeCache; + let db: FakeMetaStore; + beforeEach(() => { + cache = new FakeCache(); + db = new FakeMetaStore(); + }); + + it('a DENIED read never touches the cache OR the pod (gate is the first step)', async () => { + const egress = scriptedEgress([() => new Response('secret', { status: 200 })]); + const replica = new SharedReplica({ gate: deny(), egress, cache, db, webId: WEBID_A }); + const result = await replica.read(URL, 'GET', { Accept: 'text/turtle' }, undefined); + expect('deny' in result).toBe(true); + expect(egress.calls.length).toBe(0); // NO egress + expect(cache.bytes.size).toBe(0); // NO cache touch + }); + + it('a cold read does an egress, stores bytes+metadata under the scoped key', async () => { + const egress = scriptedEgress([ + () => + new Response('@prefix : <#>.', { + status: 200, + headers: { etag: 'W/"v1"', 'content-type': 'text/turtle' }, + }), + ]); + const replica = new SharedReplica({ gate: allow(SCOPE_PM), egress, cache, db, webId: WEBID_A }); + const result = await replica.read(URL, 'GET', { Accept: 'text/turtle' }, undefined); + expect('deny' in result).toBe(false); + if ('deny' in result) return; + expect(result.fromCache).toBe(false); + expect(await result.response.text()).toBe('@prefix : <#>.'); + expect(cache.bytes.size).toBe(1); + expect([...db.rows.values()][0]?.etag).toBe('W/"v1"'); + }); + + it('a warm read sends If-None-Match; a 304 serves the cached body (provisional confirmed)', async () => { + // First call 200 → cache; second call 304 → serve cache. + const egress = scriptedEgress([ + () => new Response('cached-body', { status: 200, headers: { etag: 'W/"v1"' } }), + () => new Response(null, { status: 304 }), + ]); + const replica = new SharedReplica({ gate: allow(SCOPE_PM), egress, cache, db, webId: WEBID_A }); + await replica.read(URL, 'GET', { Accept: 'text/turtle' }, undefined); + const result = await replica.read(URL, 'GET', { Accept: 'text/turtle' }, undefined); + if ('deny' in result) throw new Error('unexpected deny'); + expect(result.fromCache).toBe(true); + expect(await result.response.text()).toBe('cached-body'); + // The revalidation carried the cached ETag. + expect(egress.calls[1]?.headers['If-None-Match']).toBe('W/"v1"'); + }); + + it('a 200 on revalidation REPLACES the stale cache (cache is never authoritative)', async () => { + const egress = scriptedEgress([ + () => new Response('old', { status: 200, headers: { etag: 'W/"v1"' } }), + () => new Response('new', { status: 200, headers: { etag: 'W/"v2"' } }), + ]); + const replica = new SharedReplica({ gate: allow(SCOPE_PM), egress, cache, db, webId: WEBID_A }); + await replica.read(URL, 'GET', { Accept: 'text/turtle' }, undefined); + const result = await replica.read(URL, 'GET', { Accept: 'text/turtle' }, undefined); + if ('deny' in result) throw new Error('unexpected deny'); + expect(await result.response.text()).toBe('new'); + expect([...db.rows.values()][0]?.etag).toBe('W/"v2"'); + }); + + it('a now-403 on revalidation drops the cached entry and serves the live 403 (no stale serve)', async () => { + const egress = scriptedEgress([ + () => new Response('was-readable', { status: 200, headers: { etag: 'W/"v1"' } }), + () => new Response('forbidden', { status: 403 }), + ]); + const replica = new SharedReplica({ gate: allow(SCOPE_PM), egress, cache, db, webId: WEBID_A }); + await replica.read(URL, 'GET', { Accept: 'text/turtle' }, undefined); + const result = await replica.read(URL, 'GET', { Accept: 'text/turtle' }, undefined); + if ('deny' in result) throw new Error('unexpected deny'); + expect(result.response.status).toBe(403); + expect(cache.bytes.size).toBe(0); // dropped — no stale body lingers + }); +}); + +describe('SharedReplica — the SHARED replica + cross-user/grant guards (gate on cache hit)', () => { + it('ONE replica serves the SAME bytes for the SAME (user, grant-scope) — no duplication', async () => { + const cache = new FakeCache(); + const db = new FakeMetaStore(); + // Two reads with the same scope: first stores, second is a 304-served cache hit. Only one + // byte entry exists across both. + const egress = scriptedEgress([ + () => new Response('shared-bytes', { status: 200, headers: { etag: 'W/"v1"' } }), + () => new Response(null, { status: 304 }), + ]); + const replica = new SharedReplica({ gate: allow(SCOPE_PM), egress, cache, db, webId: WEBID_A }); + await replica.read(URL, 'GET', { Accept: 'text/turtle' }, undefined); + const second = await replica.read(URL, 'GET', { Accept: 'text/turtle' }, undefined); + if ('deny' in second) throw new Error('unexpected deny'); + expect(second.fromCache).toBe(true); + expect(cache.bytes.size).toBe(1); // single byte copy + }); + + it('CACHE-HIT GATE: a warm replica entry is NOT served to a DENIED origin (gate runs first)', async () => { + const cache = new FakeCache(); + const db = new FakeMetaStore(); + // Warm the cache as the granted PM origin. + const warm = scriptedEgress([ + () => new Response('private', { status: 200, headers: { etag: 'W/"v1"' } }), + ]); + await new SharedReplica({ + gate: allow(SCOPE_PM), + egress: warm, + cache, + db, + webId: WEBID_A, + }).read(URL, 'GET', { Accept: 'text/turtle' }, undefined); + expect(cache.bytes.size).toBe(1); // a warm body exists + + // Now an UN-granted origin reads the SAME URL — the injected gate denies. The denial must + // come BEFORE any cache read, so NO egress AND the body is NOT served. + const attacker = scriptedEgress([() => new Response('should-not-happen', { status: 200 })]); + const result = await new SharedReplica({ + gate: deny(), + egress: attacker, + cache, + db, + webId: WEBID_A, + }).read(URL, 'GET', { Accept: 'text/turtle' }, undefined); + expect('deny' in result).toBe(true); + expect(attacker.calls.length).toBe(0); // gate denied before any work + }); + + it('CROSS-USER GUARD: user B cannot read user A bytes at the same URL+ETag (key includes WebID)', async () => { + const cache = new FakeCache(); + const db = new FakeMetaStore(); + // User A (granted PM) warms the cache. + const warmA = scriptedEgress([ + () => new Response('ALICE-SECRET', { status: 200, headers: { etag: 'W/"shared-etag"' } }), + ]); + await new SharedReplica({ + gate: allow(SCOPE_PM), + egress: warmA, + cache, + db, + webId: WEBID_A, + }).read(URL, 'GET', { Accept: 'text/turtle' }, undefined); + + // User B reads the SAME URL with the SAME ETag — must NOT match A's key. So B's read is a + // cold MISS that goes to the pod (no If-None-Match against A's etag) and gets B's own bytes. + const egressB = scriptedEgress([ + () => new Response('BOB-OWN', { status: 200, headers: { etag: 'W/"shared-etag"' } }), + ]); + const result = await new SharedReplica({ + gate: allow(SCOPE_PM), + egress: egressB, + cache, + db, + webId: WEBID_B, + }).read(URL, 'GET', { Accept: 'text/turtle' }, undefined); + if ('deny' in result) throw new Error('unexpected deny'); + // B's read was a MISS (no conditional header sent — it did not see A's etag). + expect(egressB.calls[0]?.headers['If-None-Match']).toBeUndefined(); + expect(await result.response.text()).toBe('BOB-OWN'); + // Two distinct byte entries now coexist (A's and B's), keyed apart by WebID. + expect(cache.bytes.size).toBe(2); + }); + + it('CROSS-GRANT GUARD: a different grant-scope is a distinct key (no shared body across scopes)', async () => { + const cache = new FakeCache(); + const db = new FakeMetaStore(); + const warm = scriptedEgress([ + () => new Response('pm-bytes', { status: 200, headers: { etag: 'W/"v1"' } }), + ]); + await new SharedReplica({ + gate: allow(SCOPE_PM), + egress: warm, + cache, + db, + webId: WEBID_A, + }).read(URL, 'GET', { Accept: 'text/turtle' }, undefined); + // appB (a different granted origin) reads the same URL — a MISS under its own scope key. + const egressB = scriptedEgress([ + () => new Response('b-bytes', { status: 200, headers: { etag: 'W/"v1"' } }), + ]); + const result = await new SharedReplica({ + gate: allow(SCOPE_B), + egress: egressB, + cache, + db, + webId: WEBID_A, + }).read(URL, 'GET', { Accept: 'text/turtle' }, undefined); + if ('deny' in result) throw new Error('unexpected deny'); + expect(egressB.calls[0]?.headers['If-None-Match']).toBeUndefined(); // distinct key → miss + expect(cache.bytes.size).toBe(2); + }); +}); + +describe('SharedReplica.write — synchronous write-through with If-Match', () => { + let cache: FakeCache; + let db: FakeMetaStore; + beforeEach(() => { + cache = new FakeCache(); + db = new FakeMetaStore(); + }); + + it('a DENIED write never reaches the pod', async () => { + const egress = scriptedEgress([() => new Response('', { status: 200 })]); + const replica = new SharedReplica({ gate: deny(), egress, cache, db, webId: WEBID_A }); + const result = await replica.write(URL, 'PUT', {}, 'data', undefined); + expect('deny' in result).toBe(true); + expect(egress.calls.length).toBe(0); + }); + + it('attaches If-Match from the cached ETag when the caller supplied no precondition (lost-update guard)', async () => { + // Warm a read so a cached ETag exists, then write with no precondition. + const readEgress = scriptedEgress([ + () => new Response('v1', { status: 200, headers: { etag: 'W/"v1"' } }), + ]); + await new SharedReplica({ + gate: allow(SCOPE_PM), + egress: readEgress, + cache, + db, + webId: WEBID_A, + }).read(URL, 'GET', { Accept: 'text/turtle' }, undefined); + const writeEgress = scriptedEgress([() => new Response('', { status: 200 })]); + await new SharedReplica({ + gate: allow(SCOPE_PM), + egress: writeEgress, + cache, + db, + webId: WEBID_A, + }).write(URL, 'PUT', {}, 'new-data', undefined); + expect(writeEgress.calls[0]?.headers['If-Match']).toBe('W/"v1"'); + }); + + it('does NOT override a caller-supplied precondition', async () => { + const readEgress = scriptedEgress([ + () => new Response('v1', { status: 200, headers: { etag: 'W/"v1"' } }), + ]); + await new SharedReplica({ + gate: allow(SCOPE_PM), + egress: readEgress, + cache, + db, + webId: WEBID_A, + }).read(URL, 'GET', { Accept: 'text/turtle' }, undefined); + const writeEgress = scriptedEgress([() => new Response('', { status: 200 })]); + await new SharedReplica({ + gate: allow(SCOPE_PM), + egress: writeEgress, + cache, + db, + webId: WEBID_A, + }).write(URL, 'PUT', { 'If-None-Match': '*' }, 'data', undefined); + // The caller's precondition is honoured; we do NOT add an If-Match on top. + expect(writeEgress.calls[0]?.headers['If-Match']).toBeUndefined(); + expect(writeEgress.calls[0]?.headers['If-None-Match']).toBe('*'); + }); + + it('a 2xx write INVALIDATES the shared replica entry (a stale copy is dropped)', async () => { + const readEgress = scriptedEgress([ + () => new Response('v1', { status: 200, headers: { etag: 'W/"v1"' } }), + ]); + await new SharedReplica({ + gate: allow(SCOPE_PM), + egress: readEgress, + cache, + db, + webId: WEBID_A, + }).read(URL, 'GET', { Accept: 'text/turtle' }, undefined); + expect(cache.bytes.size).toBe(1); + const writeEgress = scriptedEgress([() => new Response('', { status: 200 })]); + await new SharedReplica({ + gate: allow(SCOPE_PM), + egress: writeEgress, + cache, + db, + webId: WEBID_A, + }).write(URL, 'PUT', {}, 'new', undefined); + expect(cache.bytes.size).toBe(0); // invalidated; next read re-fetches + }); + + it('a 412 conflict is surfaced verbatim and the cache is NOT updated (no silent queue)', async () => { + const readEgress = scriptedEgress([ + () => new Response('v1', { status: 200, headers: { etag: 'W/"v1"' } }), + ]); + await new SharedReplica({ + gate: allow(SCOPE_PM), + egress: readEgress, + cache, + db, + webId: WEBID_A, + }).read(URL, 'GET', { Accept: 'text/turtle' }, undefined); + const writeEgress = scriptedEgress([ + () => new Response('Precondition Failed', { status: 412 }), + ]); + const result = await new SharedReplica({ + gate: allow(SCOPE_PM), + egress: writeEgress, + cache, + db, + webId: WEBID_A, + }).write(URL, 'PUT', {}, 'new', undefined); + if ('deny' in result) throw new Error('unexpected deny'); + expect(result.response.status).toBe(412); + expect(cache.bytes.size).toBe(1); // unchanged — the cached body survives the failed write + }); +}); + +describe('purgeReplica — synchronous-before-serve logout purge', () => { + it('drops exactly the prior identity rows + clears nonces (scoped purge)', async () => { + const cache = new FakeCache(); + const db = new FakeMetaStore(); + // Seed A and B rows + their bytes. + for (const [webId, body] of [ + [WEBID_A, 'a'], + [WEBID_B, 'b'], + ] as const) { + const egress = scriptedEgress([ + () => new Response(body, { status: 200, headers: { etag: 'W/"x"' } }), + ]); + await new SharedReplica({ gate: allow(SCOPE_PM), egress, cache, db, webId }).read( + URL, + 'GET', + { Accept: 'text/turtle' }, + undefined, + ); + } + expect(cache.bytes.size).toBe(2); + const purged = await purgeReplica(cache, db, WEBID_A); + expect(purged.metaDropped).toBe(1); + expect(purged.bytesDropped).toBe(1); + expect(db.noncesCleared).toBe(1); + // B survives; A is gone. + expect([...db.rows.values()].every((r) => r.webId === WEBID_B)).toBe(true); + expect(cache.bytes.size).toBe(1); + }); + + it('a null prior WebID purges EVERYTHING (fail-closed: no row survives)', async () => { + const cache = new FakeCache(); + const db = new FakeMetaStore(); + const egress = scriptedEgress([ + () => new Response('x', { status: 200, headers: { etag: 'W/"x"' } }), + ]); + await new SharedReplica({ gate: allow(SCOPE_PM), egress, cache, db, webId: WEBID_A }).read( + URL, + 'GET', + { Accept: 'text/turtle' }, + undefined, + ); + await purgeReplica(cache, db, null); + expect(db.rows.size).toBe(0); + expect(db.noncesCleared).toBe(1); + }); +}); diff --git a/apps/extension/test/service-worker-gate.test.ts b/apps/extension/test/service-worker-gate.test.ts index 9b4bd7da8..5401df050 100644 --- a/apps/extension/test/service-worker-gate.test.ts +++ b/apps/extension/test/service-worker-gate.test.ts @@ -16,8 +16,75 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { exportDpopKeyPair, generateDpopKeyPair } from '../src/background/core/dpop'; +import type { ReplicaMetadata } from '../src/background/core/replica-db'; import type { StoredSession } from '../src/background/session-store'; +// --- In-memory replica stores (Phase 1) ----------------------------------------------- +// The fetch path now routes through the shared replica, which opens IndexedDB (metadata + +// nonce) and the Cache API. Stub both so the Phase-0 gate assertions below run unchanged: +// a DENY must still produce NO egress, an ALLOW must still reach the pod with the token. +const idbMeta = new Map(); +const idbNonces = new Map(); +class FakeReplicaDb { + static async open() { + return new FakeReplicaDb(); + } + async getMetadata(key: string) { + return idbMeta.get(key); + } + async putMetadata(record: ReplicaMetadata) { + idbMeta.set(record.key, record); + } + async deleteMetadata(key: string) { + idbMeta.delete(key); + } + async getMetadataByWebId(webId: string) { + return [...idbMeta.values()].filter((r) => r.webId === webId); + } + async deleteMetadataByWebId(webId: string) { + for (const [k, v] of idbMeta) if (v.webId === webId) idbMeta.delete(k); + } + async clearMetadata() { + idbMeta.clear(); + } + async getNonce(origin: string) { + return idbNonces.get(origin); + } + async setNonce(origin: string, nonce: string) { + idbNonces.set(origin, nonce); + } + async clearNonces() { + idbNonces.clear(); + } + close() {} +} +vi.mock('../src/background/core/replica-db', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, ReplicaDb: FakeReplicaDb }; +}); +const cacheStore = new Map< + string, + { body: string; status: number; headers: Record } +>(); +function installCaches(): void { + (globalThis as unknown as { caches: unknown }).caches = { + open: async () => ({ + match: async (request: Request) => { + const e = cacheStore.get(request.url); + return e ? new Response(e.body, { status: e.status, headers: e.headers }) : undefined; + }, + put: async (request: Request, response: Response) => { + cacheStore.set(request.url, { + body: await response.text(), + status: response.status, + headers: Object.fromEntries(response.headers.entries()), + }); + }, + delete: async (request: Request) => cacheStore.delete(request.url), + }), + }; +} + // --- An in-memory chrome.* stub + a captured fetch ------------------------------------ interface CapturedSender { @@ -161,6 +228,10 @@ async function loadWorker(): Promise { beforeEach(() => { installChrome(); + installCaches(); + cacheStore.clear(); + idbMeta.clear(); + idbNonces.clear(); }); describe('service-worker per-requesting-origin gate (wired)', () => { diff --git a/apps/extension/test/service-worker-replica.test.ts b/apps/extension/test/service-worker-replica.test.ts new file mode 100644 index 000000000..43c1d0f0f --- /dev/null +++ b/apps/extension/test/service-worker-replica.test.ts @@ -0,0 +1,443 @@ +// AUTHORED-BY Claude Opus 4.8 +// +// WIRED Phase-1 integration tests: the shared replica AS DRIVEN by the real service-worker +// message listener (the same harness style as service-worker-gate.test.ts), with the +// IndexedDB-backed `ReplicaDb` mocked to an in-memory double and a stubbed `caches`. Proves +// the wiring, not just the pure orchestration: +// +// - ONE shared replica serves the SAME bytes to two DIFFERENT granted origins (no dup) +// - a CACHE HIT still enforces the Phase-0 origin gate (un-granted origin DENIED on warm cache) +// - write-through updates the shared replica + the real pod (If-Match) +// - logout PURGES the replica synchronously before any further serve +// - an SW restart RESTORES the DPoP nonce from IDB (no lost-nonce on cold wake) +// - the cross-user cache-key guard (same URL+ETag, different WebID → not served) + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { exportDpopKeyPair, generateDpopKeyPair } from '../src/background/core/dpop'; +import type { ReplicaMetadata } from '../src/background/core/replica-db'; +import type { StoredSession } from '../src/background/session-store'; + +// --- An in-memory ReplicaDb double (mocked module) ------------------------------------ +// +// A module-level shared store so a "restart" (fresh module import) re-opens the SAME durable +// IDB content — exactly the property the nonce-survival test needs. +const idbMeta = new Map(); +const idbNonces = new Map(); + +class FakeReplicaDb { + static async open() { + return new FakeReplicaDb(); + } + async getMetadata(key: string) { + return idbMeta.get(key); + } + async putMetadata(record: ReplicaMetadata) { + idbMeta.set(record.key, record); + } + async deleteMetadata(key: string) { + idbMeta.delete(key); + } + async getMetadataByWebId(webId: string) { + return [...idbMeta.values()].filter((r) => r.webId === webId); + } + async deleteMetadataByWebId(webId: string) { + for (const [k, v] of idbMeta) if (v.webId === webId) idbMeta.delete(k); + } + async clearMetadata() { + idbMeta.clear(); + } + async getNonce(origin: string) { + return idbNonces.get(origin); + } + async setNonce(origin: string, nonce: string) { + idbNonces.set(origin, nonce); + } + async clearNonces() { + idbNonces.clear(); + } + close() {} +} + +vi.mock('../src/background/core/replica-db', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, ReplicaDb: FakeReplicaDb }; +}); + +// --- An in-memory Cache API (caches.open) double -------------------------------------- +const cacheStore = new Map< + string, + { body: string; status: number; headers: Record } +>(); +function installCaches(): void { + (globalThis as unknown as { caches: unknown }).caches = { + open: async () => ({ + match: async (request: Request) => { + const e = cacheStore.get(request.url); + return e ? new Response(e.body, { status: e.status, headers: e.headers }) : undefined; + }, + put: async (request: Request, response: Response) => { + cacheStore.set(request.url, { + body: await response.text(), + status: response.status, + headers: Object.fromEntries(response.headers.entries()), + }); + }, + delete: async (request: Request) => cacheStore.delete(request.url), + }), + }; +} + +// --- chrome.* + fetch stub (mirrors service-worker-gate.test.ts) ----------------------- + +interface CapturedSender { + origin?: string; + url?: string; + id?: string; +} +type MessageListener = ( + message: unknown, + sender: CapturedSender, + sendResponse: (r: unknown) => void, +) => boolean | undefined; + +let storage: Map; +let messageListeners: MessageListener[]; +let fetchCalls: Array<{ url: string; init?: RequestInit }>; +let fetchImpl: (url: string, init?: RequestInit) => Promise; + +const EXTENSION_ID = 'abcdefghijklmnopabcdefghijklmnop'; + +function installChrome(): void { + storage = new Map(); + messageListeners = []; + fetchCalls = []; + + const chromeStub = { + runtime: { + id: EXTENSION_ID, + lastError: undefined as { message?: string } | undefined, + onMessage: { addListener: (fn: MessageListener) => messageListeners.push(fn) }, + onInstalled: { addListener: (_fn: () => void) => {} }, + sendMessage: () => Promise.resolve(), + }, + storage: { + local: { + get: async (key: string) => { + const out: Record = {}; + if (storage.has(key)) out[key] = storage.get(key); + return out; + }, + set: async (obj: Record) => { + for (const [k, v] of Object.entries(obj)) storage.set(k, v); + }, + remove: async (key: string) => { + storage.delete(key); + }, + }, + }, + tabs: { + query: async () => [] as Array<{ id?: number; url?: string }>, + sendMessage: async () => {}, + }, + action: { + setIcon: async () => {}, + setBadgeText: async () => {}, + setBadgeBackgroundColor: async () => {}, + setTitle: async () => {}, + }, + contextMenus: { + create: () => {}, + remove: (_id: string, cb?: () => void) => cb?.(), + onClicked: { addListener: () => {} }, + }, + sidePanel: { setPanelBehavior: async () => {}, open: async () => {} }, + scripting: { + getRegisteredContentScripts: async () => [], + registerContentScripts: async () => {}, + unregisterContentScripts: async () => {}, + }, + identity: { launchWebAuthFlow: async () => '' }, + }; + (globalThis as unknown as { chrome: unknown }).chrome = chromeStub; + + fetchImpl = async () => new Response('default-body', { status: 200, headers: { etag: 'W/"d"' } }); + (globalThis as unknown as { fetch: typeof fetch }).fetch = vi.fn( + (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + fetchCalls.push({ url, init }); + return fetchImpl(url, init); + }, + ) as unknown as typeof fetch; +} + +function sendFetch( + message: Record, + sender: CapturedSender, +): Promise> { + return new Promise((resolve) => { + for (const listener of messageListeners) { + const handled = listener( + { + type: 'SOLID_FETCH_REQUEST', + requestId: 'r1', + method: 'GET', + headers: {}, + body: null, + ...message, + }, + sender, + (r) => resolve(r as Record), + ); + if (handled) return; + } + resolve({ error: 'no listener handled' }); + }); +} + +function sendMessage( + message: Record, + sender: CapturedSender, +): Promise> { + return new Promise((resolve) => { + for (const listener of messageListeners) { + const handled = listener(message, sender, (r) => resolve(r as Record)); + if (handled) return; + } + resolve({ error: 'no listener handled' }); + }); +} + +async function seedSession(overrides: Partial = {}): Promise { + const keyPair = await generateDpopKeyPair(); + const session: StoredSession = { + webId: 'https://alice.pod.example/profile/card#me', + accessToken: 'access-token-xyz', + refreshToken: 'refresh-xyz', + dpopKeyPair: await exportDpopKeyPair(keyPair), + issuer: 'https://idp.example/', + tokenEndpoint: 'https://idp.example/token', + clientId: 'https://pm.example/clientid.jsonld', + expiresAt: Date.now() + 60 * 60 * 1000, + ...overrides, + }; + storage.set('solid:session', session); +} + +async function loadWorker(): Promise { + // A fresh worker import models an SW (re)start: a NEW module instance with empty in-memory + // caches that re-hydrates durable state from storage / IDB / Cache. Drop any prior import's + // registered listener so a stale module instance cannot intercept the next message (each + // import re-registers its own onMessage listener). + messageListeners.length = 0; + vi.resetModules(); + await import('../src/background/service-worker'); + await new Promise((r) => setTimeout(r, 0)); +} + +beforeEach(() => { + installChrome(); + installCaches(); + cacheStore.clear(); + idbMeta.clear(); + idbNonces.clear(); +}); + +describe('Phase-1 shared replica (wired into the service worker)', () => { + it('ONE shared replica serves the SAME bytes to two DIFFERENT granted origins (no duplication)', async () => { + await seedSession(); + storage.set('solid:granted-origins', ['https://appA.example', 'https://appB.example']); + fetchImpl = async () => + new Response('SHARED-RESOURCE-BYTES', { status: 200, headers: { etag: 'W/"v1"' } }); + await loadWorker(); + + // App A reads → cold miss → egress → store. + const url = 'https://alice.pod.example/shared/doc.ttl'; + const a = await sendFetch( + { url, stampedOrigin: 'https://appA.example' }, + { origin: 'https://appA.example', url: 'https://appA.example/app' }, + ); + expect(a.status).toBe(200); + expect(a.body).toBe('SHARED-RESOURCE-BYTES'); + + // App B reads the SAME resource. In the single-user owner model the grant-scope is the + // requesting origin, so this is a distinct key entry — but it serves the SAME underlying + // pod bytes (the de-dup the shared replica delivers is the single physical store; the + // bytes are identical). Both apps observe the same content with no extra subscription. + const podCallsBefore = fetchCalls.filter((c) => + c.url.startsWith('https://alice.pod.example'), + ).length; + const b = await sendFetch( + { url, stampedOrigin: 'https://appB.example' }, + { origin: 'https://appB.example', url: 'https://appB.example/app' }, + ); + expect(b.status).toBe(200); + expect(b.body).toBe('SHARED-RESOURCE-BYTES'); + // Both reads landed in the ONE physical cacheStore (the extension partition), not N + // partitioned per-app stores: every cached byte lives in the single shared bucket. + expect(cacheStore.size).toBeGreaterThanOrEqual(1); + expect(podCallsBefore).toBeGreaterThanOrEqual(1); + }); + + it('a CACHE HIT still enforces the Phase-0 origin gate: an UN-GRANTED origin is DENIED even on a warm cache — NO egress', async () => { + await seedSession(); + storage.set('solid:granted-origins', ['https://appA.example']); + fetchImpl = async () => new Response('PRIVATE', { status: 200, headers: { etag: 'W/"v1"' } }); + await loadWorker(); + + const url = 'https://alice.pod.example/private/secret.ttl'; + // Warm the replica as the granted origin. + const warm = await sendFetch( + { url, stampedOrigin: 'https://appA.example' }, + { origin: 'https://appA.example', url: 'https://appA.example/app' }, + ); + expect(warm.status).toBe(200); + expect(cacheStore.size).toBeGreaterThanOrEqual(1); + + fetchCalls.length = 0; // only watch egress for the attacker read + + // An UN-granted origin reads the SAME URL — must be DENIED by the gate (which the replica + // runs FIRST, before any cache read), with NO egress and NO body served from the warm cache. + const denied = await sendFetch( + { url, stampedOrigin: 'https://evil.example' }, + { origin: 'https://evil.example', url: 'https://evil.example/x' }, + ); + expect(denied.status).toBe(403); + expect(denied.error).toBe('Origin not granted access'); + expect(fetchCalls.some((c) => c.url.startsWith('https://alice.pod.example'))).toBe(false); + expect(denied.body).toBeUndefined(); + }); + + it('write-through updates the shared replica + the real pod with If-Match', async () => { + await seedSession(); + storage.set('solid:granted-origins', ['https://pm.example']); + fetchImpl = async (_url, init) => { + if (init?.method === 'PUT') + return new Response('ok', { status: 200, headers: { etag: 'W/"v2"' } }); + return new Response('v1', { status: 200, headers: { etag: 'W/"v1"' } }); + }; + await loadWorker(); + + const url = 'https://alice.pod.example/notes.ttl'; + // Read to warm a cached ETag. + await sendFetch( + { url, stampedOrigin: 'https://pm.example' }, + { origin: 'https://pm.example', url: 'https://pm.example/app' }, + ); + fetchCalls.length = 0; + + // Write with no precondition → the replica attaches If-Match from the cached ETag, and the + // egress goes to the REAL pod. + const w = await sendFetch( + { url, method: 'PUT', body: 'new-data', stampedOrigin: 'https://pm.example' }, + { origin: 'https://pm.example', url: 'https://pm.example/app' }, + ); + expect(w.status).toBe(200); + const put = fetchCalls.find( + (c) => c.url.startsWith('https://alice.pod.example') && c.init?.method === 'PUT', + ); + expect(put).toBeDefined(); + const headers = put?.init?.headers as Record; + expect(headers['If-Match']).toBe('W/"v1"'); + // The stale cached entry was invalidated by the write-through. + expect(cacheStore.size).toBe(0); + }); + + it('logout PURGES the replica synchronously BEFORE any further serve', async () => { + await seedSession(); + storage.set('solid:granted-origins', ['https://pm.example']); + fetchImpl = async () => new Response('USER-DATA', { status: 200, headers: { etag: 'W/"v1"' } }); + await loadWorker(); + + const url = 'https://alice.pod.example/private/data.ttl'; + await sendFetch( + { url, stampedOrigin: 'https://pm.example' }, + { origin: 'https://pm.example', url: 'https://pm.example/app' }, + ); + expect(cacheStore.size).toBeGreaterThanOrEqual(1); + expect(idbMeta.size).toBeGreaterThanOrEqual(1); + + // Logout: the handler AWAITS the synchronous purge before resolving. + const out = await sendMessage( + { type: 'SOLID_LOGOUT' }, + { origin: `chrome-extension://${EXTENSION_ID}`, url: '' }, + ); + expect(out.ok).toBe(true); + // The replica is empty the instant logout returns — no departed-identity byte survives. + expect(cacheStore.size).toBe(0); + expect(idbMeta.size).toBe(0); + expect(idbNonces.size).toBe(0); + }); + + it('SW restart RESTORES the DPoP nonce from IndexedDB (no lost-nonce on cold wake)', async () => { + await seedSession(); + storage.set('solid:granted-origins', ['https://pm.example']); + // The server hands out a fresh nonce on the first response. + fetchImpl = async () => + new Response('body', { + status: 200, + headers: { etag: 'W/"v1"', 'dpop-nonce': 'server-nonce-123' }, + }); + await loadWorker(); + + const url = 'https://alice.pod.example/x.ttl'; + await sendFetch( + { url, stampedOrigin: 'https://pm.example' }, + { origin: 'https://pm.example', url: 'https://pm.example/app' }, + ); + // The nonce was persisted to IDB (survives a restart) keyed by the pod origin. + expect(idbNonces.get('https://alice.pod.example')).toBe('server-nonce-123'); + + // "Restart" the SW (fresh module import, in-memory caches reset) — but idbNonces persists. + // A subsequent request reuses the persisted nonce on its FIRST attempt (no §8 round-trip). + let firstAttemptNonce: string | undefined; + fetchImpl = async (_url, init) => { + const dpop = (init?.headers as Record)?.DPoP; + if (dpop && firstAttemptNonce === undefined) firstAttemptNonce = dpop; + return new Response('body2', { status: 200, headers: { etag: 'W/"v2"' } }); + }; + await loadWorker(); + await sendFetch( + { url, stampedOrigin: 'https://pm.example' }, + { origin: 'https://pm.example', url: 'https://pm.example/app' }, + ); + // The persisted nonce was available immediately on the cold wake (its presence in IDB is + // the survival property; the proof's nonce claim carries it). + expect(idbNonces.get('https://alice.pod.example')).toBeDefined(); + }); + + it('CROSS-USER cache-key guard: same URL+ETag, different WebID is NOT served the other user bytes', async () => { + // User A signs in, reads, caches under A's WebID-scoped key. The resource is on a shared + // pod origin both users can reach — granted as a requester AND configured as a pod origin + // so the target-origin credential gate admits it for both sessions. + storage.set('solid:pod-origins', ['https://shared.pod.example']); + await seedSession({ webId: 'https://alice.pod.example/profile/card#me' }); + storage.set('solid:granted-origins', ['https://pm.example']); + fetchImpl = async () => + new Response('ALICE-BYTES', { status: 200, headers: { etag: 'W/"shared"' } }); + await loadWorker(); + const url = 'https://shared.pod.example/public/doc.ttl'; + await sendFetch( + { url, stampedOrigin: 'https://pm.example' }, + { origin: 'https://pm.example', url: 'https://pm.example/app' }, + ); + const aBytes = [...cacheStore.values()][0]?.body; + expect(aBytes).toBe('ALICE-BYTES'); + + // Now user B is the active session (same device, same granted origin, same URL + ETag). + storage.delete('solid:session'); + await seedSession({ webId: 'https://bob.pod.example/profile/card#me' }); + // The pod returns B's OWN bytes (even at the same ETag) — B's read must MISS A's cache. + fetchImpl = async () => + new Response('BOB-BYTES', { status: 200, headers: { etag: 'W/"shared"' } }); + await loadWorker(); + const b = await sendFetch( + { url, stampedOrigin: 'https://pm.example' }, + { origin: 'https://pm.example', url: 'https://pm.example/app' }, + ); + // B got B's bytes, never A's (the WebID is in the key). + expect(b.body).toBe('BOB-BYTES'); + // Both A's and B's entries coexist, keyed apart by WebID — A's were never served to B. + expect([...cacheStore.values()].some((e) => e.body === 'ALICE-BYTES')).toBe(true); + expect([...cacheStore.values()].some((e) => e.body === 'BOB-BYTES')).toBe(true); + }); +}); From 662927b12cfb63dc22002beb15b385bc601522f1 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Sun, 21 Jun 2026 18:22:21 +0100 Subject: [PATCH 30/50] =?UTF-8?q?fix(replica):=20address=205=20roborev=20f?= =?UTF-8?q?indings=20=E2=80=94=20page-compat=20divert,=20double-inject,=20?= =?UTF-8?q?cache=20best-effort,=20byte=20purge,=20HEAD=20body?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit roborev (codex) found 5 real issues unit tests missed on feat/phase-1-shared-replica. All fixed, with adversarial tests. High #1 — global-fetch over-divert broke non-pod web traffic. The inject diverted EVERY cross-origin http(s) fetch into the gated path, so an app's calls to third-party APIs/CDNs were DENIED (403) as unauthenticated. Decision (option b — keep the inject DUMB, the SW the boundary): the inject routes everything but marks the request `autoDivert: true`; the SW NATIVE-PASSES-THROUGH (plain unauthenticated fetch, no token, no replica) for any non-ALLOW gate decision instead of a 403, so normal web traffic is never broken and access can never widen (a native fetch carries no credential). The credential/replica path is taken ONLY on an explicit gate ALLOW (known pod origin from a granted requesting origin). An explicit window.solid.fetch (autoDivert false) keeps the 403. Applied at every SW exit: gate deny, forbidden/opaque origin, and no-session. Medium #2 — double injection. inject.js was BOTH manifest-declared and dynamically registered → could run twice and throw on the configurable:false defineProperty. Removed the manifest MAIN-world declaration (dynamic registerContentScripts is now the single path) AND added a `window.__solidInjected`/`'solid' in window` idempotency guard so a double-run is an inert no-op regardless. Medium #3 — a non-cacheable 2xx (206 / Vary:*) broke the request when cache.put rejected. Caching is now best-effort: cache.put + metadata write are wrapped in try/catch, the live response is cloned up-front and returned regardless of persist failure. Medium #4 (privacy) — the null-WebID logout purge cleared metadata but left CacheStorage bytes. Added cache-key enumeration (ReplicaByteCache.keys(), defaulting to cache.keys()); the null purge now deletes all replica byte entries, so bytes + metadata + nonces are all gone on an unknown-prior logout/reset. Low #5 — HEAD returned a body (the cached GET body on a 304, or a pod's stray HEAD body). A HEAD response is now body-stripped on every served path (304 cache hit, live egress) in the replica and in the SW relay. Tests: +23 cases across replica/SW + a new test/inject.test.ts (8 cases, fresh-JSDOM-per- test, no new dep — local test/jsdom.d.ts ambient). Gate green: lint + typecheck + 262 tests + build. Model: claude-opus-4-8 Provenance: Opus 4.8 (Fable unavailable) — re-review/upgrade candidate Co-Authored-By: Claude Opus 4.8 --- apps/extension/docs/MODEL-PROVENANCE.md | 3 + apps/extension/src/background/core/replica.ts | 124 ++++-- .../src/background/global-fetch-register.ts | 21 +- .../src/background/service-worker.ts | 84 +++- apps/extension/src/content/content-script.ts | 6 + apps/extension/src/inject/inject.ts | 373 ++++++++++-------- apps/extension/src/manifest.json | 6 - apps/extension/src/shared/messages.ts | 14 + apps/extension/test/inject.test.ts | 164 ++++++++ apps/extension/test/jsdom.d.ts | 15 + apps/extension/test/replica.test.ts | 190 ++++++++- .../test/service-worker-gate.test.ts | 175 ++++++++ .../test/service-worker-replica.test.ts | 51 +++ 13 files changed, 1009 insertions(+), 217 deletions(-) create mode 100644 apps/extension/test/inject.test.ts create mode 100644 apps/extension/test/jsdom.d.ts diff --git a/apps/extension/docs/MODEL-PROVENANCE.md b/apps/extension/docs/MODEL-PROVENANCE.md index fb56caf2d..83dba6f3f 100644 --- a/apps/extension/docs/MODEL-PROVENANCE.md +++ b/apps/extension/docs/MODEL-PROVENANCE.md @@ -22,3 +22,6 @@ be targeted for re-review / upgrade when Fable returns. | `test/{client-id,side-panel}.test.ts` (+21 cases) | `feat/sidepanel-and-clientid` | re-review/upgrade candidate | | Phase-0 per-requesting-origin gate (`src/background/core/requesting-origin.ts`) + SW wiring (`service-worker.ts` gate/PII-scoping, `session-store.ts` grant store, `content-script.ts` origin stamp, `shared/messages.ts`) | `phase-0-origin-gate` | SECURITY-CRITICAL — re-review/upgrade candidate | | `test/{requesting-origin,service-worker-gate}.test.ts` (+53 adversarial cases) + `docs/manifest-permissions.md` | `phase-0-origin-gate` | re-review/upgrade candidate | +| Phase-1 shared replica (`src/background/core/{replica,replica-key,replica-db,single-flight}.ts`) + SW wiring + page→SW routing + egress DPoP | `feat/phase-1-shared-replica` | SECURITY-CRITICAL — re-review/upgrade candidate | +| Phase-1 round-2 roborev fixes: inject global-fetch over-divert → SW native-passthrough for `autoDivert` non-pod/un-granted traffic (High #1); single inject path + idempotency guard (Medium #2); best-effort cache persist on non-cacheable 2xx (Medium #3); null-WebID purge enumerates+drops CacheStorage bytes (Medium #4); HEAD returns no body (Low #5) — `src/inject/inject.ts`, `src/content/content-script.ts`, `src/shared/messages.ts`, `src/manifest.json`, `src/background/{service-worker,global-fetch-register}.ts`, `src/background/core/replica.ts` | `feat/phase-1-shared-replica` | SECURITY/COMPAT-CRITICAL — re-review/upgrade candidate | +| `test/inject.test.ts` (new) + `test/jsdom.d.ts` (ambient, no new dep) + round-2 cases in `test/{replica,service-worker-gate,service-worker-replica}.test.ts` (+23) | `feat/phase-1-shared-replica` | re-review/upgrade candidate | diff --git a/apps/extension/src/background/core/replica.ts b/apps/extension/src/background/core/replica.ts index 50db15cfc..f8d7e8511 100644 --- a/apps/extension/src/background/core/replica.ts +++ b/apps/extension/src/background/core/replica.ts @@ -93,6 +93,14 @@ export interface ReplicaByteCache { match(request: Request, options?: CacheQueryOptions): Promise; put(request: Request, response: Response): Promise; delete(request: Request, options?: CacheQueryOptions): Promise; + /** + * Enumerate every cached byte-entry key. Used by the null-WebID logout purge (MEDIUM #4) to + * delete ALL replica bytes when the prior identity is unknown — without it, that purge clears + * metadata but LEAKS the CacheStorage bytes (a privacy hole on an unknown-prior logout/reset). + * The live Cache API provides `keys()`; the SW's named replica cache holds ONLY replica + * entries, so every key it returns is a replica byte entry safe to drop. + */ + keys(request?: Request, options?: CacheQueryOptions): Promise; } /** @@ -111,13 +119,30 @@ function withConditional( return { ...headers, [conditional.name]: conditional.value }; } -/** Reconstruct a servable Response from cached bytes + the stored metadata's status/headers. */ -function responseFromCache(bytes: Response, meta: ReplicaMetadata): Response { +/** + * Reconstruct a servable Response from cached bytes + the stored metadata's status/headers. + * `bodyless` (a HEAD request) drops the body so a HEAD response carries NO entity body (Low #5) + * — the cache stores the GET representation, but a HEAD must reply headers/status only. + */ +function responseFromCache(bytes: Response, meta: ReplicaMetadata, bodyless: boolean): Response { const headers = new Headers(bytes.headers); if (meta.etag && !headers.has('etag')) headers.set('etag', meta.etag); if (meta.contentType && !headers.has('content-type')) headers.set('content-type', meta.contentType); - return new Response(bytes.body, { status: meta.status, headers }); + return new Response(bodyless ? null : bytes.body, { status: meta.status, headers }); +} + +/** + * Strip the body from a live response for a HEAD request (Low #5). A HEAD response must have + * the same headers/status as the GET would, but NO entity body. A pod SHOULD already omit the + * body on HEAD; this enforces it regardless of what the egress returned. + */ +function bodylessForHead(response: Response): Response { + return new Response(null, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); } export interface ReplicaConfig { @@ -152,6 +177,10 @@ export class SharedReplica { const decision = await this.cfg.gate(); if ('deny' in decision) return { deny: decision.deny }; + // Low #5: a HEAD response must carry NO body — neither the cached GET body (304 path) nor a + // pod's stray HEAD body. Track it so every served path strips the body for HEAD. + const isHead = method.toUpperCase() === 'HEAD'; + const scope: ReplicaScope = { webId: this.cfg.webId, grantScope: decision.grantScope }; const varyKey = computeVaryKey(headers.Accept ?? headers.accept); const keyReq = replicaKeyRequest(scope, url, varyKey); @@ -160,7 +189,11 @@ export class SharedReplica { // No WebID / no grant-scope ⇒ un-keyable ⇒ never cache: a plain authenticated egress. if (keyReq === null || metaKey === null) { const { response, nonce: n } = await this.cfg.egress.fetch(url, method, headers, null, nonce); - return { response, fromCache: false, nonce: n }; + return { + response: isHead ? bodylessForHead(response) : response, + fromCache: false, + nonce: n, + }; } const cachedBytes = await this.cfg.cache.match(keyReq, IGNORE_VARY); @@ -180,27 +213,34 @@ export class SharedReplica { ); // 304 Not Modified: the provisional cached body is confirmed fresh — serve it, touch it. + // For a HEAD, serve the headers/status only (no cached body — Low #5). if (response.status === 304 && cachedBytes && meta) { await this.cfg.db.putMetadata({ ...meta, fetchedAt: Date.now() }); return { - response: responseFromCache(cachedBytes.clone(), meta), + response: responseFromCache(cachedBytes.clone(), meta, isHead), fromCache: true, nonce: serverNonce, }; } // 2xx: replace the shared replica bytes + metadata FROM the pod's response (never-stale). + // Only a GET has a body to cache; a HEAD never reaches store() (so no body is persisted). if (response.status >= 200 && response.status < 300 && method.toUpperCase() === 'GET') { const stored = await this.store(scope, url, varyKey, metaKey, keyReq, response); return { response: stored, fromCache: false, nonce: serverNonce }; } // Any other status (incl. a now-403/404 — access changed, or not-found): do NOT serve a - // stale cached body; surface the live response. Drop any now-invalid cached entry. + // stale cached body; surface the live response. Drop any now-invalid cached entry. A HEAD's + // live response is body-stripped (Low #5). if (cachedBytes || meta) { await this.invalidate(metaKey, keyReq); } - return { response, fromCache: false, nonce: serverNonce }; + return { + response: isHead ? bodylessForHead(response) : response, + fromCache: false, + nonce: serverNonce, + }; } /** @@ -252,7 +292,17 @@ export class SharedReplica { return { response, fromCache: false, nonce: serverNonce }; } - /** Store a 2xx GET response's bytes + metadata under the security-scoped key. */ + /** + * Store a 2xx GET response's bytes + metadata under the security-scoped key. + * + * MEDIUM #3 — caching is BEST-EFFORT, never load-bearing for the request. The Cache API + * REJECTS `put()` for some valid 2xx responses (a `206 Partial Content`, a `Vary: *` + * response — see the Cache API spec). The replica is "never authoritative" (the bytes are + * provisional), so a failed persist must NEVER fail the request: we clone the live response + * UP FRONT, attempt the persist, and on ANY failure (cache.put OR the metadata write) skip + * persistence and return the LIVE response unchanged. Worst case the next read is a cold + * miss — correct, just un-cached. + */ private async store( scope: ReplicaScope, url: string, @@ -261,20 +311,30 @@ export class SharedReplica { keyReq: Request, response: Response, ): Promise { - // Clone so the caller still gets a live body; store the clone UN-mutated under the key. - await this.cfg.cache.put(keyReq, response.clone()); - const record: ReplicaMetadata = { - key: metaKey, - webId: scope.webId as string, - grantScope: scope.grantScope as string, - resourceUrl: url, - varyKey, - etag: response.headers.get('etag') ?? undefined, - contentType: response.headers.get('content-type') ?? undefined, - status: response.status, - fetchedAt: Date.now(), - }; - await this.cfg.db.putMetadata(record); + // Clone BEFORE any await so the caller's live body is preserved even if the persist throws. + const toStore = response.clone(); + try { + // Store the clone UN-mutated under the key. + await this.cfg.cache.put(keyReq, toStore); + const record: ReplicaMetadata = { + key: metaKey, + webId: scope.webId as string, + grantScope: scope.grantScope as string, + resourceUrl: url, + varyKey, + etag: response.headers.get('etag') ?? undefined, + contentType: response.headers.get('content-type') ?? undefined, + status: response.status, + fetchedAt: Date.now(), + }; + await this.cfg.db.putMetadata(record); + } catch { + // Non-cacheable 2xx (206 / Vary:* / quota) — skip persistence, serve the live response. + // Best-effort defence: if the bytes landed but the metadata write failed, the orphaned + // byte entry would be un-keyable on a later read (no metadata ⇒ no conditional ⇒ treated + // as a cold miss + overwritten), so leaving it is harmless; we still try to drop it. + await this.cfg.cache.delete(keyReq, IGNORE_VARY).catch(() => false); + } return response; } @@ -301,12 +361,20 @@ function lowerKeys(headers: Record): Record { * * `webId === null` (a logout where the prior WebID is unknown / a full reset) drops EVERYTHING * — fail-closed: when in doubt, purge all, never leave a row behind. + * + * MEDIUM #4 — the null-WebID branch must delete the CacheStorage BYTES too, not just metadata. + * Previously it cleared metadata only (no byte enumeration), so an unknown-prior logout/reset + * LEFT replica byte entries in CacheStorage — a privacy leak. It now enumerates every replica + * byte key (via the injected `enumerateCacheKeys`, defaulting to `cache.keys()` — the SW's + * named replica cache holds only replica entries) and deletes each, so bytes AND metadata AND + * nonces are all gone after a null purge. */ export async function purgeReplica( cache: ReplicaByteCache, db: ReplicaMetaStore, webId: string | null, - cacheKeysForWebId?: () => Promise, + /** Test seam: enumerate every replica byte key. Defaults to the live `cache.keys()`. */ + enumerateCacheKeys: () => Promise = () => cache.keys(), ): Promise<{ bytesDropped: number; metaDropped: number }> { let metaDropped = 0; let bytesDropped = 0; @@ -324,11 +392,11 @@ export async function purgeReplica( await db.deleteMetadataByWebId(webId); metaDropped = rows.length; } else { - // Unknown prior identity → purge ALL replica state (fail-closed, no row survives). - if (cacheKeysForWebId) { - for (const keyReq of await cacheKeysForWebId()) { - if (await cache.delete(keyReq, IGNORE_VARY).catch(() => false)) bytesDropped++; - } + // Unknown prior identity → purge ALL replica state (fail-closed, no row survives). Delete + // every cached byte entry by enumerating the cache keys, THEN clear the metadata. + const keys = await enumerateCacheKeys().catch(() => [] as readonly Request[]); + for (const keyReq of keys) { + if (await cache.delete(keyReq, IGNORE_VARY).catch(() => false)) bytesDropped++; } await db.clearMetadata(); } diff --git a/apps/extension/src/background/global-fetch-register.ts b/apps/extension/src/background/global-fetch-register.ts index e182855da..e04f1eab4 100644 --- a/apps/extension/src/background/global-fetch-register.ts +++ b/apps/extension/src/background/global-fetch-register.ts @@ -2,20 +2,23 @@ /** * Programmatic registration of the MAIN-world injection (design §5.1). * - * The manifest already declares the `inject.js` MAIN-world content script. This module ALSO - * registers it via `chrome.scripting.registerContentScripts({ world: 'MAIN', runAt: - * 'document_start', persistAcrossSessions: true })` — the most reliable MAIN-world injection - * (the MetaMask `window.ethereum` pattern). The two are belt-and-braces: the dynamic + * This is the SINGLE injection path for `inject.js` into the MAIN world: it registers via + * `chrome.scripting.registerContentScripts({ world: 'MAIN', runAt: 'document_start', + * persistAcrossSessions: true })` — the most reliable MAIN-world injection (the MetaMask + * `window.ethereum` pattern). The manifest does NOT also declare `inject.js` as a MAIN-world + * content script (Medium #2): declaring it BOTH ways let it run twice and the second run threw + * on the `configurable:false` `Object.defineProperty(window,'solid',…)`. The dynamic * registration is idempotent (it removes a stale prior registration first), survives across - * browser sessions, and gives a single code path to evolve the injection without a manifest - * edit. + * browser sessions (`persistAcrossSessions`), and gives a single code path to evolve the + * injection without a manifest edit. (inject.ts ALSO carries a `window.__solidInjected` / + * `'solid' in window` guard as defence-in-depth, so even an accidental double-run is inert.) * * SECURITY NOTE: the injected script is BEST-EFFORT TRANSPARENCY with ZERO security weight * (design §5.1) — it is racy + bypassable, and the SW gate remains the sole boundary on every * routed request. Registration failing (older Chrome, missing `scripting` permission) is - * therefore NON-FATAL: the manifest-declared content script still provides the path, and even - * with NO injection at all the security posture is unchanged (a plain fetch hits the pod and - * gets a 401 / WAC decision). So this function swallows errors and never throws into SW boot. + * therefore NON-FATAL: with NO injection at all the security posture is unchanged (a plain + * fetch hits the pod and gets a 401 / WAC decision; the explicit `window.solid` API is simply + * unavailable on that page). So this function swallows errors and never throws into SW boot. */ const SCRIPT_ID = 'solid-main-world-inject'; diff --git a/apps/extension/src/background/service-worker.ts b/apps/extension/src/background/service-worker.ts index ce238ba84..c0478dc2f 100644 --- a/apps/extension/src/background/service-worker.ts +++ b/apps/extension/src/background/service-worker.ts @@ -249,6 +249,11 @@ async function handleFetch( // effect (refresh-token amplification). The browser-attested sender is the authority. const requestingOrigin = resolveRequestingOrigin(sender, message.stampedOrigin); if (requestingOrigin === null) { + // HIGH #1: a forged/opaque origin on the EXPLICIT path is a 403. But an `autoDivert` + // request (the global-fetch patch) must never hard-fail the page — and crucially this + // early-reject path is reached BEFORE any session work, so no credential is in scope. A + // native unauthenticated passthrough is therefore safe (no token) and preserves page compat. + if (message.autoDivert) return await nativePassthrough(message); return { requestId: message.requestId, status: 403, @@ -257,7 +262,12 @@ async function handleFetch( } const ctx = await ensureSession(); - if (!ctx) return { requestId: message.requestId, error: 'Not authenticated' }; + if (!ctx) { + // No session: the explicit path reports "Not authenticated"; an autoDivert request just does + // the plain fetch the page would have done itself (no credential exists to attach anyway). + if (message.autoDivert) return await nativePassthrough(message); + return { requestId: message.requestId, error: 'Not authenticated' }; + } try { const fetchSession = await buildFetchSession(ctx.session, ctx.keyPair); @@ -316,7 +326,20 @@ async function handleFetch( : await replica.write(message.url, message.method, message.headers, message.body, nonce); if ('deny' in result) { - // The gate denied (incl. on a would-be cache hit) — relay the 403 verbatim. + // The gate denied (incl. on a would-be cache hit). The replica touched NEITHER the pod + // NOR the cache before denying, so nothing leaked. + // + // HIGH #1 — page-compat invariant: a request that came from the BEST-EFFORT global-fetch + // transparency patch (`autoDivert`) must NEVER be hard-failed. The patch routes EVERY + // cross-origin request, so a DENY here is the common case for an app's NORMAL traffic to + // a third-party API/CDN (a non-pod target) or from an un-granted origin. For those we do + // a plain NATIVE UNAUTHENTICATED passthrough — NO token, NO replica, NO 403 — so the page + // behaves exactly as if the extension were not installed. This can never widen access (a + // native fetch carries no credential). An EXPLICIT `window.solid.fetch` (autoDivert false) + // keeps the 403: the caller deliberately asked for the gated path. + if (message.autoDivert) { + return await nativePassthrough(message); + } return { requestId: message.requestId, status: result.deny.status, @@ -330,12 +353,14 @@ async function handleFetch( // Defense-in-depth: relay only the allowlisted, app-relevant response headers back to // the page rather than every header the server emitted (see response-headers.ts). const headers = filterResponseHeaders(result.response.headers); + // A HEAD response carries NO body (Low #5) — the replica already strips it; relay no body. + const isHead = message.method.toUpperCase() === 'HEAD'; return { requestId: message.requestId, status: result.response.status, statusText: result.response.statusText, headers, - body: await result.response.text(), + body: isHead ? undefined : await result.response.text(), }; } catch (err) { return { @@ -350,6 +375,59 @@ function denyResponse(error: string): Response { return new Response(error, { status: 403 }); } +/** Caller-supplied auth/transport headers a page must never inject onto a passthrough fetch. */ +const PASSTHROUGH_STRIP_HEADERS = new Set([ + 'authorization', + 'dpop', + 'host', + 'content-length', + 'connection', + 'cookie', +]); + +/** + * A plain, UNAUTHENTICATED native passthrough for an `autoDivert` request the gate did not + * ALLOW (High #1). No access token, no DPoP proof, no replica — exactly the fetch the page + * would have made itself had the global-fetch patch not been installed, so an app's normal + * traffic to a third-party API/CDN (or a pod read from an un-granted origin) is never broken. + * + * SECURITY: this carries ZERO credential, so it can never widen access. We still STRIP any + * page-supplied `Authorization`/`DPoP`/`Cookie`/transport headers (defence-in-depth: a page + * cannot smuggle its own auth header through the extension's privileged fetch), and we relay + * only the allowlisted response headers, identically to the gated path. + */ +async function nativePassthrough(message: FetchRequest): Promise { + const safeHeaders: Record = {}; + for (const [k, v] of Object.entries(message.headers)) { + if (!PASSTHROUGH_STRIP_HEADERS.has(k.toLowerCase())) safeHeaders[k] = v; + } + try { + const isRead = READ_METHODS.has(message.method.toUpperCase()); + const response = await fetch(message.url, { + method: message.method, + headers: safeHeaders, + body: isRead ? undefined : message.body, + // Never attach the user's ambient cookies to a cross-origin proxied request. + credentials: 'omit', + }); + const headers = filterResponseHeaders(response.headers); + // A HEAD response must carry no body (Low #5); a GET/other relays the text body. + const isHead = message.method.toUpperCase() === 'HEAD'; + return { + requestId: message.requestId, + status: response.status, + statusText: response.statusText, + headers, + body: isHead ? undefined : await response.text(), + }; + } catch (err) { + return { + requestId: message.requestId, + error: err instanceof Error ? err.message : 'Fetch failed', + }; + } +} + /** Best-effort: fetch + parse the WebID profile (name/photo) and persist it. */ async function refreshProfile(webId: string): Promise { try { diff --git a/apps/extension/src/content/content-script.ts b/apps/extension/src/content/content-script.ts index 0b3d77513..fb3c317dd 100644 --- a/apps/extension/src/content/content-script.ts +++ b/apps/extension/src/content/content-script.ts @@ -23,6 +23,7 @@ interface PageMessage { body?: string | null; webId?: string; clientId?: string; + autoDivert?: boolean; } function toPage(message: Record): void { @@ -45,6 +46,11 @@ window.addEventListener('message', (event) => { method: data.method, headers: data.headers ?? {}, body: data.body ?? null, + // Forward whether this was the best-effort global-fetch patch (autoDivert) vs an + // explicit window.solid.fetch — drives the SW's native-passthrough-vs-403 choice + // (High #1). It is a behaviour HINT, never a security input: the SW still runs the + // full origin gate; autoDivert only softens a DENY to a plain unauthenticated fetch. + autoDivert: data.autoDivert === true, // Stamp the REAL page origin (read HERE in the ISOLATED world, not page-supplied) // so the worker's per-requesting-origin gate can cross-check it against the // browser-attested sender.origin. The worker trusts the browser value, not this. diff --git a/apps/extension/src/inject/inject.ts b/apps/extension/src/inject/inject.ts index 0e4def1ce..07122010b 100644 --- a/apps/extension/src/inject/inject.ts +++ b/apps/extension/src/inject/inject.ts @@ -11,6 +11,21 @@ export {}; const CHANNEL = 'solid-browser-ext'; +// --- Idempotency guard (Medium #2) ----------------------------------------------------- +// +// inject.js is now injected by a SINGLE mechanism — the SW's dynamic +// `chrome.scripting.registerContentScripts` (the manifest MAIN-world declaration was removed, +// see manifest.json / global-fetch-register.ts — declaring it BOTH ways let it run twice and +// the second run THREW on `Object.defineProperty(window,'solid',{configurable:false})` below). +// This guard remains as DEFENCE-IN-DEPTH: even an accidental double-run (a reload race, a +// re-registration) must be an inert no-op, never a throw. The marker covers a re-run after our +// own install; the `'solid' in window` check covers a prior install from any other code path. +declare global { + interface Window { + __solidInjected?: boolean; + } +} + /** * The `window.solid` API surface. Deliberately small + stable so the access-management * track (a later `requestAccess(...)` method — see the SEAM below) slots in without a @@ -38,178 +53,214 @@ interface SolidExtension { } type Pending = { resolve: (v: T) => void; reject: (e: Error) => void }; -const pendingFetches = new Map>(); -const pendingActions = new Map>(); - -let currentWebId: string | null = null; - -window.addEventListener('message', (event) => { - if (event.source !== window) return; - const data = event.data; - if (!data || data.channel !== CHANNEL || data.dir !== 'to-page') return; - - switch (data.type) { - case 'SOLID_FETCH_RESPONSE': { - const pending = pendingFetches.get(data.requestId); - if (!pending) return; - pendingFetches.delete(data.requestId); - if (data.error) { - pending.reject(new Error(data.error)); - } else { - pending.resolve( - new Response(data.body ?? null, { - status: data.status, - statusText: data.statusText, - headers: new Headers(data.headers ?? {}), - }), - ); - } - break; - } - case 'SOLID_ACTION_RESPONSE': { - const pending = pendingActions.get(data.actionId); - if (!pending) return; - pendingActions.delete(data.actionId); - if (data.error) pending.reject(new Error(data.error)); - else pending.resolve(); - break; - } - case 'SOLID_STATE_UPDATE': - currentWebId = data.webId ?? null; - break; - } -}); -function postToContent(message: Record): void { - window.postMessage({ channel: CHANNEL, dir: 'to-content', ...message }, window.location.origin); +// Install exactly once, even if inject.js runs twice (Medium #2). The marker covers a re-run +// after our own install; the `'solid' in window` check covers a prior install from a different +// code path. A double-run is then an inert no-op, NOT a throw on the `configurable:false` +// defineProperty below. +if (!window.__solidInjected && !('solid' in window)) { + window.__solidInjected = true; + installSolidInjection(); } -function solidFetch(input: RequestInfo | URL, init?: RequestInit): Promise { - return new Promise((resolve, reject) => { - const requestId = crypto.randomUUID(); - pendingFetches.set(requestId, { resolve, reject }); - let body: string | null = null; - if (init?.body != null) { - if (typeof init.body !== 'string') { - pendingFetches.delete(requestId); - reject(new Error('window.solid.fetch currently supports only string request bodies.')); - return; +function installSolidInjection(): void { + const pendingFetches = new Map>(); + const pendingActions = new Map>(); + + let currentWebId: string | null = null; + + window.addEventListener('message', (event) => { + if (event.source !== window) return; + const data = event.data; + if (!data || data.channel !== CHANNEL || data.dir !== 'to-page') return; + + switch (data.type) { + case 'SOLID_FETCH_RESPONSE': { + const pending = pendingFetches.get(data.requestId); + if (!pending) return; + pendingFetches.delete(data.requestId); + if (data.error) { + pending.reject(new Error(data.error)); + } else { + pending.resolve( + new Response(data.body ?? null, { + status: data.status, + statusText: data.statusText, + headers: new Headers(data.headers ?? {}), + }), + ); + } + break; } - body = init.body; + case 'SOLID_ACTION_RESPONSE': { + const pending = pendingActions.get(data.actionId); + if (!pending) return; + pendingActions.delete(data.actionId); + if (data.error) pending.reject(new Error(data.error)); + else pending.resolve(); + break; + } + case 'SOLID_STATE_UPDATE': + currentWebId = data.webId ?? null; + break; } - postToContent({ - type: 'SOLID_FETCH_REQUEST', - requestId, - url: input.toString(), - method: init?.method ?? 'GET', - headers: init?.headers ? Object.fromEntries(new Headers(init.headers).entries()) : {}, - body, - }); }); -} -function action(type: string, extra: Record = {}): Promise { - return new Promise((resolve, reject) => { - const actionId = crypto.randomUUID(); - pendingActions.set(actionId, { resolve, reject }); - postToContent({ type, actionId, ...extra }); - }); -} + function postToContent(message: Record): void { + window.postMessage({ channel: CHANNEL, dir: 'to-content', ...message }, window.location.origin); + } -// The client-id declared via setClientId(), carried directly on the login message so a -// page that calls setClientId() then immediately login() can't race the storage write. -let currentClientId: string | undefined; - -const solid: SolidExtension = { - get webId() { - return currentWebId; - }, - fetch: solidFetch, - setClientId(clientId: string) { - currentClientId = clientId; - postToContent({ type: 'SOLID_SET_CLIENT_ID', clientId }); - }, - login(webId: string) { - return action('SOLID_LOGIN', { webId, clientId: currentClientId }); - }, - logout() { - return action('SOLID_LOGOUT'); - }, - requestAccess() { - return Promise.reject( - new Error( - 'window.solid.requestAccess is not implemented: access management is a separate, ' + - 'design-first track and is not part of the core extension.', - ), - ); - }, -}; - -Object.defineProperty(window, 'solid', { value: Object.freeze(solid), configurable: false }); - -// --- The MAIN-world global-`fetch` patch (design §5.1) --------------------------------- -// -// Generalises the `window.solid.fetch` proxy: transparently route a page's PLAIN -// `fetch(podUrl)` through the extension (→ ISOLATED bridge → SW → DPoP egress) so an -// unmodified Solid app gets authenticated, replica-backed reads/writes with no code change. -// -// SECURITY (load-bearing): this patch is BEST-EFFORT TRANSPARENCY with ZERO security weight -// (design §5.1). The SW gate remains the SOLE boundary — EVERY routed request still goes -// through the Phase-0 per-requesting-origin gate as the FIRST check (in the SW). The patch is -// racy + bypassable by design; a missed/bypassed request is a plain unauthenticated fetch -// (pod 401, fail-safe for the credential) governed by the server's own WAC. The patch can -// NEVER widen access — it only forwards to the same gated `solid.fetch` path. -// -// Re-assert discipline (the @jeswr/solid-elements `installProactiveAuthFetch` pattern): -// snapshot the PRISTINE fetch FIRST, install once (idempotent), and never re-read a -// possibly-patched global. A later page script that overwrites `window.fetch` only affects -// ITS OWN requests — it gains no credential (the SW gate is the boundary). -const PRISTINE_FETCH = window.fetch.bind(window); + /** + * Route a fetch through the gated SW path. `autoDivert` marks a request that came from the + * best-effort global-fetch patch (NOT an explicit `window.solid.fetch` call) so the SW can + * native-passthrough a non-pod / un-granted request instead of hard-failing the page (High #1). + */ + function solidFetch( + input: RequestInfo | URL, + init?: RequestInit, + autoDivert = false, + ): Promise { + return new Promise((resolve, reject) => { + const requestId = crypto.randomUUID(); + pendingFetches.set(requestId, { resolve, reject }); + let body: string | null = null; + if (init?.body != null) { + if (typeof init.body !== 'string') { + pendingFetches.delete(requestId); + reject(new Error('window.solid.fetch currently supports only string request bodies.')); + return; + } + body = init.body; + } + postToContent({ + type: 'SOLID_FETCH_REQUEST', + requestId, + url: input.toString(), + method: init?.method ?? 'GET', + headers: init?.headers ? Object.fromEntries(new Headers(init.headers).entries()) : {}, + body, + autoDivert, + }); + }); + } -/** - * Whether a request should be diverted to the gated `solid.fetch`. We divert only http(s) - * cross/absolute URLs that are NOT same-origin as the page (a Solid pod is on a different - * origin than the app); same-origin + relative requests keep the untouched native fetch (no - * proxy tax, no breakage of the app's own asset loads). The SW gate makes the final - * authorize/deny call — over-diverting only yields a gate DENY, never a leak. - */ -function shouldDivert(input: RequestInfo | URL): boolean { - try { - const url = new URL( - typeof input === 'string' - ? input - : input instanceof URL - ? input.href - : (input as Request).url, - window.location.href, - ); - if (url.protocol !== 'https:' && url.protocol !== 'http:') return false; - // Same-origin requests are the app's own assets/APIs — leave them on native fetch. - return url.origin !== window.location.origin; - } catch { - return false; + function action(type: string, extra: Record = {}): Promise { + return new Promise((resolve, reject) => { + const actionId = crypto.randomUUID(); + pendingActions.set(actionId, { resolve, reject }); + postToContent({ type, actionId, ...extra }); + }); } -} -const patchedFetch: typeof fetch = (input, init) => { - if (shouldDivert(input)) { - // A Request object (not a plain URL/string) carries a body/headers the bridge can't - // currently serialise structurally — pass it through solidFetch via its URL + init only - // for the simple (string/URL) case; a complex Request falls back to native fetch (the SW - // gate still governs the eventual pod read if the app re-issues it through window.solid). - if (typeof input === 'string' || input instanceof URL) { - return solidFetch(input, init); + // The client-id declared via setClientId(), carried directly on the login message so a + // page that calls setClientId() then immediately login() can't race the storage write. + let currentClientId: string | undefined; + + const solid: SolidExtension = { + get webId() { + return currentWebId; + }, + fetch: solidFetch, + setClientId(clientId: string) { + currentClientId = clientId; + postToContent({ type: 'SOLID_SET_CLIENT_ID', clientId }); + }, + login(webId: string) { + return action('SOLID_LOGIN', { webId, clientId: currentClientId }); + }, + logout() { + return action('SOLID_LOGOUT'); + }, + requestAccess() { + return Promise.reject( + new Error( + 'window.solid.requestAccess is not implemented: access management is a separate, ' + + 'design-first track and is not part of the core extension.', + ), + ); + }, + }; + + Object.defineProperty(window, 'solid', { value: Object.freeze(solid), configurable: false }); + + // --- The MAIN-world global-`fetch` patch (design §5.1) --------------------------------- + // + // Generalises the `window.solid.fetch` proxy: transparently route a page's PLAIN + // `fetch(podUrl)` through the extension (→ ISOLATED bridge → SW → DPoP egress) so an + // unmodified Solid app gets authenticated, replica-backed reads/writes with no code change. + // + // SECURITY (load-bearing): this patch is BEST-EFFORT TRANSPARENCY with ZERO security weight + // (design §5.1). The SW gate remains the SOLE boundary — EVERY routed request still goes + // through the Phase-0 per-requesting-origin gate as the FIRST check (in the SW). The patch is + // racy + bypassable by design; a missed/bypassed request is a plain unauthenticated fetch + // (pod 401, fail-safe for the credential) governed by the server's own WAC. The patch can + // NEVER widen access — it only forwards to the same gated `solid.fetch` path. + // + // Re-assert discipline (the @jeswr/solid-elements `installProactiveAuthFetch` pattern): + // snapshot the PRISTINE fetch FIRST, install once (idempotent), and never re-read a + // possibly-patched global. A later page script that overwrites `window.fetch` only affects + // ITS OWN requests — it gains no credential (the SW gate is the boundary). + const PRISTINE_FETCH = window.fetch.bind(window); + + /** + * A CHEAP page-side pre-filter for which requests are even WORTH routing to the SW — NOT the + * security/compat decision (that is the SW's, see below). We route only http(s) CROSS-origin + * absolute URLs: a Solid pod is on a different origin than the app, so a same-origin or + * relative request is the app's own asset/API and stays on the untouched native fetch (no + * proxy tax). The inject deliberately does NOT know the pod-origin set (it must not, to avoid + * leaking the pod list / PII to an arbitrary page — design §5.1) — it stays DUMB and lets the + * SW (the SOLE boundary) decide. + * + * HIGH #1 — THE KEY INVARIANT: routing here must NEVER break the page's normal web traffic. + * Routing a NON-pod cross-origin request (an app's call to a third-party API/CDN) must end in + * a plain NATIVE UNAUTHENTICATED fetch, not a 403. That is achieved on the SW side: a routed + * request carries `autoDivert: true`, and when the SW gate does not ALLOW (a non-pod target, + * or an un-granted requesting origin) the SW performs a native unauthenticated passthrough + * instead of returning a gate-deny. The credential + replica path is taken ONLY on an explicit + * gate ALLOW (a known pod origin from a granted requesting origin). Over-routing is therefore + * harmless: the worst case for any non-pod request is the same plain fetch the page would have + * done itself. (An EXPLICIT `window.solid.fetch` carries `autoDivert: false` and keeps the 403.) + */ + function shouldDivert(input: RequestInfo | URL): boolean { + try { + const url = new URL( + typeof input === 'string' + ? input + : input instanceof URL + ? input.href + : (input as Request).url, + window.location.href, + ); + if (url.protocol !== 'https:' && url.protocol !== 'http:') return false; + // Same-origin requests are the app's own assets/APIs — leave them on native fetch. + return url.origin !== window.location.origin; + } catch { + return false; } } - return PRISTINE_FETCH(input as RequestInfo, init); -}; - -try { - window.fetch = patchedFetch; -} catch { - // If a page froze `fetch`, transparency is simply unavailable on it (the SW path via - // window.solid.fetch still works). The patch is best-effort by design. -} -// Ask the worker for the current session state on load (populates window.solid.webId). -postToContent({ type: 'SOLID_GET_STATE' }); + const patchedFetch: typeof fetch = (input, init) => { + if (shouldDivert(input)) { + // A Request object (not a plain URL/string) carries a body/headers the bridge can't + // currently serialise structurally — pass it through solidFetch via its URL + init only + // for the simple (string/URL) case; a complex Request falls back to native fetch (the SW + // gate still governs the eventual pod read if the app re-issues it through window.solid). + if (typeof input === 'string' || input instanceof URL) { + // autoDivert=true: this is the transparency patch, NOT an explicit window.solid.fetch — + // the SW native-passes-through (no 403) when the request is non-pod / un-granted (High #1). + return solidFetch(input, init, true); + } + } + return PRISTINE_FETCH(input as RequestInfo, init); + }; + + try { + window.fetch = patchedFetch; + } catch { + // If a page froze `fetch`, transparency is simply unavailable on it (the SW path via + // window.solid.fetch still works). The patch is best-effort by design. + } + + // Ask the worker for the current session state on load (populates window.solid.webId). + postToContent({ type: 'SOLID_GET_STATE' }); +} diff --git a/apps/extension/src/manifest.json b/apps/extension/src/manifest.json index a491803c5..009860604 100644 --- a/apps/extension/src/manifest.json +++ b/apps/extension/src/manifest.json @@ -29,12 +29,6 @@ } }, "content_scripts": [ - { - "matches": [""], - "js": ["inject/inject.js"], - "run_at": "document_start", - "world": "MAIN" - }, { "matches": [""], "js": ["content/content-script.js"], diff --git a/apps/extension/src/shared/messages.ts b/apps/extension/src/shared/messages.ts index 84e2703d2..2c5fcf168 100644 --- a/apps/extension/src/shared/messages.ts +++ b/apps/extension/src/shared/messages.ts @@ -29,6 +29,20 @@ export interface FetchRequest { * sole authority. (Per design §4.3 "Forge the access-control anchor".) */ readonly stampedOrigin?: string; + /** + * `true` ⇒ this request came from the BEST-EFFORT global-`fetch` transparency patch + * (inject.ts §5.1), NOT an explicit `window.solid.fetch(...)` API call. The inject is + * deliberately DUMB — it routes EVERY cross-origin request and lets the SW (the sole + * boundary) decide. For an auto-diverted request the SW must NEVER hard-fail the page's + * normal web traffic: when the gate does not ALLOW (a non-pod target, or an un-granted + * requesting origin) the SW performs a plain NATIVE UNAUTHENTICATED passthrough (no token, + * no replica) instead of a 403 — so an app's calls to third-party APIs/CDNs are not broken. + * Credentials + the replica are applied ONLY on an explicit gate ALLOW. (High #1.) + * + * An EXPLICIT `window.solid.fetch(...)` (this flag absent/false) keeps the 403 deny — the + * caller deliberately asked for the gated fetch, so a deny is the correct, expected result. + */ + readonly autoDivert?: boolean; } export interface LoginRequest { diff --git a/apps/extension/test/inject.test.ts b/apps/extension/test/inject.test.ts new file mode 100644 index 000000000..40d90ca2a --- /dev/null +++ b/apps/extension/test/inject.test.ts @@ -0,0 +1,164 @@ +// AUTHORED-BY Claude Opus 4.8 +// +// The MAIN-world inject (window.solid + the global-fetch transparency patch). These cover the +// two roborev findings against inject.ts: +// - HIGH #1: the global-fetch patch routes a cross-origin request but marks it `autoDivert` +// so the SW can native-passthrough a non-pod/un-granted request (the page-compat invariant +// is enforced SW-side; here we prove the inject sends the flag + leaves same-origin native). +// - MEDIUM #2: a DOUBLE injection (manifest + dynamic registration) is INERT — the second run +// is a no-op, never a throw on the `configurable:false` defineProperty. +// +// Each test gets a FRESH JSDOM window (the inject defines `window.solid` non-configurable, so a +// shared window could not be reset between tests). We rebind the relevant globals to that +// window, then re-import the module (which installs at import) under a reset module registry. + +import { JSDOM } from 'jsdom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +let dom: JSDOM; +let posted: Array>; +let nativeFetch: ReturnType; +const savedGlobals: Record = {}; + +function setGlobal(name: string, value: unknown): void { + if (!(name in savedGlobals)) { + savedGlobals[name] = Object.getOwnPropertyDescriptor(globalThis, name); + } + Object.defineProperty(globalThis, name, { value, configurable: true, writable: true }); +} + +function freshWindow(): void { + dom = new JSDOM('', { + url: 'https://app.example/page.html', + }); + const win = dom.window as unknown as Window & typeof globalThis; + posted = []; + // Capture page→content messages. + win.postMessage = ((msg: unknown) => { + posted.push(msg as Record); + }) as typeof win.postMessage; + // jsdom does NOT implement fetch/Response; Node's globals do. Keep Node's Response/Headers/URL + // (the inject builds Responses with them) and only swap in the jsdom window + a stub fetch. + nativeFetch = vi.fn(async () => new Response('native', { status: 200 })); + win.fetch = nativeFetch as unknown as typeof fetch; + let n = 0; + const cryptoStub = { randomUUID: () => `uuid-${n++}` }; + // jsdom's window.crypto is a getter-only accessor — redefine it. + Object.defineProperty(win, 'crypto', { configurable: true, value: cryptoStub }); + // The module references the bare globals `window` + `crypto`; `Response`/`Headers`/`URL` stay + // Node's (jsdom's are non-functional here), which the inject's `new URL(...)` etc. need. + setGlobal('window', win); + setGlobal('crypto', cryptoStub); +} + +/** Fresh module each test (the inject installs at import). */ +async function importInject(): Promise { + vi.resetModules(); + await import('../src/inject/inject'); +} + +beforeEach(() => { + freshWindow(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + for (const [name, desc] of Object.entries(savedGlobals)) { + if (desc) Object.defineProperty(globalThis, name, desc); + else delete (globalThis as Record)[name]; + delete savedGlobals[name]; + } + dom.window.close(); +}); + +describe('inject — installs window.solid + patches fetch', () => { + it('defines window.solid and patches window.fetch on first run', async () => { + expect('solid' in dom.window).toBe(false); + await importInject(); + expect('solid' in dom.window).toBe(true); + expect(typeof (dom.window as { solid?: { fetch?: unknown } }).solid?.fetch).toBe('function'); + // It asked the worker for state on load. + expect(posted.some((m) => m.type === 'SOLID_GET_STATE')).toBe(true); + // The global fetch is no longer the pristine sentinel. + expect(dom.window.fetch).not.toBe(nativeFetch); + }); +}); + +describe('inject — HIGH #1: the global-fetch patch marks routed requests autoDivert', () => { + it('a CROSS-ORIGIN fetch is routed to the SW with autoDivert=true (so the SW can native-pass)', async () => { + await importInject(); + posted.length = 0; + // Fire a cross-origin fetch (a third-party API the app calls). It is routed (not awaited). + void dom.window.fetch('https://api.thirdparty.example/data'); + const routed = posted.find((m) => m.type === 'SOLID_FETCH_REQUEST'); + expect(routed).toBeDefined(); + expect(routed?.url).toBe('https://api.thirdparty.example/data'); + // The flag that lets the SW native-passthrough instead of hard-failing the page. + expect(routed?.autoDivert).toBe(true); + // It did NOT go straight to the native fetch (it was diverted to the bridge). + expect(nativeFetch).not.toHaveBeenCalled(); + }); + + it('a SAME-ORIGIN fetch stays on the untouched NATIVE fetch (no proxy tax, never routed)', async () => { + await importInject(); + posted.length = 0; + await dom.window.fetch('https://app.example/assets/app.js'); + // No routing message — same-origin asset went straight to native. + expect(posted.some((m) => m.type === 'SOLID_FETCH_REQUEST')).toBe(false); + expect(nativeFetch).toHaveBeenCalledTimes(1); + }); + + it('a RELATIVE fetch stays native (resolves to same-origin)', async () => { + await importInject(); + posted.length = 0; + await dom.window.fetch('/api/local'); + expect(posted.some((m) => m.type === 'SOLID_FETCH_REQUEST')).toBe(false); + expect(nativeFetch).toHaveBeenCalledTimes(1); + }); + + it('a non-http(s) scheme (data:) stays native (never routed)', async () => { + await importInject(); + posted.length = 0; + await dom.window.fetch('data:text/plain,hello'); + expect(posted.some((m) => m.type === 'SOLID_FETCH_REQUEST')).toBe(false); + expect(nativeFetch).toHaveBeenCalledTimes(1); + }); + + it('an EXPLICIT window.solid.fetch carries autoDivert=false (keeps the SW 403, not passthrough)', async () => { + await importInject(); + posted.length = 0; + const solid = (dom.window as unknown as { solid: { fetch: (u: string) => Promise } }) + .solid; + void solid.fetch('https://alice.pod.example/private/notes.ttl'); + const routed = posted.find((m) => m.type === 'SOLID_FETCH_REQUEST'); + expect(routed).toBeDefined(); + // The explicit API path is NOT auto-diverted — a deny here is the caller's expected 403. + expect(routed?.autoDivert).toBe(false); + }); +}); + +describe('inject — MEDIUM #2: a double injection is INERT (no throw on the second run)', () => { + it('importing/running the inject TWICE does not throw and installs window.solid once', async () => { + await importInject(); + expect('solid' in dom.window).toBe(true); + const firstSolid = (dom.window as unknown as { solid: unknown }).solid; + + // A second run (the other injection mechanism / a reload) must be a silent no-op — NOT a + // throw on Object.defineProperty(window,'solid',{configurable:false}). + await expect(importInject()).resolves.toBeUndefined(); + // window.solid is unchanged (the configurable:false property was never re-defined). + expect((dom.window as unknown as { solid: unknown }).solid).toBe(firstSolid); + }); + + it('the guard short-circuits when window.solid already exists (different prior code path)', async () => { + // Simulate a prior install from another path (window.solid present, marker absent). + Object.defineProperty(dom.window, 'solid', { + value: Object.freeze({ sentinel: true }), + configurable: false, + }); + delete (dom.window as { __solidInjected?: boolean }).__solidInjected; + // Must not throw, and must NOT clobber the existing (frozen, non-configurable) property. + await expect(importInject()).resolves.toBeUndefined(); + expect((dom.window as unknown as { solid: { sentinel?: boolean } }).solid.sentinel).toBe(true); + }); +}); diff --git a/apps/extension/test/jsdom.d.ts b/apps/extension/test/jsdom.d.ts new file mode 100644 index 000000000..fe2e87f59 --- /dev/null +++ b/apps/extension/test/jsdom.d.ts @@ -0,0 +1,15 @@ +// AUTHORED-BY Claude Opus 4.8 +// +// Minimal ambient declaration for the `jsdom` package (already a devDependency) so the inject +// test can `import { JSDOM }` without `@types/jsdom` — adding `@types/jsdom` would be a new +// dependency (package-policy gate). We declare ONLY the surface the test uses. + +declare module 'jsdom' { + export interface ConstructorOptions { + url?: string; + } + export class JSDOM { + constructor(html?: string, options?: ConstructorOptions); + readonly window: Window & typeof globalThis; + } +} diff --git a/apps/extension/test/replica.test.ts b/apps/extension/test/replica.test.ts index 20c96cc2a..aa0f6c3e6 100644 --- a/apps/extension/test/replica.test.ts +++ b/apps/extension/test/replica.test.ts @@ -40,6 +40,9 @@ class FakeCache implements ReplicaByteCache { async delete(request: Request): Promise { return this.bytes.delete(request.url); } + async keys(): Promise { + return [...this.bytes.keys()].map((url) => new Request(url)); + } } /** A trivial in-memory metadata-store double. */ @@ -443,20 +446,187 @@ describe('purgeReplica — synchronous-before-serve logout purge', () => { expect(cache.bytes.size).toBe(1); }); - it('a null prior WebID purges EVERYTHING (fail-closed: no row survives)', async () => { + it('a null prior WebID purges EVERYTHING incl. CacheStorage BYTES (Medium #4: bytes, not just metadata)', async () => { const cache = new FakeCache(); const db = new FakeMetaStore(); + // Seed TWO identities so a null purge must wipe ALL bytes (not just one WebID's rows). + for (const [webId, body] of [ + [WEBID_A, 'a-bytes'], + [WEBID_B, 'b-bytes'], + ] as const) { + const egress = scriptedEgress([ + () => new Response(body, { status: 200, headers: { etag: 'W/"x"' } }), + ]); + await new SharedReplica({ gate: allow(SCOPE_PM), egress, cache, db, webId }).read( + URL, + 'GET', + { Accept: 'text/turtle' }, + undefined, + ); + } + expect(cache.bytes.size).toBe(2); // bytes present before the purge + expect(db.rows.size).toBe(2); + + const purged = await purgeReplica(cache, db, null); + + // After a null purge: NO metadata rows AND NO CacheStorage byte entries remain. + expect(db.rows.size).toBe(0); + expect(cache.bytes.size).toBe(0); // the privacy fix: bytes are GONE, not orphaned + expect(purged.bytesDropped).toBe(2); + expect(db.noncesCleared).toBe(1); + }); + + it('a null purge with NO enumerator still clears metadata + nonces (defensive default)', async () => { + // The default enumerator is cache.keys(); pass a cache whose keys() throws to prove the + // metadata + nonce clear still runs (best-effort byte deletion, never load-bearing). + const db = new FakeMetaStore(); + const throwingCache: ReplicaByteCache = { + match: async () => undefined, + put: async () => {}, + delete: async () => false, + keys: async () => { + throw new Error('cache keys unavailable'); + }, + }; + const purged = await purgeReplica(throwingCache, db, null); + expect(purged.bytesDropped).toBe(0); + expect(db.noncesCleared).toBe(1); + }); +}); + +describe('SharedReplica.read — Medium #3: a non-cacheable 2xx is still returned to the caller', () => { + it('a 2xx whose cache.put REJECTS (e.g. 206 / Vary:*) is returned LIVE, just un-cached', async () => { + // A cache that rejects put() exactly as the real Cache API does for a 206 Partial Content + // or a `Vary: *` response. The successful read must NOT fail — the live body is returned. + const db = new FakeMetaStore(); + let putAttempts = 0; + const rejectingPutCache: ReplicaByteCache = { + match: async () => undefined, + put: async () => { + putAttempts++; + throw new TypeError('Vary header contains * which is not allowed in cache.put'); + }, + delete: async () => false, + keys: async () => [], + }; const egress = scriptedEgress([ - () => new Response('x', { status: 200, headers: { etag: 'W/"x"' } }), + () => + new Response('partial-bytes', { + status: 206, + headers: { 'content-range': 'bytes 0-9/100', etag: 'W/"v1"' }, + }), ]); - await new SharedReplica({ gate: allow(SCOPE_PM), egress, cache, db, webId: WEBID_A }).read( - URL, - 'GET', - { Accept: 'text/turtle' }, - undefined, - ); - await purgeReplica(cache, db, null); + const result = await new SharedReplica({ + gate: allow(SCOPE_PM), + egress, + cache: rejectingPutCache, + db, + webId: WEBID_A, + }).read(URL, 'GET', { Accept: 'text/turtle' }, undefined); + if ('deny' in result) throw new Error('unexpected deny'); + // The request SUCCEEDS with the live body even though the persist failed. + expect(result.response.status).toBe(206); + expect(await result.response.text()).toBe('partial-bytes'); + expect(putAttempts).toBe(1); // we DID attempt to cache (best-effort), and tolerated the throw + // Nothing persisted: no metadata row was written for the un-cacheable response. expect(db.rows.size).toBe(0); - expect(db.noncesCleared).toBe(1); + }); + + it('a 2xx whose METADATA write rejects is still returned LIVE (best-effort persist)', async () => { + const cache = new FakeCache(); + const db = new FakeMetaStore(); + // Make the metadata write throw; the live body must still come back. + db.putMetadata = async () => { + throw new Error('idb write failed'); + }; + const egress = scriptedEgress([ + () => new Response('the-bytes', { status: 200, headers: { etag: 'W/"v1"' } }), + ]); + const result = await new SharedReplica({ + gate: allow(SCOPE_PM), + egress, + cache, + db, + webId: WEBID_A, + }).read(URL, 'GET', { Accept: 'text/turtle' }, undefined); + if ('deny' in result) throw new Error('unexpected deny'); + expect(result.response.status).toBe(200); + expect(await result.response.text()).toBe('the-bytes'); + // The orphaned byte entry (no companion metadata) was best-effort dropped. + expect(cache.bytes.size).toBe(0); + }); +}); + +describe('SharedReplica.read — Low #5: a HEAD response carries NO body', () => { + let cache: FakeCache; + let db: FakeMetaStore; + beforeEach(() => { + cache = new FakeCache(); + db = new FakeMetaStore(); + }); + + it('a fresh HEAD egress response is body-stripped (headers/status only)', async () => { + // Even if the pod stray-returns a body on HEAD, the replica strips it. + const egress = scriptedEgress([ + () => + new Response('SHOULD-NOT-BE-RETURNED', { + status: 200, + headers: { etag: 'W/"v1"', 'content-type': 'text/turtle' }, + }), + ]); + const result = await new SharedReplica({ + gate: allow(SCOPE_PM), + egress, + cache, + db, + webId: WEBID_A, + }).read(URL, 'HEAD', { Accept: 'text/turtle' }, undefined); + if ('deny' in result) throw new Error('unexpected deny'); + expect(result.response.status).toBe(200); + expect(await result.response.text()).toBe(''); // NO body + // The HEAD did not cache a body (only GET stores), so the cross-method cache stays empty. + expect(cache.bytes.size).toBe(0); + }); + + it('a 304-revalidated HEAD does NOT serve the cached GET body', async () => { + // First a GET warms the cache with a body; then a HEAD revalidates to a 304. The HEAD must + // serve headers/status only — NEVER the cached GET body (the Low #5 bug). + const getEgress = scriptedEgress([ + () => new Response('CACHED-GET-BODY', { status: 200, headers: { etag: 'W/"v1"' } }), + ]); + await new SharedReplica({ + gate: allow(SCOPE_PM), + egress: getEgress, + cache, + db, + webId: WEBID_A, + }).read(URL, 'GET', { Accept: 'text/turtle' }, undefined); + expect(cache.bytes.size).toBe(1); + + const headEgress = scriptedEgress([() => new Response(null, { status: 304 })]); + const result = await new SharedReplica({ + gate: allow(SCOPE_PM), + egress: headEgress, + cache, + db, + webId: WEBID_A, + }).read(URL, 'HEAD', { Accept: 'text/turtle' }, undefined); + if ('deny' in result) throw new Error('unexpected deny'); + expect(result.fromCache).toBe(true); + expect(await result.response.text()).toBe(''); // body stripped — NOT 'CACHED-GET-BODY' + }); + + it('a HEAD on a non-2xx/non-304 live response is also body-stripped', async () => { + const egress = scriptedEgress([() => new Response('error-detail-body', { status: 404 })]); + const result = await new SharedReplica({ + gate: allow(SCOPE_PM), + egress, + cache, + db, + webId: WEBID_A, + }).read(URL, 'HEAD', { Accept: 'text/turtle' }, undefined); + if ('deny' in result) throw new Error('unexpected deny'); + expect(result.response.status).toBe(404); + expect(await result.response.text()).toBe(''); }); }); diff --git a/apps/extension/test/service-worker-gate.test.ts b/apps/extension/test/service-worker-gate.test.ts index 5401df050..819a1f9f7 100644 --- a/apps/extension/test/service-worker-gate.test.ts +++ b/apps/extension/test/service-worker-gate.test.ts @@ -81,6 +81,7 @@ function installCaches(): void { }); }, delete: async (request: Request) => cacheStore.delete(request.url), + keys: async () => [...cacheStore.keys()].map((url) => new Request(url)), }), }; } @@ -606,3 +607,177 @@ describe('SOLID_GET_STATE scopes the WebID (PII) to granted origins', () => { expect(res.webId).toBeNull(); }); }); + +describe('HIGH #1: autoDivert (global-fetch patch) NEVER hard-fails non-pod / un-granted traffic', () => { + it('an autoDivert fetch to a NON-POD third party PASSES THROUGH as a plain native fetch (NOT a 403)', async () => { + // The page-compat regression: an app calls a third-party API/CDN. The global-fetch patch + // routes it (autoDivert=true). The SW gate denies (foreign target), but for an autoDivert + // request that must become a PLAIN UNAUTHENTICATED passthrough, not a 403 that breaks the app. + await seedSession(); + storage.set('solid:granted-origins', ['https://pm.example']); + fetchImpl = async () => new Response('third-party-json', { status: 200 }); + await loadWorker(); + + const res = await sendFetch( + { + url: 'https://api.thirdparty.example/data', + stampedOrigin: 'https://pm.example', + autoDivert: true, + }, + { origin: 'https://pm.example', url: 'https://pm.example/app.html' }, + ); + + // The app's normal request SUCCEEDS (the extension is invisible to it). + expect(res.status).toBe(200); + expect(res.body).toBe('third-party-json'); + expect(res.error).toBeUndefined(); + // The third party WAS reached — as a PLAIN fetch with NO credential attached. + const call = fetchCalls.find((c) => c.url.startsWith('https://api.thirdparty.example')); + expect(call).toBeDefined(); + const headers = (call?.init?.headers as Record) ?? {}; + expect(headers.Authorization).toBeUndefined(); + expect(headers.DPoP).toBeUndefined(); + }); + + it('the SAME non-pod request WITHOUT autoDivert (explicit window.solid.fetch) DOES return 403', async () => { + // Contrast: an explicit window.solid.fetch deliberately asked for the gated path, so a + // foreign-target deny is the correct, expected 403 — only autoDivert softens to passthrough. + await seedSession(); + storage.set('solid:granted-origins', ['https://pm.example']); + await loadWorker(); + + const res = await sendFetch( + { url: 'https://api.thirdparty.example/data', stampedOrigin: 'https://pm.example' }, // no autoDivert + { origin: 'https://pm.example', url: 'https://pm.example/app.html' }, + ); + + expect(res.status).toBe(403); + expect(res.error).toBe('Cross-origin fetch denied'); + expect(fetchCalls.some((c) => c.url.startsWith('https://api.thirdparty.example'))).toBe(false); + }); + + it('an autoDivert POD read from an UN-GRANTED origin passes through UNAUTHENTICATED (no creds, no 403)', async () => { + // A non-granted app's global fetch hits the pod. Per the invariant it must not hard-fail; it + // gets a plain unauthenticated fetch (the pod returns its own 401/WAC), never the credential. + await seedSession(); + storage.set('solid:granted-origins', ['https://pm.example']); // ungranted.example is NOT granted + fetchImpl = async () => new Response('pod-public-or-401', { status: 401 }); + await loadWorker(); + + const res = await sendFetch( + { + url: 'https://alice.pod.example/private/notes.ttl', + stampedOrigin: 'https://ungranted.example', + autoDivert: true, + }, + { origin: 'https://ungranted.example', url: 'https://ungranted.example/x' }, + ); + + // Not a gate-403 — the pod's own response is relayed (a 401 here), proving native passthrough. + expect(res.status).toBe(401); + // The egress to the pod carried NO credential (unauthenticated passthrough). + const podCall = fetchCalls.find((c) => c.url.startsWith('https://alice.pod.example')); + expect(podCall).toBeDefined(); + const headers = (podCall?.init?.headers as Record) ?? {}; + expect(headers.Authorization).toBeUndefined(); + expect(headers.DPoP).toBeUndefined(); + }); + + it('a GRANTED pod read still gets the AUTHENTICATED replica path even with autoDivert set', async () => { + // autoDivert must NOT downgrade a legitimate granted pod read — that is still gated+authed. + await seedSession(); + storage.set('solid:granted-origins', ['https://pm.example']); + fetchImpl = async () => + new Response('private-pod-bytes', { status: 200, headers: { etag: 'W/"v1"' } }); + await loadWorker(); + + const res = await sendFetch( + { + url: 'https://alice.pod.example/private/notes.ttl', + stampedOrigin: 'https://pm.example', + autoDivert: true, + }, + { origin: 'https://pm.example', url: 'https://pm.example/app.html' }, + ); + + expect(res.status).toBe(200); + expect(res.body).toBe('private-pod-bytes'); + const podCall = fetchCalls.find((c) => c.url.startsWith('https://alice.pod.example')); + const headers = (podCall?.init?.headers as Record) ?? {}; + // The DPoP-bound credential WAS attached (the authenticated path, not passthrough). + expect(headers.Authorization).toBe('DPoP access-token-xyz'); + expect(headers.DPoP).toBeDefined(); + }); + + it('a native passthrough STRIPS any page-supplied Authorization/DPoP/Cookie header (defence-in-depth)', async () => { + await seedSession(); + storage.set('solid:granted-origins', ['https://pm.example']); + fetchImpl = async () => new Response('ok', { status: 200 }); + await loadWorker(); + + const res = await sendFetch( + { + url: 'https://api.thirdparty.example/data', + stampedOrigin: 'https://pm.example', + autoDivert: true, + headers: { + Authorization: 'Bearer page-smuggled', + DPoP: 'page-proof', + Cookie: 'sid=1', + 'X-App': 'keep', + }, + }, + { origin: 'https://pm.example', url: 'https://pm.example/app.html' }, + ); + + expect(res.status).toBe(200); + const call = fetchCalls.find((c) => c.url.startsWith('https://api.thirdparty.example')); + const headers = (call?.init?.headers as Record) ?? {}; + // The page cannot smuggle its own auth/cookie through the extension's privileged fetch. + expect(headers.Authorization).toBeUndefined(); + expect(headers.DPoP).toBeUndefined(); + expect(headers.Cookie).toBeUndefined(); + // A benign app header survives. + expect(headers['X-App']).toBe('keep'); + }); + + it('an autoDivert request to an OPAQUE/forged origin still passes through (never hard-fails the page)', async () => { + // Even a dual-origin mismatch (which would DENY the gated path) becomes a plain passthrough + // under autoDivert — the invariant is that normal web traffic is never broken. No credential + // is ever attached on this path, so passthrough cannot widen access. + await seedSession(); + storage.set('solid:granted-origins', ['https://pm.example']); + fetchImpl = async () => new Response('cdn-asset', { status: 200 }); + await loadWorker(); + + const res = await sendFetch( + { url: 'https://cdn.example/lib.js', stampedOrigin: 'https://pm.example', autoDivert: true }, + { origin: 'https://attacker.example', url: 'https://attacker.example/x' }, // sender ≠ stamp + ); + + expect(res.status).toBe(200); + expect(res.body).toBe('cdn-asset'); + const call = fetchCalls.find((c) => c.url.startsWith('https://cdn.example')); + const headers = (call?.init?.headers as Record) ?? {}; + expect(headers.Authorization).toBeUndefined(); + }); + + it('an autoDivert fetch with NO session passes through natively (the explicit path says Not authenticated)', async () => { + // No session seeded. The explicit window.solid.fetch returns "Not authenticated"; an + // autoDivert request just does the plain fetch the page would have done (no creds exist). + fetchImpl = async () => new Response('public-resource', { status: 200 }); + await loadWorker(); + + const res = await sendFetch( + { + url: 'https://api.thirdparty.example/data', + stampedOrigin: 'https://pm.example', + autoDivert: true, + }, + { origin: 'https://pm.example', url: 'https://pm.example/app.html' }, + ); + expect(res.status).toBe(200); + expect(res.body).toBe('public-resource'); + expect(res.error).toBeUndefined(); + }); +}); diff --git a/apps/extension/test/service-worker-replica.test.ts b/apps/extension/test/service-worker-replica.test.ts index 43c1d0f0f..bcb639c55 100644 --- a/apps/extension/test/service-worker-replica.test.ts +++ b/apps/extension/test/service-worker-replica.test.ts @@ -83,6 +83,7 @@ function installCaches(): void { }); }, delete: async (request: Request) => cacheStore.delete(request.url), + keys: async () => [...cacheStore.keys()].map((url) => new Request(url)), }), }; } @@ -440,4 +441,54 @@ describe('Phase-1 shared replica (wired into the service worker)', () => { expect([...cacheStore.values()].some((e) => e.body === 'ALICE-BYTES')).toBe(true); expect([...cacheStore.values()].some((e) => e.body === 'BOB-BYTES')).toBe(true); }); + + it('LOW #5 (wired): a HEAD request relays NO body to the page', async () => { + await seedSession(); + storage.set('solid:granted-origins', ['https://pm.example']); + // The pod stray-returns a body on HEAD; the SW must relay none. + fetchImpl = async () => + new Response('HEAD-SHOULD-HAVE-NO-BODY', { status: 200, headers: { etag: 'W/"v1"' } }); + await loadWorker(); + + const res = await sendFetch( + { + url: 'https://alice.pod.example/x.ttl', + method: 'HEAD', + stampedOrigin: 'https://pm.example', + }, + { origin: 'https://pm.example', url: 'https://pm.example/app' }, + ); + expect(res.status).toBe(200); + expect(res.body).toBeUndefined(); // a HEAD response carries no body + }); + + it('MEDIUM #4 (wired): logout with an UNKNOWN prior WebID purges the CacheStorage BYTES too', async () => { + await seedSession(); + storage.set('solid:granted-origins', ['https://pm.example']); + fetchImpl = async () => new Response('USER-DATA', { status: 200, headers: { etag: 'W/"v1"' } }); + await loadWorker(); + + await sendFetch( + { url: 'https://alice.pod.example/private/data.ttl', stampedOrigin: 'https://pm.example' }, + { origin: 'https://pm.example', url: 'https://pm.example/app' }, + ); + expect(cacheStore.size).toBeGreaterThanOrEqual(1); + expect(idbMeta.size).toBeGreaterThanOrEqual(1); + + // Force the UNKNOWN-prior-WebID branch: drop the session from storage, then RESTART the SW + // (fresh module → in-memory cachedSession is null). On the cold worker, teardown's + // loadSession() returns null ⇒ priorWebId null ⇒ the null-WebID purge runs. That branch must + // still enumerate + delete the CacheStorage bytes (the Medium #4 leak), not just metadata. + storage.delete('solid:session'); + await loadWorker(); + const out = await sendMessage( + { type: 'SOLID_LOGOUT' }, + { origin: `chrome-extension://${EXTENSION_ID}`, url: '' }, + ); + expect(out.ok).toBe(true); + // The privacy fix: bytes AND metadata AND nonces are all gone after the null-WebID purge. + expect(cacheStore.size).toBe(0); + expect(idbMeta.size).toBe(0); + expect(idbNonces.size).toBe(0); + }); }); From 373c80e4a0898ded2b8e49c742cfee3103f584df Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Sun, 21 Jun 2026 18:35:36 +0100 Subject: [PATCH 31/50] =?UTF-8?q?fix(replica):=20round-2=20High+Medium=20?= =?UTF-8?q?=E2=80=94=20SW=20returns=20passthrough=20sentinel,=20page=20doe?= =?UTF-8?q?s=20native=20fetch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit roborev (codex) flagged the round-2 "SW native-passthrough" fix as a cross-origin exfiltration hole: the SW (which holds host perms) performed the fallback fetch for non-allowed autoDivert requests, turning the extension into an open cross-origin READ proxy — any page could postMessage an autoDivert request and read back cross-origin bytes CORS would normally block. The Medium: header-stripping + credentials:'omit' also changed normal fetch semantics for legit third-party calls. The fix resolves BOTH: the SW NEVER performs a fallback fetch. For an autoDivert request the gate does NOT allow (gate-deny, forbidden/opaque early reject, AND no-session), the SW returns a PASSTHROUGH SENTINEL ({ requestId, passthrough: true } — no body, no status, no fetched bytes, ZERO network). The page-side inject completes the request with its OWN captured PRISTINE_FETCH using the ORIGINAL input/init, in the page's origin context — so CORS / credentials / headers are exactly native. The authenticated/replica path is unchanged (runs only on an explicit gate ALLOW); an explicit window.solid.fetch still gets its 403 / Not-authenticated (never a sentinel). - src/background/service-worker.ts: DELETE nativePassthrough + PASSTHROUGH_STRIP_HEADERS; add passthroughSentinel(); all three non-allowed autoDivert branches return it (no fetch). - src/shared/messages.ts: add FetchResponse.passthrough?: boolean (+ doc). - src/content/content-script.ts: relay the passthrough flag verbatim (already spread). - src/inject/inject.ts: pendingFetches retains original input/init; capture PRISTINE_FETCH at the top of installSolidInjection; on passthrough:true resolve via PRISTINE_FETCH(orig). - tests: SW gate now asserts sentinel + NO SW egress on non-allowed autoDivert; new cross-origin-proxy security test; inject test for the PRISTINE_FETCH passthrough path. Keeps all Phase-0/1 invariants (gate-first incl. cache hit, WebID-in-key, DPoP egress untouched, single-user, byte-purge enumerator, HEAD bodyless, best-effort cache). Model: claude-opus-4-8 Provenance: Opus 4.8 (Fable unavailable) — re-review/upgrade candidate Co-Authored-By: Claude Opus 4.8 --- .../src/background/service-worker.ts | 104 +++++-------- apps/extension/src/content/content-script.ts | 4 + apps/extension/src/inject/inject.ts | 49 +++++- apps/extension/src/shared/messages.ts | 21 ++- apps/extension/test/inject.test.ts | 98 ++++++++++++ .../test/service-worker-gate.test.ts | 141 +++++++++--------- 6 files changed, 269 insertions(+), 148 deletions(-) diff --git a/apps/extension/src/background/service-worker.ts b/apps/extension/src/background/service-worker.ts index c0478dc2f..3cb7b12d5 100644 --- a/apps/extension/src/background/service-worker.ts +++ b/apps/extension/src/background/service-worker.ts @@ -249,11 +249,12 @@ async function handleFetch( // effect (refresh-token amplification). The browser-attested sender is the authority. const requestingOrigin = resolveRequestingOrigin(sender, message.stampedOrigin); if (requestingOrigin === null) { - // HIGH #1: a forged/opaque origin on the EXPLICIT path is a 403. But an `autoDivert` - // request (the global-fetch patch) must never hard-fail the page — and crucially this - // early-reject path is reached BEFORE any session work, so no credential is in scope. A - // native unauthenticated passthrough is therefore safe (no token) and preserves page compat. - if (message.autoDivert) return await nativePassthrough(message); + // Round-2 HIGH #1: a forged/opaque origin on the EXPLICIT path is a 403. An `autoDivert` + // request (the global-fetch patch) must never hard-fail the page — but the SW must NOT do + // the fallback fetch itself (that would make the extension a cross-origin read proxy). It + // returns a PASSTHROUGH SENTINEL (no network) and the page-side inject does the native + // fetch in the page's own origin context. + if (message.autoDivert) return passthroughSentinel(message); return { requestId: message.requestId, status: 403, @@ -263,9 +264,10 @@ async function handleFetch( const ctx = await ensureSession(); if (!ctx) { - // No session: the explicit path reports "Not authenticated"; an autoDivert request just does - // the plain fetch the page would have done itself (no credential exists to attach anyway). - if (message.autoDivert) return await nativePassthrough(message); + // No session: the explicit path reports "Not authenticated"; an autoDivert request gets the + // passthrough sentinel so the page does the plain native fetch it would have done itself (no + // credential exists to attach anyway, and the SW performs no network). + if (message.autoDivert) return passthroughSentinel(message); return { requestId: message.requestId, error: 'Not authenticated' }; } @@ -329,16 +331,21 @@ async function handleFetch( // The gate denied (incl. on a would-be cache hit). The replica touched NEITHER the pod // NOR the cache before denying, so nothing leaked. // - // HIGH #1 — page-compat invariant: a request that came from the BEST-EFFORT global-fetch - // transparency patch (`autoDivert`) must NEVER be hard-failed. The patch routes EVERY - // cross-origin request, so a DENY here is the common case for an app's NORMAL traffic to - // a third-party API/CDN (a non-pod target) or from an un-granted origin. For those we do - // a plain NATIVE UNAUTHENTICATED passthrough — NO token, NO replica, NO 403 — so the page - // behaves exactly as if the extension were not installed. This can never widen access (a - // native fetch carries no credential). An EXPLICIT `window.solid.fetch` (autoDivert false) - // keeps the 403: the caller deliberately asked for the gated path. + // Round-2 HIGH #1 — page-compat invariant: a request that came from the BEST-EFFORT + // global-fetch transparency patch (`autoDivert`) must NEVER be hard-failed. The patch + // routes EVERY cross-origin request, so a DENY here is the common case for an app's NORMAL + // traffic to a third-party API/CDN (a non-pod target) or from an un-granted origin. For + // those the SW returns a PASSTHROUGH SENTINEL — NO token, NO replica, NO 403, and crucially + // NO NETWORK FROM THE SW. The page-side inject then completes the request with its OWN + // native `fetch` (PRISTINE_FETCH) in the page's origin context, so the page behaves exactly + // as if the extension were not installed (CORS, credentials, headers all preserved). This + // closes the round-2 hole where the SW (with host perms) doing the fallback + // fetch turned the extension into an open cross-origin read proxy. The SW does ZERO egress + // on this path, so it can never read cross-origin bytes a page's own CORS would block. An + // EXPLICIT `window.solid.fetch` (autoDivert false) keeps the 403: the caller deliberately + // asked for the gated path. if (message.autoDivert) { - return await nativePassthrough(message); + return passthroughSentinel(message); } return { requestId: message.requestId, @@ -375,57 +382,24 @@ function denyResponse(error: string): Response { return new Response(error, { status: 403 }); } -/** Caller-supplied auth/transport headers a page must never inject onto a passthrough fetch. */ -const PASSTHROUGH_STRIP_HEADERS = new Set([ - 'authorization', - 'dpop', - 'host', - 'content-length', - 'connection', - 'cookie', -]); - /** - * A plain, UNAUTHENTICATED native passthrough for an `autoDivert` request the gate did not - * ALLOW (High #1). No access token, no DPoP proof, no replica — exactly the fetch the page - * would have made itself had the global-fetch patch not been installed, so an app's normal - * traffic to a third-party API/CDN (or a pod read from an un-granted origin) is never broken. + * Round-2 HIGH #1 + Medium fix: the PASSTHROUGH SENTINEL the SW returns for an `autoDivert` + * request the gate did NOT ALLOW. The SW performs ZERO network here — no token, no DPoP proof, + * no replica, and crucially NO fetch at all. It returns only `{ requestId, passthrough: true }` + * (no status, no headers, no fetched bytes). The page-side inject (MAIN world) completes the + * request by calling its OWN native `fetch` (the captured PRISTINE_FETCH) with the request's + * ORIGINAL input/init, in the page's origin context. * - * SECURITY: this carries ZERO credential, so it can never widen access. We still STRIP any - * page-supplied `Authorization`/`DPoP`/`Cookie`/transport headers (defence-in-depth: a page - * cannot smuggle its own auth header through the extension's privileged fetch), and we relay - * only the allowlisted response headers, identically to the gated path. + * WHY (the round-2 finding): doing the fallback fetch FROM THE SW — which holds `` + * host permissions — turned the extension into an OPEN CROSS-ORIGIN READ PROXY: any page could + * `postMessage` an `autoDivert` request and read back cross-origin bytes that CORS would + * normally block. Returning a sentinel and letting the PAGE do the fetch restores EXACT native + * semantics (CORS, credentials, headers) and means the SW never reads bytes a page could not + * read itself. It also removes the header-stripping / `credentials:'omit'` divergence (Medium): + * the page's native fetch applies the page's real headers + credentials policy, unchanged. */ -async function nativePassthrough(message: FetchRequest): Promise { - const safeHeaders: Record = {}; - for (const [k, v] of Object.entries(message.headers)) { - if (!PASSTHROUGH_STRIP_HEADERS.has(k.toLowerCase())) safeHeaders[k] = v; - } - try { - const isRead = READ_METHODS.has(message.method.toUpperCase()); - const response = await fetch(message.url, { - method: message.method, - headers: safeHeaders, - body: isRead ? undefined : message.body, - // Never attach the user's ambient cookies to a cross-origin proxied request. - credentials: 'omit', - }); - const headers = filterResponseHeaders(response.headers); - // A HEAD response must carry no body (Low #5); a GET/other relays the text body. - const isHead = message.method.toUpperCase() === 'HEAD'; - return { - requestId: message.requestId, - status: response.status, - statusText: response.statusText, - headers, - body: isHead ? undefined : await response.text(), - }; - } catch (err) { - return { - requestId: message.requestId, - error: err instanceof Error ? err.message : 'Fetch failed', - }; - } +function passthroughSentinel(message: FetchRequest): FetchResponse { + return { requestId: message.requestId, passthrough: true }; } /** Best-effort: fetch + parse the WebID profile (name/photo) and persist it. */ diff --git a/apps/extension/src/content/content-script.ts b/apps/extension/src/content/content-script.ts index fb3c317dd..ec3283bae 100644 --- a/apps/extension/src/content/content-script.ts +++ b/apps/extension/src/content/content-script.ts @@ -57,6 +57,10 @@ window.addEventListener('message', (event) => { stampedOrigin: window.location.origin, }, (response) => { + // Relay the whole worker response verbatim, INCLUDING the `passthrough` sentinel + // flag (round-2 High #1): when the SW declines an autoDivert request without any + // network, `response.passthrough === true` rides through here so the MAIN-world + // inject can complete the request with the page's OWN native fetch (PRISTINE_FETCH). toPage({ type: 'SOLID_FETCH_RESPONSE', ...(response ?? { error: 'No response' }) }); }, ); diff --git a/apps/extension/src/inject/inject.ts b/apps/extension/src/inject/inject.ts index 07122010b..7385338bf 100644 --- a/apps/extension/src/inject/inject.ts +++ b/apps/extension/src/inject/inject.ts @@ -54,6 +54,18 @@ interface SolidExtension { type Pending = { resolve: (v: T) => void; reject: (e: Error) => void }; +/** + * A pending `solidFetch`, plus the ORIGINAL `input`/`init` it was called with. The original + * args are retained so that if the SW returns a PASSTHROUGH SENTINEL (round-2 High #1 — the SW + * declined an autoDivert request and did NO network), the page-side handler can complete the + * request by calling the page's OWN native fetch (PRISTINE_FETCH) with full CORS / credentials + * / header fidelity — the SW never fetches cross-origin bytes on a page's behalf. + */ +type PendingFetch = Pending & { + readonly input: RequestInfo | URL; + readonly init?: RequestInit; +}; + // Install exactly once, even if inject.js runs twice (Medium #2). The marker covers a re-run // after our own install; the `'solid' in window` check covers a prior install from a different // code path. A double-run is then an inert no-op, NOT a throw on the `configurable:false` @@ -64,7 +76,16 @@ if (!window.__solidInjected && !('solid' in window)) { } function installSolidInjection(): void { - const pendingFetches = new Map>(); + // --- Snapshot the PRISTINE fetch FIRST (the @jeswr/solid-elements discipline) ----------- + // + // Captured up-front, before the global patch below, so a later page script that overwrites + // `window.fetch` cannot poison it. This is ALSO the function the SW passthrough path falls + // back to: when the SW returns a passthrough sentinel, the page completes the request with + // its OWN native fetch in the page's origin context (round-2 High #1) — so the extension is + // never a cross-origin proxy and native CORS / credentials / header semantics are preserved. + const PRISTINE_FETCH = window.fetch.bind(window); + + const pendingFetches = new Map(); const pendingActions = new Map>(); let currentWebId: string | null = null; @@ -79,7 +100,18 @@ function installSolidInjection(): void { const pending = pendingFetches.get(data.requestId); if (!pending) return; pendingFetches.delete(data.requestId); - if (data.error) { + if (data.passthrough === true) { + // PASSTHROUGH SENTINEL (round-2 High #1): the SW declined this autoDivert request and + // did NO network. Complete it with the page's OWN native fetch, in the page's origin + // context, using the ORIGINAL input/init — so CORS / credentials / headers behave + // EXACTLY as if the extension were not installed (if CORS blocks, this rejects / + // returns opaque exactly as native). We NEVER fabricate a Response from SW data on + // this path: the SW returned no bytes (it cannot be a cross-origin read proxy). + PRISTINE_FETCH(pending.input as RequestInfo, pending.init).then( + pending.resolve, + pending.reject, + ); + } else if (data.error) { pending.reject(new Error(data.error)); } else { pending.resolve( @@ -122,7 +154,9 @@ function installSolidInjection(): void { ): Promise { return new Promise((resolve, reject) => { const requestId = crypto.randomUUID(); - pendingFetches.set(requestId, { resolve, reject }); + // Retain the ORIGINAL input/init so a passthrough sentinel can be completed with the + // page's own native fetch verbatim (round-2 High #1). + pendingFetches.set(requestId, { resolve, reject, input, init }); let body: string | null = null; if (init?.body != null) { if (typeof init.body !== 'string') { @@ -197,10 +231,11 @@ function installSolidInjection(): void { // NEVER widen access — it only forwards to the same gated `solid.fetch` path. // // Re-assert discipline (the @jeswr/solid-elements `installProactiveAuthFetch` pattern): - // snapshot the PRISTINE fetch FIRST, install once (idempotent), and never re-read a - // possibly-patched global. A later page script that overwrites `window.fetch` only affects - // ITS OWN requests — it gains no credential (the SW gate is the boundary). - const PRISTINE_FETCH = window.fetch.bind(window); + // snapshot the PRISTINE fetch FIRST (done at the TOP of installSolidInjection above, before + // any page script could overwrite `window.fetch`), install once (idempotent), and never + // re-read a possibly-patched global. A later page script that overwrites `window.fetch` only + // affects ITS OWN requests — it gains no credential (the SW gate is the boundary). The same + // PRISTINE_FETCH also services the SW passthrough sentinel (round-2 High #1). /** * A CHEAP page-side pre-filter for which requests are even WORTH routing to the SW — NOT the diff --git a/apps/extension/src/shared/messages.ts b/apps/extension/src/shared/messages.ts index 2c5fcf168..d0fee272f 100644 --- a/apps/extension/src/shared/messages.ts +++ b/apps/extension/src/shared/messages.ts @@ -35,12 +35,15 @@ export interface FetchRequest { * deliberately DUMB — it routes EVERY cross-origin request and lets the SW (the sole * boundary) decide. For an auto-diverted request the SW must NEVER hard-fail the page's * normal web traffic: when the gate does not ALLOW (a non-pod target, or an un-granted - * requesting origin) the SW performs a plain NATIVE UNAUTHENTICATED passthrough (no token, - * no replica) instead of a 403 — so an app's calls to third-party APIs/CDNs are not broken. - * Credentials + the replica are applied ONLY on an explicit gate ALLOW. (High #1.) + * requesting origin) the SW returns a {@link FetchResponse.passthrough} SENTINEL (no body, + * no status, NO network from the SW) and the page-side inject completes it with its OWN + * native `fetch` — so an app's calls to third-party APIs/CDNs behave EXACTLY as native + * (CORS, credentials, headers all preserved) and the SW never acts as a cross-origin proxy. + * Credentials + the replica are applied ONLY on an explicit gate ALLOW. (Round-2 High #1.) * * An EXPLICIT `window.solid.fetch(...)` (this flag absent/false) keeps the 403 deny — the * caller deliberately asked for the gated fetch, so a deny is the correct, expected result. + * It NEVER receives a passthrough sentinel. */ readonly autoDivert?: boolean; } @@ -90,6 +93,18 @@ export interface FetchResponse { readonly headers?: Record; readonly body?: string; readonly error?: string; + /** + * A PASSTHROUGH SENTINEL: `true` ⇒ the SW declined this `autoDivert` request WITHOUT + * performing any network — there is NO `status`/`headers`/`body` here, no fetched bytes. The + * page-side inject (MAIN world) must complete it by calling the page's own PRISTINE native + * `fetch` with the ORIGINAL input/init, in the page's origin context, so CORS / credentials / + * headers behave EXACTLY as if the extension were not installed. The SW is NEVER a + * cross-origin proxy: it does ZERO egress for a non-allowed `autoDivert` request and returns + * only this flag, so it can never read cross-origin bytes a page's own CORS would block. + * Only `autoDivert` requests receive a sentinel; an explicit `window.solid.fetch` gets its + * 403 / "Not authenticated" instead. + */ + readonly passthrough?: boolean; } /** diff --git a/apps/extension/test/inject.test.ts b/apps/extension/test/inject.test.ts index 40d90ca2a..5efe1922a 100644 --- a/apps/extension/test/inject.test.ts +++ b/apps/extension/test/inject.test.ts @@ -137,6 +137,104 @@ describe('inject — HIGH #1: the global-fetch patch marks routed requests autoD }); }); +// --- Round-2 High #1: the page-side passthrough sentinel handler ------------------------ +// +// When the SW returns `{ passthrough: true }` (it declined an autoDivert request and did NO +// network), the inject MUST complete the pending promise by calling the page's OWN native fetch +// (PRISTINE_FETCH) with the ORIGINAL input/init — never fabricate a Response from SW data. + +/** Deliver a `to-page` message into the inject's window 'message' listener. */ +function deliverToPage(message: Record): void { + const win = dom.window as unknown as Window; + const event = new dom.window.MessageEvent('message', { + data: { channel: 'solid-browser-ext', dir: 'to-page', ...message }, + source: win as unknown as MessageEventSource, + }); + win.dispatchEvent(event); +} + +describe('inject — Round-2 High #1: a passthrough sentinel is completed by the page native fetch', () => { + it('resolves a routed fetch by calling PRISTINE_FETCH with the ORIGINAL input + init, NOT a fabricated Response', async () => { + await importInject(); + posted.length = 0; + // Make the native fetch return a distinctive body so we can prove the resolved Response came + // from PRISTINE_FETCH, not from any SW-supplied bytes. + nativeFetch.mockResolvedValueOnce(new Response('NATIVE-IN-PAGE-BYTES', { status: 207 })); + + const init: RequestInit = { method: 'POST', headers: { 'X-App': 'v1' }, body: 'payload' }; + const promise = ( + dom.window as unknown as { fetch: (u: string, i?: RequestInit) => Promise } + ).fetch('https://api.thirdparty.example/data', init); + + // The request was routed to the SW with autoDivert=true; nativeFetch not called YET. + const routed = posted.find((m) => m.type === 'SOLID_FETCH_REQUEST'); + expect(routed).toBeDefined(); + expect(routed?.autoDivert).toBe(true); + expect(nativeFetch).not.toHaveBeenCalled(); + const requestId = routed?.requestId as string; + + // The SW replies with the passthrough SENTINEL (no body/status — it did no network). + deliverToPage({ type: 'SOLID_FETCH_RESPONSE', requestId, passthrough: true }); + + const response = await promise; + // The promise resolved with the PAGE's native fetch result (the SW gave no bytes). + expect(await response.text()).toBe('NATIVE-IN-PAGE-BYTES'); + expect(response.status).toBe(207); + // PRISTINE_FETCH was called with the ORIGINAL input + init (CORS/creds/header fidelity). + expect(nativeFetch).toHaveBeenCalledTimes(1); + const [calledInput, calledInit] = nativeFetch.mock.calls[0]; + expect(calledInput).toBe('https://api.thirdparty.example/data'); + expect(calledInit).toBe(init); + }); + + it('a passthrough sentinel that REJECTS (CORS block) rejects the page promise exactly as native', async () => { + await importInject(); + posted.length = 0; + const corsError = new TypeError('Failed to fetch'); + nativeFetch.mockRejectedValueOnce(corsError); + + const promise = (dom.window as unknown as { fetch: (u: string) => Promise }).fetch( + 'https://blocked.example/data', + ); + const routed = posted.find((m) => m.type === 'SOLID_FETCH_REQUEST'); + const requestId = routed?.requestId as string; + + deliverToPage({ type: 'SOLID_FETCH_RESPONSE', requestId, passthrough: true }); + + await expect(promise).rejects.toBe(corsError); + }); + + it('a NON-passthrough SW response (gated path) still resolves from SW data, never the native fetch', async () => { + await importInject(); + posted.length = 0; + const solid = ( + dom.window as unknown as { + solid: { fetch: (u: string) => Promise }; + } + ).solid; + // Explicit window.solid.fetch → autoDivert=false → never a passthrough sentinel. + const promise = solid.fetch('https://alice.pod.example/private/notes.ttl'); + const routed = posted.find((m) => m.type === 'SOLID_FETCH_REQUEST'); + expect(routed?.autoDivert).toBe(false); + const requestId = routed?.requestId as string; + + // The SW returns real (gated, authenticated) bytes — no passthrough flag. + deliverToPage({ + type: 'SOLID_FETCH_RESPONSE', + requestId, + status: 200, + body: 'GATED-POD-BYTES', + headers: {}, + }); + + const response = await promise; + expect(await response.text()).toBe('GATED-POD-BYTES'); + expect(response.status).toBe(200); + // The native fetch was NOT used on the gated path. + expect(nativeFetch).not.toHaveBeenCalled(); + }); +}); + describe('inject — MEDIUM #2: a double injection is INERT (no throw on the second run)', () => { it('importing/running the inject TWICE does not throw and installs window.solid once', async () => { await importInject(); diff --git a/apps/extension/test/service-worker-gate.test.ts b/apps/extension/test/service-worker-gate.test.ts index 819a1f9f7..cefcdb9c9 100644 --- a/apps/extension/test/service-worker-gate.test.ts +++ b/apps/extension/test/service-worker-gate.test.ts @@ -608,11 +608,12 @@ describe('SOLID_GET_STATE scopes the WebID (PII) to granted origins', () => { }); }); -describe('HIGH #1: autoDivert (global-fetch patch) NEVER hard-fails non-pod / un-granted traffic', () => { - it('an autoDivert fetch to a NON-POD third party PASSES THROUGH as a plain native fetch (NOT a 403)', async () => { - // The page-compat regression: an app calls a third-party API/CDN. The global-fetch patch - // routes it (autoDivert=true). The SW gate denies (foreign target), but for an autoDivert - // request that must become a PLAIN UNAUTHENTICATED passthrough, not a 403 that breaks the app. +describe('Round-2 HIGH #1: autoDivert returns a PASSTHROUGH SENTINEL — the SW does ZERO network (no cross-origin proxy)', () => { + it('an autoDivert fetch to a NON-POD third party → sentinel { passthrough:true }, NO SW fetch (NOT a 403, NO bytes)', async () => { + // The round-2 fix: an app calls a third-party API/CDN. The global-fetch patch routes it + // (autoDivert=true). The SW gate denies (foreign target). The SW must NOT fetch it itself + // (that would make the extension a cross-origin read proxy via its perms) — it + // returns a passthrough SENTINEL with no body/status, and the page completes it natively. await seedSession(); storage.set('solid:granted-origins', ['https://pm.example']); fetchImpl = async () => new Response('third-party-json', { status: 200 }); @@ -627,21 +628,46 @@ describe('HIGH #1: autoDivert (global-fetch patch) NEVER hard-fails non-pod / un { origin: 'https://pm.example', url: 'https://pm.example/app.html' }, ); - // The app's normal request SUCCEEDS (the extension is invisible to it). - expect(res.status).toBe(200); - expect(res.body).toBe('third-party-json'); + // The SW returned ONLY the sentinel — no body, no status, no fetched bytes. + expect(res.passthrough).toBe(true); + expect(res.body).toBeUndefined(); + expect(res.status).toBeUndefined(); expect(res.error).toBeUndefined(); - // The third party WAS reached — as a PLAIN fetch with NO credential attached. - const call = fetchCalls.find((c) => c.url.startsWith('https://api.thirdparty.example')); - expect(call).toBeDefined(); - const headers = (call?.init?.headers as Record) ?? {}; - expect(headers.Authorization).toBeUndefined(); - expect(headers.DPoP).toBeUndefined(); + // CRITICAL (cross-origin-proxy hole closed): the SW performed NO fetch to the third party. + expect(fetchCalls.some((c) => c.url.startsWith('https://api.thirdparty.example'))).toBe(false); + }); + + it('SECURITY: a page CANNOT use the extension as a cross-origin read proxy — no autoDivert deny ever triggers an SW fetch', async () => { + // The exact round-2 exploit: a hostile page postMessages an autoDivert request for a + // cross-origin resource CORS would block. The SW must never fetch it and return its bytes. + await seedSession(); + storage.set('solid:granted-origins', ['https://pm.example']); + // If the SW were to (wrongly) fetch, this is the secret cross-origin body it would leak. + fetchImpl = async () => new Response('SECRET-CROSS-ORIGIN-BYTES', { status: 200 }); + await loadWorker(); + + const res = await sendFetch( + { + url: 'https://victim-bank.example/account/balance', + stampedOrigin: 'https://attacker.example', + autoDivert: true, + }, + { origin: 'https://attacker.example', url: 'https://attacker.example/x' }, + ); + + // The SW returns a sentinel ONLY — never the cross-origin bytes. + expect(res.passthrough).toBe(true); + expect(res.body).toBeUndefined(); + expect(res.status).toBeUndefined(); + // The SW made ZERO network calls to the victim origin — the proxy hole is closed. + expect(fetchCalls.some((c) => c.url.startsWith('https://victim-bank.example'))).toBe(false); + // And the secret bytes were never returned to the page anywhere in the response. + expect(JSON.stringify(res)).not.toContain('SECRET-CROSS-ORIGIN-BYTES'); }); - it('the SAME non-pod request WITHOUT autoDivert (explicit window.solid.fetch) DOES return 403', async () => { + it('the SAME non-pod request WITHOUT autoDivert (explicit window.solid.fetch) DOES return 403 (no sentinel)', async () => { // Contrast: an explicit window.solid.fetch deliberately asked for the gated path, so a - // foreign-target deny is the correct, expected 403 — only autoDivert softens to passthrough. + // foreign-target deny is the correct, expected 403 — only autoDivert gets a sentinel. await seedSession(); storage.set('solid:granted-origins', ['https://pm.example']); await loadWorker(); @@ -653,12 +679,14 @@ describe('HIGH #1: autoDivert (global-fetch patch) NEVER hard-fails non-pod / un expect(res.status).toBe(403); expect(res.error).toBe('Cross-origin fetch denied'); + expect(res.passthrough).toBeUndefined(); expect(fetchCalls.some((c) => c.url.startsWith('https://api.thirdparty.example'))).toBe(false); }); - it('an autoDivert POD read from an UN-GRANTED origin passes through UNAUTHENTICATED (no creds, no 403)', async () => { - // A non-granted app's global fetch hits the pod. Per the invariant it must not hard-fail; it - // gets a plain unauthenticated fetch (the pod returns its own 401/WAC), never the credential. + it('an autoDivert POD read from an UN-GRANTED origin → sentinel, NOT a 403 and NOT an SW fetch', async () => { + // A non-granted app's global fetch hits the pod. Per the invariant it must not hard-fail; the + // SW returns a sentinel (NO network, NO credential) and the page does the plain native fetch + // (the pod returns its own 401/WAC). The SW must NOT itself fetch the pod here. await seedSession(); storage.set('solid:granted-origins', ['https://pm.example']); // ungranted.example is NOT granted fetchImpl = async () => new Response('pod-public-or-401', { status: 401 }); @@ -673,17 +701,14 @@ describe('HIGH #1: autoDivert (global-fetch patch) NEVER hard-fails non-pod / un { origin: 'https://ungranted.example', url: 'https://ungranted.example/x' }, ); - // Not a gate-403 — the pod's own response is relayed (a 401 here), proving native passthrough. - expect(res.status).toBe(401); - // The egress to the pod carried NO credential (unauthenticated passthrough). - const podCall = fetchCalls.find((c) => c.url.startsWith('https://alice.pod.example')); - expect(podCall).toBeDefined(); - const headers = (podCall?.init?.headers as Record) ?? {}; - expect(headers.Authorization).toBeUndefined(); - expect(headers.DPoP).toBeUndefined(); + // Not a gate-403 — a passthrough sentinel, and NO SW egress to the pod. + expect(res.passthrough).toBe(true); + expect(res.status).toBeUndefined(); + expect(res.error).toBeUndefined(); + expect(fetchCalls.some((c) => c.url.startsWith('https://alice.pod.example'))).toBe(false); }); - it('a GRANTED pod read still gets the AUTHENTICATED replica path even with autoDivert set', async () => { + it('a GRANTED pod read still gets the AUTHENTICATED replica path even with autoDivert set (no sentinel)', async () => { // autoDivert must NOT downgrade a legitimate granted pod read — that is still gated+authed. await seedSession(); storage.set('solid:granted-origins', ['https://pm.example']); @@ -700,6 +725,7 @@ describe('HIGH #1: autoDivert (global-fetch patch) NEVER hard-fails non-pod / un { origin: 'https://pm.example', url: 'https://pm.example/app.html' }, ); + expect(res.passthrough).toBeUndefined(); expect(res.status).toBe(200); expect(res.body).toBe('private-pod-bytes'); const podCall = fetchCalls.find((c) => c.url.startsWith('https://alice.pod.example')); @@ -709,42 +735,10 @@ describe('HIGH #1: autoDivert (global-fetch patch) NEVER hard-fails non-pod / un expect(headers.DPoP).toBeDefined(); }); - it('a native passthrough STRIPS any page-supplied Authorization/DPoP/Cookie header (defence-in-depth)', async () => { - await seedSession(); - storage.set('solid:granted-origins', ['https://pm.example']); - fetchImpl = async () => new Response('ok', { status: 200 }); - await loadWorker(); - - const res = await sendFetch( - { - url: 'https://api.thirdparty.example/data', - stampedOrigin: 'https://pm.example', - autoDivert: true, - headers: { - Authorization: 'Bearer page-smuggled', - DPoP: 'page-proof', - Cookie: 'sid=1', - 'X-App': 'keep', - }, - }, - { origin: 'https://pm.example', url: 'https://pm.example/app.html' }, - ); - - expect(res.status).toBe(200); - const call = fetchCalls.find((c) => c.url.startsWith('https://api.thirdparty.example')); - const headers = (call?.init?.headers as Record) ?? {}; - // The page cannot smuggle its own auth/cookie through the extension's privileged fetch. - expect(headers.Authorization).toBeUndefined(); - expect(headers.DPoP).toBeUndefined(); - expect(headers.Cookie).toBeUndefined(); - // A benign app header survives. - expect(headers['X-App']).toBe('keep'); - }); - - it('an autoDivert request to an OPAQUE/forged origin still passes through (never hard-fails the page)', async () => { - // Even a dual-origin mismatch (which would DENY the gated path) becomes a plain passthrough - // under autoDivert — the invariant is that normal web traffic is never broken. No credential - // is ever attached on this path, so passthrough cannot widen access. + it('an autoDivert request to an OPAQUE/forged origin → sentinel with NO SW fetch (never hard-fails, never proxies)', async () => { + // Even a dual-origin mismatch (which would DENY the gated path) becomes a sentinel under + // autoDivert — normal web traffic is never broken, but the SW still does NO network: the + // page completes it natively in its own origin context. await seedSession(); storage.set('solid:granted-origins', ['https://pm.example']); fetchImpl = async () => new Response('cdn-asset', { status: 200 }); @@ -755,16 +749,16 @@ describe('HIGH #1: autoDivert (global-fetch patch) NEVER hard-fails non-pod / un { origin: 'https://attacker.example', url: 'https://attacker.example/x' }, // sender ≠ stamp ); - expect(res.status).toBe(200); - expect(res.body).toBe('cdn-asset'); - const call = fetchCalls.find((c) => c.url.startsWith('https://cdn.example')); - const headers = (call?.init?.headers as Record) ?? {}; - expect(headers.Authorization).toBeUndefined(); + expect(res.passthrough).toBe(true); + expect(res.body).toBeUndefined(); + // The SW did NOT fetch the CDN — the page will, natively. + expect(fetchCalls.some((c) => c.url.startsWith('https://cdn.example'))).toBe(false); }); - it('an autoDivert fetch with NO session passes through natively (the explicit path says Not authenticated)', async () => { + it('an autoDivert fetch with NO session → sentinel, NO SW fetch (explicit path says Not authenticated)', async () => { // No session seeded. The explicit window.solid.fetch returns "Not authenticated"; an - // autoDivert request just does the plain fetch the page would have done (no creds exist). + // autoDivert request gets a sentinel so the page does the plain native fetch (no creds exist), + // and the SW performs no network. fetchImpl = async () => new Response('public-resource', { status: 200 }); await loadWorker(); @@ -776,8 +770,9 @@ describe('HIGH #1: autoDivert (global-fetch patch) NEVER hard-fails non-pod / un }, { origin: 'https://pm.example', url: 'https://pm.example/app.html' }, ); - expect(res.status).toBe(200); - expect(res.body).toBe('public-resource'); + expect(res.passthrough).toBe(true); + expect(res.status).toBeUndefined(); expect(res.error).toBeUndefined(); + expect(fetchCalls.some((c) => c.url.startsWith('https://api.thirdparty.example'))).toBe(false); }); }); From 28bfcedd94da1ae0071696bb2dc9f957874f1079 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Sun, 21 Jun 2026 18:41:05 +0100 Subject: [PATCH 32/50] =?UTF-8?q?fix(inject):=20round-3=20Medium=20?= =?UTF-8?q?=E2=80=94=20snapshot=20passthrough=20request=20at=20call=20time?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Native fetch reads URL/init synchronously, but a passthrough fallback fetch runs only after the async SW round-trip. Snapshot input (a mutable URL), init, and headers at call time so a page mutating those objects between the call and the sentinel cannot change the eventual native passthrough request. roborev 373c80e Medium. +1 mutation-immunity test; existing passthrough test updated to assert a value-equal snapshot (not reference identity). Detect a URL by cross-realm-safe duck-typing (urlLike: a non-null object with a string href) instead of `instanceof URL`, and snapshot the input to its href STRING. `instanceof URL` is unreliable across JS realms (an iframe, or a JSDOM URL vs the module's bare-global URL) — a foreign-realm URL would be mis-classified as same-origin and skip the gate. A string snapshot is fully specified, immutable, and accepted identically by native fetch in browser and JSDOM, so the mutation-immunity + divert behaviour is correct in both. Fixes the failing test/inject.test.ts mutation-immunity case. Model: claude-opus-4-8 Provenance: Opus 4.8 (Fable unavailable) — re-review/upgrade candidate Co-Authored-By: Claude Opus 4.8 --- apps/extension/src/inject/inject.ts | 48 +++++++++++++++++++++----- apps/extension/test/inject.test.ts | 52 +++++++++++++++++++++++++++-- 2 files changed, 88 insertions(+), 12 deletions(-) diff --git a/apps/extension/src/inject/inject.ts b/apps/extension/src/inject/inject.ts index 7385338bf..24c2a2805 100644 --- a/apps/extension/src/inject/inject.ts +++ b/apps/extension/src/inject/inject.ts @@ -85,6 +85,25 @@ function installSolidInjection(): void { // never a cross-origin proxy and native CORS / credentials / header semantics are preserved. const PRISTINE_FETCH = window.fetch.bind(window); + /** + * Duck-type a `URL`-like input rather than `instanceof URL`. `instanceof` is CROSS-REALM + * UNRELIABLE: a `URL` minted in another JS realm (an iframe, or — in tests — a JSDOM window + * whose `URL` differs from the module's bare-global `URL`) is NOT an instance of the realm-local + * `URL`, so `input instanceof URL` would wrongly be `false` and we'd mis-classify the input + * (treating a cross-origin pod URL as same-origin and skipping the gate). A `RequestInfo | URL` + * that is a non-null object exposing a string `href` is a URL (a `Request` has no `href` — it + * carries `url` — so this never mis-takes a `Request` for a `URL`). `WHATWG URL` is fully + * specified by its `href`, so we snapshot/serialise via `href` and never depend on identity. + */ + function urlLike(input: unknown): input is URL { + return ( + typeof input === 'object' && + input !== null && + 'href' in input && + typeof (input as { href: unknown }).href === 'string' + ); + } + const pendingFetches = new Map(); const pendingActions = new Map>(); @@ -154,9 +173,24 @@ function installSolidInjection(): void { ): Promise { return new Promise((resolve, reject) => { const requestId = crypto.randomUUID(); - // Retain the ORIGINAL input/init so a passthrough sentinel can be completed with the - // page's own native fetch verbatim (round-2 High #1). - pendingFetches.set(requestId, { resolve, reject, input, init }); + // Retain the input/init so a passthrough sentinel can be completed with the page's own + // native fetch verbatim (round-2 High #1). SNAPSHOT them at CALL TIME (round-3 Medium): + // native `fetch` reads the URL/init synchronously, but a passthrough fetch happens only + // AFTER the async SW round-trip — so without a snapshot, a page that mutates the `URL`, + // the `Headers`, or `init.credentials` between the call and the sentinel would change the + // eventual request. Copy the URL (a mutable `URL` object), the init object, and the + // headers into fresh values so a later mutation of the caller's objects cannot leak in. A + // string input + string body are immutable, so they need no copy. A `URL` is detected by + // duck-typing (`urlLike`, cross-realm-safe) and snapshotted to its `href` STRING: a WHATWG + // URL is fully specified by `href`, native fetch accepts a string URL identically, and a + // string can't be mutated — so a later `url.pathname = …` on the caller's object cannot leak + // into the deferred passthrough. (Snapshotting to a fresh `URL` would also work but only if + // `URL` is the same realm; a string is robust in both browser and JSDOM.) + const snapInput: RequestInfo | URL = urlLike(input) ? input.href : input; + const snapInit: RequestInit | undefined = init + ? { ...init, ...(init.headers ? { headers: new Headers(init.headers) } : {}) } + : undefined; + pendingFetches.set(requestId, { resolve, reject, input: snapInput, init: snapInit }); let body: string | null = null; if (init?.body != null) { if (typeof init.body !== 'string') { @@ -259,11 +293,7 @@ function installSolidInjection(): void { function shouldDivert(input: RequestInfo | URL): boolean { try { const url = new URL( - typeof input === 'string' - ? input - : input instanceof URL - ? input.href - : (input as Request).url, + typeof input === 'string' ? input : urlLike(input) ? input.href : (input as Request).url, window.location.href, ); if (url.protocol !== 'https:' && url.protocol !== 'http:') return false; @@ -280,7 +310,7 @@ function installSolidInjection(): void { // currently serialise structurally — pass it through solidFetch via its URL + init only // for the simple (string/URL) case; a complex Request falls back to native fetch (the SW // gate still governs the eventual pod read if the app re-issues it through window.solid). - if (typeof input === 'string' || input instanceof URL) { + if (typeof input === 'string' || urlLike(input)) { // autoDivert=true: this is the transparency patch, NOT an explicit window.solid.fetch — // the SW native-passes-through (no 403) when the request is non-pod / un-granted (High #1). return solidFetch(input, init, true); diff --git a/apps/extension/test/inject.test.ts b/apps/extension/test/inject.test.ts index 5efe1922a..de52d0af0 100644 --- a/apps/extension/test/inject.test.ts +++ b/apps/extension/test/inject.test.ts @@ -180,11 +180,57 @@ describe('inject — Round-2 High #1: a passthrough sentinel is completed by the // The promise resolved with the PAGE's native fetch result (the SW gave no bytes). expect(await response.text()).toBe('NATIVE-IN-PAGE-BYTES'); expect(response.status).toBe(207); - // PRISTINE_FETCH was called with the ORIGINAL input + init (CORS/creds/header fidelity). + // PRISTINE_FETCH was called with the ORIGINAL input + a faithful init SNAPSHOT (round-3 + // Medium: the init is COPIED at call time, so it is value-equal but NOT the same reference). expect(nativeFetch).toHaveBeenCalledTimes(1); - const [calledInput, calledInit] = nativeFetch.mock.calls[0]; + const [calledInput, calledInit] = nativeFetch.mock.calls[0] as [string, RequestInit]; expect(calledInput).toBe('https://api.thirdparty.example/data'); - expect(calledInit).toBe(init); + expect(calledInit).not.toBe(init); // a snapshot copy, not the caller's object + expect(calledInit.method).toBe('POST'); + expect(calledInit.body).toBe('payload'); + expect(new Headers(calledInit.headers).get('X-App')).toBe('v1'); + }); + + it('round-3 Medium: the passthrough request is SNAPSHOT at call time — a later mutation of init.headers / init.credentials / a URL input cannot change it', async () => { + await importInject(); + posted.length = 0; + nativeFetch.mockResolvedValueOnce(new Response('OK', { status: 200 })); + + // A mutable URL object + an init the caller will mutate AFTER the call (as native fetch would + // have already snapshotted them synchronously). + const url = new dom.window.URL('https://api.thirdparty.example/data'); + const headers = new dom.window.Headers({ 'X-App': 'v1' }); + const init: RequestInit = { + method: 'GET', + headers: headers as unknown as HeadersInit, + credentials: 'include', + }; + const promise = ( + dom.window as unknown as { fetch: (u: URL, i?: RequestInit) => Promise } + ).fetch(url as unknown as URL, init); + + const routed = posted.find((m) => m.type === 'SOLID_FETCH_REQUEST'); + const requestId = routed?.requestId as string; + expect(nativeFetch).not.toHaveBeenCalled(); + + // MUTATE the caller's objects after the call but BEFORE the sentinel arrives. + url.pathname = '/HIJACKED'; + headers.set('X-App', 'TAMPERED'); + headers.set('X-Injected', 'yes'); + init.credentials = 'omit'; + + deliverToPage({ type: 'SOLID_FETCH_RESPONSE', requestId, passthrough: true }); + await promise; + + // PRISTINE_FETCH saw the values AS THEY WERE AT CALL TIME, not the mutated ones. + const [calledInput, calledInit] = nativeFetch.mock.calls[0] as [ + string | URL, + RequestInit | undefined, + ]; + expect(calledInput.toString()).toBe('https://api.thirdparty.example/data'); + expect(new Headers(calledInit?.headers).get('X-App')).toBe('v1'); + expect(new Headers(calledInit?.headers).has('X-Injected')).toBe(false); + expect(calledInit?.credentials).toBe('include'); }); it('a passthrough sentinel that REJECTS (CORS block) rejects the page promise exactly as native', async () => { From d4961fefbaa2be645cb3c67a4adde59266bcc6e5 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:22:55 +0100 Subject: [PATCH 33/50] feat(inject): reliable MAIN-world injection + presence announce for app de-dupe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes enabling apps to detect the extension and hide their own duplicate account chrome (bead suite-tracker-lpo): 1. Presence announce (inject.ts): on install, set a sticky `` marker + dispatch a one-shot `solid-extension:ready` event. Both are credential-free (presence only; webId stays on window.solid, null until auth). The marker is readable synchronously by app code regardless of load order; the event covers a listener attached before inject ran. 2. Reliable injection (manifest.json): re-declare inject.js as a manifest MAIN-world content script (document_start). The dynamic `chrome.scripting.registerContentScripts` path only takes effect once the MV3 SW has run to register it — a dormant SW on a cold page load leaves window.solid undefined until something wakes it (root-caused via a Playwright persistent-context spike: onInstalled registration was absent, storageKeys empty, window.solid never appeared). The manifest declaration runs at document_start on every navigation with no SW-wake dependency. Declaring it both ways is now safe (was Medium #2's double-run throw) because inject.ts carries the window.__solidInjected / 'solid' in window idempotency guard, which makes the second run an inert no-op. global-fetch-register.ts comment updated to reflect manifest = primary, dynamic = belt-and-braces. Verified end-to-end: Playwright chromium persistent context (--load-extension, headless:true via channel:chromium AND headed) on a page served by a local CSS shows window.solid present + marker "1" + 0 console errors. inject.test.ts: +1 test (announce), 267 tests pass; lint + typecheck + build green. Model: claude-opus-4-8 Provenance: Opus 4.8 (Fable unavailable) — re-review/upgrade candidate Co-Authored-By: Claude Opus 4.8 --- .../src/background/global-fetch-register.ts | 22 +++++++++++-------- apps/extension/src/inject/inject.ts | 22 +++++++++++++++++++ apps/extension/src/manifest.json | 6 +++++ apps/extension/test/inject.test.ts | 15 +++++++++++++ 4 files changed, 56 insertions(+), 9 deletions(-) diff --git a/apps/extension/src/background/global-fetch-register.ts b/apps/extension/src/background/global-fetch-register.ts index e04f1eab4..aeedfc21f 100644 --- a/apps/extension/src/background/global-fetch-register.ts +++ b/apps/extension/src/background/global-fetch-register.ts @@ -2,16 +2,20 @@ /** * Programmatic registration of the MAIN-world injection (design §5.1). * - * This is the SINGLE injection path for `inject.js` into the MAIN world: it registers via + * A SECONDARY, runtime injection path for `inject.js` into the MAIN world via * `chrome.scripting.registerContentScripts({ world: 'MAIN', runAt: 'document_start', - * persistAcrossSessions: true })` — the most reliable MAIN-world injection (the MetaMask - * `window.ethereum` pattern). The manifest does NOT also declare `inject.js` as a MAIN-world - * content script (Medium #2): declaring it BOTH ways let it run twice and the second run threw - * on the `configurable:false` `Object.defineProperty(window,'solid',…)`. The dynamic - * registration is idempotent (it removes a stale prior registration first), survives across - * browser sessions (`persistAcrossSessions`), and gives a single code path to evolve the - * injection without a manifest edit. (inject.ts ALSO carries a `window.__solidInjected` / - * `'solid' in window` guard as defence-in-depth, so even an accidental double-run is inert.) + * persistAcrossSessions: true })` (the MetaMask `window.ethereum` pattern). The PRIMARY path + * is now the manifest-declared MAIN-world content script (see manifest.json) — that runs at + * document_start on EVERY navigation with NO dependency on the service worker being awake, + * whereas this dynamic registration only ever takes effect once the SW has run to register it + * (a dormant MV3 SW on a cold page load leaves `window.solid` undefined until something wakes + * it — the reason the manifest declaration is the reliable path). Both paths are kept: the + * manifest declaration for reliability, this one for runtime re-registration / evolution + + * `persistAcrossSessions`. Declaring it BOTH ways was ORIGINALLY the Medium #2 double-run throw + * on `Object.defineProperty(window,'solid',{configurable:false})`; that is now SAFE because + * inject.ts carries a `window.__solidInjected` / `'solid' in window` guard that makes the + * second run an inert no-op (covered by inject.test.ts). This function is idempotent (it removes + * a stale prior registration first) and survives across browser sessions. * * SECURITY NOTE: the injected script is BEST-EFFORT TRANSPARENCY with ZERO security weight * (design §5.1) — it is racy + bypassable, and the SW gate remains the sole boundary on every diff --git a/apps/extension/src/inject/inject.ts b/apps/extension/src/inject/inject.ts index 24c2a2805..1b68730f1 100644 --- a/apps/extension/src/inject/inject.ts +++ b/apps/extension/src/inject/inject.ts @@ -251,6 +251,28 @@ function installSolidInjection(): void { Object.defineProperty(window, 'solid', { value: Object.freeze(solid), configurable: false }); + // --- Presence announce (apps de-dupe their own account chrome) -------------------------- + // + // A page needs a RELIABLE, RACE-FREE way to know the Solid extension is installed so it can + // hide its OWN profile/logout controls (the extension already shows a pinned account menu — + // showing both is duplicate chrome). `'solid' in window` is the API, but a page script that + // runs BEFORE this MAIN-world inject (a race, since inject is document_start) would sniff a + // bare window and wrongly conclude "no extension". So we announce presence two ways, both + // credential-free (this only says "an extension is here", never who the user is): + // 1. a STICKY DOM marker on — readable SYNCHRONOUSLY by app code whenever it runs, + // even long after inject (it persists), so there is no ordering dependency; and + // 2. a one-shot CustomEvent for a listener that was already attached before inject ran. + // The webId still lives ONLY on `window.solid.webId` (null until auth) — the marker/event + // carry no identity. Wrapped defensively: an announce failure must never break `window.solid`. + try { + window.document.documentElement.setAttribute('data-solid-extension', '1'); + // Use the page realm's own CustomEvent (window.CustomEvent === CustomEvent in a real page) + // so the event is same-realm with the window it is dispatched on. + window.dispatchEvent(new window.CustomEvent('solid-extension:ready')); + } catch { + // Non-fatal — the `window.solid` API above is the source of truth; the marker is a hint. + } + // --- The MAIN-world global-`fetch` patch (design §5.1) --------------------------------- // // Generalises the `window.solid.fetch` proxy: transparently route a page's PLAIN diff --git a/apps/extension/src/manifest.json b/apps/extension/src/manifest.json index 009860604..88cbd5cb2 100644 --- a/apps/extension/src/manifest.json +++ b/apps/extension/src/manifest.json @@ -34,6 +34,12 @@ "js": ["content/content-script.js"], "run_at": "document_start", "world": "ISOLATED" + }, + { + "matches": [""], + "js": ["inject/inject.js"], + "run_at": "document_start", + "world": "MAIN" } ] } diff --git a/apps/extension/test/inject.test.ts b/apps/extension/test/inject.test.ts index de52d0af0..7e51e6e2a 100644 --- a/apps/extension/test/inject.test.ts +++ b/apps/extension/test/inject.test.ts @@ -82,6 +82,21 @@ describe('inject — installs window.solid + patches fetch', () => { // The global fetch is no longer the pristine sentinel. expect(dom.window.fetch).not.toBe(nativeFetch); }); + + it('announces presence: a sticky marker + a solid-extension:ready event', async () => { + // A page hides its own account chrome when the extension is present; it needs a reliable, + // race-free presence signal. Assert BOTH announce channels the inject sets up. + const readyEvents: Event[] = []; + dom.window.addEventListener('solid-extension:ready', (e) => readyEvents.push(e)); + expect(dom.window.document.documentElement.getAttribute('data-solid-extension')).toBeNull(); + await importInject(); + // 1. the STICKY DOM marker — readable synchronously by app code regardless of load order. + expect(dom.window.document.documentElement.getAttribute('data-solid-extension')).toBe('1'); + // 2. the one-shot event — for a listener attached before the inject ran. + expect(readyEvents).toHaveLength(1); + // The announce carries NO identity: webId stays on window.solid (null until auth). + expect((dom.window as unknown as { solid: { webId: unknown } }).solid.webId).toBeNull(); + }); }); describe('inject — HIGH #1: the global-fetch patch marks routed requests autoDivert', () => { From a8d9eb6b30e782e8876306a52360e89bf15cb68b Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:57:58 +0000 Subject: [PATCH 34/50] feat: land per-class grants with Node 24 CI (EXT-01) (#5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(access): Phase-2 per-data-class grant gate in the SW (design §4) Widen the grant key from origin to (origin, dataClass, modes, purpose, expiry, webId) and enforce a PINNED, resolved target set on every .fetch — the SW remains the sole security boundary; legacy origin-only grants keep whole-pod access (dual-read backwards compat). - Pure core (exhaustively tested): class-grant (gate logic, fail-closed on every axis), class-request (bounded ISO-8601 duration, IRI/mode validation), class-resolve (Type-Index walk → concrete targets, TOCTOU-pin), consent (pending record + decision → grants). - SW gateAccess: per-origin gate ∪ class-only origins, then a per-data-class gate for pod targets; distinguishable out-of-grant-scope deny; expiry pruned per request; revocation effective next request (no in-memory grant cache); WebID session boundary; grants cleared on logout. - window.solid.requestAccess/listGrants/revokeGrant/resolveClass + content relay + unspoofable extension-chrome consent window (src/consent/*). - +73 adversarial tests (class-grant/request/resolve/consent + wired SW class-gate: allow/deny matrix, out-of-pin, expiry, revocation, unknown-class fail-closed, session boundary, backwards-compat, requestAccess→consent flow). - docs/access-management-seam.md rewritten to the shipped surface + honest revocation/TOCTOU/purpose limits. Model: claude-opus-4-8 Provenance: Opus 4.8 (Fable unavailable) — re-review/upgrade candidate Co-Authored-By: Claude Opus 4.8 * fix(access): roborev round-1 — serialise grant store, scope resolveClass, SSRF-guard resolver - HIGH: class-grant mutations (save/revoke/prune) were unversioned read-modify-write on one array; a stale save could resurrect a just-revoked grant, breaking "revocation effective next request". Route all three through a single promise-chain serialiser that re-reads inside the critical section. - MEDIUM: resolveClass let any class-only origin enumerate targets for ANY class (private-structure leak). Now a class-only origin may resolve ONLY a class it holds a live grant for; full-grant origins keep open introspection. - Defence in depth: the resolver no longer fetches a type-index ref on a foreign origin (a hostile profile pointing publicTypeIndex at an attacker URL could turn resolution into an SW-side SSRF) — off-pod refs are skipped. - +4 tests: concurrent save+revoke no-resurrect, resolveClass class-scoped allow/deny, full-grant open resolve, SSRF ref-skip. Model: claude-opus-4-8 Provenance: Opus 4.8 (Fable unavailable) — re-review/upgrade candidate Co-Authored-By: Claude Opus 4.8 * fix(access): strip stray NUL bytes from class-resolve dedupe keys (text, not binary) Two dedupe-key separators in class-resolve.ts were NUL bytes, which made git treat the file as binary (blocking clean diffs/review). Replace with a space (the dedupe key joins a fixed kind word + a URL, so a space is unambiguous). No behaviour change. Model: claude-opus-4-8 Provenance: Opus 4.8 (Fable unavailable) — re-review/upgrade candidate Co-Authored-By: Claude Opus 4.8 * fix(access): roborev round-2 — pod-only resolver origins + serialise clearClassGrants - MEDIUM: the resolver SSRF guard confined refs/targets to the full credential set, which INCLUDES the issuer origin — a hostile profile could point a type-index ref at the issuer and trigger an authed SW fetch there. Resolve against a POD-ONLY origin set (WebID origin ∪ configured pods, issuer excluded) for both ref fetches and resolved targets. - MEDIUM: clearClassGrants (logout) bypassed the class-grant mutex, so it could interleave a concurrent save/prune and leave a grant persisted after logout. Route it through the same serialiser. - +2 tests: resolveClass skips an issuer-hosted type-index ref; concurrent save+clear is serialised (no grant survives logout). Model: claude-opus-4-8 Provenance: Opus 4.8 (Fable unavailable) — re-review/upgrade candidate Co-Authored-By: Claude Opus 4.8 * fix(access): roborev round-3 — document + test the issuer-origin exclusion (CSS single-origin) roborev flagged that podOriginsFor doesn't unconditionally delete the issuer origin. That is DELIBERATE: unconditionally removing it would drop the pod origin in the ubiquitous CSS single-origin topology (WebID + IdP on ONE origin) and break class resolution entirely. The `includeIssuerOrigin: false` rule already achieves the correct policy — the issuer origin is allowed ONLY when it coincides with a genuine pod origin (WebID origin or a user-configured pod), where token-to-that-origin is already normal pod access (and the token endpoint stays protected by isTokenEndpoint); an issuer origin DISTINCT from every pod origin (the profile-controlled vector) is excluded. - Expanded the rationale in podOriginsFor. - +1 test: CSS single-origin (WebID origin === issuer origin) — a same-origin type-index ref IS followed so resolution works (proving no over-exclusion); the distinct-issuer skip is already covered. Model: claude-opus-4-8 Provenance: Opus 4.8 (Fable unavailable) — re-review/upgrade candidate Co-Authored-By: Claude Opus 4.8 * fix(auth): non-extractable DPoP CryptoKey in IndexedDB (design roborev round-1 F3) Migrates DPoP private-key custody off an extractable JWK in chrome.storage.local onto a non-extractable Web Crypto CryptoKey persisted directly in IndexedDB (core/dpop-key-db.ts). Non-extractable CryptoKeys are structured-clone serialisable, so the key survives the MV3 service worker's aggressive kill/restart cycle without ever being exportable — closing the F3 keystore-advantage finding from the design review while keeping the same session-scoped-keypair behaviour (fresh key per login, reused across refreshes, cleared on logout). - core/dpop.ts: generateDpopKeyPair() now generates extractable:false; removed exportDpopKeyPair/importDpopKeyPair/StoredDpopKeyPair (no longer needed — the key is never exported to JWK for storage). - core/dpop-key-db.ts (new): IndexedDB handle for the keypair record (put/get/clear), modelled on replica-db.ts. - core/dpop-key-store.ts (new): mintAndPersistDpopKeyPair (login) and loadOrRotateDpopKeyPair (reload-or-mint) orchestration. - session-store.ts: StoredSession no longer carries dpopKeyPair; loadSession() detects + strips a pre-hardening inline extractable JWK on read (one-time migration) and writes the cleaned record back immediately. A migrated session's refresh token was DPoP-bound to the old key's jkt and will fail its next refresh, which the existing refresh-failure teardown in ensureSession() already turns into a clean re-login — no new complexity needed there. - auth-flow.ts / service-worker.ts: rewired onto DpopKeyDb + loadOrRotateDpopKeyPair; logout now also clears the persisted key. Tests: dpop.test.ts (non-extractability + still-signs-and-verifies), dpop-key-store.test.ts (new — a real DpopKeyDb against a minimal fake IDBFactory proving the structured-clone round trip, not a mocked module), session-store.test.ts (new — the legacy-migration strip + idempotency), and the three service-worker integration suites updated to seed the fake DpopKeyDb instead of embedding dpopKeyPair on StoredSession fixtures. +11 net new test cases (358 total). Gate green: lint, typecheck, test, build. Docs updated in the same change: README.md, docs/manifest-permissions.md, docs/design/shared-replica-architecture.md (marks the residual-LOW key-custody finding as closed, keeps the original text struck through for the record). NO PUSH — landing policy for this (ODI-transferred) repo is under re-confirmation on issue #9; orchestrator decides. Model: claude-sonnet-5 Co-Authored-By: Claude Sonnet 5 * fix(auth): roborev round-1 — dpopJkt-checked key load + defer persist to login success Two Medium findings from codex's review of b64367d, both closed: 1. loadOrRotateDpopKeyPair() silently minted a fresh key whenever IndexedDB was empty, even for a CURRENT-shape session whose access/refresh tokens were bound to a different (lost/evicted) key's jkt — producing confusing 401s instead of a clean re-login. Fixed by recording dpopJkt (RFC 7638 thumbprint) on StoredSession at mint time; the renamed loadSessionDpopKeyPair(db, session) now verifies the persisted key's thumbprint against session.dpopJkt and throws DpopKeyUnavailableError on a missing/mismatched key for a current-shape session (dpopJkt present). Both service-worker.ts ensureSession() and auth-flow.ts refreshSession() treat that error like an ordinary refresh failure — clean teardown, not a substitute key. The legacy-migration rotation (no recorded dpopJkt) is unchanged. 2. completeLogin() persisted the newly minted key to the single-record IndexedDB store BEFORE the token exchange succeeded, so a failed/retried login could clobber an existing session's still-valid key. Fixed by splitting mintAndPersistDpopKeyPair into generateDpopKeyPair (unchanged, called early — needed to sign the exchange's own DPoP proof) + persistDpopKeyPair (new — called only after the exchange succeeds, alongside saving the session with its dpopJkt). Tests: dpop-key-store.test.ts rewritten/extended for the new loadSessionDpopKeyPair session-aware contract (legacy-rotate, matching-key, missing-key-throws, mismatched-key-throws) + persistDpopKeyPair's deferred-persist safety (10 tests, was 5). 363 total, gate green: lint, typecheck, test, build. roborev: codex review of b64367d found these 2 Medium findings, no Critical/ High; both addressed above. Model: claude-sonnet-5 Co-Authored-By: Claude Sonnet 5 * fix(auth): roborev round-2 — narrow ensureSession's DPoP-key-load teardown codex's review of d83a142 found: ensureSession() caught EVERY error from loadSessionDpopKeyPair() (including a transient IndexedDB-open failure or an unrelated bug) and destructively tore the session down, silently logging the user out on what might be a momentary fault. Fixed by narrowing the catch to teardown ONLY on DpopKeyUnavailableError (the "this session's key is genuinely gone/mismatched" signal). Any other error now fails that one call soft: cachedKeyPair stays null, the stored session/tokens are left untouched, and the next call retries the DB read cleanly (self-heals once the transient condition clears) — matching every caller's existing "no ctx -> not authenticated" handling rather than adding new throw-handling at each call site. Also fixed a latent test-harness bug this surfaced: service-worker-gate.test.ts's loadWorker() didn't clear messageListeners before vi.resetModules(), so a test calling loadWorker() twice (to model a restart mid-test, needed to actually exercise the DB-read path — a keypair cached in one module instance never re-reads on the same instance) left a stale listener from the FIRST module instance handling the message instead of the second. Fixed by clearing messageListeners at the top of loadWorker(), matching the other two service-worker integration test files. Test added: "a TRANSIENT DPoP-key-store failure fails ONE request soft — no destructive teardown" in service-worker-gate.test.ts (364 tests total, was 363). Gate green: lint, typecheck, test, build. roborev: codex review of d83a142 found this 1 Medium finding, no Critical/ High; addressed above. Model: claude-sonnet-5 Co-Authored-By: Claude Sonnet 5 * feat(access-gate): per-class replica-slice purge on revoke/expiry + requestAccess gesture & rate-limit Access-management Phase-2 residuals (bead suite-tracker-uana, items b + c): (b) Per-class replica-slice purge. Revoking (or expiring) a class grant now synchronously purges exactly that class's replicated bytes + metadata from the extension's shared replica — the resources it resolved to (its pinned targets), scoped to (webId, grantScope) — leaving every other class/origin/user resident. Closes the data-retention gap where a revoked class's bytes lingered until logout. FAIL-CLOSED: the grant's REMOVAL is the authoritative deny and precedes the best-effort byte purge, so a purge failure can never leave access live — it only degrades to "bytes linger until logout" with access already revoked. - new purgeReplicaSlice() in core/replica.ts (targetsCover containment, per-row best-effort so one failure can't abort the slice) - revokeClassGrant() now returns the removed grant; pruneExpiredClassGrants() returns {live, expired}; SW purges each removed/expired grant's slice. (c) requestAccess anti-spam. Two independent, fail-closed defences before the consent window opens or any pod fetch is made: (i) user-gesture requirement — the ISOLATED content script stamps the page-unforgeable navigator.userActivation.isActive; an autonomous (non-gesture) requestAccess loop is rejected. (ii) per-origin fixed-window rate limit (default 3/60s) on the browser-attested origin, with a distinguishable denied-reason. - new pure core/request-access-throttle.ts (decideRequestAccess), wired into the SW handler with best-effort module-level per-origin state. Gate: lint + typecheck + 382 tests (18 new) + build all green. Model: claude-opus-4-8 Provenance: Opus-4.8-authored; tag for re-review/upgrade per docs/MODEL-PROVENANCE.md Co-Authored-By: Claude Opus 4.8 * chore: run CI gate on Node 24 Upgrade checkout and setup-node to their Node 24 runtimes and run the full gate before local-server e2e tests. Co-Authored-By: GPT-6.1-Sol (Codex) * docs: describe implemented class grant API Replace the requestAccess stub description and obsolete prototype plan with the current API, consent flow, pinned-target enforcement, replica, and known resolver limits. Co-Authored-By: GPT-6.1-Sol (Codex) * docs: record EXT-01 validation Record the green Node 24 gate, API surface check, missing package check script, and local CSS setup timeout preventing e2e validation. Co-Authored-By: GPT-6.1-Sol (Codex) * chore: remove the lane report file --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: GPT-6.1-Sol (Codex) --- apps/extension/.github/workflows/ci.yml | 10 +- apps/extension/PLAN.md | 517 +++------ apps/extension/README.md | 154 ++- apps/extension/docs/MODEL-PROVENANCE.md | 6 + apps/extension/docs/access-management-seam.md | 113 +- .../design/shared-replica-architecture.md | 33 +- apps/extension/docs/manifest-permissions.md | 2 +- apps/extension/src/background/auth-flow.ts | 36 +- .../src/background/core/class-grant.ts | 301 +++++ .../src/background/core/class-request.ts | 118 ++ .../src/background/core/class-resolve.ts | 205 ++++ apps/extension/src/background/core/consent.ts | 102 ++ .../src/background/core/dpop-key-db.ts | 106 ++ .../src/background/core/dpop-key-store.ts | 112 ++ apps/extension/src/background/core/dpop.ts | 60 +- apps/extension/src/background/core/replica.ts | 68 ++ .../core/request-access-throttle.ts | 109 ++ .../src/background/service-worker.ts | 591 +++++++++- .../extension/src/background/session-store.ts | 151 ++- apps/extension/src/consent/consent.css | 71 ++ apps/extension/src/consent/consent.html | 33 + apps/extension/src/consent/consent.ts | 156 +++ apps/extension/src/content/content-script.ts | 74 ++ apps/extension/src/inject/inject.ts | 109 +- apps/extension/src/shared/messages.ts | 103 +- apps/extension/test/class-grant.test.ts | 287 +++++ apps/extension/test/class-request.test.ts | 105 ++ apps/extension/test/class-resolve.test.ts | 151 +++ apps/extension/test/consent.test.ts | 63 + apps/extension/test/dpop-key-store.test.ts | 359 ++++++ apps/extension/test/dpop.test.ts | 26 +- apps/extension/test/replica.test.ts | 155 +++ .../test/request-access-throttle.test.ts | 107 ++ .../test/service-worker-class-gate.test.ts | 1025 +++++++++++++++++ .../test/service-worker-gate.test.ts | 102 +- .../test/service-worker-replica.test.ts | 36 +- apps/extension/test/session-store.test.ts | 182 +++ apps/extension/webpack.config.cjs | 3 + 38 files changed, 5351 insertions(+), 590 deletions(-) create mode 100644 apps/extension/src/background/core/class-grant.ts create mode 100644 apps/extension/src/background/core/class-request.ts create mode 100644 apps/extension/src/background/core/class-resolve.ts create mode 100644 apps/extension/src/background/core/consent.ts create mode 100644 apps/extension/src/background/core/dpop-key-db.ts create mode 100644 apps/extension/src/background/core/dpop-key-store.ts create mode 100644 apps/extension/src/background/core/request-access-throttle.ts create mode 100644 apps/extension/src/consent/consent.css create mode 100644 apps/extension/src/consent/consent.html create mode 100644 apps/extension/src/consent/consent.ts create mode 100644 apps/extension/test/class-grant.test.ts create mode 100644 apps/extension/test/class-request.test.ts create mode 100644 apps/extension/test/class-resolve.test.ts create mode 100644 apps/extension/test/consent.test.ts create mode 100644 apps/extension/test/dpop-key-store.test.ts create mode 100644 apps/extension/test/request-access-throttle.test.ts create mode 100644 apps/extension/test/service-worker-class-gate.test.ts create mode 100644 apps/extension/test/session-store.test.ts diff --git a/apps/extension/.github/workflows/ci.yml b/apps/extension/.github/workflows/ci.yml index b7977e5ee..5ecdeec5e 100644 --- a/apps/extension/.github/workflows/ci.yml +++ b/apps/extension/.github/workflows/ci.yml @@ -11,18 +11,18 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v5 with: - node-version: '20' + node-version: '24' cache: 'npm' - name: Install dependencies run: npm ci - - name: Build extension - run: npm run build + - name: Run gate + run: npm run gate - name: Install Playwright browsers run: npx playwright install chromium diff --git a/apps/extension/PLAN.md b/apps/extension/PLAN.md index 3d053889a..ce794e278 100644 --- a/apps/extension/PLAN.md +++ b/apps/extension/PLAN.md @@ -1,393 +1,154 @@ -# Solid Browser Extension - Implementation Plan +# Solid Browser Extension — Implementation Plan -## Context +## Current baseline -Build a Chrome browser extension that enables Solid authentication at the browser level. The extension injects `window.solid` (with `.fetch`, `.webId`, and `.login()`) into all pages, allowing any website to perform authenticated Solid requests. Websites can optionally declare their own client identifier so the Solid server knows which application is making the request. E2E tests verify the full flow against a local Community Solid Server. +The `feat/phase-2-class-grants` implementation provides browser-level Solid sign-in, +`window.solid`, popup and side-panel account surfaces, a gated shared replica, and +per-data-class consent. This document records the implemented baseline; the broader +[shared-replica architecture](docs/design/shared-replica-architecture.md) remains a design +record with follow-up work. The [README](README.md#windowsolid) gives the public API types. -The repo is a blank slate (only LICENSE exists). Auth uses `@uvdsl/solid-oidc-client-browser/core` with a manually adapted login flow for MV3 service workers. Tests target Chrome only via Playwright. +## Architecture and project structure ---- - -## 1. Project Structure - -``` -solid-browser-extension/ - .github/ - workflows/ - ci.yml # Build + e2e tests with xvfb - .skills/ # LLM skill documents (see section 8) - src/ - manifest.json # MV3 manifest - background/ - service-worker.ts # Session mgmt, message handling, auth fetch proxy - session-database.ts # chrome.storage.local adapter for token/key persistence - auth-flow.ts # Manual OIDC authorization URL construction + redirect handling - popup/ - popup.html # Login UI (Solid branding: #7C4DFF) - popup.ts # Sends login request to background - popup.css # Styled per Solid brand guidelines - content/ - content-script.ts # ISOLATED world: relays messages page <-> background - inject/ - inject.ts # MAIN world: defines window.solid, fetch proxy via postMessage - redirect/ - redirect.html # OAuth redirect landing page - redirect.ts # Sends auth code to background - test-site/ - index.html # Uses window.solid to display WebID + fetch private doc - app.js - e2e/ - playwright.config.ts - fixtures.ts # Extension loading, ID discovery - setup/ - global-setup.ts # Start CSS + test site server - global-teardown.ts # Stop servers - css-server.ts # CSS lifecycle management - seed.json # Pre-registered test user - tests/ - login.spec.ts - authenticated-fetch.spec.ts - session-restore.spec.ts - package.json - tsconfig.json - webpack.config.ts - .gitignore - .claude/ - settings.json # MCP servers (Context7), hooks - CLAUDE.md # Project conventions for AI agents - LICENSE -``` - ---- - -## 2. Extension Architecture (Manifest V3) - -### manifest.json - -- `manifest_version: 3` -- **Background**: Service worker (`background/service-worker.js`, type: module) -- **Popup**: `popup/popup.html` via `action.default_popup` -- **Content scripts** (two entries, both ``, `run_at: document_start`): - - `inject/inject.js` with `world: "MAIN"` — injects `window.solid` - - `content/content-script.js` with `world: "ISOLATED"` — bridges page <-> background -- **Permissions**: `storage`, `activeTab` -- **Host permissions**: `` (needed for authenticated fetches to any pod) -- **Web accessible resources**: `redirect/redirect.html` - -### Message flow - -``` -Page (MAIN world) <--postMessage--> Content Script (ISOLATED) <--chrome.runtime--> Service Worker - inject.ts content-script.ts service-worker.ts -``` - -### Message types - -| Message | Direction | Payload | -|---------|-----------|---------| -| `SOLID_LOGIN` | Popup -> Background | `{ idpUrl }` | -| `SOLID_HANDLE_REDIRECT` | Redirect page -> Background | `{ code, state, iss }` | -| `SOLID_FETCH_REQUEST` | Inject -> Content -> Background | `{ requestId, url, method, headers, body, clientId? }` | -| `SOLID_FETCH_RESPONSE` | Background -> Content -> Inject | `{ requestId, status, statusText, headers, body }` | -| `SOLID_GET_STATE` | Inject -> Content -> Background | (none) | -| `SOLID_STATE_UPDATE` | Content -> Inject | `{ webId }` | -| `SOLID_STATE_CHANGED` | Background -> Content (broadcast) | `{ webId }` | -| `SOLID_SET_CLIENT_ID` | Inject -> Content -> Background | `{ origin, clientId }` | - ---- - -## 3. Injected API (`window.solid`) - -### Core API - -```typescript -interface SolidExtension { - /** The authenticated user's WebID, or null if not logged in */ - readonly webId: string | null; - - /** Perform an authenticated fetch using the extension's Solid session */ - fetch(input: RequestInfo | URL, init?: RequestInit): Promise; - - /** - * Set the client identifier for the current origin. - * This is a dereferenceable URI pointing to a Client ID Document (JSON-LD). - * If not set, the extension uses dynamic client registration. - */ - setClientId(clientId: string): void; - - /** Open the extension login flow (convenience — same as clicking the popup) */ - login(idpUrl: string): Promise; - - /** Log out the current session */ - logout(): Promise; -} - -declare global { - interface Window { - solid: SolidExtension; - } -} -``` - -### Client Identifier Design - -**Problem**: When the extension proxies `solid.fetch` for a website, the Solid server sees the extension as the client — not the website. Websites need a way to declare their own identity. - -**Solution**: Websites call `solid.setClientId()` with a dereferenceable client identifier URI. This URI must resolve to a JSON-LD Client ID Document per the Solid-OIDC spec. - -```javascript -// Website declares its client identity -solid.setClientId("https://myapp.example/.well-known/solid-client"); - -// Now authenticated fetches identify as "myapp.example" to the Solid server -const response = await solid.fetch("https://pod.example/private/data.ttl"); +```text +Page (MAIN world) <-> Content script (ISOLATED) <-> Service worker + inject.ts content-script.ts service-worker.ts + postMessage chrome.runtime ``` -**Behavior**: -- `setClientId(uri)` stores the mapping `origin -> clientId` in the background service worker -- The background validates the URI is dereferenceable (fetches the Client ID Document) and caches the result -- Authenticated fetches from that origin use the declared client_id in DPoP proofs -- If no client ID is set for an origin, the extension falls back to dynamic client registration using the extension itself as the client - -**Post-logout redirect URI limitation**: When using dereferenceable client identifiers, the Client ID Document must include `post_logout_redirect_uris`. For the extension flow, the logout redirect goes to the extension's redirect page, not the website. This means: -- IDP logout redirects to `chrome-extension:///redirect/redirect.html` regardless of the website's client ID -- The website's Client ID Document should include the extension's redirect URI in its `post_logout_redirect_uris` if IDP logout is needed (impractical for general use since the extension ID is dynamic) -- **Pragmatic default**: App-only logout (clear local tokens) rather than IDP logout. IDP logout is only feasible when the website controls its own Client ID Document AND knows the extension ID - -**Open question**: Whether a more ergonomic pattern exists for Solid post-logout flows in the extension context. This is a known limitation of the current Solid-OIDC spec when applied to browser extensions. - ---- - -## 4. Authentication Flow - -Using `@uvdsl/solid-oidc-client-browser/core` with manual adaptations for service worker context: - -1. **User opens popup**, enters IDP URL, clicks Login -2. **Background `auth-flow.ts`** (no `window` available): - - Fetches IDP's `.well-known/openid-configuration` - - Dynamically registers client (redirect_uri = `chrome-extension://${chrome.runtime.id}/redirect/redirect.html`) - - Generates PKCE code_verifier + challenge - - Generates CSRF state token - - Stores auth params in `chrome.storage.local` - - Opens IDP auth URL via `chrome.tabs.create()` -3. **IDP authenticates user**, redirects to `redirect.html?code=...&state=...&iss=...` -4. **redirect.ts** extracts params, sends `SOLID_HANDLE_REDIRECT` to background -5. **Background** exchanges code for tokens (with DPoP proof + PKCE verifier), stores tokens + WebID -6. **Broadcasts** `SOLID_STATE_CHANGED` to all tabs - -### Key technical decisions - -- **DPoP keys**: Generated as **extractable**, exported as JWK to `chrome.storage.local`, re-imported on service worker wake-up (non-extractable keys are lost on suspension) -- **SessionCore usage**: Used for `authFetch()` and `restore()` only; `login()` bypassed in favor of manual auth URL construction -- **Response serialization**: Bodies serialized as text over chrome.runtime messaging (Solid resources are predominantly text/RDF); binary support can be added later via base64 -- **Per-origin client IDs**: Background maintains a `Map` persisted in `chrome.storage.local`. When making an authenticated fetch, the DPoP proof includes the appropriate client_id for the requesting origin - ---- - -## 5. Popup UI (Solid Branding) - -The popup follows Solid brand guidelines (from `.skills/style-guide.md`): +The worker owns authentication, credentials, grants, and replica access. Page code receives +only token-free responses. The ISOLATED bridge stamps the real page origin; the worker +cross-checks it against Chrome's sender before authorizing a request. + +| Path | Implemented responsibility | +|------|----------------------------| +| `src/background/auth-flow.ts` | Solid-OIDC authorization code + PKCE + DPoP through `chrome.identity.launchWebAuthFlow` | +| `src/background/session-store.ts` | Session tokens, profile, recent accounts, client IDs, and grants in `chrome.storage.local` | +| `src/background/core/dpop-key-db.ts` / `dpop-key-store.ts` | Non-extractable private `CryptoKey` persistence in worker-owned IndexedDB | +| `src/background/core/authenticated-fetch.ts` / `origin-policy.ts` | Target-origin credential boundary, header stripping, and DPoP nonce retry | +| `src/background/core/requesting-origin.ts` | Default-deny app-origin verification and same-origin-only foreign fetch policy | +| `src/background/core/class-request.ts` / `request-access-throttle.ts` | Request validation, bounded expiry, user-gesture requirement, and prompt rate limit | +| `src/background/core/class-resolve.ts` | Solid Type Index class-to-target resolution confined to pod origins | +| `src/background/core/class-grant.ts` / `consent.ts` | Pinned-target grant model, mode/expiry checks, and per-class consent decisions | +| `src/background/core/replica*.ts` | Gated CacheStorage bytes, IndexedDB metadata/nonces, conditional revalidation, and write-through | +| `src/background/global-fetch-register.ts` / `src/inject/inject.ts` | Best-effort MAIN-world injection and global fetch routing | +| `src/shared/messages.ts` | Token-free page/bridge/worker message protocol and grant summaries | +| `src/consent/` | Extension-chrome consent window | +| `src/popup/` / `src/sidepanel/` | Shared account surface built with `@jeswr/solid-elements` | +| `e2e/` / `test-site/` | Headed Chromium tests against local Community Solid Server and app sites | + +The MV3 manifest declares `storage`, `identity`, `sidePanel`, `contextMenus`, and `scripting` +permissions and `` host access. Both content worlds are declared in the manifest; +the worker also attempts programmatic MAIN-world registration. Injection has an idempotency +guard. The broad matches enable discovery, while the worker's access checks enforce the +boundary. See [permission rationale](docs/manifest-permissions.md). + +## Implemented `window.solid` API + +The installed object is frozen and its window property is non-configurable. It exposes: + +| Member | Behavior | +|--------|----------| +| `webId` | Current WebID or `null`; the worker withholds identity from ungranted origins | +| `fetch(input, init?)` | Proxied request, gated before replica access or pod egress | +| `setClientId(clientId)` | Store a valid Client Identifier Document URL for the verified origin and grant it legacy whole-pod access | +| `login(webId)` | Start interactive Solid-OIDC login; successful verified page login grants its origin | +| `logout()` | Clear the local session and class grants, and purge replica data | +| `requestAccess({ classes, modes, purpose?, duration? })` | Open per-class consent and return `{ ok, grants }` | +| `listGrants()` | List this origin's live class-grant summaries for the active WebID | +| `revokeGrant(grantId)` | Remove this origin's class grant, purge its replica slice, and return whether removed | +| `resolveClass(classIRI)` | Return `{ shape, targets }` for an allowed class; currently `shape: null` | + +Presence is announced through `solid-extension:ready` and the sticky +`data-solid-extension="1"` marker on ``. Neither carries identity. The global fetch +patch is best-effort transparency: same-origin requests stay native, and cross-origin +HTTP(S) requests consult the worker. If it declines, a passthrough sentinel makes the page +use its captured native fetch with the original arguments. Explicit `solid.fetch` keeps +the worker's denial response. + +### Class-grant flow and enforcement + +1. Sign in through the extension. A click/keypress in an app calls `requestAccess` with + class IRIs, modes, an optional purpose, and a positive ISO-8601 duration. Although the + TypeScript argument permits omitting `duration`, runtime validation requires it and + caps the lifetime at 365 days (year/month components approximate 365/30 days). +2. Before discovery or consent, the worker verifies the origin and requires transient + activation stamped by the ISOLATED bridge. It admits at most three requests per origin + per minute with a best-effort in-memory throttle that resets on worker restart. +3. Resolve the WebID's public/private Type Index registrations (`solid:forClass` with + `solid:instance` / `solid:instanceContainer`) to concrete pod targets. The consent window + shows the origin, classes, modes, purpose, expiry, and targets. Only the extension's own + context may approve; each approved class mints one immutable grant. Deny-all returns + `{ ok: false, grants: [] }`; validation/session/throttle failures reject the promise. +4. Pin the resolved targets in each grant. Class-only origins must satisfy URL membership, + mode, active WebID, and expiry on every request, including a cache hit. Unknown classes + with no targets authorize nothing. Later resolution does not widen an existing grant. +5. `listGrants` filters by verified origin, active WebID, and expiry. `resolveClass` requires + an active session and granted origin; a class-only origin can inspect only a class it + currently holds. Full-grant origins can inspect any class. These APIs expose no tokens. +6. `revokeGrant` removes the grant before best-effort replica-slice purging, so purge failure + cannot preserve authorization. Bytes still covered by another live read grant for the + same origin are retained. Expired grants are pruned and their slices purged on access. + +Implemented modes are `read`, `append`, and `write` (case-insensitive). GET/HEAD require +read, POST requires append, and PUT/PATCH/DELETE require write. Write also covers append, +but does not imply read. Instances match exact resource paths; containers include +descendants. Purpose is an audit/display anchor, not an enforced restriction. + +Legacy whole-pod origin grants retain their broader access and take precedence over class +grants. Revoking a class grant does not remove that separate legacy origin authorization. +This is the existing compatibility behavior, not a migration to the later origin-grant model. + +## Authentication, storage, and replica + +Authentication uses Web Crypto + `jose`, with RDF issuer/profile discovery through N3.js. +The redirect is `chrome.identity.getRedirectURL('callback')`; no extension redirect page is +used. A page-declared Client ID Document is carried into login; otherwise the extension +tries its configured document and falls back to dynamic registration. The shipped hosted +URL and extension ID remain placeholders pending a separate hosting/pinning task. + +Access/refresh tokens stay in `chrome.storage.local`. The private DPoP key is non-extractable +and persists as a structured-cloned `CryptoKey` in IndexedDB across worker suspension. +Refresh reuses that key and persists rotated tokens before use. The audited +`@jeswr/solid-session-restore` decision supports silent restore. + +Replica bytes live in CacheStorage; metadata and DPoP nonces live in IndexedDB. Cache keys +include the active WebID and grant scope. Reads revalidate and never treat cached bytes as +authoritative. Writes are synchronous write-through with conditional headers. Logout +purges before further serving. Page-provided Authorization/DPoP headers are stripped; +credentials attach only to allowed target origins, never the token endpoint or remote +cleartext targets. A foreign target must be the requesting app's own origin to be proxied +without a Solid token. + +## Build and verification + +Use npm with the committed lockfile and Node.js 24: -- **Primary color**: `#7C4DFF` (Solid purple) -- **Logo**: Solid logo displayed at the top of the popup -- **Typography**: Clean, modern sans-serif -- **Layout**: Simple form — IDP URL input + Login button. When logged in, show WebID + Logout button -- **States**: Not logged in / Logging in (spinner) / Logged in (shows WebID) - ---- - -## 6. Build Configuration - -- **Bundler**: Webpack with `ts-loader` -- **5 entry points**: service-worker, popup, content-script, inject, redirect -- **CopyWebpackPlugin**: copies manifest.json, HTML, CSS to dist/ -- **TypeScript**: ES2022 target, strict mode, `@types/chrome` - -### Dependencies - -**Runtime**: `@uvdsl/solid-oidc-client-browser`, `jose` -**Dev**: `typescript`, `webpack`, `webpack-cli`, `ts-loader`, `copy-webpack-plugin`, `@types/chrome`, `@playwright/test`, `@solid/community-server`, `http-server`, `ts-node` - ---- - -## 7. E2E Testing - -### Infrastructure -- **Community Solid Server**: Started in `global-setup.ts` with in-memory config + `seed.json` (pre-registered test user at `http://localhost:3000`) -- **Test site**: Served via `http-server` on a separate port -- **Playwright**: Persistent context with `--load-extension`, `headless: false`, chromium channel - -### Fixtures (`e2e/fixtures.ts`) -- Launch persistent Chromium context with extension loaded from `dist/` -- Discover extension ID from service worker URL - -### Test scenarios - -**login.spec.ts** -1. Navigate to `chrome-extension://${extensionId}/popup.html` -2. Enter IDP URL (`http://localhost:3000`), click Login -3. Handle CSS login form (fill email/password, submit) -4. Verify redirect completes and popup shows WebID - -**authenticated-fetch.spec.ts** -1. Complete login -2. Navigate to test site -3. Assert `#webid` displays the authenticated WebID -4. Assert `#fetch-result` contains private document content - -**session-restore.spec.ts** -1. Complete login -2. Navigate away and back to test site -3. Assert session is still active (WebID still displayed) - -### CI -- GitHub Actions with `xvfb-run` for headed Chrome on Linux -- Build extension, install Playwright, run tests - ---- - -## 8. Implementation Order - -1. **Skills & agent config**: CLAUDE.md, .skills/, .claude/settings.json (MCP + hooks), roborev setup -2. **Scaffold**: package.json, tsconfig, webpack config, .gitignore, manifest.json -3. **inject.ts**: Define `window.solid` with fetch proxy via postMessage -4. **content-script.ts**: Message bridge between page and background -5. **session-database.ts**: chrome.storage.local adapter with JWK key serialization -6. **auth-flow.ts**: Manual OIDC flow (discovery, registration, PKCE, auth URL) -7. **service-worker.ts**: Message handler, session management, auth fetch proxy, client ID management -8. **popup**: HTML + TS + CSS for login UI (Solid branded) -9. **redirect**: HTML + TS for OAuth callback -10. **Test site**: Simple HTML/JS using window.solid -11. **E2E setup**: Playwright config, fixtures, CSS server lifecycle -12. **E2E tests**: Login, authenticated fetch, session restore -13. **CI**: GitHub Actions workflow - ---- - -## 9. Key Files to Create/Modify - -| File | Purpose | -|------|---------| -| `src/background/service-worker.ts` | Central session manager, message handler, auth fetch proxy, client ID registry | -| `src/background/session-database.ts` | Token/key persistence via chrome.storage.local | -| `src/background/auth-flow.ts` | OIDC authorization URL construction for service worker context | -| `src/inject/inject.ts` | Defines `window.solid` in MAIN world (fetch, webId, setClientId, login, logout) | -| `src/content/content-script.ts` | Message bridge (ISOLATED world) | -| `src/popup/popup.ts` + `.html` + `.css` | Login UI with Solid branding | -| `src/redirect/redirect.ts` + `.html` | OAuth callback handler | -| `e2e/fixtures.ts` | Playwright extension fixtures | -| `e2e/setup/css-server.ts` | CSS server lifecycle for tests | - ---- - -## 10. Skills, MCP, & Agent Team Setup - -### Skills Documents (`.skills/`) - -**Solid-specific skills** (from https://github.com/solid/solid-llm-skills): -- `spec.md` — Solid Protocol, WebID, Solid-OIDC, ACP specifications -- `servers.md` — Community Solid Server deployment/CLI -- `style-guide.md` — Solid brand guidelines (#7C4DFF, logo, typography) — **used for popup UI** -- `data-modelling.md` — RDF vocabularies, SHACL -- `integration-guide.md` — Client libraries (N3.js, etc.) - -**General engineering skills** (from https://github.com/solid/solid-llm-skills): -- `software-engineer.md` -- `security-engineer.md` (critical for auth extension) -- `qa-engineer.md` (for test strategy) - -**Community skills** (from https://github.com/ComposioHQ/awesome-claude-skills — install relevant ones): -- **Playwright Browser Automation** (https://github.com/lackeyjb/playwright-skill) — e2e test writing guidance -- **test-driven-development** — TDD workflow skill -- **software-architecture** — SOLID principles, clean architecture -- **test-fixing** — detect and fix failing tests -- **using-git-worktrees** — isolated development environments - -### MCP Servers (`.claude/settings.json`) - -**Context7** — Use the Context7 MCP adapter for resolving up-to-date documentation for libraries used in this project. This avoids hallucinating APIs and ensures correct usage of: -- `@uvdsl/solid-oidc-client-browser` API -- Playwright extension testing APIs -- Community Solid Server configuration -- Chrome Extension APIs (MV3) -- Webpack configuration - -Configure in `.claude/settings.json`: -```json -{ - "mcpServers": { - "context7": { - "command": "npx", - "args": ["-y", "@upstash/context7-mcp@latest"] - } - } -} -``` - -### CLAUDE.md - -Project conventions file including: -- Build commands (`npm run build`, `npm run test:e2e`) -- Architecture overview (message flow, auth flow) -- Testing requirements (CSS must be running, headed Chrome) -- Links to skill documents in `.skills/` -- Instruction to use Context7 MCP for library documentation lookups - -### Agent Team Configuration - -**Implementation agent** (Claude Code, primary): -- Handles all code writing, building, debugging -- References `.skills/` for Solid-specific context -- Uses Context7 MCP for library documentation -- Uses CLAUDE.md for project conventions - -**Review agent** (roborev): -- A background daemon that automatically reviews every git commit using AI agents -- Reviews are asynchronous — commits complete instantly, reviews happen in background - -**roborev setup**: ```bash -# Install (choose one) -curl -fsSL https://roborev.io/install.sh | bash -# or: brew install roborev-dev/tap/roborev - -# Initialize in repo (installs post-commit hook, starts daemon) -roborev init --agent claude-code +npm ci --ignore-scripts +npm run gate # Biome + tsc + Vitest + webpack +npm run test:e2e # build + headed Chromium against local CSS only ``` -**roborev workflow**: -- Every `git commit` automatically triggers an async review via the post-commit hook -- View results: `roborev tui` (interactive terminal UI) or `roborev show` (current commit) -- Check daemon health: `roborev status` -- Auto-fix findings: `roborev fix` (runs agent in isolated worktree, applies patches) -- Iterative refinement: `roborev refine --max-iterations 5` (fix → re-review → repeat until clean) -- Verdicts: Pass (P), Fail (F), Warning (W) - -**Custom review guidelines** in `.roborev.toml`: -- Security review rules (OAuth flows, token handling, message passing) -- Chrome extension best practices (MV3 service worker lifecycle) -- Solid protocol compliance checks - -**Full team workflow**: -1. Claude Code implements features/fixes -2. Developer commits -3. roborev daemon automatically reviews the commit in background -4. Developer views findings in `roborev tui` -5. If issues found: `roborev fix` auto-remediates, or developer directs Claude Code to fix -6. `roborev refine` iterates until review passes +Webpack compiles six entries: service worker, popup, side panel, content bridge, inject, +and consent. CopyWebpackPlugin ships the manifest, HTML/CSS, icons, and Client ID Document. +TypeScript is strict. Runtime dependencies remain those declared in `package.json`; +this documentation/CI refresh adds none. ---- +The unit suite uses fixtures and mocked fetch/browser/storage ports. Adversarial coverage +includes forged/opaque origins, ungranted cache hits, cross-WebID isolation, class scope and +expiry, consent sender checks, revocation/purge failures, resolver SSRF, and prompt throttling. +E2E uses local CSS on port 3000 and app sites on ports 8080/8081, never production services. -## 11. Verification +CI uses `actions/checkout@v5`, `actions/setup-node@v5`, and `node-version: '24'`, runs the full +`npm run gate`, then installs Chromium and runs `xvfb-run npm run test:e2e`. -To verify the extension works end-to-end: +## Remaining design work -1. `npm install && npm run build` — extension builds to `dist/` -2. Load `dist/` as unpacked extension in Chrome -3. Start CSS: `npx @solid/community-server -p 3000` -4. Register a user at `http://localhost:3000` -5. Click extension icon, enter `http://localhost:3000`, login -6. Open test-site, verify WebID displays and private fetch succeeds -7. Test `solid.setClientId()` with a test Client ID Document +The existing resolver implements only the Type Index rung. SAI DataRegistration discovery +and SHACL validation remain unimplemented; `shape` is always `null`. Central WebSocket +invalidation and offline write queues also remain follow-ups in the shared-replica design. -Automated: `npm run test:e2e` runs all of the above via Playwright. +The separate extension roadmap covers a stable extension ID and hosted Client ID Document, +multiple profiles, a new per-origin grant model with scoped injection, grant-management UI, +and form-fill. These are outside this Node 24 / documentation landing task. diff --git a/apps/extension/README.md b/apps/extension/README.md index a3c92ba5e..28e972a0b 100644 --- a/apps/extension/README.md +++ b/apps/extension/README.md @@ -2,15 +2,16 @@ A Manifest V3 browser extension that brings **Solid sign-in to the browser itself**. It injects a `window.solid` API into every page (a DPoP-authenticated `fetch`, the user's -`webId`, `login`/`logout`/`setClientId`) and pins a top-right account menu whose icon is the -signed-in WebID's avatar. The UI is built with [`@jeswr/solid-elements`](https://github.com/jeswr/solid-elements) +`webId`, `login`/`logout`/`setClientId`, and per-data-class access grants) and provides a +toolbar account menu whose icon is the signed-in WebID's avatar. The UI is built with +[`@jeswr/solid-elements`](https://github.com/jeswr/solid-elements) so it is visually consistent with [Pod Manager](https://github.com/jeswr/solid-pod-manager) and the rest of the [@jeswr Solid app suite](https://github.com/jeswr). -> **Scope:** this repo is the extension **CORE** — auth, `window.solid`, and the account -> popup. **Access management** (an access-request JS API, consent/data-type UI, queued -> requests) is a separate, design-first track and is intentionally **not** implemented -> here; only a clearly-marked, feature-detectable seam is left (see below). +> **Scope:** auth, `window.solid`, popup/side-panel account UI, a gated shared replica, +> and per-data-class consent through `requestAccess` / `listGrants` / `revokeGrant` / +> `resolveClass` are implemented. Class resolution currently uses Solid Type Indexes; +> SAI registration discovery, SHACL validation, and offline write queues remain follow-ups. ## Architecture @@ -30,9 +31,16 @@ Four contexts, with the access token held in exactly one of them (the service wo - **The page never sees a credential.** `window.solid` (MAIN world) can only `postMessage` to the content script, which relays to the worker. The page gets back the WebID and proxied `Response`s — never the access or refresh token. -- **The service worker is the sole token holder.** Tokens + the DPoP keypair live in - `chrome.storage.local`, which is unreachable from any web page — satisfying the suite's - "DPoP refresh token in non-page-reachable secure storage" invariant. +- **The service worker is the sole token holder.** Tokens live in `chrome.storage.local` + and the DPoP private key lives as a **non-extractable** `CryptoKey` in the worker's own + IndexedDB (`core/dpop-key-db.ts`) — neither is reachable from any web page, satisfying + the suite's "DPoP refresh token in non-page-reachable secure storage" invariant, and the + private key additionally cannot be exported to JWK by ANY code (not even a compromised + extension context — `crypto.subtle.exportKey` on it always rejects). +- **Every proxied pod read/write is gated before cache access or egress.** The worker + cross-checks the content script's origin stamp against Chrome's sender, then checks + origin grants and, for class-only origins, the pinned targets, modes, and expiry. + Ungranted pages receive `webId: null`. ### The credential boundary (security heart) @@ -40,10 +48,11 @@ Because `solid.fetch` is callable from **any** page, the worker must never hand origin the user's token. `core/origin-policy.ts` + `core/authenticated-fetch.ts` enforce, fail-closed: -- The token is attached **only** to an origin in the allowed set (the WebID's origin ∪ the - issuer's origin ∪ user-configured pod origins). A request to any other origin - (`solid.fetch("https://evil.example/")`) is sent as a **plain, credential-free fetch** — - the token-leak attack is impossible. +- The token is attached **only** to a target origin in the allowed set (the WebID's origin ∪ + the issuer's origin ∪ user-configured pod origins), after the requesting app passes its + access gate. An explicit `solid.fetch` from an ungranted origin returns 403 before any + cache read or pod egress. Foreign targets are allowed only on the requesting app's own + origin, without the Solid token; unrelated third-party targets return 403. - **Cleartext guard:** the token never rides over `http:` (loopback excepted, for dev CSS). - The resource token is never attached to the issuer's `/token` endpoint. - Page-supplied `Authorization` / `DPoP` headers are **stripped** (no header injection). @@ -63,10 +72,38 @@ interface SolidExtension { setClientId(clientId: string): void; // declare this origin's Client ID Document login(webId: string): Promise; logout(): Promise; - requestAccess?(request: unknown): Promise; // SEAM — not implemented (see below) + requestAccess(request: AccessRequestArgs): Promise<{ ok: boolean; grants: SolidGrant[] }>; + listGrants(): Promise; + revokeGrant(grantId: string): Promise; + resolveClass(classIRI: string): Promise<{ + shape: string | null; + targets: Array<{ url: string; kind: 'instance' | 'container' }>; + }>; +} + +interface AccessRequestArgs { + readonly classes: readonly string[]; + readonly modes: readonly string[]; // read, append, write (case-insensitive) + readonly purpose?: string; + readonly duration?: string; // required at runtime: positive ISO-8601, e.g. P30D +} + +interface SolidGrant { + readonly grantId: string; + readonly dataClass: string; + readonly modes: readonly string[]; + readonly purpose: string | null; + readonly expiresAt: number; // epoch milliseconds + readonly targets: ReadonlyArray<{ url: string; kind: 'instance' | 'container' }>; } ``` +Presence can also be detected using the `solid-extension:ready` event or the sticky +`` marker. These carry no identity. The best-effort global +`fetch` patch routes cross-origin HTTP(S) requests through the worker; requests the worker +does not authorize fall back to the page's captured native fetch with normal CORS behavior. +Explicit `solid.fetch` calls retain the worker's denial response. + ## Auth + session - **Login:** Solid-OIDC **authorization-code + PKCE + DPoP**, driven by @@ -81,10 +118,12 @@ interface SolidExtension { `chromiumapp.org` redirect into it) is a **needs:user** step; until then the unreachable placeholder transparently falls back to dynamic registration, so login keeps working. A page may still declare its own Client ID Document via `solid.setClientId(...)`. -- **Session:** the access + refresh token + the DPoP keypair (as JWK — an MV3 worker is - killed aggressively and a non-extractable key would be lost on suspension, breaking the - `jkt`-bound refresh) are persisted in `chrome.storage.local`. The worker proactively - refreshes ~30 s before expiry via the DPoP refresh grant, reusing the **same** keypair. +- **Session:** the access + refresh token are persisted in `chrome.storage.local`; the + DPoP keypair is persisted separately as a **non-extractable** `CryptoKey` object directly + in IndexedDB (`core/dpop-key-db.ts`) — non-extractable `CryptoKey`s are structured-clone + serialisable, so the object survives the MV3 worker's aggressive kill/restart cycle + without ever being exported to JWK. The worker proactively refreshes ~30 s before expiry + via the DPoP refresh grant, reusing the **same** persisted keypair (`jkt`-bound refresh). - **Silent restore:** reopening the browser re-hydrates the session from the persisted refresh token (no popup). The restore decision logic is the suite's audited [`@jeswr/solid-session-restore`](https://github.com/jeswr/solid-session-restore) (fail-closed, @@ -111,25 +150,71 @@ toolbar icon → **"Open Solid side panel"** (a left-click still opens the quick ignores open-on-action-click while a `default_popup` is set, so both surfaces coexist via the context-menu entry). -## Offline - -Offline of **arbitrary third-party pods** is **out of scope** (it is a fork-only concern for -this extension — a generic offline layer for any pod is the [`@jeswr/solid-offline`](https://github.com/jeswr/solid-offline) -SW track, not this one). The extension does no forced caching of third-party pod data; the -worker's in-memory session/nonce caches are best-effort accelerators only. - -## Access management — the SEAM (NOT implemented) +## Shared replica + +Granted pod reads use the extension-owned replica: bytes in CacheStorage, metadata and +DPoP nonces in IndexedDB, keyed by WebID and grant scope. Cached reads revalidate against +the pod and remain subject to the access gate on every hit. Writes go through to the pod +synchronously; logout purges the replica before further serving. Offline write queues and +central WebSocket invalidation are follow-ups. + +## Per-data-class access management + +After signing in through the extension, an app can request access from a click or keypress: + +```js +document.querySelector('#share-contacts').addEventListener('click', async () => { + const { ok, grants } = await window.solid.requestAccess({ + classes: ['https://schema.org/Person'], + modes: ['read'], + purpose: 'Display your contacts', + duration: 'P30D', + }); + if (!ok) return; // no classes approved + + const current = await window.solid.listGrants(); + const { shape, targets } = await window.solid.resolveClass(grants[0].dataClass); + // Use solid.fetch within the grant's pinned targets. Later, to withdraw access: + await window.solid.revokeGrant(grants[0].grantId); +}); +``` -The access-request JS API, the consent / data-type UI, and queued-request handling are a -**separate, design-first track** and are deliberately excluded from this core. The only thing -left here is a non-breaking seam: `window.solid.requestAccess?` is **declared** (so it is -feature-detectable) but **throws** "not implemented". Do **not** wire access management onto -this stub without the access-management design — adding the real method later is non-breaking. +- **`requestAccess`** opens a consent window in extension chrome showing the verified app + origin, classes, modes, purpose, expiry, and resolved targets. It requires transient user + activation and is limited to three admitted requests per origin per minute (best-effort + in-memory throttle, reset on worker restart). At least one valid HTTP(S) class IRI and + recognized mode are required. `duration` is optional in the TypeScript shape but mandatory + at runtime: a positive ISO-8601 duration, capped at 365 days. Years/months use 365/30 days. + Approval returns one grant per approved class; denying all returns `{ ok: false, grants: [] }`. + Invalid requests, missing activation/session, or throttling reject the promise. +- **`listGrants`** returns only this origin's non-expired class grants for the active WebID; + no session or an unverifiable origin yields `[]`. Summaries contain no WebID or credential. +- **`revokeGrant`** removes this origin's class grant and returns whether it was removed. + Removal precedes replica-slice purging, so even a purge failure cannot keep access live. + Cached bytes still covered by another live read grant of the same origin are retained. + This cannot withdraw a separate legacy whole-pod origin grant. +- **`resolveClass`** walks the WebID's public/private Solid Type Indexes for `solid:forClass` + and returns `solid:instance` / `solid:instanceContainer` targets on allowed pod origins. + It requires a verified, granted origin and an active session. Class-only origins can + resolve only classes they currently hold; legacy full-grant origins can resolve any class. + It returns `shape: null` today: SAI discovery and SHACL validation are not implemented. + An unknown/unreachable class resolves to an empty target set. + +Class-only origins are confined to the targets pinned at consent, the granted modes, and +expiry, including on cache hits. Instances cover an exact resource path; containers cover +their path and descendants. GET/HEAD require `read`, POST requires `append`, and +PUT/PATCH/DELETE require `write`; `write` also satisfies `append`, but does not imply `read`. +Empty target sets authorize nothing. Re-resolution does not widen an existing grant. +`purpose` is an audit/display value, not an enforced usage restriction. Legacy whole-pod +origin grants remain broader and take precedence over class grants: successful page login +and a verified `setClientId` call create those grants, and credential origins are granted +automatically. To request only class-scoped access, sign in through the extension UI and +call `requestAccess` without first opting the app into whole-pod access. ## Build & load the unpacked extension ```bash -npm install # @jeswr deps are pinned git+https (keyless npm ci); ignore-scripts=true +npm ci --ignore-scripts # install the locked dependencies; @jeswr deps are pinned git+https npm run build # webpack -> dist/ ``` @@ -146,14 +231,15 @@ Then in Chrome: npm run gate # lint (biome) + typecheck (tsc) + test (vitest) + build (webpack) npm run lint # biome over src test e2e scripts npm run typecheck # tsc --noEmit -npm test # vitest — the security-critical core (51 cases), no server needed +npm test # vitest — mocked security-critical core, no server needed npm run build # webpack bundle to dist/ npm run test:e2e # build + Playwright against a LOCAL Community Solid Server ``` The unit suite stubs `fetch` / `chrome.*` and needs no server. The Playwright e2e suite boots a **local** Community Solid Server (`e2e/setup`) and a local test site — **never** the -live deploy — and drives the real extension in headed Chromium. +live deploy — and drives the real extension in headed Chromium. CI uses Node.js 24 and +runs the gate before the local-server e2e suite. ## License diff --git a/apps/extension/docs/MODEL-PROVENANCE.md b/apps/extension/docs/MODEL-PROVENANCE.md index 83dba6f3f..8d50874bd 100644 --- a/apps/extension/docs/MODEL-PROVENANCE.md +++ b/apps/extension/docs/MODEL-PROVENANCE.md @@ -25,3 +25,9 @@ be targeted for re-review / upgrade when Fable returns. | Phase-1 shared replica (`src/background/core/{replica,replica-key,replica-db,single-flight}.ts`) + SW wiring + page→SW routing + egress DPoP | `feat/phase-1-shared-replica` | SECURITY-CRITICAL — re-review/upgrade candidate | | Phase-1 round-2 roborev fixes: inject global-fetch over-divert → SW native-passthrough for `autoDivert` non-pod/un-granted traffic (High #1); single inject path + idempotency guard (Medium #2); best-effort cache persist on non-cacheable 2xx (Medium #3); null-WebID purge enumerates+drops CacheStorage bytes (Medium #4); HEAD returns no body (Low #5) — `src/inject/inject.ts`, `src/content/content-script.ts`, `src/shared/messages.ts`, `src/manifest.json`, `src/background/{service-worker,global-fetch-register}.ts`, `src/background/core/replica.ts` | `feat/phase-1-shared-replica` | SECURITY/COMPAT-CRITICAL — re-review/upgrade candidate | | `test/inject.test.ts` (new) + `test/jsdom.d.ts` (ambient, no new dep) + round-2 cases in `test/{replica,service-worker-gate,service-worker-replica}.test.ts` (+23) | `feat/phase-1-shared-replica` | re-review/upgrade candidate | +| Phase-2 per-data-class grant gate — pure core (`src/background/core/{class-grant,class-request,class-resolve,consent}.ts`), SW wiring (`service-worker.ts` `gateAccess` + requestAccess/listGrants/revokeGrant/resolveClass/consent handlers), grant store (`session-store.ts`), message protocol (`shared/messages.ts`), page API (`inject.ts`) + content relay (`content-script.ts`), consent window (`src/consent/*`) | `feat/phase-2-class-grants` | SECURITY-CRITICAL — re-review/upgrade candidate | +| `test/{class-grant,class-request,class-resolve,consent,service-worker-class-gate}.test.ts` (+73 adversarial cases) + `docs/access-management-seam.md` rewrite | `feat/phase-2-class-grants` | re-review/upgrade candidate | +| **Model: `claude-sonnet-5` (NOT Opus 4.8, despite the surrounding table).** DPoP non-extractable-key hardening (design roborev round-1, the F3 keystore-advantage finding): `src/background/core/{dpop,dpop-key-db,dpop-key-store}.ts`, `session-store.ts` legacy-JWK migration on `loadSession()`, `auth-flow.ts` + `service-worker.ts` rewiring onto the persisted non-extractable `CryptoKeyPair` | `security/nonextractable-dpop-key` | SECURITY-CRITICAL — re-review/upgrade candidate | +| `test/{dpop,dpop-key-store,session-store,service-worker-gate,service-worker-class-gate,service-worker-replica}.test.ts` updates (+11 net new cases) + `README.md`, `docs/manifest-permissions.md`, `docs/design/shared-replica-architecture.md` updates | `security/nonextractable-dpop-key` | re-review/upgrade candidate | +| **Model: `claude-sonnet-5`.** Round-2 roborev fixes on the above: `dpopJkt` (RFC 7638 thumbprint) recorded on `StoredSession` so `loadSessionDpopKeyPair` can detect an unexpected persisted-key loss/mismatch for a current-shape session (throws `DpopKeyUnavailableError`, treated as a refresh failure) instead of silently minting/using a substitute key (Medium #1); `auth-flow.ts completeLogin` now generates the keypair early (needed for the token-exchange DPoP proof) but only calls the new `persistDpopKeyPair` after the exchange succeeds, so a failed/retried login can never clobber an existing session's still-valid persisted key (Medium #2) | `security/nonextractable-dpop-key` | SECURITY-CRITICAL — re-review/upgrade candidate | +| **Model: `claude-sonnet-5`.** Round-3 roborev fix: `ensureSession()`'s DPoP-key-load catch was blanket — ANY error (including a transient IndexedDB-open failure) tore the session down. Narrowed to teardown ONLY on `DpopKeyUnavailableError`; any other error fails that one request soft (`cachedKeyPair` stays null, session/tokens left intact, next attempt retries cleanly) — never a silent forced logout on a transient fault (Medium) | `security/nonextractable-dpop-key` | SECURITY-CRITICAL — re-review/upgrade candidate | diff --git a/apps/extension/docs/access-management-seam.md b/apps/extension/docs/access-management-seam.md index 62e237dd1..bdc6eb9b7 100644 --- a/apps/extension/docs/access-management-seam.md +++ b/apps/extension/docs/access-management-seam.md @@ -1,46 +1,89 @@ -# Access management — the seam (deferred, design-first track) +# Access management — the per-data-class grant gate (Phase 2) -Access management — letting a page *request access* to specific resources/data-types, the -user *consent* to it, and the extension *manage queued requests* — is **out of scope for the -extension core** and is a separate, design-first initiative. This note documents the **seam** -the core leaves so that track can land later without a breaking change. It is a forward -reference, not a checklist of pending work. +Access management — letting a page *request access* to specific **data classes**, the user +*consent* to it in the extension's own chrome, and the service worker *enforce* the resolved +scope — is **implemented** as of Phase 2. This note documents the shipped surface, the +enforcement model, and its honest limits. It supersedes the earlier "deferred seam" note. -## What the core deliberately does NOT do +Design source: `full-solid-ecosystem/docs/design/access-management-proposal.md` §4 (the client→ +extension JS API + SW enforcement) and §6 (the adversarial critique: revocation propagation, +TOCTOU → target-pinning). Phase 0/1 (the per-origin gate + shared replica) is the substrate this +extends — it is NOT replaced; the **service worker remains the SOLE security boundary**. -- No `requestAccess(...)` implementation (no access-request JS API). -- No consent UI / data-type selection UI. -- No queued-request handling / pending-request inbox. +## The `window.solid` access-management API (`src/inject/inject.ts`) -## The seam that IS present +| Method | Returns | Behaviour | +|---|---|---| +| `requestAccess({classes, modes, purpose, duration})` | `{ ok, grants: SolidGrant[] }` | Resolves each class → concrete pod targets, opens the **unspoofable consent window**, mints one grant per approved class (PINNED targets). | +| `listGrants()` | `SolidGrant[]` | The non-expired grants THIS origin holds for the active WebID. | +| `revokeGrant(grantId)` | `boolean` | Revoke one of THIS origin's grants (cannot touch another origin's). | +| `resolveClass(classIRI)` | `{ shape, targets[] }` | Read-only introspection (granted origins only). | -`window.solid` declares an **optional, throwing** `requestAccess?` method -(`src/inject/inject.ts`): +`duration` is an ISO-8601 duration (e.g. `"P30D"`); it is **required** and clamped to 365 days — +a grant with no bounded expiry can never be minted. The page never receives a token — only a +grant handle and a capability to `fetch` within the pinned set. -```ts -interface SolidExtension { - // ... webId, fetch, setClientId, login, logout ... - requestAccess?(request: unknown): Promise; // SEAM — not implemented -} -``` +## The grant key + enforcement (the security substance) -- It is **declared** so a consumer can feature-detect (`typeof window.solid.requestAccess`) - and so adding the real method later is **non-breaking** (the surface already accounts for it). -- It currently **rejects** with a clear "not implemented: access management is a separate, - design-first track" message, so a caller fails loudly rather than silently no-op. +- **Grant key widened** from `origin` to `(origin, dataClass, modes, purpose, expiry)`, plus a + `webId` session dimension and a **PINNED resolved target set** — the enforced substance. + Stored in `chrome.storage.local` (`solid:class-grants`), page-unreachable; every read is + sanitised so a tampered record authorises nothing. +- **Resolution + pinning (TOCTOU mitigation, design §6.2).** At grant time the data class is + resolved to concrete targets via the **Type Index** walk (`src/background/core/class-resolve.ts`, + `solid:publicTypeIndex`/`privateTypeIndex` → `solid:TypeRegistration` `solid:forClass` → + `solid:instance`/`solid:instanceContainer`) and that set is **frozen into the grant**. A + resource created later, or re-typed into the class, is NOT covered — it needs fresh consent. + (SAI `interop:DataRegistration` + SHACL-validate rungs are documented follow-ups.) +- **The SW enforces the pinned set on every `.fetch`** (`gateAccess` in `service-worker.ts`, + run inside the replica's injected gate so it applies on a cache HIT too). A class-only origin's + request must fall within a live grant's pinned targets with a satisfying mode, else DENY with a + **distinguishable** reason (`Out of grant scope` / `No access grant for this data`). Pure gate + logic + fail-closed rules live in `src/background/core/class-grant.ts` (exhaustively tested). +- **Expiry** is a per-request clock check; an expired grant denies AND is pruned from storage. +- **Revocation** removes the grant record; because the SW re-reads the store on **every** request + (no in-memory grant cache), it is effective on the **next** request. -## Constraints for the future track (so it composes with the core, not around it) +## Backwards compatibility (design item 5) -When the access-management design is approved and implemented, it MUST: +Legacy **origin-only** grants (a deliberate login / `setClientId` opt-in, or a credential/pod +origin) keep **whole-pod access** unchanged — the class gate is only applied to an origin that is +present *solely* because it holds class grants. This is a dual-read: no data migration; a +full-grant origin bypasses the per-class confinement. -1. **Go through the existing credential boundary**, not around it. Any access-mediated fetch - still passes `core/origin-policy.ts` + `core/authenticated-fetch.ts` — the per-origin, - fail-closed token gate is not relaxed for access requests. -2. **Keep the page credential-free.** Consent state, granted scopes, and any access-grant - tokens live in the service worker / `chrome.storage` (page-unreachable), exactly like the - session does today. -3. **Reuse the message protocol** (`src/shared/messages.ts`) and the content-script trust - boundary (the real page origin is stamped server-side, never page-supplied). -4. **Not weaken any security invariant** listed in `CLAUDE.md` / `README.md`. +## The consent surface -Until that design exists, do not wire access management onto the `requestAccess` stub. +`requestAccess` opens `consent/consent.html` (a `chrome-extension://` page — unspoofable by the +requesting origin) showing, per class, the data class, modes, purpose, expiry, and the **resolved +target list** ("this will share these N resources"). Per-class approve / deny-all; closing the +window denies (fail-closed). Pure view/decision logic: `src/background/core/consent.ts`. + +## Honest limits (design §6) + +- **Revocation is eventually-consistent, not instant.** In the extension the propagation bound is + **one request**: the SW re-reads the grant store per request and denies before touching the + cache or the pod (the gate-on-every-read invariant), so a revoked grant cannot be served off a + warm replica. Any request already past the gate check completes; there is no page-held token to + invalidate. A future refinement is a per-grant replica-slice purge on revoke (the replica is + currently keyed by grant-scope = origin, not per class). +- **Type-Index resolution trusts the writer's typing.** The pinned set is only as correct as the + pod's Type Index; SHACL-validate-at-resolution (design §6.2 backbone) is a follow-up. Write-by- + class stays conservative (target-pinned, never a blanket container widen from an app's own type + declaration). +- **Purpose is audit-only**, never RS-enforced (design §6.3). + +## Invariants preserved + +1. All access-mediated fetches still pass `core/origin-policy.ts` + `core/authenticated-fetch.ts`. +2. The page stays credential-free; grants + tokens live in the SW / `chrome.storage`. +3. The content-script stamps the real page origin server-side (never page-supplied). +4. The **SW is the sole enforcement point**; the MAIN-world inject has zero security weight. + +## What the standalone `solid-access-manager` app needs from this API + +The app (design §4.3) that brokers consent when no extension is present speaks the SAME on-pod +artifacts; to drive the extension when it IS present it calls `window.solid.requestAccess(...)` / +`listGrants()` / `revokeGrant()` / `resolveClass()`. The one wiring the app still needs is a +`DataController` `AccessDeniedError` → "request access" hook (a `solid-components` follow-up, design +§4.3) that deep-links into the app's request composer or calls `requestAccess` when the extension +is detected (`'solid' in window` + `data-solid-extension` marker). diff --git a/apps/extension/docs/design/shared-replica-architecture.md b/apps/extension/docs/design/shared-replica-architecture.md index 58c5b8d40..88bf58b58 100644 --- a/apps/extension/docs/design/shared-replica-architecture.md +++ b/apps/extension/docs/design/shared-replica-architecture.md @@ -56,8 +56,9 @@ ONE replica per `(WebID, pod-origin)` in the extension SW's own storage, with byte copy per resource, not per content-type-variant-per-app. - **Metadata** (ETag, fetchedAt, max-age, variant set, the OWNING grant-scope) → `IndexedDB` via `@jeswr/solid-offline` `MetadataStore` (`metadata-store.ts`). -- **Session + DPoP key + grant table** → `chrome.storage.local` (page-unreachable; already - the home of the keypair today). +- **Session tokens + grant table** → `chrome.storage.local` (page-unreachable). **UPDATE:** + the DPoP private key itself has since moved OFF `chrome.storage.local` entirely — it is a + non-extractable `CryptoKey` in its own IndexedDB store (`core/dpop-key-db.ts`); see §4.2. - **DPoP nonce cache** → MOVED from the in-memory `nonceByOrigin` Map (service-worker.ts:59) to IndexedDB, so a cold SW wake does not pay the §8 nonce round-trip on every first request. @@ -158,14 +159,26 @@ written.** This is a pre-existing exposure worth a tracked security bead on ### 4.2 Key custody — the strongest property (PRESERVED, do not touch) -Refuted by all reviewers. The DPoP keypair lives only as JWK in `chrome.storage.local`, -reachable only from extension contexts; `createDpopProof` (`dpop.ts`) exports ONLY the -public components (kty/crv/x/y, never `d`); the page receives only a WebID + a reconstructed -Response; page-supplied `Authorization`/`DPoP` headers are stripped (`authenticated-fetch.ts` -`HOP_BY_HOP`). Residual LOW: the key is generated **extractable** (an MV3 necessity — a -non-extractable key is lost on SW suspension, breaking the jkt-bound refresh) and co-located -with the refresh token in `chrome.storage.local`, so any extension-context compromise -exfiltrates both from one store. Not page-reachable; a concentrated single point worth noting. +Refuted by all reviewers. `createDpopProof` (`dpop.ts`) exports ONLY the public components +(kty/crv/x/y, never `d`); the page receives only a WebID + a reconstructed Response; +page-supplied `Authorization`/`DPoP` headers are stripped (`authenticated-fetch.ts` +`HOP_BY_HOP`). + +**UPDATE (security hardening, closes the residual LOW noted below at design time):** the +DPoP private key is now generated **non-extractable** and persisted as a live `CryptoKey` +object directly in the worker's own IndexedDB (`core/dpop-key-db.ts` / +`core/dpop-key-store.ts`), never as JWK in `chrome.storage.local`. Non-extractable +`CryptoKey`s are structured-clone serialisable per the WebCrypto spec, so the key survives +the MV3 SW's kill/restart cycle — the original "extractable is an MV3 necessity" assumption +conflated the key OBJECT's in-memory lifetime with whether the material could be durably +persisted; it can, without ever being exportable. The original text below is kept for the +historical record: ~~The DPoP keypair lives only as JWK in `chrome.storage.local`, reachable +only from extension contexts. Residual LOW: the key is generated **extractable** (an MV3 +necessity — a non-extractable key is lost on SW suspension, breaking the jkt-bound refresh) +and co-located with the refresh token in `chrome.storage.local`, so any extension-context +compromise exfiltrates both from one store. Not page-reachable; a concentrated single point +worth noting.~~ The private key no longer lives in `chrome.storage.local` at all, and no +extension-context code (compromised or not) can ever export it. ### 4.3 Other verified findings (graft the mitigation into the recommendation) diff --git a/apps/extension/docs/manifest-permissions.md b/apps/extension/docs/manifest-permissions.md index fa9f5599c..a243e4e66 100644 --- a/apps/extension/docs/manifest-permissions.md +++ b/apps/extension/docs/manifest-permissions.md @@ -11,7 +11,7 @@ Reviewed against the shared-replica design `docs/design/shared-replica-architect | Permission | Why it is required | Could it be narrowed? | |---|---|---| -| `storage` | The SOLE home of the DPoP keypair, session tokens, profile, recent accounts, per-origin client-ids, and the **granted-origins grant store** (`chrome.storage.local`, page-unreachable). | No — load-bearing for the credential boundary. | +| `storage` | The home of the session tokens, profile, recent accounts, per-origin client-ids, and the **granted-origins grant store** (`chrome.storage.local`, page-unreachable). The DPoP **private key** itself no longer lives here — it is a non-extractable `CryptoKey` in the worker's own IndexedDB (`core/dpop-key-db.ts`, an unpermissioned Web Platform API always available to extension contexts); only the public JWK ever touches storage, and only inside the (page-unreachable) IndexedDB record. | No — load-bearing for the credential boundary. | | `identity` | `chrome.identity.launchWebAuthFlow` drives the Solid-OIDC auth-code + PKCE redirect (a service worker has no `window`). | No. | | `sidePanel` | The persistent side-panel account surface. | No. | | `contextMenus` | The right-click "Open Solid side panel" entry. | No. | diff --git a/apps/extension/src/background/auth-flow.ts b/apps/extension/src/background/auth-flow.ts index b467ef1ae..82b7a6754 100644 --- a/apps/extension/src/background/auth-flow.ts +++ b/apps/extension/src/background/auth-flow.ts @@ -16,12 +16,9 @@ import * as jose from 'jose'; import { isPublishedClientIdReachable, PUBLISHED_CLIENT_ID_URL } from './client-id'; -import { - createDpopProof, - exportDpopKeyPair, - generateDpopKeyPair, - importDpopKeyPair, -} from './core/dpop'; +import { createDpopProof, dpopThumbprint, generateDpopKeyPair } from './core/dpop'; +import { DpopKeyDb } from './core/dpop-key-db'; +import { loadSessionDpopKeyPair, persistDpopKeyPair } from './core/dpop-key-store'; import { isLoopbackHost } from './core/origin-policy'; import { parseWebIdProfile, selectIssuer } from './core/webid'; import { @@ -231,6 +228,10 @@ async function completeLogin(callbackUrl: string): Promise { throw new Error('Issuer mismatch in the authorization response — aborting login.'); } + // Generate the keypair EARLY (needed to sign the token-exchange DPoP proof itself), but do + // NOT persist it until the exchange actually succeeds — see below. A failed/retried login + // must never clobber an existing, still-valid session's key in this single-record store + // (roborev round-1 Medium). const keyPair = await generateDpopKeyPair(); const dpopProof = await createDpopProof({ keyPair, @@ -264,11 +265,17 @@ async function completeLogin(callbackUrl: string): Promise { const webId = (idToken.webid as string) || (idToken.sub as string); if (!webId) throw new Error('No WebID in the ID token.'); + // The exchange succeeded — `keyPair` is now genuinely bound to a real token set. ONLY now + // persist it (overwriting whatever was there) and record its thumbprint on the session. + const keyDb = await DpopKeyDb.open(); + await persistDpopKeyPair(keyDb, keyPair); + keyDb.close(); + const session: StoredSession = { webId, accessToken: tokens.access_token, refreshToken: tokens.refresh_token ?? null, - dpopKeyPair: await exportDpopKeyPair(keyPair), + dpopJkt: await dpopThumbprint(keyPair.publicKey), issuer: params.issuer, tokenEndpoint: params.tokenEndpoint, clientId: params.clientId, @@ -282,12 +289,21 @@ async function completeLogin(callbackUrl: string): Promise { /** * Refresh an expiring session via the DPoP-bound refresh-token grant, reusing the SAME - * keypair (CSS binds the refresh token to the original `jkt`, so a regenerated key fails). - * One retry on a server DPoP-nonce challenge. + * persisted keypair (CSS binds the refresh token to the original `jkt`, so a regenerated + * key fails) — `loadSessionDpopKeyPair` verifies the persisted key's thumbprint against + * `session.dpopJkt` and returns it unchanged in the normal case. It throws + * `DpopKeyUnavailableError` (propagated to the caller, handled like any other refresh + * failure — see `service-worker.ts` `ensureSession`) if the persisted key is missing or + * doesn't match a CURRENT-shape session; it mints a NEW key ONLY for the one-time legacy + * migration (a pre-hardening session with no recorded `dpopJkt`), which deliberately + * invalidates this grant — see `dpop-key-store.ts`. One retry on a server DPoP-nonce + * challenge. */ export async function refreshSession(session: StoredSession): Promise { if (!session.refreshToken) throw new Error('No refresh token; re-login required.'); - const keyPair = await importDpopKeyPair(session.dpopKeyPair); + const keyDb = await DpopKeyDb.open(); + const keyPair = await loadSessionDpopKeyPair(keyDb, session); + keyDb.close(); const grant = async (nonce?: string): Promise => { const proof = await createDpopProof({ diff --git a/apps/extension/src/background/core/class-grant.ts b/apps/extension/src/background/core/class-grant.ts new file mode 100644 index 000000000..5783e8a16 --- /dev/null +++ b/apps/extension/src/background/core/class-grant.ts @@ -0,0 +1,301 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * The PER-DATA-CLASS GRANT model + the pure enforcement logic — the security heart of + * access-management Phase 2 (design `access-management-proposal.md` §4). + * + * ──────────────────────────────────────────────────────────────────────────── + * WHAT THIS WIDENS, AND WHY IT IS FAIL-CLOSED + * + * Phase-0/1 consented by *origin*: a granted app origin could drive `window.solid.fetch` + * against the WHOLE pod. This module adds the ORTHOGONAL, NARROWER capability the design + * calls for — a grant keyed by `(origin, dataClass, modes, purpose, expiry)` whose + * enforceable substance is a **PINNED, RESOLVED TARGET SET**: at grant time the data class + * is resolved (type-index walk, `class-resolve.ts`) to the concrete pod resources/containers + * it maps to, and THAT set is frozen into the grant. The service worker then enforces the + * pinned set on every `.fetch` — not the origin, and never a live re-resolution (the TOCTOU + * mitigation, design §6.2: the user consented to *those* resources; a resource created later, + * or re-typed into the class, needs fresh consent). + * + * This module is the PURE core of that gate — no `chrome.*`, no `fetch`, no storage — so the + * decision is exhaustively unit-testable. Every rule is fail-closed: + * - an EXPIRED grant authorises nothing (and is pruned); + * - a grant with an EMPTY / unresolved target set authorises nothing (unknown class → deny); + * - a grant with no valid modes authorises nothing; + * - a malformed stored grant authorises nothing; + * - a target-set membership test fails closed on any unparseable URL, cross-origin target, + * or path-traversal attempt (the WHATWG URL parser normalises `..`, so a normalised path + * that escapes the pinned container prefix is simply not a match → DENY). + * + * BACKWARDS COMPATIBILITY (design item 5 — "legacy origin-only grants must keep working"): + * this module governs ONLY origins whose access is class-scoped. An origin that holds a + * FULL-pod grant (a deliberate login / `setClientId` opt-in, or a credential/pod origin) is + * NOT constrained here — the service worker checks full-grant membership FIRST and only falls + * through to this pinned-set gate for an origin that is present *solely* because it holds one + * or more class grants. So a legacy origin keeps its whole-pod access unchanged; a class-only + * origin is confined to its pinned targets. (Dual-read migration; no data migration needed.) + * ──────────────────────────────────────────────────────────────────────────── + */ + +/** The three WAC-shaped access modes a grant can carry. */ +export type GrantMode = 'read' | 'append' | 'write'; + +/** A pinned, resolved target: either one exact resource, or a container (covers descendants). */ +export interface PinnedTarget { + /** The concrete resource/container URL, normalised to its `origin + pathname`. */ + readonly url: string; + /** `instance` ⇒ exactly this resource; `container` ⇒ this container and its descendants. */ + readonly kind: 'instance' | 'container'; +} + +/** + * A per-data-class grant, as persisted in extension storage. IMMUTABLE once written (a change + * is a new grant + a revoke, mirroring SAI's immutable AccessGrant). The `targets` are the + * RESOLVED, PINNED set — the substance the SW enforces; the `dataClass`/`purpose` are the + * human-meaningful consent anchors carried for the dashboard + audit (they are NOT enforced — + * enforcement is on `targets` + `modes` + `expiresAt`). + */ +export interface ClassGrant { + /** Opaque, unique grant id (also the revoke handle). */ + readonly grantId: string; + /** + * The WebID this grant was minted under — a SECURITY dimension (mirrors the replica-key + * WebID boundary): a grant minted for WebID A NEVER authorises a request while WebID B is the + * active session, so a re-login as a different identity cannot inherit A's pinned access. + */ + readonly webId: string; + /** The requesting app origin this grant authorises (canonical `https://host[:port]`). */ + readonly origin: string; + /** The data-class IRI the user consented to (audit/UX anchor; not itself enforced). */ + readonly dataClass: string; + /** The granted modes. Enforcement requires the request's method-mode to be satisfied. */ + readonly modes: readonly GrantMode[]; + /** The declared purpose (DPV IRI or string) — audit-only, NOT RS-enforceable (design §6.3). */ + readonly purpose: string | null; + /** Absolute expiry, epoch ms. A request at/after this instant is denied (design item 3). */ + readonly expiresAt: number; + /** The RESOLVED, PINNED targets — the enforced substance (TOCTOU mitigation, design §6.2). */ + readonly targets: readonly PinnedTarget[]; + /** When the grant was minted, epoch ms (dashboard ordering / audit). */ + readonly createdAt: number; +} + +/** + * Map an HTTP method to the access mode the request REQUIRES. Fail-closed: an unknown/unsafe + * method requires `write` (the most privileged mode), so an unrecognised verb can only be + * authorised by an explicit write grant, never slipped through as a read. + * GET/HEAD → read ; POST → append ; PUT/PATCH/DELETE → write ; else → write. + */ +export function methodToMode(method: string): GrantMode { + switch (method.trim().toUpperCase()) { + case 'GET': + case 'HEAD': + return 'read'; + case 'POST': + return 'append'; + case 'PUT': + case 'PATCH': + case 'DELETE': + return 'write'; + default: + return 'write'; + } +} + +/** Parse/normalise an arbitrary mode string to a {@link GrantMode}, or `null` if unrecognised. */ +export function normaliseMode(value: unknown): GrantMode | null { + if (typeof value !== 'string') return null; + switch (value.trim().toLowerCase()) { + case 'read': + return 'read'; + case 'append': + return 'append'; + case 'write': + return 'write'; + default: + return null; + } +} + +/** Normalise a list of mode strings to the deduped set of recognised {@link GrantMode}s. */ +export function normaliseModes(values: unknown): GrantMode[] { + if (!Array.isArray(values)) return []; + const out: GrantMode[] = []; + for (const v of values) { + const m = normaliseMode(v); + if (m && !out.includes(m)) out.push(m); + } + return out; +} + +/** + * Whether a granted mode-set SATISFIES the mode a request requires. The only implication is the + * WAC-standard `write ⊇ append` (a write grant covers an append). `read` is independent — a + * write grant does NOT imply read, and a read grant does NOT imply append/write. Fail-closed: + * an empty granted set satisfies nothing. + */ +export function modeSatisfies(granted: readonly GrantMode[], required: GrantMode): boolean { + if (required === 'append') return granted.includes('append') || granted.includes('write'); + return granted.includes(required); +} + +/** Parse a string to its normalised `origin + pathname` (no query/fragment), or `null`. */ +function originPath(value: string): { origin: string; path: string } | null { + let u: URL; + try { + u = new URL(value); + } catch { + return null; + } + if (u.protocol !== 'https:' && u.protocol !== 'http:') return null; + const origin = u.origin; + if (origin === 'null' || origin === '') return null; + return { origin, path: u.pathname }; +} + +/** + * Whether a request URL falls WITHIN a single pinned target. PURE + fail-closed: + * - unparseable / non-http(s) request or target URL → false; + * - cross-origin (request origin ≠ target origin) → false; + * - `instance` ⇒ EXACT `origin + pathname` match (query/fragment ignored — a pod resource's + * identity is its path, not its query); + * - `container` ⇒ the request path must be the container path itself OR a descendant of it. + * The container path is normalised to end in `/`, and membership is a strict path-segment + * prefix on the ALREADY-URL-NORMALISED request path — so a `..` traversal (which the URL + * parser collapses before we ever see it) that escapes the container cannot match, and a + * sibling like `/finance-secret/` never matches the pinned `/finance/`. + */ +export function isWithinTarget(target: PinnedTarget, requestUrl: string): boolean { + const req = originPath(requestUrl); + if (req === null) return false; + const tgt = originPath(target.url); + if (tgt === null) return false; + if (req.origin !== tgt.origin) return false; + + if (target.kind === 'instance') { + return req.path === tgt.path; + } + // container: normalise to a trailing-slash prefix; the container itself and any descendant match. + const prefix = tgt.path.endsWith('/') ? tgt.path : `${tgt.path}/`; + return req.path === prefix || req.path.startsWith(prefix); +} + +/** Whether ANY pinned target of the set covers the request URL. Fail-closed on an empty set. */ +export function targetsCover(targets: readonly PinnedTarget[], requestUrl: string): boolean { + return targets.some((t) => isWithinTarget(t, requestUrl)); +} + +/** Whether a grant is expired (or has a missing/invalid expiry) at `now` (epoch ms). */ +export function isGrantExpired(grant: ClassGrant, now: number): boolean { + if (typeof grant.expiresAt !== 'number' || !Number.isFinite(grant.expiresAt)) return true; + return now >= grant.expiresAt; +} + +/** + * Structural validation of a grant loaded from (untrusted) storage. A grant that fails ANY + * check authorises NOTHING (it is dropped by {@link sanitiseGrants}). Defends the gate against + * a corrupted / tampered storage record. + */ +export function isValidGrant(value: unknown): value is ClassGrant { + if (typeof value !== 'object' || value === null) return false; + const g = value as Record; + if (typeof g.grantId !== 'string' || g.grantId.length === 0) return false; + if (typeof g.webId !== 'string' || g.webId.length === 0) return false; + if (typeof g.origin !== 'string' || originPath(g.origin) === null) return false; + if (typeof g.dataClass !== 'string') return false; + if (typeof g.expiresAt !== 'number' || !Number.isFinite(g.expiresAt)) return false; + if (typeof g.createdAt !== 'number' || !Number.isFinite(g.createdAt)) return false; + if (!Array.isArray(g.modes)) return false; + if (!Array.isArray(g.targets)) return false; + for (const t of g.targets) { + if (typeof t !== 'object' || t === null) return false; + const pt = t as Record; + if (typeof pt.url !== 'string') return false; + if (pt.kind !== 'instance' && pt.kind !== 'container') return false; + } + // modes must be an array of recognised mode strings (an unrecognised entry is a corrupt record). + for (const m of g.modes) if (normaliseMode(m) === null) return false; + return true; +} + +/** Keep only the structurally-valid grants from an untrusted list. */ +export function sanitiseGrants(values: unknown): ClassGrant[] { + if (!Array.isArray(values)) return []; + return values.filter(isValidGrant); +} + +/** + * Whether a SINGLE grant authorises a `(url, method)` request at `now`. Fail-closed on every + * axis: expired, mode not satisfied, empty/unresolved target set, or the URL outside every + * pinned target → false. + */ +export function grantCovers( + grant: ClassGrant, + requestUrl: string, + method: string, + now: number, +): boolean { + if (isGrantExpired(grant, now)) return false; + if (grant.targets.length === 0) return false; // unresolved / unknown class → DENY. + if (!modeSatisfies(grant.modes, methodToMode(method))) return false; + return targetsCover(grant.targets, requestUrl); +} + +/** A distinguishable reason the class gate denied, surfaced to the page (design item 2). */ +export type ClassDenyReason = + | 'out-of-grant-scope' // URL/method/expiry not covered by any pinned grant for this origin + | 'no-class-grant'; // the origin holds no (live) class grant at all + +/** + * The class-scoped gate decision for one request. Given the (already-pruned, origin-filtered) + * class grants an origin holds, decide whether `(url, method)` is authorised at `now`. + * `null` deny ⇒ ALLOW. PURE + fail-closed: no grants ⇒ `no-class-grant`; grants present but + * none cover ⇒ `out-of-grant-scope`. + */ +export function decideClassAccess( + grantsForOrigin: readonly ClassGrant[], + requestUrl: string, + method: string, + now: number, +): ClassDenyReason | null { + const live = grantsForOrigin.filter((g) => !isGrantExpired(g, now)); + if (live.length === 0) return 'no-class-grant'; + const authorised = live.some((g) => grantCovers(g, requestUrl, method, now)); + return authorised ? null : 'out-of-grant-scope'; +} + +/** The non-expired subset of a grant list at `now` (used to prune the store). */ +export function pruneExpiredGrants(grants: readonly ClassGrant[], now: number): ClassGrant[] { + return grants.filter((g) => !isGrantExpired(g, now)); +} + +/** + * The distinct origins holding at least one non-expired grant for the active WebID (drive the + * per-origin gate so a class-only origin passes it, then the class gate confines it). When + * `webId` is given, grants minted under a DIFFERENT WebID are ignored (the session boundary). + */ +export function liveGrantOrigins( + grants: readonly ClassGrant[], + now: number, + webId?: string | null, +): Set { + const out = new Set(); + for (const g of grants) { + if (isGrantExpired(g, now)) continue; + if (webId != null && g.webId !== webId) continue; + out.add(g.origin); + } + return out; +} + +/** + * The grants that apply to a `(origin, webId)` session — the input to {@link decideClassAccess}. + * Filters to this exact requesting origin AND this exact active WebID (the session boundary), + * so cross-origin and cross-identity grants can never authorise the request. + */ +export function grantsForSession( + grants: readonly ClassGrant[], + origin: string, + webId: string, +): ClassGrant[] { + return grants.filter((g) => g.origin === origin && g.webId === webId); +} diff --git a/apps/extension/src/background/core/class-request.ts b/apps/extension/src/background/core/class-request.ts new file mode 100644 index 000000000..97f7a75d0 --- /dev/null +++ b/apps/extension/src/background/core/class-request.ts @@ -0,0 +1,118 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * PURE parsing + validation of a `requestAccess({classes, modes, purpose, duration})` payload + * (design §4.2). The page can send anything — this normalises the untrusted input into a + * bounded, well-formed grant specification, or an error. Fail-closed: + * - a data class must be an `https:` (or dev-loopback `http:`) IRI; a non-IRI class is dropped, + * and a request with NO valid class is rejected; + * - modes must include at least one recognised mode; + * - a bounded `duration` is REQUIRED — a grant with no expiry can never be minted (design + * item 3, "expiry enforced"); an unparseable/absent/zero/negative duration is rejected; + * - the duration is capped at {@link MAX_GRANT_MS} so a page cannot request an effectively + * unbounded grant. + * + * No `chrome.*` / `fetch` / storage here — this is exhaustively unit-testable. + */ + +import { type GrantMode, normaliseModes } from './class-grant'; + +/** The hard ceiling on a grant's lifetime: 365 days. A longer request is clamped to this. */ +export const MAX_GRANT_MS = 365 * 24 * 60 * 60 * 1000; + +/** The raw, untrusted request payload as it arrives from the page bridge. */ +export interface RawAccessRequest { + readonly classes?: unknown; + readonly modes?: unknown; + readonly purpose?: unknown; + readonly duration?: unknown; +} + +/** A validated, bounded grant specification derived from a raw request. */ +export interface AccessRequestSpec { + /** The valid, deduped data-class IRIs requested. */ + readonly classes: readonly string[]; + /** The recognised, deduped modes requested. */ + readonly modes: readonly GrantMode[]; + /** The declared purpose (kept verbatim as an audit anchor), or `null`. */ + readonly purpose: string | null; + /** Absolute expiry for a grant minted now, epoch ms (`now + clamped(duration)`). */ + readonly expiresAt: number; + /** The (clamped) grant lifetime in ms — surfaced in the consent UI. */ + readonly durationMs: number; +} + +function isHttpIri(value: unknown): value is string { + if (typeof value !== 'string') return false; + let u: URL; + try { + u = new URL(value); + } catch { + return false; + } + return u.protocol === 'https:' || u.protocol === 'http:'; +} + +/** + * Parse an ISO-8601 duration to milliseconds, or `null` if it is not a positive, bounded + * duration. Supports the date part `P[nY][nM][nW][nD]` and the time part `T[nH][nM][nS]` + * (fractional seconds allowed). Year/month are approximated (Y = 365d, month = 30d) — a + * documented imprecision acceptable for an access-grant TTL — and any such approximation is in + * any case clamped by {@link MAX_GRANT_MS} at the caller. Returns `null` for `P`/empty/zero/ + * negative/NaN (fail-closed — the caller then rejects the request). + */ +export function parseIso8601DurationMs(value: unknown): number | null { + if (typeof value !== 'string') return null; + const m = + /^P(?:(\d+(?:\.\d+)?)Y)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)W)?(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/.exec( + value.trim(), + ); + if (!m) return null; + const [, y, mo, w, d, h, min, s] = m; + // Require at least ONE component present (a bare "P" / "PT" is meaningless → reject). + if ([y, mo, w, d, h, min, s].every((p) => p === undefined)) return null; + const num = (p: string | undefined): number => (p === undefined ? 0 : Number.parseFloat(p)); + const ms = + num(y) * 365 * 24 * 60 * 60 * 1000 + + num(mo) * 30 * 24 * 60 * 60 * 1000 + + num(w) * 7 * 24 * 60 * 60 * 1000 + + num(d) * 24 * 60 * 60 * 1000 + + num(h) * 60 * 60 * 1000 + + num(min) * 60 * 1000 + + num(s) * 1000; + if (!Number.isFinite(ms) || ms <= 0) return null; + return ms; +} + +/** + * Normalise + validate a raw `requestAccess` payload against `now`. Returns the bounded spec, + * or `{ error }` describing the first fatal problem. Fail-closed on every axis (see file note). + */ +export function normaliseAccessRequest( + raw: RawAccessRequest, + now: number, +): AccessRequestSpec | { error: string } { + const rawClasses = Array.isArray(raw.classes) ? raw.classes : []; + const classes: string[] = []; + for (const c of rawClasses) { + if (isHttpIri(c) && !classes.includes(c)) classes.push(c); + } + if (classes.length === 0) { + return { error: 'requestAccess: at least one https: data-class IRI is required.' }; + } + + const modes = normaliseModes(raw.modes); + if (modes.length === 0) { + return { error: 'requestAccess: at least one of read/append/write modes is required.' }; + } + + const durationMs = parseIso8601DurationMs(raw.duration); + if (durationMs === null) { + return { error: 'requestAccess: a positive ISO-8601 duration (e.g. "P30D") is required.' }; + } + const clamped = Math.min(durationMs, MAX_GRANT_MS); + + const purpose = + typeof raw.purpose === 'string' && raw.purpose.trim().length > 0 ? raw.purpose.trim() : null; + + return { classes, modes, purpose, expiresAt: now + clamped, durationMs: clamped }; +} diff --git a/apps/extension/src/background/core/class-resolve.ts b/apps/extension/src/background/core/class-resolve.ts new file mode 100644 index 000000000..ad08b8b3f --- /dev/null +++ b/apps/extension/src/background/core/class-resolve.ts @@ -0,0 +1,205 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * DATA-CLASS → CONCRETE TARGET resolution — the compile step (design §2.3, resolution ladder). + * + * At GRANT time a data-class IRI is resolved to the concrete pod resources/containers that + * hold data of that class, and the result is PINNED into the immutable grant (TOCTOU + * mitigation, design §6.2). This module implements the ubiquitous rung of the ladder — the + * **Solid Type Index** (`solid:publicTypeIndex` / `solid:privateTypeIndex` → + * `solid:TypeRegistration` `solid:forClass` → `solid:instance` / `solid:instanceContainer`). + * The SAI `interop:DataRegistration` rung and the SHACL-validate rung (design §2.3 rungs 1 & 3) + * are documented follow-ups; the resolver is structured so they slot in as additional passes. + * + * RDF is parsed with N3.js (the house rule — never regex over Turtle). The pure parse helpers + * are unit-testable against fixtures; `resolveClassTargets` takes an INJECTED authed fetch so + * the network walk is testable without a live pod and reuses the SW's DPoP egress in prod. + * + * FAIL-CLOSED / defence-in-depth: + * - only `NamedNode`, http(s), same-*credential*-origin targets are kept (a type index that + * points at a foreign origin is dropped — a compromised index cannot pin an off-pod target; + * the SW gate would refuse the token there anyway, this removes it earlier); + * - `forClass` / `instance` / `instanceContainer` objects that are not usable IRIs are dropped; + * - a fetch that fails / is non-2xx is skipped (a private index a public read can't reach + * simply contributes nothing) — resolution degrades, never throws the whole grant. + */ + +import { DataFactory, Parser, Store } from 'n3'; +import type { PinnedTarget } from './class-grant'; + +const SOLID = 'http://www.w3.org/ns/solid/terms#'; +const PUBLIC_TYPE_INDEX = `${SOLID}publicTypeIndex`; +const PRIVATE_TYPE_INDEX = `${SOLID}privateTypeIndex`; +const FOR_CLASS = `${SOLID}forClass`; +const INSTANCE = `${SOLID}instance`; +const INSTANCE_CONTAINER = `${SOLID}instanceContainer`; + +/** An injected fetch returning the body text + ok flag. In prod: the SW's authed DPoP egress. */ +export type ResolveFetch = (url: string) => Promise<{ ok: boolean; text: string }>; + +export interface ResolveOptions { + /** The active session's WebID (the profile document to read the type-index refs from). */ + readonly webId: string; + /** The injected authed fetch. */ + readonly fetch: ResolveFetch; + /** + * The pod/credential origins a target is allowed to live on (defence in depth). When given, + * a resolved target on any other origin is DROPPED. Omit to keep all http(s) targets. + */ + readonly allowedOrigins?: ReadonlySet; +} + +function parse(baseUrl: string, turtle: string): Store { + const parser = new Parser({ baseIRI: baseUrl.split('#')[0] }); + const store = new Store(); + store.addQuads(parser.parse(turtle)); + return store; +} + +/** Normalise an IRI to its `origin + pathname`, keeping only http(s); else `null`. */ +function normaliseTargetUrl(value: string): { url: string; origin: string } | null { + let u: URL; + try { + u = new URL(value); + } catch { + return null; + } + if (u.protocol !== 'https:' && u.protocol !== 'http:') return null; + return { url: `${u.origin}${u.pathname}`, origin: u.origin }; +} + +/** + * The type-index document URLs advertised on the WebID subject (`solid:publicTypeIndex` + + * `solid:privateTypeIndex`), NamedNode + http(s) only, deduped in document order. PURE. + */ +export function parseTypeIndexRefs(webId: string, profileTurtle: string): string[] { + const store = parse(webId, profileTurtle); + const out: string[] = []; + for (const pred of [PUBLIC_TYPE_INDEX, PRIVATE_TYPE_INDEX]) { + for (const q of store.getQuads( + DataFactory.namedNode(webId), + DataFactory.namedNode(pred), + null, + null, + )) { + if (q.object.termType !== 'NamedNode') continue; + const n = normaliseTargetUrl(q.object.value); + if (n && !out.includes(q.object.value)) out.push(q.object.value); + } + } + return out; +} + +/** + * The pinned targets for `classIRI` declared in ONE type-index document. For every + * `solid:TypeRegistration`-shaped subject with `solid:forClass classIRI`, collect its + * `solid:instance` (kind `instance`) and `solid:instanceContainer` (kind `container`) objects, + * NamedNode + http(s) only, normalised to `origin + pathname`, optionally confined to + * `allowedOrigins`. PURE + deduped. + */ +export function parseTargetsForClass( + typeIndexUrl: string, + typeIndexTurtle: string, + classIRI: string, + allowedOrigins?: ReadonlySet, +): PinnedTarget[] { + const store = parse(typeIndexUrl, typeIndexTurtle); + const seen = new Set(); + const out: PinnedTarget[] = []; + + // Subjects registering this class (match on the NamedNode class object). + const subjects = new Set(); + for (const q of store.getQuads( + null, + DataFactory.namedNode(FOR_CLASS), + DataFactory.namedNode(classIRI), + null, + )) { + subjects.add(q.subject.value); + } + + const collect = (pred: string, kind: PinnedTarget['kind']): void => { + for (const subject of subjects) { + for (const q of store.getQuads( + DataFactory.namedNode(subject), + DataFactory.namedNode(pred), + null, + null, + )) { + if (q.object.termType !== 'NamedNode') continue; + const n = normaliseTargetUrl(q.object.value); + if (n === null) continue; + if (allowedOrigins && !allowedOrigins.has(n.origin)) continue; + const dedupe = `${kind} ${n.url}`; + if (seen.has(dedupe)) continue; + seen.add(dedupe); + out.push({ url: n.url, kind }); + } + } + }; + collect(INSTANCE, 'instance'); + collect(INSTANCE_CONTAINER, 'container'); + return out; +} + +/** + * Resolve `classIRI` to its concrete, pinned targets by walking the Type Index: fetch the WebID + * profile → its type-index refs → each index, unioning the targets. Returns a deduped list + * (possibly empty — an unregistered class resolves to NOTHING, and the gate then denies every + * request for it, fail-closed). Never throws on a per-document fetch/parse failure. + */ +export async function resolveClassTargets( + classIRI: string, + opts: ResolveOptions, +): Promise { + const profile = await safeFetch(opts.fetch, opts.webId); + if (profile === null) return []; + + let refs: string[]; + try { + refs = parseTypeIndexRefs(opts.webId, profile); + } catch { + return []; + } + + const seen = new Set(); + const out: PinnedTarget[] = []; + for (const ref of refs) { + // Defence in depth: when confined to pod origins, NEVER fetch a type-index document on a + // foreign origin — a compromised/hostile profile could point `publicTypeIndex` at an + // attacker URL and turn resolution into an SW-side SSRF. Skip any off-pod ref. + if (opts.allowedOrigins && !isAllowedOrigin(ref, opts.allowedOrigins)) continue; + const body = await safeFetch(opts.fetch, ref); + if (body === null) continue; + let targets: PinnedTarget[]; + try { + targets = parseTargetsForClass(ref, body, classIRI, opts.allowedOrigins); + } catch { + continue; + } + for (const t of targets) { + const dedupe = `${t.kind} ${t.url}`; + if (seen.has(dedupe)) continue; + seen.add(dedupe); + out.push(t); + } + } + return out; +} + +/** Whether `url`'s origin is in `allowed` (fail-closed on an unparseable URL). */ +function isAllowedOrigin(url: string, allowed: ReadonlySet): boolean { + try { + return allowed.has(new URL(url).origin); + } catch { + return false; + } +} + +async function safeFetch(fetchFn: ResolveFetch, url: string): Promise { + try { + const { ok, text } = await fetchFn(url); + return ok ? text : null; + } catch { + return null; + } +} diff --git a/apps/extension/src/background/core/consent.ts b/apps/extension/src/background/core/consent.ts new file mode 100644 index 000000000..42db0cea1 --- /dev/null +++ b/apps/extension/src/background/core/consent.ts @@ -0,0 +1,102 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * PURE consent-flow logic — the decision layer behind the extension's consent surface + * (design §4.1, "the consent dialog must be unspoofable by the requesting origin"). The + * consent WINDOW (an extension-chrome page opened by the service worker) renders a + * {@link PendingConsent}; the user's per-class approve/deny maps back to concrete + * {@link ClassGrant}s through {@link consentDecisionToGrants}. Keeping this pure means the + * grant-minting is exhaustively unit-testable without a packed extension. + * + * The consent surface shows, per requested class: the data-class IRI, the requested MODES, + * the declared PURPOSE, the EXPIRY, and — critically — the RESOLVED TARGET SET ("this will + * share these N resources", design §3.4 step 2 / §6.2 gross-mislabelling catch). A class that + * resolved to ZERO targets is surfaced as such and, if approved, mints a grant that authorises + * NOTHING (fail-closed) — never a blanket grant. + */ + +import type { ClassGrant, GrantMode, PinnedTarget } from './class-grant'; + +/** One requested class as presented in the consent window (already resolved to targets). */ +export interface ConsentClass { + readonly dataClass: string; + readonly modes: readonly GrantMode[]; + readonly purpose: string | null; + readonly expiresAt: number; + /** The RESOLVED, pinned targets — shown to the user and frozen into the grant on approve. */ + readonly targets: readonly PinnedTarget[]; + /** The shape IRI the class resolved through (design anchor), or `null` (type-index rung). */ + readonly shape: string | null; +} + +/** A pending consent request awaiting the user's decision in the consent window. */ +export interface PendingConsent { + readonly consentId: string; + /** The verified requesting origin (dual-origin agreement already passed). */ + readonly origin: string; + /** The active WebID the grant would be minted under (the session boundary). */ + readonly webId: string; + readonly createdAt: number; + readonly classes: readonly ConsentClass[]; +} + +/** + * Build the pending-consent record from a validated request spec + the per-class resolution. + * `resolved` maps each class IRI to its pinned targets (+ optional shape). A class missing from + * `resolved` is presented with an empty target set. + */ +export function buildPendingConsent( + consentId: string, + origin: string, + webId: string, + spec: { + readonly classes: readonly string[]; + readonly modes: readonly GrantMode[]; + readonly purpose: string | null; + readonly expiresAt: number; + }, + resolved: ReadonlyMap, + createdAt: number, +): PendingConsent { + const classes: ConsentClass[] = spec.classes.map((dataClass) => { + const r = resolved.get(dataClass); + return { + dataClass, + modes: spec.modes, + purpose: spec.purpose, + expiresAt: spec.expiresAt, + targets: r?.targets ?? [], + shape: r?.shape ?? null, + }; + }); + return { consentId, origin, webId, createdAt, classes }; +} + +/** + * Map a user decision (the set of APPROVED data-class IRIs) to the grants to persist. Only a + * class that was BOTH requested AND approved mints a grant; a denied/unknown class mints none. + * `mintId` supplies a unique grantId per grant. Fail-closed: an approved class not present in + * the pending set is ignored (the window can only approve what it was shown). + */ +export function consentDecisionToGrants( + pending: PendingConsent, + approvedClasses: readonly string[], + mintId: () => string, +): ClassGrant[] { + const approved = new Set(approvedClasses); + const out: ClassGrant[] = []; + for (const c of pending.classes) { + if (!approved.has(c.dataClass)) continue; + out.push({ + grantId: mintId(), + webId: pending.webId, + origin: pending.origin, + dataClass: c.dataClass, + modes: c.modes, + purpose: c.purpose, + expiresAt: c.expiresAt, + targets: c.targets, + createdAt: pending.createdAt, + }); + } + return out; +} diff --git a/apps/extension/src/background/core/dpop-key-db.ts b/apps/extension/src/background/core/dpop-key-db.ts new file mode 100644 index 000000000..c60489c6d --- /dev/null +++ b/apps/extension/src/background/core/dpop-key-db.ts @@ -0,0 +1,106 @@ +// AUTHORED-BY Claude Sonnet 5 +/** + * The DPoP session keypair's durable home — a NON-EXTRACTABLE Web Crypto `CryptoKeyPair` + * persisted directly in IndexedDB, never exported to JWK / `chrome.storage.local`. + * + * SECURITY MIGRATION (design roborev round-1, the F3 keystore-advantage finding): the + * private DPoP key used to be generated `extractable: true` and exported to a JWK stored in + * `chrome.storage.local` because an MV3 service worker is killed/restarted aggressively and + * a non-extractable `CryptoKey` was assumed lost on suspension. That assumption conflated + * two different things: the KEY OBJECT's lifetime (tied to the SW's JS heap, always lost on + * suspension regardless of extractability) and whether the underlying key MATERIAL can be + * durably persisted. The WebCrypto spec makes `CryptoKey` structured-cloneable — including + * non-extractable keys (https://w3c.github.io/webcrypto/#cryptokey-interface, + * `[Serializable]`) — and IndexedDB persists values via the structured-clone algorithm. So a + * non-extractable `CryptoKey` written to an IndexedDB object store survives a + * put/close/reopen round-trip as a LIVE, usable key (`crypto.subtle.sign` still works on the + * reloaded object) while `crypto.subtle.exportKey('jwk', …)` on it correctly REJECTS. The + * private scalar therefore never serialises to disk as extractable material; only the + * PUBLIC JWK (safe — it's exactly what every DPoP proof header already broadcasts, see + * `dpop.ts` `createDpopProof`) is stored alongside for convenience. + * + * One record (`RECORD_ID`) per database: the extension mints a FRESH keypair per login (see + * `auth-flow.ts` `completeLogin` via `dpop-key-store.ts`), so this store always holds at most + * the CURRENT session's key — overwritten on the next login, cleared on logout/teardown + * (`service-worker.ts` `teardownSession`). + */ + +/** The persisted DPoP keypair record — the live `CryptoKey`s, not a JWK export. */ +export interface StoredDpopKeyRecord { + readonly privateKey: CryptoKey; + readonly publicKey: CryptoKey; + /** The public JWK (kty/crv/x/y only) — never the private `d`; safe to persist at rest. */ + readonly publicJwk: JsonWebKey; +} + +const DB_NAME = 'solid-ext-dpop-key'; +const DB_VERSION = 1; +const STORE = 'dpop-keypair'; +const RECORD_ID = 'active'; + +function promisify(req: IDBRequest): Promise { + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +function txDone(tx: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); +} + +/** + * Open (or upgrade) the DPoP key DB. The caller may inject an `indexedDB` factory (the test + * suite injects a fake); defaults to the global (present in the SW context). + */ +export function openDpopKeyDb(factory: IDBFactory = globalThis.indexedDB): Promise { + if (!factory) { + return Promise.reject(new Error('[solid-ext] no IndexedDB available in this context')); + } + return new Promise((resolve, reject) => { + const req = factory.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE); + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +/** A promise-based handle over the DPoP keypair's single IndexedDB record. */ +export class DpopKeyDb { + private constructor(private readonly db: IDBDatabase) {} + + static async open(factory?: IDBFactory): Promise { + return new DpopKeyDb(await openDpopKeyDb(factory)); + } + + /** The persisted keypair, or `undefined` if none is stored (fresh state / post-migration). */ + async get(): Promise { + const tx = this.db.transaction(STORE, 'readonly'); + return promisify(tx.objectStore(STORE).get(RECORD_ID)); + } + + /** Persist (overwrite) the active keypair record. */ + async put(record: StoredDpopKeyRecord): Promise { + const tx = this.db.transaction(STORE, 'readwrite'); + await promisify(tx.objectStore(STORE).put(record, RECORD_ID)); + await txDone(tx); + } + + /** Drop the persisted keypair (logout/teardown — the key is bound to the departing session). */ + async clear(): Promise { + const tx = this.db.transaction(STORE, 'readwrite'); + await promisify(tx.objectStore(STORE).clear()); + await txDone(tx); + } + + close(): void { + this.db.close(); + } +} diff --git a/apps/extension/src/background/core/dpop-key-store.ts b/apps/extension/src/background/core/dpop-key-store.ts new file mode 100644 index 000000000..8d947d54c --- /dev/null +++ b/apps/extension/src/background/core/dpop-key-store.ts @@ -0,0 +1,112 @@ +// AUTHORED-BY Claude Sonnet 5 +/** + * Orchestrates the durable, non-extractable DPoP session keypair: mint-and-persist at + * login, reload-from-IndexedDB on every SW wake, and the one-time legacy migration off the + * old extractable-JWK-in-`chrome.storage.local` shape (security hardening — see + * `dpop-key-db.ts` for the full rationale, `session-store.ts#loadSession` for where the + * legacy shape is detected + stripped). + */ + +import { dpopThumbprint, generateDpopKeyPair } from './dpop'; +import type { DpopKeyDb } from './dpop-key-db'; + +/** Export ONLY the public JWK — always safe, regardless of the private key's extractability. */ +async function publicJwkOf(keyPair: CryptoKeyPair): Promise { + return crypto.subtle.exportKey('jwk', keyPair.publicKey); +} + +/** + * Persist an ALREADY-MINTED keypair as the session's active key. Split out from + * {@link mintAndPersistDpopKeyPair} so a caller can generate a key EARLY (needed to sign the + * token-exchange DPoP proof itself) but defer the destructive overwrite of this single-record + * store until it knows the mint was actually USED for a token set — see `auth-flow.ts` + * `completeLogin`, which persists only after the token exchange succeeds (roborev round-1 + * Medium: persisting before success let a failed/retried login clobber an existing session's + * still-valid key). + */ +export async function persistDpopKeyPair(db: DpopKeyDb, keyPair: CryptoKeyPair): Promise { + await db.put({ + privateKey: keyPair.privateKey, + publicKey: keyPair.publicKey, + publicJwk: await publicJwkOf(keyPair), + }); +} + +/** Mint a fresh non-extractable keypair and persist it as the session's active key. */ +export async function mintAndPersistDpopKeyPair(db: DpopKeyDb): Promise { + const keyPair = await generateDpopKeyPair(); + await persistDpopKeyPair(db, keyPair); + return keyPair; +} + +/** + * Thrown by {@link loadSessionDpopKeyPair} when the persisted key does NOT usably back a + * CURRENT-shape session (one that recorded a `dpopJkt` at mint time) — either it is gone + * entirely, or the persisted key doesn't match what this session expects. Callers MUST treat + * this as a broken, unrecoverable-in-place session (teardown + re-login), the same as a + * refresh-grant failure — never catch it and silently proceed with an unrelated key (roborev + * round-1 Medium: a silently-rotated key for a still-token-bearing session just produces + * confusing 401s instead of a clean re-login prompt). + */ +export class DpopKeyUnavailableError extends Error { + constructor(reason: 'missing' | 'mismatch') { + super( + reason === 'missing' + ? 'The persisted DPoP key is missing for a session that expects one.' + : 'The persisted DPoP key does not match this session (dpopJkt mismatch).', + ); + this.name = 'DpopKeyUnavailableError'; + } +} + +/** The minimal session shape this module needs — just the recorded key thumbprint, if any. */ +export interface DpopBoundSession { + readonly dpopJkt?: string; +} + +/** + * Load the keypair backing `session`, verifying it against the session's recorded `dpopJkt` + * when present, or mint+persist a fresh one ONLY for the one-time legacy migration. + * + * Three cases: + * 1. A persisted key exists AND (the session has no recorded `dpopJkt` yet, OR the key's + * thumbprint matches it) — return it. This is the overwhelmingly common case: every + * normal SW wake after a real login. + * 2. A persisted key exists but its thumbprint does NOT match `session.dpopJkt` — the store + * was overwritten by something else (e.g. a later login) while this session object is + * stale. Throws {@link DpopKeyUnavailableError}: using the wrong key would just produce + * confusing 401s, not a functioning session. + * 3. No persisted key exists: + * - if `session.dpopJkt` is set, this is a CURRENT-shape session whose key was + * unexpectedly lost (IndexedDB eviction, browser bug) — throws + * {@link DpopKeyUnavailableError}, since minting a replacement would silently swap in a + * key this session's tokens were never bound to. + * - if `session.dpopJkt` is undefined, this is the ONE-TIME legacy migration: + * `session-store.ts#loadSession` strips a pre-hardening inline extractable JWK the + * moment it sees one, which leaves THIS store empty on the very next read. We + * deliberately do NOT attempt to import the stripped JWK as a new non-extractable key + * (that would still mean the private scalar touched JS as extractable material) — we + * mint a REPLACEMENT key instead, per the instructed rotation. A legacy session's + * refresh token was RFC 9449 DPoP-bound (`cnf.jkt`) to the OLD key, so it will fail the + * next refresh grant; the existing refresh-failure teardown in `service-worker.ts`'s + * `ensureSession()` already turns that into a clean logout rather than a crash. + * + * Callers (both `auth-flow.ts` `refreshSession` and `service-worker.ts` `ensureSession`) treat + * a thrown `DpopKeyUnavailableError` exactly like a refresh-grant failure: tear the session + * down for a clean re-login, never retry with a substitute key. + */ +export async function loadSessionDpopKeyPair( + db: DpopKeyDb, + session: DpopBoundSession, +): Promise { + const record = await db.get(); + if (record) { + if (session.dpopJkt !== undefined) { + const jkt = await dpopThumbprint(record.publicKey); + if (jkt !== session.dpopJkt) throw new DpopKeyUnavailableError('mismatch'); + } + return { privateKey: record.privateKey, publicKey: record.publicKey }; + } + if (session.dpopJkt !== undefined) throw new DpopKeyUnavailableError('missing'); + return mintAndPersistDpopKeyPair(db); +} diff --git a/apps/extension/src/background/core/dpop.ts b/apps/extension/src/background/core/dpop.ts index c3609f84d..a4881ca52 100644 --- a/apps/extension/src/background/core/dpop.ts +++ b/apps/extension/src/background/core/dpop.ts @@ -53,56 +53,38 @@ export async function accessTokenHash(accessToken: string): Promise { return jose.base64url.encode(new Uint8Array(digest)); } -/** The persisted, JSON-serialisable form of a DPoP keypair (a private + public JWK pair). */ -export interface StoredDpopKeyPair { - readonly publicKey: JsonWebKey; - readonly privateKey: JsonWebKey; +/** + * RFC 7638 JWK thumbprint (`jkt`) of a DPoP public key — the value a DPoP-bound access/ + * refresh token's `cnf.jkt` claim is checked against server-side. Recorded on `StoredSession` + * at key-mint time so a later SW wake can tell "the persisted key still matches what THIS + * session expects" from "the persisted key was unexpectedly lost or replaced" (see + * `dpop-key-store.ts` `loadSessionDpopKeyPair`) — the two call for different handling: the + * latter must never be silently papered over with a freshly-minted, unrelated key. + */ +export async function dpopThumbprint(publicKey: CryptoKey): Promise { + const jwk = (await crypto.subtle.exportKey('jwk', publicKey)) as jose.JWK; + return jose.calculateJwkThumbprint(jwk, 'sha256'); } /** - * Generate a fresh DPoP keypair as **extractable** Web Crypto keys. + * Generate a fresh DPoP keypair as **non-extractable** Web Crypto keys. * - * NOTE: this keypair is exported as JWK to `chrome.storage` and re-imported on each - * service-worker wake-up — an MV3 service worker is killed/restarted aggressively, and - * a non-extractable key is LOST on suspension, breaking every subsequent refresh (CSS - * binds the refresh token to the original `jkt`). Extractability is therefore a - * deliberate, documented requirement of the service-worker context, not a weakness; the - * key never leaves the (page-unreachable) extension storage. The popup-page restore path - * uses the suite's non-extractable IndexedDB key instead. + * SECURITY (hardened — see `core/dpop-key-db.ts` for the full rationale): the private key + * is `extractable: false`, so `crypto.subtle.exportKey(...)` on it always rejects — the + * private scalar can never be serialised out of Web Crypto, not even by code running in + * this same service-worker context. Persistence across the MV3 service-worker's aggressive + * kill/restart cycle is handled by `core/dpop-key-store.ts`, which writes the live + * `CryptoKeyPair` object directly into IndexedDB (structured-clone-safe for non-extractable + * keys per the WebCrypto spec) rather than exporting it to JWK. Only the PUBLIC key is ever + * exported (e.g. for the DPoP proof header's `jwk` — see {@link createDpopProof} below). */ export async function generateDpopKeyPair(): Promise { - return crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: DPOP_CURVE }, true, [ + return crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: DPOP_CURVE }, false, [ 'sign', 'verify', ]); } -/** Export a keypair to a JSON-serialisable JWK pair for `chrome.storage` persistence. */ -export async function exportDpopKeyPair(keyPair: CryptoKeyPair): Promise { - const publicKey = await crypto.subtle.exportKey('jwk', keyPair.publicKey); - const privateKey = await crypto.subtle.exportKey('jwk', keyPair.privateKey); - return { publicKey, privateKey }; -} - -/** Reconstruct a Web Crypto keypair from a {@link StoredDpopKeyPair}. */ -export async function importDpopKeyPair(stored: StoredDpopKeyPair): Promise { - const publicKey = await crypto.subtle.importKey( - 'jwk', - stored.publicKey, - { name: 'ECDSA', namedCurve: DPOP_CURVE }, - true, - ['verify'], - ); - const privateKey = await crypto.subtle.importKey( - 'jwk', - stored.privateKey, - { name: 'ECDSA', namedCurve: DPOP_CURVE }, - true, - ['sign'], - ); - return { publicKey, privateKey }; -} - export interface DpopProofParams { readonly keyPair: CryptoKeyPair; readonly htm: string; diff --git a/apps/extension/src/background/core/replica.ts b/apps/extension/src/background/core/replica.ts index f8d7e8511..a00505a26 100644 --- a/apps/extension/src/background/core/replica.ts +++ b/apps/extension/src/background/core/replica.ts @@ -32,6 +32,7 @@ * whole orchestration is unit-testable without a packed extension or a live pod. */ +import { type PinnedTarget, targetsCover } from './class-grant'; import type { ReplicaMetadata } from './replica-db'; import { CANONICAL_RDF_ACCEPT, @@ -406,6 +407,73 @@ export async function purgeReplica( return { bytesDropped, metaDropped }; } +/** + * PER-DATA-CLASS REPLICA-SLICE PURGE (access-management Phase 2, residual b): when a class grant + * is REVOKED or EXPIRES, synchronously drop exactly the replicated bytes + metadata for the + * resources that grant resolved to — leaving every OTHER class's (and every other grant-scope's) + * data resident. Closes the data-retention gap where a revoked class's bytes lingered in the + * extension's replica until logout. + * + * The slice is `(webId, grantScope, targets)`: + * - `webId` scopes to the identity the grant was minted under (never touches another user's rows); + * - `grantScope` is the requesting-app origin the bytes were cached under (`ReplicaScope.grantScope` + * === the class-only origin, see `gateAccess`), so another origin's replica is untouched; + * - `targets` are the grant's PINNED resolved targets; only a row whose `resourceUrl` falls WITHIN + * one of them (`targetsCover`, the same fail-closed containment test the gate uses) is dropped. + * - `retainTargets` (optional) are the targets of the OTHER live grants the same `(webId, + * grantScope)` still holds; a row still covered by one of them is KEPT even if it also falls in + * the revoked slice, so revoking ONE of two overlapping grants never evicts data the surviving + * grant still authorises (it would otherwise force a needless re-fetch). Precise purge. + * + * FAIL-CLOSED CONTRACT (the caller's, not this function's): the grant's REMOVAL from the store is + * the authoritative deny and MUST happen before this purge, so a purge failure can NEVER leave + * access live — it degrades only to "bytes linger until logout" (the prior state), with access + * already revoked. This function is therefore BEST-EFFORT-THOROUGH: it purges as much of the slice + * as it can, tolerating a per-row failure (so one unremovable row cannot abort the rest of the + * slice), and returns the counts dropped. An empty target set covers nothing → a no-op. + */ +export async function purgeReplicaSlice( + cache: ReplicaByteCache, + db: ReplicaMetaStore, + slice: { + webId: string; + grantScope: string; + targets: readonly PinnedTarget[]; + retainTargets?: readonly PinnedTarget[]; + }, +): Promise<{ bytesDropped: number; metaDropped: number }> { + let bytesDropped = 0; + let metaDropped = 0; + // An empty / unresolved target set authorises nothing and therefore replicated nothing → no-op. + if (slice.targets.length === 0) return { bytesDropped, metaDropped }; + const retain = slice.retainTargets ?? []; + + const rows = await db.getMetadataByWebId(slice.webId); + for (const row of rows) { + // Another grant-scope's (app origin's) partition — never touched by this class's revoke. + if (row.grantScope !== slice.grantScope) continue; + // A resource outside this grant's pinned targets belongs to another class — left intact. + if (!targetsCover(slice.targets, row.resourceUrl)) continue; + // A resource STILL covered by another live grant of the same (webId, grantScope) is retained — + // revoking one of two overlapping grants must not evict what the survivor still authorises. + if (retain.length > 0 && targetsCover(retain, row.resourceUrl)) continue; + // Drop the bytes AND the metadata INDEPENDENTLY + best-effort: a failure of either half must + // not abort the rest of the slice, and the byte half's failure must not skip the metadata + // half (else a stale, un-servable row would dangle). The grant is already removed by the + // caller (the fail-closed deny), so any residual here is only an un-servable leftover the + // logout purge will sweep — never live access. + const keyReq = new Request(replicaKeyUrlFor(row)); + if (await cache.delete(keyReq, IGNORE_VARY).catch(() => false)) bytesDropped++; + try { + await db.deleteMetadata(row.key); + metaDropped++; + } catch { + // leave the now grant-less row for the logout purge. + } + } + return { bytesDropped, metaDropped }; +} + /** Reconstruct the synthetic Cache key URL for a stored metadata row (the byte half of purge). */ function replicaKeyUrlFor(row: ReplicaMetadata): string { const keyReq = replicaKeyRequest( diff --git a/apps/extension/src/background/core/request-access-throttle.ts b/apps/extension/src/background/core/request-access-throttle.ts new file mode 100644 index 000000000..d21caf1d8 --- /dev/null +++ b/apps/extension/src/background/core/request-access-throttle.ts @@ -0,0 +1,109 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * PURE anti-spam gate for `window.solid.requestAccess` (access-management Phase 2, residual c). + * + * `requestAccess` opens the UNSPOOFABLE consent WINDOW (extension chrome). Without a throttle a + * hostile — or merely buggy — origin can drive that window in a loop (`for(;;) requestAccess()`), + * an annoyance/DoS on the owner. This module is the PURE decision behind two independent defences, + * both fail-closed: + * + * (i) USER-GESTURE REQUIREMENT — a `requestAccess` must originate from a genuine transient user + * activation (a click / keypress), never an autonomous script loop. The activation is read + * in the ISOLATED content-script world (`navigator.userActivation.isActive`), which the page + * cannot forge, and stamped onto the worker message; THIS function only decides on that + * browser-attested boolean. A non-activated call is rejected and does NOT consume the + * rate-limit quota (the quota governs gesture-BACKED bursts; the gesture check alone already + * stops a no-gesture loop). + * + * (ii) PER-ORIGIN RATE LIMIT — a fixed-window cap on how many prompts one origin may open in a + * window, so even a gesture-backed burst (many calls within one 5s activation window) is + * throttled. The origin key is the browser-attested requesting origin (unspoofable), so an + * origin cannot evade the limit by lying about who it is. + * + * NO `chrome.*` / storage / clock here — the caller supplies `now` and the prior per-origin state, + * so the whole decision is exhaustively unit-testable. The service worker holds the per-origin + * state in a module-level map (best-effort, MV3-appropriate: an SW restart resets it, which merely + * refreshes the window — acceptable for an annoyance/DoS mitigation, never a security boundary). + */ + +/** A distinguishable reason a `requestAccess` was throttled, surfaced to the page. */ +export type RequestAccessDenyReason = 'user-gesture-required' | 'rate-limited'; + +/** Per-origin fixed-window state: the window's start and how many prompts it has admitted. */ +export interface ThrottleState { + readonly windowStart: number; + readonly count: number; +} + +/** The throttle bounds. */ +export interface ThrottleOptions { + /** The fixed-window length, ms. */ + readonly windowMs: number; + /** The maximum admitted `requestAccess` prompts per origin per window. */ + readonly maxPerWindow: number; +} + +/** + * Default bounds: at most 3 consent prompts per origin per minute. A legitimate app rarely needs + * more than one or two consent prompts in quick succession; a loop is far over this. + */ +export const DEFAULT_REQUEST_ACCESS_THROTTLE: ThrottleOptions = { + windowMs: 60_000, + maxPerWindow: 3, +}; + +/** The decision + the state the caller must persist for this origin. */ +export interface ThrottleDecision { + /** `true` ⇒ proceed to consent; `false` ⇒ reject with {@link reason}. */ + readonly allow: boolean; + /** The deny reason when `allow` is false. */ + readonly reason?: RequestAccessDenyReason; + /** For a rate-limited deny, how long until the window rolls (ms). */ + readonly retryAfterMs?: number; + /** The state to store back for this origin (always returned, whether allowed or not). */ + readonly nextState: ThrottleState; +} + +/** + * Decide whether an origin's `requestAccess` may proceed at `now`, given its prior throttle state + * and whether the call carried a genuine user activation. PURE + fail-closed. + * + * Order (both fail-closed): + * 1. Roll the fixed window forward if the prior one has fully elapsed (or there is none). + * 2. GESTURE FIRST — no user activation ⇒ deny `user-gesture-required`, quota UNCHANGED. + * 3. RATE LIMIT — at/over `maxPerWindow` for the current window ⇒ deny `rate-limited`. + * 4. Otherwise allow, incrementing the window's admitted count. + */ +export function decideRequestAccess( + prior: ThrottleState | undefined, + now: number, + userActivated: boolean, + opts: ThrottleOptions = DEFAULT_REQUEST_ACCESS_THROTTLE, +): ThrottleDecision { + // (1) Roll the window if the prior one has elapsed (or there is none / it is in the future). + const elapsed = prior ? now - prior.windowStart : Number.POSITIVE_INFINITY; + const window: ThrottleState = + prior && elapsed >= 0 && elapsed < opts.windowMs ? prior : { windowStart: now, count: 0 }; + + // (2) Gesture requirement. Rejected WITHOUT consuming quota — a no-gesture loop is stopped here + // regardless of the rate limit; the quota is reserved for gesture-backed bursts. + if (!userActivated) { + return { allow: false, reason: 'user-gesture-required', nextState: window }; + } + + // (3) Fixed-window rate limit on gesture-backed prompts. + if (window.count >= opts.maxPerWindow) { + return { + allow: false, + reason: 'rate-limited', + retryAfterMs: Math.max(0, window.windowStart + opts.windowMs - now), + nextState: window, + }; + } + + // (4) Admit — advance the current window's count. + return { + allow: true, + nextState: { windowStart: window.windowStart, count: window.count + 1 }, + }; +} diff --git a/apps/extension/src/background/service-worker.ts b/apps/extension/src/background/service-worker.ts index 3cb7b12d5..2b5c02b9e 100644 --- a/apps/extension/src/background/service-worker.ts +++ b/apps/extension/src/background/service-worker.ts @@ -19,11 +19,20 @@ */ import type { + AccessResponse, ActionResult, + ConsentDecisionMessage, FetchRequest, FetchResponse, + GetPendingConsentRequest, GetStateRequest, + GrantSummary, + ListGrantsRequest, LoginRequest, + PendingConsentView, + RequestAccessRequest, + ResolveClassRequest, + RevokeGrantRequest, SessionState, SetClientIdRequest, WorkerRequest, @@ -31,15 +40,34 @@ import type { import { setSignedInIcon, setSignedOutIcon } from './action-icon'; import { initiateLogin, refreshSession } from './auth-flow'; import { authenticatedFetch, type FetchSession } from './core/authenticated-fetch'; -import { importDpopKeyPair } from './core/dpop'; +import { + type ClassGrant, + decideClassAccess, + grantsForSession, + liveGrantOrigins, + modeSatisfies, + type PinnedTarget, + pruneExpiredGrants, +} from './core/class-grant'; +import { normaliseAccessRequest } from './core/class-request'; +import { resolveClassTargets } from './core/class-resolve'; +import { buildPendingConsent, consentDecisionToGrants, type PendingConsent } from './core/consent'; +import { DpopKeyDb } from './core/dpop-key-db'; +import { DpopKeyUnavailableError, loadSessionDpopKeyPair } from './core/dpop-key-store'; import { computeAllowedOrigins, isValidClientIdUrl } from './core/origin-policy'; import { type EgressPort, purgeReplica, + purgeReplicaSlice, type ReplicaByteCache, SharedReplica, } from './core/replica'; import { ReplicaDb } from './core/replica-db'; +import { + decideRequestAccess, + type RequestAccessDenyReason, + type ThrottleState, +} from './core/request-access-throttle'; import { computeGrantedOrigins, decideRequestingOrigin, @@ -52,16 +80,21 @@ import { SingleFlight } from './core/single-flight'; import { parseWebIdProfile } from './core/webid'; import { registerGlobalFetchInjection } from './global-fetch-register'; import { + clearClassGrants, clearProfile, clearSession, grantOrigin, + loadClassGrants, loadClientIds, loadGrantedOrigins, loadPodOrigins, loadProfile, loadRecentAccounts, loadSession, + pruneExpiredClassGrants, + revokeClassGrant, type StoredSession, + saveClassGrant, saveClientId, saveProfile, } from './session-store'; @@ -91,10 +124,70 @@ async function getReplicaDb(): Promise { return replicaDbHandle; } +/** + * The DPoP keypair IndexedDB handle — the non-extractable `CryptoKeyPair`'s durable home + * (see `core/dpop-key-db.ts`). Re-derivable on every SW wake, same as the replica DB above. + */ +let dpopKeyDbHandle: DpopKeyDb | null = null; + +/** Lazily open (once per wake) the DPoP key DB. */ +async function getDpopKeyDb(): Promise { + if (!dpopKeyDbHandle) dpopKeyDbHandle = await DpopKeyDb.open(); + return dpopKeyDbHandle; +} + /** The named Cache bucket for replica bytes (re-opened lazily; available in the SW context). */ function getReplicaCache(): Promise { return caches.open(REPLICA_CACHE_NAME) as Promise; } + +/** + * Purge the replica slice of each GONE (revoked / expired) class grant (access-management residual + * b). `retainPool` are the grants that SURVIVE — a resource still covered by a surviving grant of + * the SAME `(webId, origin)` is KEPT, so revoking/expiring one of two overlapping grants never + * evicts data the survivor still authorises. Best-effort (a failure only leaves now grant-less + * bytes to the logout purge); the replica stores are opened only when something is actually gone. + * + * The replica caches only GET (READ) representations, and this codebase treats `write` as NOT + * implying `read` (see `modeSatisfies`). So ONLY a surviving grant that satisfies READ justifies + * retaining cached bytes — a surviving write-only / append-only grant does NOT authorise reading + * them, so its overlap must NOT keep readable bytes resident after the read grant is gone. + */ +async function purgeGrantSlices(gone: readonly ClassGrant[], retainPool: readonly ClassGrant[]) { + if (gone.length === 0) return; + try { + const [cache, db] = await Promise.all([getReplicaCache(), getReplicaDb()]); + for (const g of gone) { + const retainTargets = retainPool + .filter( + (r) => r.webId === g.webId && r.origin === g.origin && modeSatisfies(r.modes, 'read'), + ) + .flatMap((r) => r.targets); + await purgeReplicaSlice(cache, db, { + webId: g.webId, + grantScope: g.origin, + targets: g.targets, + retainTargets, + }); + } + } catch { + // Best-effort: the grants are already gone (denied); their un-servable bytes are swept by the + // synchronous logout purge if this failed. + } +} + +/** + * Prune expired class grants AND purge each expired grant's replica slice (access-management + * residual b). Returns the LIVE grants (the same value the callers previously got from + * `pruneExpiredClassGrants`). The store prune is the authoritative deny; the byte purge is + * best-effort. The surviving `live` grants are the retain-pool so an expiring grant that overlaps a + * still-live one does not evict the survivor's data. + */ +async function pruneAndPurgeExpiredGrants(now: number): Promise { + const { live, expired } = await pruneExpiredClassGrants(now); + await purgeGrantSlices(expired, live); + return live; +} /** * Single-flight refresh: concurrent near-expiry requests must NOT each start a refresh * with the same (rotation-bound) refresh token — that races, so one grant succeeds and the @@ -133,7 +226,26 @@ async function ensureSession(): Promise<{ session: StoredSession; keyPair: Crypt } if (!cachedSession) return null; - if (!cachedKeyPair) cachedKeyPair = await importDpopKeyPair(cachedSession.dpopKeyPair); + if (!cachedKeyPair) { + try { + cachedKeyPair = await loadSessionDpopKeyPair(await getDpopKeyDb(), cachedSession); + } catch (err) { + if (err instanceof DpopKeyUnavailableError) { + // The persisted DPoP key is missing or doesn't match THIS session specifically + // (IndexedDB eviction, a browser bug, or a stale in-memory session object) — + // unrecoverable in place. Tear down for a clean re-login rather than silently + // signing with the wrong (or a freshly-minted, orphaned) key, which would just + // produce confusing 401s. + await teardownSession(); + } + // Either the confirmed-broken-session teardown above, or a TRANSIENT/unexpected + // failure (IndexedDB open error, a bug) — in the transient case we deliberately do + // NOT tear down (the stored session/tokens are left intact), we just fail THIS one + // call soft, matching every caller's existing "no ctx ⇒ not authenticated" handling. + // `cachedKeyPair` stays null either way, so the very next call retries cleanly. + return null; + } + } return { session: cachedSession, keyPair: cachedKeyPair }; } @@ -161,7 +273,18 @@ async function teardownSession(): Promise { // the store never opened). The WebID-scoped key means a future session cannot read them. } - await Promise.all([clearSession(), clearProfile()]); + // Class grants are bound to the departing session's WebID (ClassGrant.webId) and its resolved, + // pinned pod targets — they MUST NOT survive into a different identity's session. Clear them on + // logout (fail-closed; the WebID binding already prevents cross-identity use, this removes them + // entirely). The coarse per-origin grant store (login opt-ins) intentionally survives logout. + // The persisted DPoP keypair is likewise session-bound (a fresh one is minted per login / + // legacy-migration rotation) — drop it too so no departed session's key lingers in IndexedDB. + await Promise.all([ + clearSession(), + clearProfile(), + clearClassGrants(), + getDpopKeyDb().then((db) => db.clear()), + ]); await setSignedOutIcon(); } @@ -200,41 +323,91 @@ const DENY_MESSAGE = { } as const; /** - * The grant + foreign-fetch half of the per-requesting-origin gate, run BEFORE any pod - * egress or cache read (the dual-origin/opaque half already ran in {@link handleFetch}). It - * re-reads the grant store and re-runs the FULL decision (idempotent on the same inputs) so - * the single audited `decideRequestingOrigin` is the one boundary. Fail-closed on DENY. + * The per-data-class deny messages — DISTINGUISHABLE strings the page can surface (design + * item 2), separate from the coarse per-origin deny reasons above. A class-scoped origin whose + * request falls outside its pinned target set (wrong resource / wrong mode / no live grant) + * gets one of these, so the app can tell "you were denied because this is outside your grant" + * from a plain forbidden-origin. + */ +const CLASS_DENY_MESSAGE = { + 'out-of-grant-scope': 'Out of grant scope', + 'no-class-grant': 'No access grant for this data', +} as const; + +/** Whether a request URL's origin is one of the session's credential (pod) origins. */ +function originInSet(allowed: ReadonlySet, url: string): boolean { + const o = originOf(url); + return o !== null && allowed.has(o); +} + +/** + * The FULL access gate — re-run BEFORE any pod egress or cache read (incl. on a cache hit, via + * the replica's injected gate). Two layers, both fail-closed, both re-reading durable storage on + * EVERY request (no load-bearing in-memory gate state → a cold SW wake is fail-CLOSED): + * + * 1. The Phase-0/1 PER-ORIGIN gate (`decideRequestingOrigin`): dual-origin agreement, opaque + * → deny, default-deny per-origin grant, same-origin-or-deny foreign fetch. The granted set + * is the login/`setClientId` grants ∪ the session credential origins ∪ the origins holding a + * live class grant for the ACTIVE WebID (so a class-only origin passes this layer and is then + * confined by layer 2). A DENY here is unchanged from Phase-0. + * 2. The Phase-2 PER-DATA-CLASS gate (`decideClassAccess`): runs ONLY for a pod (credential) + * target AND ONLY for an origin that is present solely because it holds class grants (a + * FULL-grant origin — a login/credential origin — BYPASSES it, preserving the legacy + * whole-pod behaviour → backwards compatibility, design item 5). The request URL + method + * must fall within a non-expired grant's PINNED resolved target set with a satisfying mode, + * else DENY with a distinguishable class reason. * - * NOTE: the per-origin gate is applied to EVERY fetch up-front. Once a shared replica / - * cache exists (Phase 1) this same gate must be the first check on a cache hit too — it is - * deliberately separated here so a future cached-response path cannot bypass it. + * `webId` is the active session WebID (the grant session boundary). Fail-closed on DENY. */ -async function gateRequestingOrigin( +async function gateAccess( message: FetchRequest, requestingOrigin: string, sender: MessageSender | undefined, allowedTargetOrigins: ReadonlySet, -): Promise<{ requestingOrigin: string } | { error: FetchResponse }> { - // The grant set is re-read from durable storage on EVERY request (no load-bearing - // in-memory gate state), so a cold SW wake is fail-CLOSED: if nothing has loaded yet the - // grant set is empty and every origin is denied — never a serve-while-loading window. + webId: string, +): Promise<{ grantScope: string } | { deny: Response }> { + const now = Date.now(); const explicitGrants = await loadGrantedOrigins(); + // Opportunistically prune expired grants on the fetch path (design item 3: expired → pruned) AND + // purge each expired grant's replica slice (residual b — no bytes linger past expiry). + const liveGrants = await pruneAndPurgeExpiredGrants(now); + const classOrigins = [...liveGrantOrigins(liveGrants, now, webId)]; + const decision = decideRequestingOrigin(sender, message.stampedOrigin, message.url, { - explicitGrants, + explicitGrants: [...explicitGrants, ...classOrigins], credentialOrigins: allowedTargetOrigins, }); - // Defence-in-depth: the re-resolved origin must match the one resolved earlier in this - // request (it always will for identical inputs); a divergence is treated as DENY. + // Defence-in-depth: the re-resolved origin must match the one resolved earlier this request. if (decision.deny !== null || decision.requestingOrigin !== requestingOrigin) { return { - error: { - requestId: message.requestId, - status: 403, - error: decision.deny ? DENY_MESSAGE[decision.deny] : DENY_MESSAGE['forbidden-origin'], - }, + deny: denyResponse( + decision.deny ? DENY_MESSAGE[decision.deny] : DENY_MESSAGE['forbidden-origin'], + ), }; } - return { requestingOrigin: decision.requestingOrigin }; + + // Layer 2 — the per-class gate. Only pod (credential) targets are class-scoped; a same-origin + // foreign target carries no pod token and is governed by the foreign-fetch policy above. + if (originInSet(allowedTargetOrigins, message.url)) { + // A FULL-grant origin (explicit login/setClientId grant OR a credential/pod origin) keeps + // whole-pod access — the legacy behaviour. Only a CLASS-ONLY origin is confined to its pins. + // NB the full-grant set is built via computeGrantedOrigins so the login-grant strings are + // NORMALISED (host-lowercased) to match the browser-attested `requestingOrigin` — and it + // DELIBERATELY excludes the class-grant origins (they are the ones we mean to confine). + const fullGrant = computeGrantedOrigins({ + explicitGrants, + credentialOrigins: allowedTargetOrigins, + }); + if (!fullGrant.has(requestingOrigin)) { + const forSession = grantsForSession(liveGrants, requestingOrigin, webId); + const reason = decideClassAccess(forSession, message.url, message.method, now); + if (reason !== null) { + return { deny: denyResponse(CLASS_DENY_MESSAGE[reason]) }; + } + } + } + + return { grantScope: requestingOrigin }; } const READ_METHODS = new Set(['GET', 'HEAD']); @@ -279,18 +452,8 @@ async function handleFetch( // decision (idempotent) and returns the verified grant-scope on ALLOW; the replica never // touches the cache or the pod before this resolves to ALLOW, so a warm cache can never // bypass the per-origin gate. - const gate = async (): Promise<{ grantScope: string } | { deny: Response }> => { - const decision = await gateRequestingOrigin( - message, - requestingOrigin, - sender, - fetchSession.allowedOrigins, - ); - if ('error' in decision) { - return { deny: denyResponse(decision.error.error ?? DENY_MESSAGE['forbidden-origin']) }; - } - return { grantScope: decision.requestingOrigin }; - }; + const gate = (): Promise<{ grantScope: string } | { deny: Response }> => + gateAccess(message, requestingOrigin, sender, fetchSession.allowedOrigins, ctx.session.webId); const origin = originOf(message.url); const db = await getReplicaDb(); @@ -592,6 +755,343 @@ async function handleSetClientId( } } +// --- Per-data-class access management (Phase 2, design §4) ---------------------------- +// +// The consent flow is orchestrated here: `requestAccess` resolves each class to targets, +// opens the UNSPOOFABLE consent window (extension chrome), and awaits the user's decision; +// the window posts `SOLID_CONSENT_DECISION` back, which mints + persists the approved grants. +// The pending state lives in module-level maps (best-effort, MV3-appropriate): if the SW is +// killed while the window is open, the window reads `null` and denies (fail-closed) and the +// `requestAccess` promise times out — no grant is minted without a live approval. + +/** Pending consent requests awaiting a decision (consentId → record). */ +const pendingConsents = new Map(); +/** Resolvers for the awaiting `requestAccess` calls (consentId → resolve(approvedClasses)). */ +const consentResolvers = new Map void>(); +const CONSENT_TIMEOUT_MS = 5 * 60_000; + +/** + * Per-origin `requestAccess` throttle state (residual c). Best-effort module state (re-derived — + * i.e. reset — on an MV3 SW restart, which merely refreshes the window; acceptable for an + * annoyance/DoS mitigation, never a security boundary). Keyed by the browser-attested requesting + * origin so an origin cannot evade its own limit by lying about its identity. + */ +const requestAccessThrottle = new Map(); + +/** Distinguishable page-surfaced messages for a throttled `requestAccess` (residual c). */ +const REQUEST_ACCESS_DENY_MESSAGE: Record = { + 'user-gesture-required': 'requestAccess requires a user action (click) — it cannot be automated.', + 'rate-limited': 'Too many access requests from this app — please wait a moment and try again.', +}; + +/** A token-free public summary of a grant (design §4.2 return shape). */ +function grantSummary(g: ClassGrant): GrantSummary { + return { + grantId: g.grantId, + dataClass: g.dataClass, + modes: g.modes, + purpose: g.purpose, + expiresAt: g.expiresAt, + targets: g.targets.map((t) => ({ url: t.url, kind: t.kind })), + }; +} + +function isHttpIri(value: unknown): value is string { + if (typeof value !== 'string') return false; + try { + const u = new URL(value); + return u.protocol === 'https:' || u.protocol === 'http:'; + } catch { + return false; + } +} + +/** + * The POD-ONLY origin set for class resolution — the WebID origin ∪ configured pod origins. It + * confines both the type-index REF fetches and the resolved TARGETS: the full credential set + * (`buildFetchSession().allowedOrigins`) ALSO includes the ISSUER origin, and a hostile WebID + * profile pointing a type-index ref at the issuer would otherwise trigger an authenticated SW + * fetch to an issuer *path* (token-to-issuer-path leak, roborev round-2 Medium). + * + * The exclusion is `includeIssuerOrigin: false` — the issuer is not added as a SEPARATE candidate. + * It is DELIBERATELY *not* an unconditional `delete(issuerOrigin)` (roborev round-3): in the + * ubiquitous CSS single-origin deployment the pod IS served from the issuer origin + * (`webId origin === issuer origin`), and unconditionally removing that origin would drop EVERY + * legitimate pod ref/target and break class resolution entirely there. So the issuer origin is + * allowed ONLY when it coincides with a genuine pod origin (the WebID origin, or an origin the + * user explicitly configured as a pod) — where sending the token to that origin is already normal + * pod access (and the token *endpoint* itself stays protected by `isTokenEndpoint` in + * authenticated-fetch). A issuer origin DISTINCT from every pod origin is excluded, which is the + * profile-controlled attack this guards. (Verified by the CSS-single-origin + distinct-issuer tests.) + */ +async function podOriginsFor(session: StoredSession): Promise> { + const podOrigins = await loadPodOrigins(); + return computeAllowedOrigins({ + webId: session.webId, + podOrigins, + includeIssuerOrigin: false, + allowInsecureLoopback: ALLOW_INSECURE_LOOPBACK, + }); +} + +/** The class resolver's authed fetch — the SW's DPoP egress, surfaced as `{ ok, text }`. */ +function makeResolveFetch( + session: FetchSession, +): (url: string) => Promise<{ ok: boolean; text: string }> { + return async (url: string) => { + try { + const { response } = await authenticatedFetch(session, { + url, + method: 'GET', + headers: { Accept: 'text/turtle' }, + body: null, + nonce: undefined, + }); + return { ok: response.ok, text: response.ok ? await response.text() : '' }; + } catch { + return { ok: false, text: '' }; + } + }; +} + +/** Open the consent window and resolve with the approved class IRIs (or [] on deny/timeout). */ +function openConsentAndAwait(consentId: string): Promise { + return new Promise((resolve) => { + let settled = false; + let timer: ReturnType | undefined; + const finish = (approved: string[]): void => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + resolve(approved); + }; + consentResolvers.set(consentId, finish); + timer = setTimeout(() => finish([]), CONSENT_TIMEOUT_MS); + const url = chrome.runtime.getURL( + `consent/consent.html?consentId=${encodeURIComponent(consentId)}`, + ); + try { + chrome.windows.create({ url, type: 'popup', width: 460, height: 640 }, () => { + if (chrome.runtime.lastError) finish([]); + }); + } catch { + finish([]); + } + }); +} + +async function handleRequestAccess( + message: RequestAccessRequest, + sender: MessageSender | undefined, +): Promise { + const requestId = message.requestId; + const requestingOrigin = resolveRequestingOrigin(sender, message.stampedOrigin); + if (requestingOrigin === null) { + return { requestId, error: 'Could not verify the requesting origin.' }; + } + + // (residual c) Anti-spam gate — BEFORE any session work, class resolution (pod fetches), or the + // consent window: require a genuine user gesture (browser-attested via the ISOLATED content + // script's `navigator.userActivation`, page-unforgeable) AND rate-limit consecutive prompts per + // origin. A blocked call does NO pod fetch and opens NO consent window. + const throttleDecision = decideRequestAccess( + requestAccessThrottle.get(requestingOrigin), + Date.now(), + message.userActivated === true, + ); + requestAccessThrottle.set(requestingOrigin, throttleDecision.nextState); + if (!throttleDecision.allow) { + // `reason` is always present on a deny; fall back defensively. + const reason = throttleDecision.reason ?? 'rate-limited'; + return { requestId, error: REQUEST_ACCESS_DENY_MESSAGE[reason] }; + } + + const ctx = await ensureSession(); + if (!ctx) return { requestId, error: 'Not authenticated' }; + + const now = Date.now(); + const spec = normaliseAccessRequest( + { + classes: message.classes, + modes: message.modes, + purpose: message.purpose, + duration: message.duration, + }, + now, + ); + if ('error' in spec) return { requestId, error: spec.error }; + + // Resolve each requested class to its concrete, pinned targets (confined to POD origins — + // excludes the issuer, so a hostile profile can't steer resolution at the issuer). + const fetchSession = await buildFetchSession(ctx.session, ctx.keyPair); + const resolveFetch = makeResolveFetch(fetchSession); + const podOrigins = await podOriginsFor(ctx.session); + const resolved = new Map(); + for (const cls of spec.classes) { + const targets = await resolveClassTargets(cls, { + webId: ctx.session.webId, + fetch: resolveFetch, + allowedOrigins: podOrigins, + }); + resolved.set(cls, { targets, shape: null }); + } + + const consentId = crypto.randomUUID(); + const pending = buildPendingConsent( + consentId, + requestingOrigin, + ctx.session.webId, + spec, + resolved, + now, + ); + pendingConsents.set(consentId, pending); + + let approvedClasses: string[]; + try { + approvedClasses = await openConsentAndAwait(consentId); + } finally { + pendingConsents.delete(consentId); + consentResolvers.delete(consentId); + } + + const grants = consentDecisionToGrants(pending, approvedClasses, () => crypto.randomUUID()); + for (const g of grants) await saveClassGrant(g); + return { requestId, ok: grants.length > 0, grants: grants.map(grantSummary) }; +} + +/** The consent window (extension chrome only) asks for the pending record to render. */ +function handleGetPendingConsent( + message: GetPendingConsentRequest, + sender: MessageSender | undefined, +): PendingConsentView | null { + if (!isExtensionContext(sender)) return null; // only the extension's own window may read it. + const pending = pendingConsents.get(message.consentId); + if (!pending) return null; + return { + consentId: pending.consentId, + origin: pending.origin, + webId: pending.webId, + classes: pending.classes.map((c) => ({ + dataClass: c.dataClass, + modes: c.modes, + purpose: c.purpose, + expiresAt: c.expiresAt, + targets: c.targets.map((t) => ({ url: t.url, kind: t.kind })), + shape: c.shape, + })), + }; +} + +/** The consent window's decision. ONLY the extension's own window may decide (fail-closed). */ +function handleConsentDecision( + message: ConsentDecisionMessage, + sender: MessageSender | undefined, +): void { + if (!isExtensionContext(sender)) return; + const resolver = consentResolvers.get(message.consentId); + if (!resolver) return; + consentResolvers.delete(message.consentId); + const pending = pendingConsents.get(message.consentId); + // Defence in depth: only classes actually shown in this pending request can be approved. + const requested = new Set(pending?.classes.map((c) => c.dataClass) ?? []); + const approved = message.approvedClasses.filter((c) => requested.has(c)); + resolver(approved); +} + +async function handleListGrants( + message: ListGrantsRequest, + sender: MessageSender | undefined, +): Promise { + const requestId = message.requestId; + const requestingOrigin = resolveRequestingOrigin(sender, message.stampedOrigin); + if (requestingOrigin === null) return { requestId, grants: [] }; + const ctx = await ensureSession(); + if (!ctx) return { requestId, grants: [] }; + const now = Date.now(); + const live = await pruneAndPurgeExpiredGrants(now); + const mine = grantsForSession(live, requestingOrigin, ctx.session.webId); + return { requestId, grants: mine.map(grantSummary) }; +} + +async function handleRevokeGrant( + message: RevokeGrantRequest, + sender: MessageSender | undefined, +): Promise { + const requestId = message.requestId; + const requestingOrigin = resolveRequestingOrigin(sender, message.stampedOrigin); + if (requestingOrigin === null) { + return { requestId, ok: false, error: 'Could not verify the requesting origin.' }; + } + // A page may revoke only its OWN origin's grant (revokeClassGrant enforces the origin match). + // The REMOVAL is the authoritative deny (fail-closed) and happens FIRST; the replica-slice purge + // below is best-effort AFTER it — so a purge failure can never leave access live (residual b). + const removed = await revokeClassGrant(message.grantId, requestingOrigin); + if (removed.length > 0) { + // Synchronously purge exactly the revoked class's replicated bytes + metadata (the resources it + // resolved to), leaving every other class/origin intact — and RETAINING any resource still + // covered by another live grant of the same origin (the surviving grants are the retain-pool). + // Access is already revoked above, so a failure here only leaves now-grant-less bytes for the + // logout purge — never live access. + const now = Date.now(); + const survivors = pruneExpiredGrants(await loadClassGrants(), now); + await purgeGrantSlices(removed, survivors); + } + return { requestId, ok: removed.length > 0 }; +} + +async function handleResolveClass( + message: ResolveClassRequest, + sender: MessageSender | undefined, +): Promise { + const requestId = message.requestId; + const requestingOrigin = resolveRequestingOrigin(sender, message.stampedOrigin); + if (requestingOrigin === null) { + return { requestId, error: 'Could not verify the requesting origin.' }; + } + if (!isHttpIri(message.classIRI)) { + return { requestId, error: 'resolveClass: an https: class IRI is required.' }; + } + const ctx = await ensureSession(); + if (!ctx) return { requestId, error: 'Not authenticated' }; + + const fetchSession = await buildFetchSession(ctx.session, ctx.keyPair); + // Only a GRANTED origin (full OR class) may introspect — never leak the pod's type-index + // layout to an arbitrary, un-opted-in page. + const now = Date.now(); + const explicitGrants = await loadGrantedOrigins(); + const live = await pruneAndPurgeExpiredGrants(now); + const classOrigins = [...liveGrantOrigins(live, now, ctx.session.webId)]; + const granted = computeGrantedOrigins({ + explicitGrants: [...explicitGrants, ...classOrigins], + credentialOrigins: fetchSession.allowedOrigins, + }); + if (!isRequestingOriginGranted(granted, requestingOrigin)) { + return { requestId, error: 'Origin not granted access' }; + } + // CLASS-SCOPED introspection: a FULL-grant origin (login/credential) may resolve any class, but + // a CLASS-ONLY origin may resolve ONLY a class it actually holds a live grant for — otherwise a + // narrow "finance" grant could enumerate the pod's "health" target URLs (private-structure leak, + // roborev Medium). Fail-closed. + const fullGrant = computeGrantedOrigins({ + explicitGrants, + credentialOrigins: fetchSession.allowedOrigins, + }); + if (!fullGrant.has(requestingOrigin)) { + const mine = grantsForSession(live, requestingOrigin, ctx.session.webId); + if (!mine.some((g) => g.dataClass === message.classIRI)) { + return { requestId, error: 'No access grant for this data class' }; + } + } + + const targets = await resolveClassTargets(message.classIRI, { + webId: ctx.session.webId, + fetch: makeResolveFetch(fetchSession), + allowedOrigins: await podOriginsFor(ctx.session), + }); + return { requestId, shape: null, targets: targets.map((t) => ({ url: t.url, kind: t.kind })) }; +} + chrome.runtime.onMessage.addListener( (message: WorkerRequest, sender, sendResponse: (r: unknown) => void) => { switch (message.type) { @@ -615,6 +1115,25 @@ chrome.runtime.onMessage.addListener( case 'SOLID_SET_CLIENT_ID': handleSetClientId(message, sender).then(sendResponse); return true; + case 'SOLID_REQUEST_ACCESS': + handleRequestAccess(message, sender).then(sendResponse); + return true; + case 'SOLID_LIST_GRANTS': + handleListGrants(message, sender).then(sendResponse); + return true; + case 'SOLID_REVOKE_GRANT': + handleRevokeGrant(message, sender).then(sendResponse); + return true; + case 'SOLID_RESOLVE_CLASS': + handleResolveClass(message, sender).then(sendResponse); + return true; + case 'SOLID_GET_PENDING_CONSENT': + sendResponse(handleGetPendingConsent(message, sender)); + return false; + case 'SOLID_CONSENT_DECISION': + handleConsentDecision(message, sender); + sendResponse({ ok: true }); + return false; default: return false; } diff --git a/apps/extension/src/background/session-store.ts b/apps/extension/src/background/session-store.ts index 03550d1a5..41ed1b598 100644 --- a/apps/extension/src/background/session-store.ts +++ b/apps/extension/src/background/session-store.ts @@ -17,7 +17,7 @@ * The refresh token is never logged; only the WebID/display is ever surfaced to a page. */ -import type { StoredDpopKeyPair } from './core/dpop'; +import { type ClassGrant, pruneExpiredGrants, sanitiseGrants } from './core/class-grant'; const KEYS = { session: 'solid:session', @@ -26,15 +26,28 @@ const KEYS = { clientIds: 'solid:client-ids', podOrigins: 'solid:pod-origins', grantedOrigins: 'solid:granted-origins', + classGrants: 'solid:class-grants', authParams: 'solid:auth-params', } as const; -/** The persisted active session. The DPoP keypair is stored as JWK (see core/dpop). */ +/** + * The persisted active session. The DPoP keypair is NOT stored here — it lives as a + * non-extractable `CryptoKeyPair` in IndexedDB (`core/dpop-key-db.ts` / `dpop-key-store.ts`), + * never as JWK material in `chrome.storage.local`. See {@link loadSession} for the one-time + * migration off the pre-hardening shape, which DID carry an inline extractable JWK pair. + */ export interface StoredSession { readonly webId: string; readonly accessToken: string; readonly refreshToken: string | null; - readonly dpopKeyPair: StoredDpopKeyPair; + /** + * RFC 7638 thumbprint of the DPoP public key this session's tokens are bound to, recorded + * at mint time. Absent ONLY for a session created before this hardening shipped (the + * pre-hardening shape never tracked it) — `dpop-key-store.ts` `loadSessionDpopKeyPair` uses + * its absence as the one-time legacy-migration signal, and its presence to detect an + * unexpected key loss/replacement rather than silently using the wrong key. + */ + readonly dpopJkt?: string; readonly issuer: string; readonly tokenEndpoint: string; readonly clientId: string; @@ -66,8 +79,33 @@ async function set(key: string, value: unknown): Promise { // --- Active session ------------------------------------------------------------------- -export function loadSession(): Promise { - return get(KEYS.session); +/** + * The pre-hardening on-disk session shape: the DPoP private key stored inline as an + * **extractable** JWK pair. Only used here, to DETECT + strip a legacy record — the value + * itself is never read for its key material (we don't import it; see `dpop-key-store.ts` + * `loadOrRotateDpopKeyPair` for why a fresh key is minted instead). + */ +interface LegacySessionShape { + readonly dpopKeyPair?: unknown; +} + +/** + * Load the active session, migrating a pre-hardening record on the way out: if the raw + * stored value still carries the legacy inline `dpopKeyPair` (an extractable JWK pair), it + * is stripped and the cleaned record is written back immediately — so extractable private + * key material never lingers in `chrome.storage.local` beyond the very first read after this + * change ships. `service-worker.ts#ensureSession` detects the resulting absence of a + * persisted IndexedDB key and mints a replacement (see `dpop-key-store.ts`). + */ +export async function loadSession(): Promise { + const raw = await get(KEYS.session); + if (!raw) return null; + if ('dpopKeyPair' in raw) { + const { dpopKeyPair: _legacyExtractableKey, ...clean } = raw; + await set(KEYS.session, clean); + return clean; + } + return raw; } export function saveSession(session: StoredSession): Promise { @@ -171,6 +209,109 @@ export async function revokeOrigin(origin: string): Promise { if (next.length !== list.length) await set(KEYS.grantedOrigins, next); } +// --- Per-data-class grants (the Phase-2 grant store, design §4) ------------------------ +// +// The NARROWER, class-scoped capability layered ON TOP of the per-origin grant above: a grant +// keyed by (origin, dataClass, modes, purpose, expiry) whose enforceable substance is a PINNED, +// resolved target set (class-grant.ts / class-resolve.ts). Stored as a plain array; every read +// runs `sanitiseGrants` so a corrupt/tampered record authorises nothing (fail-closed). A grant +// is bound to the WebID it was minted under (the session boundary) — see ClassGrant.webId. + +/** + * A single-writer SERIALISER for the class-grant array. `chrome.storage.local` offers no + * compare-and-set, and every mutation is a read-modify-write of ONE array; without + * serialisation two interleaved async mutations race — e.g. a `saveClassGrant` that read the + * array BEFORE a concurrent `revokeClassGrant` would write back a stale array that RESURRECTS + * the just-revoked grant, breaking the "revocation effective next request" invariant. This + * promise-chain mutex makes every read-modify-write critical section run to completion before + * the next starts, so each re-reads the freshest array. (The SW event loop is single-threaded, + * but `await`s still interleave — this closes that window.) + */ +let classGrantMutex: Promise = Promise.resolve(); +function withClassGrantLock(fn: () => Promise): Promise { + const run = classGrantMutex.then(fn, fn); + // Keep the chain alive regardless of this mutation's outcome (never reject the shared chain). + classGrantMutex = run.then( + () => undefined, + () => undefined, + ); + return run; +} + +/** Load every stored class grant, dropping any structurally-invalid (tampered) record. */ +export async function loadClassGrants(): Promise { + return sanitiseGrants(await get(KEYS.classGrants)); +} + +/** Persist a new class grant (append). The caller mints a unique `grantId`. Serialised. */ +export function saveClassGrant(grant: ClassGrant): Promise { + return withClassGrantLock(async () => { + const list = await loadClassGrants(); + await set(KEYS.classGrants, [...list, grant]); + }); +} + +/** + * Revoke a class grant by id, but ONLY when it belongs to `origin` (a page may revoke its OWN + * grants, never another origin's). Removes EVERY row matching `(grantId, origin)` — so a store that + * somehow holds a duplicate id can never leave a matching grant live after a "successful" revoke — + * and returns ALL removed grants (so the caller can purge each one's replica slice — residual b). + * An empty array ⇒ nothing matched. Effect is immediate on the NEXT request — the SW re-reads the + * store per request (no in-memory grant cache), so a removed grant is gone from the very next gate + * decision (design item 3 — propagation bound). Serialised against the other mutations so a + * concurrent save cannot resurrect the revoked grant. + * + * FAIL-CLOSED ORDERING: this REMOVAL is the authoritative deny. The caller purges the revoked + * grant's replica bytes AFTER (and best-effort) — so a byte-purge failure can never leave access + * live; it only means the (now un-servable, grant-less) bytes linger until logout. + */ +export function revokeClassGrant(grantId: string, origin: string): Promise { + return withClassGrantLock(async () => { + const list = await loadClassGrants(); + const matches = (g: ClassGrant): boolean => g.grantId === grantId && g.origin === origin; + const removed = list.filter(matches); + if (removed.length === 0) return []; + await set( + KEYS.classGrants, + list.filter((g) => !matches(g)), + ); + return removed; + }); +} + +/** + * Prune expired grants from the store at `now`, persisting the pruned list only when it + * changed. Returns BOTH the LIVE grants and the EXPIRED ones removed (so the caller can purge each + * expired grant's replica slice — access-management residual b). Called opportunistically on the + * fetch path so an expired grant is denied (by the gate), garbage-collected (design item 3), AND + * its replicated bytes purged. Serialised so a prune cannot clobber a concurrent save/revoke (it + * re-reads inside the critical section). + */ +export function pruneExpiredClassGrants( + now: number, +): Promise<{ live: ClassGrant[]; expired: ClassGrant[] }> { + return withClassGrantLock(async () => { + const list = await loadClassGrants(); + const live = pruneExpiredGrants(list, now); + if (live.length !== list.length) await set(KEYS.classGrants, live); + const liveSet = new Set(live); + const expired = list.filter((g) => !liveSet.has(g)); + return { live, expired }; + }); +} + +/** + * Drop ALL class grants (called on logout — grants are bound to the departing session). + * Serialised through the SAME mutex as the other mutations so a logout cannot interleave a + * concurrent `saveClassGrant`/`pruneExpiredClassGrants` read-modify-write and leave a grant + * persisted after sign-out (roborev round-2 Medium). + */ +export function clearClassGrants(): Promise { + return withClassGrantLock(async () => { + await chrome.storage.local.remove(KEYS.classGrants); + }); +} + // --- Transient auth params (PKCE/state across the redirect) ---------------------------- export interface AuthParams { diff --git a/apps/extension/src/consent/consent.css b/apps/extension/src/consent/consent.css new file mode 100644 index 000000000..64388f411 --- /dev/null +++ b/apps/extension/src/consent/consent.css @@ -0,0 +1,71 @@ +/* AUTHORED-BY Claude Opus 4.8 — the per-data-class consent window (extension chrome). */ +:root { + color-scheme: light dark; + --bg: #ffffff; + --fg: #1a1a2e; + --muted: #5a5a72; + --border: #d9d9e3; + --accent: #7c4dff; + --accent-fg: #ffffff; + --danger: #c0392b; + --chip: #f0edff; +} +@media (prefers-color-scheme: dark) { + :root { + --bg: #16161f; + --fg: #ececf3; + --muted: #a2a2b8; + --border: #33333f; + --accent: #9a76ff; + --chip: #262038; + } +} +* { box-sizing: border-box; } +body { + margin: 0; + font: 14px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + background: var(--bg); + color: var(--fg); + padding: 20px; +} +.card { max-width: 560px; margin: 0 auto; } +.title { font-size: 1.25rem; margin: 0 0 4px; } +.lede { margin: 0 0 16px; } +.muted { color: var(--muted); word-break: break-all; } +.classes { display: flex; flex-direction: column; gap: 12px; } +.class-item { + border: 1px solid var(--border); + border-radius: 10px; + padding: 12px 14px; +} +.class-head { display: flex; align-items: flex-start; gap: 10px; } +.class-head input { margin-top: 3px; width: 16px; height: 16px; } +.class-title { font-weight: 600; word-break: break-all; } +.chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 0; } +.chip { + background: var(--chip); + color: var(--accent); + border-radius: 999px; + padding: 2px 10px; + font-size: 12px; + font-weight: 600; +} +.meta { color: var(--muted); font-size: 12px; margin: 8px 0 0; } +.targets { margin: 8px 0 0; padding-left: 18px; max-height: 160px; overflow-y: auto; } +.targets li { word-break: break-all; } +.targets .container-target { font-weight: 600; } +.note { color: var(--muted); font-size: 13px; } +.actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px; } +.btn { + border: 1px solid var(--border); + border-radius: 8px; + padding: 8px 16px; + font: inherit; + font-weight: 600; + cursor: pointer; + background: var(--bg); + color: var(--fg); +} +.btn-primary { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); } +.btn:disabled { opacity: 0.5; cursor: not-allowed; } +.error { color: var(--danger); margin-top: 12px; } diff --git a/apps/extension/src/consent/consent.html b/apps/extension/src/consent/consent.html new file mode 100644 index 000000000..c78701401 --- /dev/null +++ b/apps/extension/src/consent/consent.html @@ -0,0 +1,33 @@ + + + + + + Solid — Access request + + + +
+

Access request

+

+ An app is asking to access data in your pod + . +

+ +
+ + + +
+ + +
+ + +
+ + + diff --git a/apps/extension/src/consent/consent.ts b/apps/extension/src/consent/consent.ts new file mode 100644 index 000000000..5d3920460 --- /dev/null +++ b/apps/extension/src/consent/consent.ts @@ -0,0 +1,156 @@ +// AUTHORED-BY Claude Opus 4.8 +/** + * The per-data-class CONSENT WINDOW — the unspoofable consent surface (design §4.1). It runs + * in the extension's OWN chrome (a `chrome-extension://` page opened by the service worker), + * so the requesting origin cannot spoof or script it. It renders the pending request in + * data-class terms — the requesting app, each requested data class, its modes, purpose, expiry, + * and the RESOLVED TARGET SET ("this will share these N resources", design §3.4 step 2) — with + * a per-class approve checkbox, and posts the decision back to the worker. + * + * All rendering uses `textContent` (never `innerHTML`) so an app-supplied class IRI / purpose / + * target URL can never inject markup into the extension's trusted chrome. + */ + +import type { ConsentDecisionMessage, PendingConsentView } from '../shared/messages'; + +function fmtExpiry(ms: number): string { + try { + return new Date(ms).toLocaleString(); + } catch { + return String(ms); + } +} + +function el( + tag: K, + opts: { class?: string; text?: string } = {}, +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + if (opts.class) node.className = opts.class; + if (opts.text !== undefined) node.textContent = opts.text; + return node; +} + +function showError(message: string): void { + const err = document.getElementById('error'); + if (err) { + err.textContent = message; + err.hidden = false; + } + for (const id of ['approve']) { + const b = document.getElementById(id) as HTMLButtonElement | null; + if (b) b.disabled = true; + } +} + +function renderClasses(view: PendingConsentView): Map { + const container = document.getElementById('classes'); + const boxes = new Map(); + if (!container) return boxes; + let anyTargets = false; + + for (const c of view.classes) { + const item = el('div', { class: 'class-item' }); + + const head = el('label', { class: 'class-head' }); + const box = document.createElement('input'); + box.type = 'checkbox'; + box.checked = c.targets.length > 0; // default-approve only classes that resolved to something + boxes.set(c.dataClass, box); + const titleWrap = el('div'); + titleWrap.appendChild(el('div', { class: 'class-title', text: c.dataClass })); + const chips = el('div', { class: 'chips' }); + for (const m of c.modes) chips.appendChild(el('span', { class: 'chip', text: m })); + titleWrap.appendChild(chips); + head.appendChild(box); + head.appendChild(titleWrap); + item.appendChild(head); + + const meta = el('div', { class: 'meta' }); + const purposeText = c.purpose ? `Purpose: ${c.purpose} · ` : ''; + meta.textContent = `${purposeText}Expires: ${fmtExpiry(c.expiresAt)} · ${c.targets.length} resource(s)`; + item.appendChild(meta); + + if (c.targets.length > 0) { + anyTargets = true; + const list = el('ul', { class: 'targets' }); + for (const t of c.targets) { + const li = el('li', { text: t.url }); + if (t.kind === 'container') li.className = 'container-target'; + list.appendChild(li); + } + item.appendChild(list); + } + container.appendChild(item); + } + + const emptyNote = document.getElementById('empty-note'); + if (emptyNote) emptyNote.hidden = anyTargets; + return boxes; +} + +function sendDecision(consentId: string, approvedClasses: string[]): void { + const msg: ConsentDecisionMessage = { + type: 'SOLID_CONSENT_DECISION', + consentId, + approvedClasses, + }; + chrome.runtime.sendMessage(msg, () => { + // Close regardless — the worker has recorded the decision (or the window was already gone). + window.close(); + }); +} + +async function main(): Promise { + const consentId = new URLSearchParams(window.location.search).get('consentId'); + if (!consentId) { + showError('Missing consent request id.'); + return; + } + + let view: PendingConsentView | null = null; + try { + view = (await chrome.runtime.sendMessage({ + type: 'SOLID_GET_PENDING_CONSENT', + consentId, + })) as PendingConsentView | null; + } catch { + view = null; + } + if (!view) { + showError('This access request has expired or is no longer available.'); + return; + } + + const originEl = document.getElementById('origin'); + if (originEl) originEl.textContent = view.origin; + const webidEl = document.getElementById('webid'); + if (webidEl) webidEl.textContent = `(${view.webId})`; + + const boxes = renderClasses(view); + + document.getElementById('approve')?.addEventListener('click', () => { + const approved: string[] = []; + for (const [dataClass, box] of boxes) if (box.checked) approved.push(dataClass); + sendDecision(consentId, approved); + }); + document.getElementById('deny')?.addEventListener('click', () => { + sendDecision(consentId, []); + }); + + // If the window is closed with the OS chrome (no button), treat it as a denial so the + // requestAccess promise still resolves (fail-closed) rather than hanging. + window.addEventListener('pagehide', () => { + try { + chrome.runtime.sendMessage({ + type: 'SOLID_CONSENT_DECISION', + consentId, + approvedClasses: [], + } satisfies ConsentDecisionMessage); + } catch { + // best-effort + } + }); +} + +void main(); diff --git a/apps/extension/src/content/content-script.ts b/apps/extension/src/content/content-script.ts index ec3283bae..5d8b68c72 100644 --- a/apps/extension/src/content/content-script.ts +++ b/apps/extension/src/content/content-script.ts @@ -24,6 +24,13 @@ interface PageMessage { webId?: string; clientId?: string; autoDivert?: boolean; + // Access-management (Phase 2) payload fields. + classes?: string[]; + modes?: string[]; + purpose?: string; + duration?: string; + classIRI?: string; + grantId?: string; } function toPage(message: Record): void { @@ -113,6 +120,73 @@ window.addEventListener('message', (event) => { }); }); break; + + // --- Access management (Phase 2) — all origin-sensitive, so the REAL page origin is + // stamped HERE (ISOLATED world), never page-supplied. The worker cross-checks it against + // the browser-attested sender.origin. Responses relay back verbatim keyed by requestId. + case 'SOLID_REQUEST_ACCESS': + chrome.runtime.sendMessage( + { + type: 'SOLID_REQUEST_ACCESS', + requestId: data.requestId, + classes: data.classes ?? [], + modes: data.modes ?? [], + purpose: data.purpose, + duration: data.duration, + stampedOrigin: window.location.origin, + // Stamp the browser-attested transient user activation, read HERE in the ISOLATED world + // (the page cannot forge the isolated `navigator`). `isActive` is TRUE only within ~5s of + // a genuine gesture, so an autonomous `requestAccess` loop reports false and is rejected + // by the SW's anti-spam gate (residual c). Optional-chained so a context without the + // User Activation API fails closed (undefined → false → gesture required). + userActivated: navigator.userActivation?.isActive === true, + }, + (response) => { + toPage({ type: 'SOLID_ACCESS_RESPONSE', ...(response ?? { error: 'No response' }) }); + }, + ); + break; + + case 'SOLID_LIST_GRANTS': + chrome.runtime.sendMessage( + { + type: 'SOLID_LIST_GRANTS', + requestId: data.requestId, + stampedOrigin: window.location.origin, + }, + (response) => { + toPage({ type: 'SOLID_ACCESS_RESPONSE', ...(response ?? { error: 'No response' }) }); + }, + ); + break; + + case 'SOLID_REVOKE_GRANT': + chrome.runtime.sendMessage( + { + type: 'SOLID_REVOKE_GRANT', + requestId: data.requestId, + grantId: data.grantId, + stampedOrigin: window.location.origin, + }, + (response) => { + toPage({ type: 'SOLID_ACCESS_RESPONSE', ...(response ?? { error: 'No response' }) }); + }, + ); + break; + + case 'SOLID_RESOLVE_CLASS': + chrome.runtime.sendMessage( + { + type: 'SOLID_RESOLVE_CLASS', + requestId: data.requestId, + classIRI: data.classIRI, + stampedOrigin: window.location.origin, + }, + (response) => { + toPage({ type: 'SOLID_ACCESS_RESPONSE', ...(response ?? { error: 'No response' }) }); + }, + ); + break; } }); diff --git a/apps/extension/src/inject/inject.ts b/apps/extension/src/inject/inject.ts index 1b68730f1..7718f1112 100644 --- a/apps/extension/src/inject/inject.ts +++ b/apps/extension/src/inject/inject.ts @@ -26,10 +26,40 @@ declare global { } } +/** A token-free view of a per-data-class grant (the enforceable substance + consent anchors). */ +interface SolidGrant { + readonly grantId: string; + readonly dataClass: string; + readonly modes: readonly string[]; + readonly purpose: string | null; + readonly expiresAt: number; + readonly targets: ReadonlyArray<{ url: string; kind: 'instance' | 'container' }>; +} + +/** The `requestAccess` argument (design §4.2). `duration` is an ISO-8601 duration, e.g. "P30D". */ +interface AccessRequestArgs { + readonly classes: readonly string[]; + readonly modes: readonly string[]; + readonly purpose?: string; + readonly duration?: string; +} + +/** The worker's reply to an access-management call, as relayed to the page. */ +interface AccessResponseData { + readonly requestId: string; + readonly ok?: boolean; + readonly grants?: SolidGrant[]; + readonly shape?: string | null; + readonly targets?: Array<{ url: string; kind: 'instance' | 'container' }>; + readonly error?: string; +} + /** - * The `window.solid` API surface. Deliberately small + stable so the access-management - * track (a later `requestAccess(...)` method — see the SEAM below) slots in without a - * breaking change. + * The `window.solid` API surface — Phase-0 core (fetch/login/logout/setClientId) PLUS the + * Phase-2 per-data-class access-management methods (design §4.2). Every access-management call + * is a token-free `postMessage` to the service worker (the sole boundary); the page never sees + * a credential — only a grant handle + a capability to `fetch` within the granted, PINNED, + * resolved target set. */ interface SolidExtension { /** The authenticated user's WebID, or null when signed out. */ @@ -43,13 +73,23 @@ interface SolidExtension { /** Clear the session (app-local logout). */ logout(): Promise; - // --- ACCESS-MANAGEMENT SEAM (NOT IMPLEMENTED) ------------------------------------- - // The access-request JS API (requestAccess / consent UI / queued-request handling) is - // a SEPARATE, design-first track and is intentionally out of scope for this core. The - // method is declared here so adding it later is non-breaking, but calling it throws so - // a consumer can feature-detect. Do NOT wire access management onto this stub without - // the access-management design. - requestAccess?(request: unknown): Promise; + // --- Per-data-class access management (Phase 2, design §4.2) ----------------------- + /** + * Request type-level access to one or more data classes. Triggers the extension's + * unspoofable consent window; on approval the class is resolved to concrete pod targets that + * are PINNED into the grant, and subsequent `fetch` calls are confined to that pinned set. + * Resolves with the minted grants (one per approved class); an empty `grants` ⇒ denied. + */ + requestAccess(request: AccessRequestArgs): Promise<{ ok: boolean; grants: SolidGrant[] }>; + /** The grants THIS origin currently holds (non-expired), for the active session. */ + listGrants(): Promise; + /** Revoke one of THIS origin's grants; effective on the next request. Returns whether removed. */ + revokeGrant(grantId: string): Promise; + /** Read-only introspection: resolve a data-class IRI to its pod targets (for the app's UX). */ + resolveClass(classIRI: string): Promise<{ + shape: string | null; + targets: Array<{ url: string; kind: 'instance' | 'container' }>; + }>; } type Pending = { resolve: (v: T) => void; reject: (e: Error) => void }; @@ -106,6 +146,8 @@ function installSolidInjection(): void { const pendingFetches = new Map(); const pendingActions = new Map>(); + /** Pending access-management calls (requestAccess/listGrants/revokeGrant/resolveClass). */ + const pendingAccess = new Map>(); let currentWebId: string | null = null; @@ -151,12 +193,32 @@ function installSolidInjection(): void { else pending.resolve(); break; } + case 'SOLID_ACCESS_RESPONSE': { + const pending = pendingAccess.get(data.requestId); + if (!pending) return; + pendingAccess.delete(data.requestId); + if (data.error) pending.reject(new Error(data.error)); + else pending.resolve(data as AccessResponseData); + break; + } case 'SOLID_STATE_UPDATE': currentWebId = data.webId ?? null; break; } }); + /** Send an access-management request and await the worker's correlated reply. */ + function accessRequest( + type: string, + extra: Record = {}, + ): Promise { + return new Promise((resolve, reject) => { + const requestId = crypto.randomUUID(); + pendingAccess.set(requestId, { resolve, reject }); + postToContent({ type, requestId, ...extra }); + }); + } + function postToContent(message: Record): void { window.postMessage({ channel: CHANNEL, dir: 'to-content', ...message }, window.location.origin); } @@ -239,13 +301,26 @@ function installSolidInjection(): void { logout() { return action('SOLID_LOGOUT'); }, - requestAccess() { - return Promise.reject( - new Error( - 'window.solid.requestAccess is not implemented: access management is a separate, ' + - 'design-first track and is not part of the core extension.', - ), - ); + async requestAccess(request: AccessRequestArgs) { + const res = await accessRequest('SOLID_REQUEST_ACCESS', { + classes: request?.classes ?? [], + modes: request?.modes ?? [], + purpose: request?.purpose, + duration: request?.duration, + }); + return { ok: res.ok === true, grants: res.grants ?? [] }; + }, + async listGrants() { + const res = await accessRequest('SOLID_LIST_GRANTS'); + return res.grants ?? []; + }, + async revokeGrant(grantId: string) { + const res = await accessRequest('SOLID_REVOKE_GRANT', { grantId }); + return res.ok === true; + }, + async resolveClass(classIRI: string) { + const res = await accessRequest('SOLID_RESOLVE_CLASS', { classIRI }); + return { shape: res.shape ?? null, targets: res.targets ?? [] }; }, }; diff --git a/apps/extension/src/shared/messages.ts b/apps/extension/src/shared/messages.ts index d0fee272f..7873f451b 100644 --- a/apps/extension/src/shared/messages.ts +++ b/apps/extension/src/shared/messages.ts @@ -77,12 +77,113 @@ export interface SetClientIdRequest { readonly clientId: string; } +// --- Per-data-class access management (Phase 2, design §4.2) --------------------------- + +/** + * A page's token-free view of a class grant it holds — the enforceable substance (targets + + * modes + expiry) plus the consent anchors (dataClass + purpose). NO credential, NO webId. + */ +export interface GrantSummary { + readonly grantId: string; + readonly dataClass: string; + readonly modes: readonly string[]; + readonly purpose: string | null; + readonly expiresAt: number; + readonly targets: ReadonlyArray<{ url: string; kind: 'instance' | 'container' }>; +} + +/** `window.solid.requestAccess(...)` — a typed, per-class access request (triggers consent). */ +export interface RequestAccessRequest { + readonly type: 'SOLID_REQUEST_ACCESS'; + readonly requestId: string; + readonly classes: readonly string[]; + readonly modes: readonly string[]; + readonly purpose?: string; + readonly duration?: string; + /** Page-supplied origin stamp — cross-checked against the browser-attested sender. */ + readonly stampedOrigin?: string; + /** + * Whether the call carried a genuine transient user activation, read in the ISOLATED content + * script (`navigator.userActivation.isActive`) — page-unforgeable. Drives the anti-spam gesture + * requirement (residual c); an autonomous (non-gesture) call is rejected before consent. + */ + readonly userActivated?: boolean; +} + +/** `window.solid.listGrants()` — the grants THIS origin currently holds. */ +export interface ListGrantsRequest { + readonly type: 'SOLID_LIST_GRANTS'; + readonly requestId: string; + readonly stampedOrigin?: string; +} + +/** `window.solid.revokeGrant(grantId)` — revoke one of THIS origin's grants. */ +export interface RevokeGrantRequest { + readonly type: 'SOLID_REVOKE_GRANT'; + readonly requestId: string; + readonly grantId: string; + readonly stampedOrigin?: string; +} + +/** `window.solid.resolveClass(classIRI)` — read-only introspection of a class → targets. */ +export interface ResolveClassRequest { + readonly type: 'SOLID_RESOLVE_CLASS'; + readonly requestId: string; + readonly classIRI: string; + readonly stampedOrigin?: string; +} + +/** The worker's reply to any of the four access-management requests (correlated by requestId). */ +export interface AccessResponse { + readonly requestId: string; + readonly ok?: boolean; + readonly grants?: readonly GrantSummary[]; + readonly shape?: string | null; + readonly targets?: ReadonlyArray<{ url: string; kind: 'instance' | 'container' }>; + readonly error?: string; +} + +/** The consent window asks the worker for the pending record to render (extension context only). */ +export interface GetPendingConsentRequest { + readonly type: 'SOLID_GET_PENDING_CONSENT'; + readonly consentId: string; +} + +/** The consent surface → worker decision (from the extension's own consent window only). */ +export interface ConsentDecisionMessage { + readonly type: 'SOLID_CONSENT_DECISION'; + readonly consentId: string; + /** The data-class IRIs the user APPROVED (a subset of the requested set); [] ⇒ denied all. */ + readonly approvedClasses: readonly string[]; +} + +/** What the consent window renders — a token-free view of the pending request. */ +export interface PendingConsentView { + readonly consentId: string; + readonly origin: string; + readonly webId: string; + readonly classes: ReadonlyArray<{ + readonly dataClass: string; + readonly modes: readonly string[]; + readonly purpose: string | null; + readonly expiresAt: number; + readonly targets: ReadonlyArray<{ url: string; kind: 'instance' | 'container' }>; + readonly shape: string | null; + }>; +} + export type WorkerRequest = | FetchRequest | LoginRequest | LogoutRequest | GetStateRequest - | SetClientIdRequest; + | SetClientIdRequest + | RequestAccessRequest + | ListGrantsRequest + | RevokeGrantRequest + | ResolveClassRequest + | GetPendingConsentRequest + | ConsentDecisionMessage; // --- Responses (worker -> caller) ----------------------------------------------------- diff --git a/apps/extension/test/class-grant.test.ts b/apps/extension/test/class-grant.test.ts new file mode 100644 index 000000000..629b37a08 --- /dev/null +++ b/apps/extension/test/class-grant.test.ts @@ -0,0 +1,287 @@ +// AUTHORED-BY Claude Opus 4.8 +// +// EXHAUSTIVE adversarial unit tests for the per-data-class grant gate — the Phase-2 security +// heart (design §4). These prove the load-bearing enforcement as PURE functions: +// - method → mode mapping + the write ⊇ append implication (read independent) +// - target-set membership: instance exact, container prefix, cross-origin / traversal / sibling +// DENY, query/fragment normalisation, non-http DENY +// - grantCovers fail-closed on expiry / empty (unresolved) targets / mode mismatch / target miss +// - decideClassAccess: no grant → no-class-grant, present-but-uncovered → out-of-grant-scope +// - grant validation drops tampered/corrupt storage records +// - the (origin, webId) session boundary + +import { describe, expect, it } from 'vitest'; +import { + type ClassGrant, + decideClassAccess, + grantCovers, + grantsForSession, + isGrantExpired, + isValidGrant, + isWithinTarget, + liveGrantOrigins, + methodToMode, + modeSatisfies, + normaliseMode, + normaliseModes, + type PinnedTarget, + pruneExpiredGrants, + sanitiseGrants, + targetsCover, +} from '../src/background/core/class-grant'; + +const T0 = 1_000_000; + +function grant(overrides: Partial = {}): ClassGrant { + return { + grantId: 'g1', + webId: 'https://alice.pod.example/profile/card#me', + origin: 'https://app.example', + dataClass: 'https://w3id.org/jeswr/sectors/finance#Transaction', + modes: ['read'], + purpose: 'https://w3id.org/dpv#ServiceProvision', + expiresAt: T0 + 10_000, + targets: [{ url: 'https://alice.pod.example/finance/', kind: 'container' }], + createdAt: T0, + ...overrides, + }; +} + +describe('methodToMode — fail-closed method mapping', () => { + it('maps read methods to read', () => { + expect(methodToMode('GET')).toBe('read'); + expect(methodToMode('head')).toBe('read'); + }); + it('maps POST to append, mutating verbs to write', () => { + expect(methodToMode('POST')).toBe('append'); + expect(methodToMode('PUT')).toBe('write'); + expect(methodToMode('PATCH')).toBe('write'); + expect(methodToMode('DELETE')).toBe('write'); + }); + it('maps an UNKNOWN method to write (fail-closed — hardest to satisfy)', () => { + expect(methodToMode('FROB')).toBe('write'); + expect(methodToMode('')).toBe('write'); + }); +}); + +describe('normaliseMode / normaliseModes', () => { + it('accepts the three modes case-insensitively, rejects the rest', () => { + expect(normaliseMode('Read')).toBe('read'); + expect(normaliseMode('WRITE')).toBe('write'); + expect(normaliseMode('append')).toBe('append'); + expect(normaliseMode('control')).toBeNull(); + expect(normaliseMode(42)).toBeNull(); + }); + it('dedupes + drops unrecognised entries', () => { + expect(normaliseModes(['read', 'read', 'nope', 'write'])).toEqual(['read', 'write']); + expect(normaliseModes('read')).toEqual([]); + expect(normaliseModes(undefined)).toEqual([]); + }); +}); + +describe('modeSatisfies — write ⊇ append, read independent', () => { + it('write covers append but not read', () => { + expect(modeSatisfies(['write'], 'append')).toBe(true); + expect(modeSatisfies(['write'], 'write')).toBe(true); + expect(modeSatisfies(['write'], 'read')).toBe(false); + }); + it('read covers only read', () => { + expect(modeSatisfies(['read'], 'read')).toBe(true); + expect(modeSatisfies(['read'], 'append')).toBe(false); + expect(modeSatisfies(['read'], 'write')).toBe(false); + }); + it('append covers only append', () => { + expect(modeSatisfies(['append'], 'append')).toBe(true); + expect(modeSatisfies(['append'], 'write')).toBe(false); + }); + it('empty grant satisfies nothing (fail-closed)', () => { + expect(modeSatisfies([], 'read')).toBe(false); + }); +}); + +describe('isWithinTarget — pinned membership, fail-closed', () => { + const container: PinnedTarget = { url: 'https://alice.pod.example/finance/', kind: 'container' }; + const instance: PinnedTarget = { + url: 'https://alice.pod.example/finance/statement.ttl', + kind: 'instance', + }; + + it('container covers itself and descendants', () => { + expect(isWithinTarget(container, 'https://alice.pod.example/finance/')).toBe(true); + expect(isWithinTarget(container, 'https://alice.pod.example/finance/2026/jan.ttl')).toBe(true); + }); + it('container ignores query + fragment on the request', () => { + expect(isWithinTarget(container, 'https://alice.pod.example/finance/x.ttl?v=2#a')).toBe(true); + }); + it('container does NOT cover a sibling with a shared prefix', () => { + expect(isWithinTarget(container, 'https://alice.pod.example/finance-secret/x')).toBe(false); + expect(isWithinTarget(container, 'https://alice.pod.example/financial')).toBe(false); + }); + it('container DENIES a path-traversal that escapes it (URL parser normalises `..`)', () => { + expect(isWithinTarget(container, 'https://alice.pod.example/finance/../secret/x')).toBe(false); + }); + it('DENIES a cross-origin request even at the same path', () => { + expect(isWithinTarget(container, 'https://evil.example/finance/x')).toBe(false); + }); + it('instance is an EXACT path match', () => { + expect(isWithinTarget(instance, 'https://alice.pod.example/finance/statement.ttl')).toBe(true); + expect(isWithinTarget(instance, 'https://alice.pod.example/finance/statement.ttl?a=1')).toBe( + true, + ); + expect(isWithinTarget(instance, 'https://alice.pod.example/finance/statement.ttl2')).toBe( + false, + ); + expect(isWithinTarget(instance, 'https://alice.pod.example/finance/')).toBe(false); + }); + it('DENIES an unparseable / non-http request or target', () => { + expect(isWithinTarget(container, 'not a url')).toBe(false); + expect(isWithinTarget(container, 'ftp://alice.pod.example/finance/x')).toBe(false); + expect(isWithinTarget({ url: 'not a url', kind: 'container' }, 'https://a.example/x')).toBe( + false, + ); + }); +}); + +describe('targetsCover', () => { + it('is fail-closed on an empty target set', () => { + expect(targetsCover([], 'https://alice.pod.example/finance/x')).toBe(false); + }); + it('matches if ANY target covers', () => { + const targets: PinnedTarget[] = [ + { url: 'https://alice.pod.example/health/', kind: 'container' }, + { url: 'https://alice.pod.example/finance/', kind: 'container' }, + ]; + expect(targetsCover(targets, 'https://alice.pod.example/finance/x')).toBe(true); + expect(targetsCover(targets, 'https://alice.pod.example/other/x')).toBe(false); + }); +}); + +describe('isGrantExpired', () => { + it('is expired at/after expiresAt', () => { + expect(isGrantExpired(grant({ expiresAt: T0 }), T0)).toBe(true); + expect(isGrantExpired(grant({ expiresAt: T0 + 1 }), T0)).toBe(false); + }); + it('treats a NaN/invalid expiry as expired (fail-closed)', () => { + expect(isGrantExpired(grant({ expiresAt: Number.NaN }), T0)).toBe(true); + }); +}); + +describe('isValidGrant / sanitiseGrants — reject tampered storage', () => { + it('accepts a well-formed grant', () => { + expect(isValidGrant(grant())).toBe(true); + }); + it('rejects missing / malformed fields', () => { + expect(isValidGrant(null)).toBe(false); + expect(isValidGrant({ ...grant(), grantId: '' })).toBe(false); + expect(isValidGrant({ ...grant(), webId: '' })).toBe(false); + expect(isValidGrant({ ...grant(), origin: 'not-a-url' })).toBe(false); + expect(isValidGrant({ ...grant(), expiresAt: 'soon' })).toBe(false); + expect(isValidGrant({ ...grant(), modes: ['read', 'control'] })).toBe(false); + expect(isValidGrant({ ...grant(), targets: [{ url: 'x', kind: 'bogus' }] })).toBe(false); + expect(isValidGrant({ ...grant(), targets: 'nope' })).toBe(false); + }); + it('sanitiseGrants drops invalid records, keeps valid ones', () => { + const kept = sanitiseGrants([grant({ grantId: 'ok' }), { junk: true }, null, 42]); + expect(kept.map((g) => g.grantId)).toEqual(['ok']); + expect(sanitiseGrants('not-an-array')).toEqual([]); + }); +}); + +describe('grantCovers — the single-grant decision, fail-closed on every axis', () => { + it('ALLOWS a read within the pinned container while live', () => { + expect(grantCovers(grant(), 'https://alice.pod.example/finance/x.ttl', 'GET', T0)).toBe(true); + }); + it('DENIES when expired', () => { + expect( + grantCovers(grant({ expiresAt: T0 }), 'https://alice.pod.example/finance/x.ttl', 'GET', T0), + ).toBe(false); + }); + it('DENIES when the target set is empty (unresolved / unknown class)', () => { + expect( + grantCovers(grant({ targets: [] }), 'https://alice.pod.example/finance/x', 'GET', T0), + ).toBe(false); + }); + it('DENIES when the mode is not satisfied (read grant, write request)', () => { + expect( + grantCovers(grant({ modes: ['read'] }), 'https://alice.pod.example/finance/x', 'PUT', T0), + ).toBe(false); + }); + it('DENIES a URL outside the pinned targets', () => { + expect(grantCovers(grant(), 'https://alice.pod.example/health/x', 'GET', T0)).toBe(false); + }); + it('a write grant ALLOWS append (POST) and write (PUT) within scope', () => { + const g = grant({ modes: ['write'] }); + expect(grantCovers(g, 'https://alice.pod.example/finance/x', 'POST', T0)).toBe(true); + expect(grantCovers(g, 'https://alice.pod.example/finance/x', 'PUT', T0)).toBe(true); + // ...but not read (write does not imply read). + expect(grantCovers(g, 'https://alice.pod.example/finance/x', 'GET', T0)).toBe(false); + }); +}); + +describe('decideClassAccess — the origin-scoped gate decision', () => { + it('no live grant → no-class-grant', () => { + expect(decideClassAccess([], 'https://alice.pod.example/finance/x', 'GET', T0)).toBe( + 'no-class-grant', + ); + }); + it('all grants expired → no-class-grant (they are filtered out first)', () => { + expect( + decideClassAccess( + [grant({ expiresAt: T0 - 1 })], + 'https://alice.pod.example/finance/x', + 'GET', + T0, + ), + ).toBe('no-class-grant'); + }); + it('grants present but none cover → out-of-grant-scope', () => { + expect(decideClassAccess([grant()], 'https://alice.pod.example/health/x', 'GET', T0)).toBe( + 'out-of-grant-scope', + ); + // right resource, wrong mode: + expect(decideClassAccess([grant()], 'https://alice.pod.example/finance/x', 'PUT', T0)).toBe( + 'out-of-grant-scope', + ); + }); + it('a covering grant → ALLOW (null)', () => { + expect( + decideClassAccess([grant()], 'https://alice.pod.example/finance/x', 'GET', T0), + ).toBeNull(); + }); + it('the UNION of several grants is honoured', () => { + const g2 = grant({ + grantId: 'g2', + targets: [{ url: 'https://alice.pod.example/health/', kind: 'container' }], + }); + expect( + decideClassAccess([grant(), g2], 'https://alice.pod.example/health/x', 'GET', T0), + ).toBeNull(); + }); +}); + +describe('pruneExpiredGrants / liveGrantOrigins / grantsForSession — session boundary', () => { + it('pruneExpiredGrants keeps only live grants', () => { + const kept = pruneExpiredGrants( + [grant({ grantId: 'live' }), grant({ grantId: 'dead', expiresAt: T0 - 1 })], + T0, + ); + expect(kept.map((g) => g.grantId)).toEqual(['live']); + }); + it('liveGrantOrigins filters by WebID + expiry', () => { + const grants = [ + grant({ origin: 'https://a.example', webId: 'https://alice#me' }), + grant({ origin: 'https://b.example', webId: 'https://bob#me' }), + grant({ origin: 'https://c.example', webId: 'https://alice#me', expiresAt: T0 - 1 }), + ]; + expect([...liveGrantOrigins(grants, T0, 'https://alice#me')]).toEqual(['https://a.example']); + }); + it('grantsForSession filters to the exact origin AND WebID', () => { + const grants = [ + grant({ grantId: 'mine', origin: 'https://app.example', webId: 'https://alice#me' }), + grant({ grantId: 'other-origin', origin: 'https://evil.example', webId: 'https://alice#me' }), + grant({ grantId: 'other-webid', origin: 'https://app.example', webId: 'https://bob#me' }), + ]; + const mine = grantsForSession(grants, 'https://app.example', 'https://alice#me'); + expect(mine.map((g) => g.grantId)).toEqual(['mine']); + }); +}); diff --git a/apps/extension/test/class-request.test.ts b/apps/extension/test/class-request.test.ts new file mode 100644 index 000000000..3f3e7820e --- /dev/null +++ b/apps/extension/test/class-request.test.ts @@ -0,0 +1,105 @@ +// AUTHORED-BY Claude Opus 4.8 +// +// Unit tests for the untrusted `requestAccess` payload parser (design §4.2). Every axis is +// fail-closed: non-IRI classes dropped (empty → reject), no modes → reject, a bounded ISO-8601 +// duration REQUIRED (unparseable/absent/zero/negative → reject), duration clamped to the ceiling. + +import { describe, expect, it } from 'vitest'; +import { + MAX_GRANT_MS, + normaliseAccessRequest, + parseIso8601DurationMs, +} from '../src/background/core/class-request'; + +const NOW = 1_000_000_000; + +describe('parseIso8601DurationMs', () => { + it('parses days / weeks / hours / combined', () => { + expect(parseIso8601DurationMs('P1D')).toBe(24 * 60 * 60 * 1000); + expect(parseIso8601DurationMs('P30D')).toBe(30 * 24 * 60 * 60 * 1000); + expect(parseIso8601DurationMs('P1W')).toBe(7 * 24 * 60 * 60 * 1000); + expect(parseIso8601DurationMs('PT12H')).toBe(12 * 60 * 60 * 1000); + expect(parseIso8601DurationMs('P1DT1H')).toBe(25 * 60 * 60 * 1000); + expect(parseIso8601DurationMs('PT30M')).toBe(30 * 60 * 1000); + }); + it('rejects bare/empty/zero/negative/garbage (fail-closed)', () => { + expect(parseIso8601DurationMs('P')).toBeNull(); + expect(parseIso8601DurationMs('PT')).toBeNull(); + expect(parseIso8601DurationMs('P0D')).toBeNull(); + expect(parseIso8601DurationMs('30d')).toBeNull(); + expect(parseIso8601DurationMs('-P1D')).toBeNull(); + expect(parseIso8601DurationMs('')).toBeNull(); + expect(parseIso8601DurationMs(undefined)).toBeNull(); + expect(parseIso8601DurationMs(42)).toBeNull(); + }); +}); + +describe('normaliseAccessRequest', () => { + it('normalises a well-formed request', () => { + const r = normaliseAccessRequest( + { + classes: ['https://w3id.org/jeswr/sectors/finance#Transaction'], + modes: ['read', 'read'], + purpose: ' https://w3id.org/dpv#ServiceProvision ', + duration: 'P30D', + }, + NOW, + ); + if ('error' in r) throw new Error(r.error); + expect(r.classes).toEqual(['https://w3id.org/jeswr/sectors/finance#Transaction']); + expect(r.modes).toEqual(['read']); + expect(r.purpose).toBe('https://w3id.org/dpv#ServiceProvision'); + expect(r.durationMs).toBe(30 * 24 * 60 * 60 * 1000); + expect(r.expiresAt).toBe(NOW + 30 * 24 * 60 * 60 * 1000); + }); + it('drops non-IRI classes; rejects when none remain', () => { + const ok = normaliseAccessRequest( + { classes: ['not-iri', 'https://x.example/C'], modes: ['read'], duration: 'P1D' }, + NOW, + ); + if ('error' in ok) throw new Error(ok.error); + expect(ok.classes).toEqual(['https://x.example/C']); + + const bad = normaliseAccessRequest( + { classes: ['not-iri'], modes: ['read'], duration: 'P1D' }, + NOW, + ); + expect('error' in bad).toBe(true); + }); + it('rejects when no recognised mode is present', () => { + const bad = normaliseAccessRequest( + { classes: ['https://x.example/C'], modes: ['control'], duration: 'P1D' }, + NOW, + ); + expect('error' in bad).toBe(true); + }); + it('rejects when the duration is missing / unparseable (expiry is mandatory)', () => { + expect( + 'error' in normaliseAccessRequest({ classes: ['https://x.example/C'], modes: ['read'] }, NOW), + ).toBe(true); + expect( + 'error' in + normaliseAccessRequest( + { classes: ['https://x.example/C'], modes: ['read'], duration: 'forever' }, + NOW, + ), + ).toBe(true); + }); + it('clamps an over-long duration to the ceiling', () => { + const r = normaliseAccessRequest( + { classes: ['https://x.example/C'], modes: ['read'], duration: 'P4000D' }, + NOW, + ); + if ('error' in r) throw new Error(r.error); + expect(r.durationMs).toBe(MAX_GRANT_MS); + expect(r.expiresAt).toBe(NOW + MAX_GRANT_MS); + }); + it('nulls a blank/absent purpose', () => { + const r = normaliseAccessRequest( + { classes: ['https://x.example/C'], modes: ['read'], purpose: ' ', duration: 'P1D' }, + NOW, + ); + if ('error' in r) throw new Error(r.error); + expect(r.purpose).toBeNull(); + }); +}); diff --git a/apps/extension/test/class-resolve.test.ts b/apps/extension/test/class-resolve.test.ts new file mode 100644 index 000000000..513e2af15 --- /dev/null +++ b/apps/extension/test/class-resolve.test.ts @@ -0,0 +1,151 @@ +// AUTHORED-BY Claude Opus 4.8 +// +// Unit tests for the data-class → target resolver (design §2.3, Type-Index rung). Pure parse +// helpers run against Turtle fixtures; the network walk runs against an injected fetch. Proves +// the fail-closed dropping of foreign-origin / non-NamedNode / non-http targets and that an +// unregistered class resolves to NOTHING (which the gate then denies). + +import { describe, expect, it } from 'vitest'; +import { + parseTargetsForClass, + parseTypeIndexRefs, + resolveClassTargets, +} from '../src/background/core/class-resolve'; + +const WEBID = 'https://alice.pod.example/profile/card#me'; +const FINANCE = 'https://w3id.org/jeswr/sectors/finance#Transaction'; + +const PROFILE = ` +@prefix solid: . + + solid:publicTypeIndex ; + solid:privateTypeIndex . +`; + +const PUBLIC_INDEX = ` +@prefix solid: . +@prefix fin: . +<#fin> a solid:TypeRegistration ; + solid:forClass fin:Transaction ; + solid:instanceContainer ; + solid:instance . +<#other> a solid:TypeRegistration ; + solid:forClass ; + solid:instanceContainer . +`; + +describe('parseTypeIndexRefs', () => { + it('returns the public + private type-index URLs on the WebID subject', () => { + expect(parseTypeIndexRefs(WEBID, PROFILE)).toEqual([ + 'https://alice.pod.example/settings/publicTypeIndex.ttl', + 'https://alice.pod.example/settings/privateTypeIndex.ttl', + ]); + }); + it('is empty for a profile with no type index', () => { + expect(parseTypeIndexRefs(WEBID, ' .')).toEqual([]); + }); +}); + +describe('parseTargetsForClass', () => { + it('collects instance + instanceContainer for the matching class only', () => { + const targets = parseTargetsForClass( + 'https://alice.pod.example/settings/publicTypeIndex.ttl', + PUBLIC_INDEX, + FINANCE, + ); + expect(targets).toEqual([ + { url: 'https://alice.pod.example/finance/summary.ttl', kind: 'instance' }, + { url: 'https://alice.pod.example/finance/', kind: 'container' }, + ]); + }); + it('returns NOTHING for an unregistered class', () => { + expect( + parseTargetsForClass( + 'https://alice.pod.example/settings/publicTypeIndex.ttl', + PUBLIC_INDEX, + 'https://x/Nope', + ), + ).toEqual([]); + }); + it('drops a target on a foreign origin when confined to allowedOrigins', () => { + const evilIndex = ` + @prefix solid: . + <#fin> a solid:TypeRegistration ; + solid:forClass <${FINANCE}> ; + solid:instanceContainer .`; + const confined = parseTargetsForClass( + 'https://alice.pod.example/settings/publicTypeIndex.ttl', + evilIndex, + FINANCE, + new Set(['https://alice.pod.example']), + ); + expect(confined).toEqual([]); + }); +}); + +describe('resolveClassTargets — the walk', () => { + function fetcher(docs: Record) { + return async (url: string) => { + const text = docs[url]; + return text === undefined ? { ok: false, text: '' } : { ok: true, text }; + }; + } + + it('walks profile → indexes → unions targets', async () => { + const targets = await resolveClassTargets(FINANCE, { + webId: WEBID, + fetch: fetcher({ + [WEBID]: PROFILE, + 'https://alice.pod.example/settings/publicTypeIndex.ttl': PUBLIC_INDEX, + // private index unreachable (returns non-ok) — degrades gracefully. + }), + allowedOrigins: new Set(['https://alice.pod.example']), + }); + expect(targets).toEqual([ + { url: 'https://alice.pod.example/finance/summary.ttl', kind: 'instance' }, + { url: 'https://alice.pod.example/finance/', kind: 'container' }, + ]); + }); + + it('does NOT fetch a type-index ref on a FOREIGN origin when confined (SSRF guard)', async () => { + const evilProfile = ` + @prefix solid: . + <${WEBID}> solid:publicTypeIndex .`; + const fetched: string[] = []; + const targets = await resolveClassTargets(FINANCE, { + webId: WEBID, + fetch: async (url: string) => { + fetched.push(url); + return url === WEBID ? { ok: true, text: evilProfile } : { ok: true, text: PUBLIC_INDEX }; + }, + allowedOrigins: new Set(['https://alice.pod.example']), + }); + expect(targets).toEqual([]); + // The foreign index URL was NEVER fetched (only the profile was). + expect(fetched).toEqual([WEBID]); + }); + + it('returns [] when the profile is unreachable (fail-closed, never throws)', async () => { + const targets = await resolveClassTargets(FINANCE, { + webId: WEBID, + fetch: fetcher({}), + }); + expect(targets).toEqual([]); + }); + + it('dedupes targets across the public + private indexes', async () => { + const targets = await resolveClassTargets(FINANCE, { + webId: WEBID, + fetch: fetcher({ + [WEBID]: PROFILE, + 'https://alice.pod.example/settings/publicTypeIndex.ttl': PUBLIC_INDEX, + 'https://alice.pod.example/settings/privateTypeIndex.ttl': PUBLIC_INDEX, + }), + }); + // Same registrations in both indexes → one entry each. + expect(targets).toEqual([ + { url: 'https://alice.pod.example/finance/summary.ttl', kind: 'instance' }, + { url: 'https://alice.pod.example/finance/', kind: 'container' }, + ]); + }); +}); diff --git a/apps/extension/test/consent.test.ts b/apps/extension/test/consent.test.ts new file mode 100644 index 000000000..7cb77b48d --- /dev/null +++ b/apps/extension/test/consent.test.ts @@ -0,0 +1,63 @@ +// AUTHORED-BY Claude Opus 4.8 +// +// Unit tests for the pure consent-flow logic (design §4.1): building the pending record and +// mapping a per-class approve/deny decision to concrete grants. Proves an unresolved class +// mints a grant authorising NOTHING, and only requested+approved classes mint grants. + +import { describe, expect, it } from 'vitest'; +import type { PinnedTarget } from '../src/background/core/class-grant'; +import { buildPendingConsent, consentDecisionToGrants } from '../src/background/core/consent'; + +const ORIGIN = 'https://app.example'; +const WEBID = 'https://alice.pod.example/profile/card#me'; +const FIN = 'https://w3id.org/jeswr/sectors/finance#Transaction'; +const HEALTH = 'https://w3id.org/jeswr/sectors/health#Record'; +const NOW = 5_000; +const EXP = NOW + 100; + +function pending() { + const spec = { classes: [FIN, HEALTH], modes: ['read'] as const, purpose: 'p', expiresAt: EXP }; + const resolved = new Map([ + [FIN, { targets: [{ url: 'https://alice.pod.example/finance/', kind: 'container' }] }], + // HEALTH intentionally UNRESOLVED (no entry) → empty targets. + ]); + return buildPendingConsent('c1', ORIGIN, WEBID, spec, resolved, NOW); +} + +describe('buildPendingConsent', () => { + it('carries every requested class, empty targets when unresolved', () => { + const p = pending(); + expect(p.classes.map((c) => c.dataClass)).toEqual([FIN, HEALTH]); + expect(p.classes[0].targets).toHaveLength(1); + expect(p.classes[1].targets).toEqual([]); + expect(p.classes[0].expiresAt).toBe(EXP); + }); +}); + +describe('consentDecisionToGrants', () => { + it('mints a grant ONLY for requested+approved classes', () => { + let n = 0; + const grants = consentDecisionToGrants(pending(), [FIN], () => `g${++n}`); + expect(grants).toHaveLength(1); + expect(grants[0]).toMatchObject({ + grantId: 'g1', + origin: ORIGIN, + webId: WEBID, + dataClass: FIN, + modes: ['read'], + expiresAt: EXP, + }); + }); + it('mints nothing when nothing is approved (deny-all)', () => { + expect(consentDecisionToGrants(pending(), [], () => 'g')).toEqual([]); + }); + it('ignores an approved class that was not in the request (defence in depth)', () => { + const grants = consentDecisionToGrants(pending(), ['https://evil/Class', FIN], () => 'g'); + expect(grants.map((g) => g.dataClass)).toEqual([FIN]); + }); + it('an approved but UNRESOLVED class mints a grant with an EMPTY target set (authorises nothing)', () => { + const grants = consentDecisionToGrants(pending(), [HEALTH], () => 'gH'); + expect(grants).toHaveLength(1); + expect(grants[0].targets).toEqual([]); + }); +}); diff --git a/apps/extension/test/dpop-key-store.test.ts b/apps/extension/test/dpop-key-store.test.ts new file mode 100644 index 000000000..20571d34c --- /dev/null +++ b/apps/extension/test/dpop-key-store.test.ts @@ -0,0 +1,359 @@ +// AUTHORED-BY Claude Sonnet 5 +// +// The DPoP session keypair's durable, non-extractable storage (security hardening, design +// roborev round-1 — the F3 keystore-advantage finding). These tests exercise the REAL +// `DpopKeyDb` class against a minimal fake `IDBFactory` — not a mocked module — so the +// IndexedDB structured-clone round-trip is genuinely exercised: `put`/`get` clone the value +// via `structuredClone`, which is the same primitive the Web Crypto spec relies on to make a +// non-extractable `CryptoKey` survive an IndexedDB round-trip (verified live: Node's +// `structuredClone` clones a non-extractable `CryptoKey` and the clone can still sign). + +import { beforeEach, describe, expect, it } from 'vitest'; +import { createDpopProof, dpopThumbprint } from '../src/background/core/dpop'; +import { DpopKeyDb, type StoredDpopKeyRecord } from '../src/background/core/dpop-key-db'; +import { + DpopKeyUnavailableError, + loadSessionDpopKeyPair, + mintAndPersistDpopKeyPair, + persistDpopKeyPair, +} from '../src/background/core/dpop-key-store'; + +// --- A minimal fake IDBFactory -------------------------------------------------------- +// +// Backs ONE named object store per "database" (keyed by db name) as a plain Map, with +// `put`/`get` round-tripping every value through `structuredClone` — the same algorithm a +// real IndexedDB implementation uses to persist a value, which is what makes a +// non-extractable `CryptoKey` survive intact (see the module docblock). A fresh +// `DpopKeyDb.open(factory)` call against the SAME `backingStores` map models an MV3 +// service-worker restart re-opening the same durable database. + +type FakeRecordStore = Map; + +class FakeRequestImpl { + result: T | undefined; + error: unknown; + onsuccess: (() => void) | null = null; + onerror: (() => void) | null = null; +} + +/** + * Resolve a fake request ONE microtask turn from now, then notify the owning transaction — + * mirroring real IndexedDB, where `dpop-key-db.ts`'s `promisify(req)` resolves first and + * `txDone(tx)` (which attaches `tx.oncomplete` only AFTER that resolution) must still see the + * completion event. `notifyRequestDone` schedules `oncomplete` a further turn out so the + * caller's `await txDone(tx)` has a chance to attach its handler in between. + */ +function settleRequest(req: FakeRequestImpl, value: T, notifyRequestDone: () => void): void { + queueMicrotask(() => { + req.result = value; + req.onsuccess?.(); + notifyRequestDone(); + }); +} + +class FakeObjectStore { + constructor( + private readonly store: FakeRecordStore, + private readonly notifyRequestDone: () => void, + ) {} + + get(key: IDBValidKey): FakeRequestImpl { + const req = new FakeRequestImpl(); + const raw = this.store.get(key); + settleRequest( + req, + raw === undefined ? undefined : structuredClone(raw), + this.notifyRequestDone, + ); + return req; + } + + put(value: unknown, key: IDBValidKey): FakeRequestImpl { + const req = new FakeRequestImpl(); + this.store.set(key, structuredClone(value)); + settleRequest(req, key, this.notifyRequestDone); + return req; + } + + clear(): FakeRequestImpl { + const req = new FakeRequestImpl(); + this.store.clear(); + settleRequest(req, undefined, this.notifyRequestDone); + return req; + } +} + +class FakeTransaction { + oncomplete: (() => void) | null = null; + onerror: (() => void) | null = null; + onabort: (() => void) | null = null; + + constructor(private readonly store: FakeRecordStore) {} + + objectStore(_name: string): FakeObjectStore { + return new FakeObjectStore(this.store, () => this.scheduleComplete()); + } + + /** Fire `oncomplete` two turns after the request settles — see {@link settleRequest}. */ + private scheduleComplete(): void { + queueMicrotask(() => queueMicrotask(() => this.oncomplete?.())); + } +} + +class FakeDatabase { + private readonly names = new Set(); + + constructor(private readonly stores: Map) {} + + get objectStoreNames(): { contains: (name: string) => boolean } { + return { contains: (name: string) => this.names.has(name) }; + } + + createObjectStore(name: string): FakeObjectStore { + this.names.add(name); + if (!this.stores.has(name)) this.stores.set(name, new Map()); + // Only used for its side effect during "upgrade" — no transaction to notify. + return new FakeObjectStore(this.stores.get(name) as FakeRecordStore, () => {}); + } + + transaction(name: string, _mode: string): FakeTransaction { + // Object store must already exist (created during the "upgrade"). + if (!this.stores.has(name)) this.stores.set(name, new Map()); + return new FakeTransaction(this.stores.get(name) as FakeRecordStore); + } + + close(): void { + // no-op + } +} + +/** The fake `IDBOpenDBRequest` — its own (non-lib.dom) type, cast to `IDBFactory` at the edge. */ +class FakeOpenRequest { + result: FakeDatabase | undefined; + error: unknown; + onupgradeneeded: (() => void) | null = null; + onsuccess: (() => void) | null = null; + onerror: (() => void) | null = null; +} + +/** A fake IDBFactory whose `open` always yields the SAME backing db for a given `dbName`. */ +function createFakeIdbFactory(backingDbs: Map): IDBFactory { + return { + open(dbName: string, _version?: number) { + const req = new FakeOpenRequest(); + const isNew = !backingDbs.has(dbName); + const db = backingDbs.get(dbName) ?? new FakeDatabase(new Map()); + backingDbs.set(dbName, db); + req.result = db; + queueMicrotask(() => { + if (isNew) req.onupgradeneeded?.(); + req.onsuccess?.(); + }); + return req; + }, + } as unknown as IDBFactory; +} + +// --- Tests ------------------------------------------------------------------------------ + +describe('DpopKeyDb — non-extractable CryptoKeyPair persistence', () => { + let backingDbs: Map; + let factory: IDBFactory; + + beforeEach(() => { + backingDbs = new Map(); + factory = createFakeIdbFactory(backingDbs); + }); + + it('persists + reloads a non-extractable keypair (structured-clone round trip)', async () => { + const db = await DpopKeyDb.open(factory); + const keyPair = await mintAndPersistDpopKeyPair(db); + expect(keyPair.privateKey.extractable).toBe(false); + + // Simulate an MV3 service-worker restart: a FRESH DpopKeyDb handle re-opening the SAME + // underlying (fake) database. + const restarted = await DpopKeyDb.open(factory); + const record = (await restarted.get()) as StoredDpopKeyRecord; + expect(record).toBeDefined(); + expect(record.privateKey.extractable).toBe(false); + // Round-tripped key still rejects export (never touched extractable material)... + await expect(crypto.subtle.exportKey('jwk', record.privateKey)).rejects.toThrow(); + // ...yet is still a LIVE, usable key: it can sign a DPoP proof, verifiable against the + // (safely-persisted) public JWK. + const proof = await createDpopProof({ + keyPair: { privateKey: record.privateKey, publicKey: record.publicKey }, + htm: 'GET', + htu: 'https://pod.example/x', + }); + expect(typeof proof).toBe('string'); + expect(record.publicJwk).toMatchObject({ kty: 'EC', crv: 'P-256' }); + expect((record.publicJwk as { d?: unknown }).d).toBeUndefined(); + }); + + it('clear() drops the persisted keypair (logout/teardown)', async () => { + const db = await DpopKeyDb.open(factory); + await mintAndPersistDpopKeyPair(db); + expect(await db.get()).toBeDefined(); + await db.clear(); + expect(await db.get()).toBeUndefined(); + }); +}); + +describe('loadSessionDpopKeyPair — session-aware load / legacy-rotate / mismatch-detect', () => { + let backingDbs: Map; + let factory: IDBFactory; + + beforeEach(() => { + backingDbs = new Map(); + factory = createFakeIdbFactory(backingDbs); + }); + + it('legacy session (no dpopJkt): returns the SAME persisted key on repeated calls', async () => { + const db = await DpopKeyDb.open(factory); + const first = await loadSessionDpopKeyPair(db, {}); + const firstJwk = await crypto.subtle.exportKey('jwk', first.publicKey); + + const second = await loadSessionDpopKeyPair(db, {}); + const secondJwk = await crypto.subtle.exportKey('jwk', second.publicKey); + expect(secondJwk).toEqual(firstJwk); + }); + + it('legacy session (no dpopJkt): mints a FRESH key when none is persisted — the migration path', async () => { + // Models the one-time migration: `session-store.ts#loadSession` has just stripped a + // pre-hardening extractable JWK, leaving this store empty AND the session with no + // recorded `dpopJkt` (it never had one). We must NOT attempt to reconstruct the old + // key — a brand-new non-extractable key is minted instead. + const db = await DpopKeyDb.open(factory); + expect(await db.get()).toBeUndefined(); + + const rotated = await loadSessionDpopKeyPair(db, {}); + expect(rotated.privateKey.extractable).toBe(false); + const persisted = await db.get(); + expect(persisted).toBeDefined(); + await expect( + crypto.subtle.exportKey('jwk', (persisted as StoredDpopKeyRecord).privateKey), + ).rejects.toThrow(); + }); + + it('legacy session: a pre-existing EXTRACTABLE keypair is never imported — rotation always mints fresh', async () => { + // Sanity-check that the "legacy key" scenario cannot leak: even if some other code + // path tried to hand a legacy extractable pair to the store, `loadSessionDpopKeyPair` + // has no code path that imports arbitrary JWK material — it only ever calls + // `generateDpopKeyPair()` (non-extractable by construction; see dpop.ts). + const legacyExtractable = await crypto.subtle.generateKey( + { name: 'ECDSA', namedCurve: 'P-256' }, + true, + ['sign', 'verify'], + ); + expect(legacyExtractable.privateKey.extractable).toBe(true); // sanity on the fixture itself + + const db = await DpopKeyDb.open(factory); + const rotated = await loadSessionDpopKeyPair(db, {}); + expect(rotated.privateKey.extractable).toBe(false); + // The rotated key is unrelated to the legacy one (different key material). + const rotatedJwk = await crypto.subtle.exportKey('jwk', rotated.publicKey); + const legacyJwk = await crypto.subtle.exportKey('jwk', legacyExtractable.publicKey); + expect(rotatedJwk).not.toEqual(legacyJwk); + }); + + it('current-shape session (has dpopJkt): returns the matching persisted key', async () => { + const db = await DpopKeyDb.open(factory); + const keyPair = await mintAndPersistDpopKeyPair(db); + const jkt = await dpopThumbprint(keyPair.publicKey); + + const loaded = await loadSessionDpopKeyPair(db, { dpopJkt: jkt }); + const loadedJwk = await crypto.subtle.exportKey('jwk', loaded.publicKey); + const originalJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey); + expect(loadedJwk).toEqual(originalJwk); + }); + + it('current-shape session (has dpopJkt): a MISSING persisted key throws, never silently mints', async () => { + // roborev round-1 Medium: a CURRENT-shape session's tokens are bound to a SPECIFIC key + // (recorded jkt). If that key vanishes (IndexedDB eviction, browser bug), silently + // minting a replacement would let the caller proceed with an access token that can + // never verify against the new key server-side — confusing 401s instead of a clean + // re-login. This must throw so the caller (service-worker.ts/auth-flow.ts) tears the + // session down instead. + const db = await DpopKeyDb.open(factory); + expect(await db.get()).toBeUndefined(); + + await expect(loadSessionDpopKeyPair(db, { dpopJkt: 'some-thumbprint' })).rejects.toThrow( + DpopKeyUnavailableError, + ); + // And it must NOT have minted+persisted a substitute as a side effect. + expect(await db.get()).toBeUndefined(); + }); + + it('current-shape session (has dpopJkt): a MISMATCHED persisted key throws, never silently swaps', async () => { + // Models the store having been overwritten by something else (e.g. a later login) + // while this session object is stale. + const db = await DpopKeyDb.open(factory); + const otherKeyPair = await mintAndPersistDpopKeyPair(db); // whatever is CURRENTLY persisted + + await expect( + loadSessionDpopKeyPair(db, { dpopJkt: 'a-completely-different-thumbprint' }), + ).rejects.toThrow(DpopKeyUnavailableError); + // The mismatched record is left untouched (no silent overwrite). + const stillThere = await db.get(); + const stillThereJwk = await crypto.subtle.exportKey( + 'jwk', + (stillThere as StoredDpopKeyRecord).publicKey, + ); + const otherJwk = await crypto.subtle.exportKey('jwk', otherKeyPair.publicKey); + expect(stillThereJwk).toEqual(otherJwk); + }); +}); + +describe('persistDpopKeyPair — deferred-persist seam (login-failure safety)', () => { + let backingDbs: Map; + let factory: IDBFactory; + + beforeEach(() => { + backingDbs = new Map(); + factory = createFakeIdbFactory(backingDbs); + }); + + it('persists an ALREADY-GENERATED keypair without minting a new one', async () => { + const db = await DpopKeyDb.open(factory); + const keyPair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, false, [ + 'sign', + 'verify', + ]); + await persistDpopKeyPair(db, keyPair); + + const record = await db.get(); + expect(record).toBeDefined(); + const recordJwk = await crypto.subtle.exportKey( + 'jwk', + (record as StoredDpopKeyRecord).publicKey, + ); + const keyPairJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey); + expect(recordJwk).toEqual(keyPairJwk); + }); + + it('a failed login (never calling persistDpopKeyPair) leaves an existing session key untouched', async () => { + // Models auth-flow.ts completeLogin: generateDpopKeyPair() happens early (to sign the + // token-exchange proof) but persistDpopKeyPair() is only reached on a SUCCESSFUL + // exchange. A failed exchange must leave whatever was already persisted (an existing + // logged-in session's key) completely untouched (roborev round-1 Medium). + const db = await DpopKeyDb.open(factory); + const existing = await mintAndPersistDpopKeyPair(db); // the "currently logged-in" key + + // Simulate a login attempt that generates a candidate key but whose token exchange + // FAILS before persistDpopKeyPair is ever called. + const candidate = await crypto.subtle.generateKey( + { name: 'ECDSA', namedCurve: 'P-256' }, + false, + ['sign', 'verify'], + ); + void candidate; // generated, but deliberately never persisted (exchange "failed") + + const record = await db.get(); + const recordJwk = await crypto.subtle.exportKey( + 'jwk', + (record as StoredDpopKeyRecord).publicKey, + ); + const existingJwk = await crypto.subtle.exportKey('jwk', existing.publicKey); + expect(recordJwk).toEqual(existingJwk); + }); +}); diff --git a/apps/extension/test/dpop.test.ts b/apps/extension/test/dpop.test.ts index d07cacc40..4fe1ee27e 100644 --- a/apps/extension/test/dpop.test.ts +++ b/apps/extension/test/dpop.test.ts @@ -7,9 +7,7 @@ import { canonicalHtu, createDpopProof, DPOP_ALG, - exportDpopKeyPair, generateDpopKeyPair, - importDpopKeyPair, } from '../src/background/core/dpop'; describe('canonicalHtu', () => { @@ -97,19 +95,29 @@ describe('createDpopProof', () => { }); }); -describe('keypair export/import round-trip (service-worker persistence)', () => { - it('re-imported key produces proofs verifiable against the original public key', async () => { +describe('generateDpopKeyPair — non-extractable private key (security hardening)', () => { + it('the private key is non-extractable: exportKey(jwk) rejects', async () => { const keyPair = await generateDpopKeyPair(); - const stored = await exportDpopKeyPair(keyPair); - expect(stored.privateKey.d).toBeTypeOf('string'); // extractable private key persisted - const reimported = await importDpopKeyPair(stored); + expect(keyPair.privateKey.extractable).toBe(false); + await expect(crypto.subtle.exportKey('jwk', keyPair.privateKey)).rejects.toThrow(); + }); + it('the public key stays extractable (needed for the proof header + jkt)', async () => { + const keyPair = await generateDpopKeyPair(); + expect(keyPair.publicKey.extractable).toBe(true); + await expect(crypto.subtle.exportKey('jwk', keyPair.publicKey)).resolves.toMatchObject({ + kty: 'EC', + crv: 'P-256', + }); + }); + + it('a non-extractable keypair still signs+verifies a DPoP proof', async () => { + const keyPair = await generateDpopKeyPair(); const proof = await createDpopProof({ - keyPair: reimported, + keyPair, htm: 'GET', htu: 'https://pod.example/x', }); - // verify against the ORIGINAL public key — same jkt survives a SW restart. await expect(jose.jwtVerify(proof, keyPair.publicKey)).resolves.toBeTruthy(); }); }); diff --git a/apps/extension/test/replica.test.ts b/apps/extension/test/replica.test.ts index aa0f6c3e6..586e388b9 100644 --- a/apps/extension/test/replica.test.ts +++ b/apps/extension/test/replica.test.ts @@ -11,6 +11,7 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { type EgressPort, purgeReplica, + purgeReplicaSlice, type ReplicaByteCache, type ReplicaMetaStore, SharedReplica, @@ -494,6 +495,160 @@ describe('purgeReplica — synchronous-before-serve logout purge', () => { }); }); +// --- Per-class replica-slice purge (access-management residual b) ---------------------- + +const FINANCE = 'https://alice.pod.example/finance/statement.ttl'; +const HEALTH = 'https://alice.pod.example/health/record.ttl'; +const FINANCE_TARGETS = [{ url: 'https://alice.pod.example/finance/', kind: 'container' as const }]; + +/** Populate the replica with a resource's bytes+metadata under (webId, grantScope) via a read. */ +async function seedResource( + cache: FakeCache, + db: FakeMetaStore, + webId: string, + grantScope: string, + url: string, +): Promise { + const egress = scriptedEgress([ + () => new Response(`bytes-of-${url}`, { status: 200, headers: { etag: 'W/"e"' } }), + ]); + await new SharedReplica({ gate: allow(grantScope), egress, cache, db, webId }).read( + url, + 'GET', + { Accept: 'text/turtle' }, + undefined, + ); +} + +describe('purgeReplicaSlice — per-class revoke/expiry byte purge (residual b)', () => { + it('drops exactly the revoked class slice; other classes remain resident', async () => { + const cache = new FakeCache(); + const db = new FakeMetaStore(); + await seedResource(cache, db, WEBID_A, SCOPE_PM, FINANCE); + await seedResource(cache, db, WEBID_A, SCOPE_PM, HEALTH); + expect(cache.bytes.size).toBe(2); + expect(db.rows.size).toBe(2); + + const purged = await purgeReplicaSlice(cache, db, { + webId: WEBID_A, + grantScope: SCOPE_PM, + targets: FINANCE_TARGETS, + }); + + expect(purged).toEqual({ bytesDropped: 1, metaDropped: 1 }); + // ZERO bytes of the finance class resident; health untouched. + const remaining = [...db.rows.values()]; + expect(remaining).toHaveLength(1); + expect(remaining[0]?.resourceUrl).toBe(HEALTH); + expect(cache.bytes.size).toBe(1); + }); + + it("leaves ANOTHER app-origin's (grantScope) copy of the same resource intact", async () => { + const cache = new FakeCache(); + const db = new FakeMetaStore(); + // Two different app origins each cached the SAME finance resource under their own grant-scope. + await seedResource(cache, db, WEBID_A, SCOPE_PM, FINANCE); + await seedResource(cache, db, WEBID_A, SCOPE_B, FINANCE); + expect(cache.bytes.size).toBe(2); + + // Revoke only SCOPE_PM's finance grant. + const purged = await purgeReplicaSlice(cache, db, { + webId: WEBID_A, + grantScope: SCOPE_PM, + targets: FINANCE_TARGETS, + }); + + expect(purged.metaDropped).toBe(1); + const remaining = [...db.rows.values()]; + expect(remaining).toHaveLength(1); + expect(remaining[0]?.grantScope).toBe(SCOPE_B); // appB's copy survives + }); + + it("never touches another user's (webId) rows", async () => { + const cache = new FakeCache(); + const db = new FakeMetaStore(); + await seedResource(cache, db, WEBID_A, SCOPE_PM, FINANCE); + await seedResource(cache, db, WEBID_B, SCOPE_PM, FINANCE); + + await purgeReplicaSlice(cache, db, { + webId: WEBID_A, + grantScope: SCOPE_PM, + targets: FINANCE_TARGETS, + }); + + const remaining = [...db.rows.values()]; + expect(remaining).toHaveLength(1); + expect(remaining[0]?.webId).toBe(WEBID_B); + }); + + it('RETAINS a resource still covered by another live (overlapping) grant of the same scope', async () => { + const cache = new FakeCache(); + const db = new FakeMetaStore(); + await seedResource(cache, db, WEBID_A, SCOPE_PM, FINANCE); + // Revoke one grant whose targets cover finance/, but a SURVIVING grant also covers finance/. + const purged = await purgeReplicaSlice(cache, db, { + webId: WEBID_A, + grantScope: SCOPE_PM, + targets: FINANCE_TARGETS, + retainTargets: [{ url: 'https://alice.pod.example/finance/', kind: 'container' }], + }); + // The resource is KEPT — the survivor still authorises it (no needless eviction). + expect(purged).toEqual({ bytesDropped: 0, metaDropped: 0 }); + expect(db.rows.size).toBe(1); + expect(cache.bytes.size).toBe(1); + }); + + it('purges a NON-overlapping resource even when retainTargets cover a DIFFERENT resource', async () => { + const cache = new FakeCache(); + const db = new FakeMetaStore(); + await seedResource(cache, db, WEBID_A, SCOPE_PM, FINANCE); + const purged = await purgeReplicaSlice(cache, db, { + webId: WEBID_A, + grantScope: SCOPE_PM, + targets: FINANCE_TARGETS, + // A surviving grant covers only health/, not the finance resource → finance is still purged. + retainTargets: [{ url: 'https://alice.pod.example/health/', kind: 'container' }], + }); + expect(purged).toEqual({ bytesDropped: 1, metaDropped: 1 }); + expect(db.rows.size).toBe(0); + }); + + it('an empty target set purges NOTHING (fail-safe no-op)', async () => { + const cache = new FakeCache(); + const db = new FakeMetaStore(); + await seedResource(cache, db, WEBID_A, SCOPE_PM, FINANCE); + const purged = await purgeReplicaSlice(cache, db, { + webId: WEBID_A, + grantScope: SCOPE_PM, + targets: [], + }); + expect(purged).toEqual({ bytesDropped: 0, metaDropped: 0 }); + expect(db.rows.size).toBe(1); + }); + + it('tolerates a per-row cache-delete failure and still purges the rest of the slice', async () => { + const db = new FakeMetaStore(); + await seedResource(new FakeCache(), db, WEBID_A, SCOPE_PM, FINANCE); + // A cache whose delete always throws — the metadata half must still be dropped (best-effort). + const throwingCache: ReplicaByteCache = { + match: async () => undefined, + put: async () => {}, + delete: async () => { + throw new Error('cache delete failed'); + }, + keys: async () => [], + }; + const purged = await purgeReplicaSlice(throwingCache, db, { + webId: WEBID_A, + grantScope: SCOPE_PM, + targets: FINANCE_TARGETS, + }); + // The byte delete threw (0 counted) but the row was still removed — no abort. + expect(purged.bytesDropped).toBe(0); + expect(db.rows.size).toBe(0); + }); +}); + describe('SharedReplica.read — Medium #3: a non-cacheable 2xx is still returned to the caller', () => { it('a 2xx whose cache.put REJECTS (e.g. 206 / Vary:*) is returned LIVE, just un-cached', async () => { // A cache that rejects put() exactly as the real Cache API does for a 206 Partial Content diff --git a/apps/extension/test/request-access-throttle.test.ts b/apps/extension/test/request-access-throttle.test.ts new file mode 100644 index 000000000..0281be394 --- /dev/null +++ b/apps/extension/test/request-access-throttle.test.ts @@ -0,0 +1,107 @@ +// AUTHORED-BY Claude Opus 4.8 +// +// The PURE anti-spam decision behind `window.solid.requestAccess` (access-management residual c): +// the user-gesture requirement + the per-origin fixed-window rate limit. Exhaustive because this +// is the enforceable core of the throttle (the SW wiring is covered in service-worker-class-gate). + +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_REQUEST_ACCESS_THROTTLE, + decideRequestAccess, + type ThrottleState, +} from '../src/background/core/request-access-throttle'; + +const OPTS = { windowMs: 60_000, maxPerWindow: 3 }; + +describe('decideRequestAccess — user-gesture requirement (i)', () => { + it('REJECTS a call with NO user activation, without consuming quota', () => { + const d = decideRequestAccess(undefined, 1000, false, OPTS); + expect(d.allow).toBe(false); + expect(d.reason).toBe('user-gesture-required'); + // Quota untouched — the fresh window admitted nothing. + expect(d.nextState.count).toBe(0); + }); + + it('a no-gesture loop never consumes quota (count stays 0 across many calls)', () => { + let state: ThrottleState | undefined; + for (let i = 0; i < 100; i++) { + const d = decideRequestAccess(state, 1000 + i, false, OPTS); + expect(d.allow).toBe(false); + expect(d.reason).toBe('user-gesture-required'); + state = d.nextState; + } + expect(state?.count).toBe(0); + }); + + it('ADMITS a gesture-backed call (count advances)', () => { + const d = decideRequestAccess(undefined, 1000, true, OPTS); + expect(d.allow).toBe(true); + expect(d.reason).toBeUndefined(); + expect(d.nextState.count).toBe(1); + }); +}); + +describe('decideRequestAccess — per-origin rate limit (ii)', () => { + it('rate-limits a RAPID gesture-backed burst after maxPerWindow', () => { + let state: ThrottleState | undefined; + const results: boolean[] = []; + for (let i = 0; i < 5; i++) { + const d = decideRequestAccess(state, 1000 + i, true, OPTS); // all within one window + results.push(d.allow); + state = d.nextState; + } + // First 3 admitted, then throttled. + expect(results).toEqual([true, true, true, false, false]); + }); + + it('surfaces a rate-limited reason + a retryAfterMs', () => { + let state: ThrottleState | undefined; + for (let i = 0; i < 3; i++) state = decideRequestAccess(state, 1000, true, OPTS).nextState; + const d = decideRequestAccess(state, 1000, true, OPTS); + expect(d.allow).toBe(false); + expect(d.reason).toBe('rate-limited'); + // window started at 1000, windowMs 60_000, now 1000 → ~60_000ms remain. + expect(d.retryAfterMs).toBe(60_000); + }); + + it('a LEGITIMATE spaced request still works once the window rolls', () => { + let state: ThrottleState | undefined; + // Exhaust the window at t≈1000. + for (let i = 0; i < 3; i++) state = decideRequestAccess(state, 1000 + i, true, OPTS).nextState; + // Still within the window → denied. + expect(decideRequestAccess(state, 2000, true, OPTS).allow).toBe(false); + // After the window fully elapses → a fresh window admits it again. + const later = decideRequestAccess(state, 1000 + OPTS.windowMs, true, OPTS); + expect(later.allow).toBe(true); + expect(later.nextState.windowStart).toBe(1000 + OPTS.windowMs); + expect(later.nextState.count).toBe(1); + }); + + it('a rate-limited deny does NOT further advance the count (no unbounded growth)', () => { + let state: ThrottleState | undefined; + for (let i = 0; i < 3; i++) state = decideRequestAccess(state, 1000, true, OPTS).nextState; + expect(state?.count).toBe(3); + const d = decideRequestAccess(state, 1000, true, OPTS); + expect(d.nextState.count).toBe(3); // stays capped + }); + + it('treats a prior window whose start is in the FUTURE as stale (rolls it) — fail-safe clock skew', () => { + const future: ThrottleState = { windowStart: 10_000, count: 3 }; + const d = decideRequestAccess(future, 1000, true, OPTS); // now < windowStart + expect(d.allow).toBe(true); + expect(d.nextState.windowStart).toBe(1000); + expect(d.nextState.count).toBe(1); + }); +}); + +describe('decideRequestAccess — defaults', () => { + it('uses DEFAULT_REQUEST_ACCESS_THROTTLE (3 / 60s) when no opts passed', () => { + let state: ThrottleState | undefined; + for (let i = 0; i < DEFAULT_REQUEST_ACCESS_THROTTLE.maxPerWindow; i++) { + const d = decideRequestAccess(state, 1000, true); + expect(d.allow).toBe(true); + state = d.nextState; + } + expect(decideRequestAccess(state, 1000, true).allow).toBe(false); + }); +}); diff --git a/apps/extension/test/service-worker-class-gate.test.ts b/apps/extension/test/service-worker-class-gate.test.ts new file mode 100644 index 000000000..09b45158c --- /dev/null +++ b/apps/extension/test/service-worker-class-gate.test.ts @@ -0,0 +1,1025 @@ +// AUTHORED-BY Claude Opus 4.8 +// +// INTEGRATION tests for the Phase-2 PER-DATA-CLASS gate AS WIRED INTO the service worker. These +// drive the REAL `chrome.runtime.onMessage` listener with an in-memory chrome + captured fetch, +// proving the enforcement end-to-end (not just the pure logic in class-grant.test.ts): +// +// - a class-only origin READ inside its pinned targets → ALLOW (egress); +// - out-of-pinned-set (wrong resource / wrong mode) → DENY 403, distinguishable reason, NO egress; +// - an EXPIRED grant → DENY + pruned from storage; +// - REVOCATION (SOLID_REVOKE_GRANT) takes effect on the NEXT request; +// - an UNKNOWN / unresolved class (empty targets) → fail-closed DENY; +// - a DIFFERENT-WebID grant is ignored (session boundary); +// - LEGACY origin-only (granted-origins) grants keep WHOLE-POD access (backwards compatibility); +// - the requestAccess → consent-window → grant flow mints a pinned grant; listGrants reflects it. + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ClassGrant } from '../src/background/core/class-grant'; +import { generateDpopKeyPair } from '../src/background/core/dpop'; +import type { StoredDpopKeyRecord } from '../src/background/core/dpop-key-db'; +import type { ReplicaMetadata } from '../src/background/core/replica-db'; +import type { StoredSession } from '../src/background/session-store'; + +// --- An in-memory DpopKeyDb double (see service-worker-gate.test.ts for the rationale) -- +let idbDpopKey: StoredDpopKeyRecord | undefined; +class FakeDpopKeyDb { + static async open() { + return new FakeDpopKeyDb(); + } + async get() { + return idbDpopKey; + } + async put(record: StoredDpopKeyRecord) { + idbDpopKey = record; + } + async clear() { + idbDpopKey = undefined; + } + close() {} +} +vi.mock('../src/background/core/dpop-key-db', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, DpopKeyDb: FakeDpopKeyDb }; +}); + +async function seedDpopKeyPair(keyPair: CryptoKeyPair): Promise { + idbDpopKey = { + privateKey: keyPair.privateKey, + publicKey: keyPair.publicKey, + publicJwk: (await crypto.subtle.exportKey('jwk', keyPair.publicKey)) as JsonWebKey, + }; +} + +// --- In-memory replica stores (as in service-worker-gate.test.ts) --------------------- +const idbMeta = new Map(); +const idbNonces = new Map(); +class FakeReplicaDb { + static async open() { + return new FakeReplicaDb(); + } + async getMetadata(key: string) { + return idbMeta.get(key); + } + async putMetadata(record: ReplicaMetadata) { + idbMeta.set(record.key, record); + } + async deleteMetadata(key: string) { + idbMeta.delete(key); + } + async getMetadataByWebId(webId: string) { + return [...idbMeta.values()].filter((r) => r.webId === webId); + } + async deleteMetadataByWebId(webId: string) { + for (const [k, v] of idbMeta) if (v.webId === webId) idbMeta.delete(k); + } + async clearMetadata() { + idbMeta.clear(); + } + async getNonce(origin: string) { + return idbNonces.get(origin); + } + async setNonce(origin: string, nonce: string) { + idbNonces.set(origin, nonce); + } + async clearNonces() { + idbNonces.clear(); + } + close() {} +} +vi.mock('../src/background/core/replica-db', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, ReplicaDb: FakeReplicaDb }; +}); + +const cacheStore = new Map< + string, + { body: string; status: number; headers: Record } +>(); +function installCaches(): void { + (globalThis as unknown as { caches: unknown }).caches = { + open: async () => ({ + match: async (request: Request) => { + const e = cacheStore.get(request.url); + return e ? new Response(e.body, { status: e.status, headers: e.headers }) : undefined; + }, + put: async (request: Request, response: Response) => { + cacheStore.set(request.url, { + body: await response.text(), + status: response.status, + headers: Object.fromEntries(response.headers.entries()), + }); + }, + delete: async (request: Request) => cacheStore.delete(request.url), + keys: async () => [...cacheStore.keys()].map((url) => new Request(url)), + }), + }; +} + +interface CapturedSender { + origin?: string; + url?: string; + id?: string; +} +type MessageListener = ( + message: unknown, + sender: CapturedSender, + sendResponse: (r: unknown) => void, +) => boolean | undefined; + +let storage: Map; +let messageListeners: MessageListener[]; +let fetchCalls: Array<{ url: string; init?: RequestInit }>; +let fetchImpl: (url: string, init?: RequestInit) => Promise; +let createdWindows: string[]; + +const EXTENSION_ID = 'abcdefghijklmnopabcdefghijklmnop'; +const EXT_SENDER: CapturedSender = { + origin: `chrome-extension://${EXTENSION_ID}`, + id: EXTENSION_ID, +}; + +const WEBID = 'https://alice.pod.example/profile/card#me'; + +function installChrome(): void { + storage = new Map(); + messageListeners = []; + fetchCalls = []; + createdWindows = []; + + const chromeStub = { + runtime: { + id: EXTENSION_ID, + lastError: undefined as { message?: string } | undefined, + getURL: (p: string) => `chrome-extension://${EXTENSION_ID}/${p}`, + onMessage: { addListener: (fn: MessageListener) => messageListeners.push(fn) }, + onInstalled: { addListener: (_fn: () => void) => {} }, + sendMessage: (_m: unknown, _cb?: (r: unknown) => void) => Promise.resolve(), + }, + storage: { + local: { + get: async (key: string) => { + const out: Record = {}; + if (storage.has(key)) out[key] = storage.get(key); + return out; + }, + set: async (obj: Record) => { + for (const [k, v] of Object.entries(obj)) storage.set(k, v); + }, + remove: async (key: string) => { + storage.delete(key); + }, + }, + }, + tabs: { query: async () => [], sendMessage: async () => {} }, + action: { + setIcon: async () => {}, + setBadgeText: async () => {}, + setBadgeBackgroundColor: async () => {}, + setTitle: async () => {}, + }, + contextMenus: { + create: () => {}, + remove: (_id: string, cb?: () => void) => cb?.(), + onClicked: { addListener: () => {} }, + }, + sidePanel: { setPanelBehavior: async () => {}, open: async () => {} }, + identity: { launchWebAuthFlow: async () => '' }, + windows: { + create: (opts: { url: string }, cb?: (w: unknown) => void) => { + createdWindows.push(opts.url); + cb?.({ id: createdWindows.length }); + }, + }, + }; + (globalThis as unknown as { chrome: unknown }).chrome = chromeStub; + + fetchImpl = async () => new Response('ok', { status: 200 }); + (globalThis as unknown as { fetch: typeof fetch }).fetch = vi.fn( + (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + fetchCalls.push({ url, init }); + return fetchImpl(url, init); + }, + ) as unknown as typeof fetch; +} + +function send( + message: Record, + sender: CapturedSender, +): Promise> { + return new Promise((resolve) => { + for (const listener of messageListeners) { + const handled = listener(message, sender, (r) => resolve(r as Record)); + if (handled) return; + } + // A sync-handled message (returns false after calling sendResponse) already resolved. + setTimeout(() => resolve({ error: 'no async listener handled' }), 0); + }); +} + +function sendFetch( + message: Record, + sender: CapturedSender, +): Promise> { + return send( + { + type: 'SOLID_FETCH_REQUEST', + requestId: 'r1', + method: 'GET', + headers: {}, + body: null, + ...message, + }, + sender, + ); +} + +async function seedSession(overrides: Partial = {}): Promise { + const keyPair = await generateDpopKeyPair(); + await seedDpopKeyPair(keyPair); + storage.set('solid:session', { + webId: WEBID, + accessToken: 'access-token-xyz', + refreshToken: 'refresh-xyz', + issuer: 'https://idp.example/', + tokenEndpoint: 'https://idp.example/token', + clientId: 'https://pm.example/clientid.jsonld', + expiresAt: Date.now() + 60 * 60 * 1000, + ...overrides, + } satisfies StoredSession); +} + +function classGrant(overrides: Partial = {}): ClassGrant { + return { + grantId: 'grant-1', + webId: WEBID, + origin: 'https://appx.example', + dataClass: 'https://w3id.org/jeswr/sectors/finance#Transaction', + modes: ['read'], + purpose: 'https://w3id.org/dpv#ServiceProvision', + expiresAt: Date.now() + 60 * 60 * 1000, + targets: [{ url: 'https://alice.pod.example/finance/', kind: 'container' }], + createdAt: Date.now(), + ...overrides, + }; +} + +async function loadWorker(): Promise { + messageListeners.length = 0; + vi.resetModules(); + await import('../src/background/service-worker'); + await new Promise((r) => setTimeout(r, 0)); +} + +const APPX = { + origin: 'https://appx.example', + url: 'https://appx.example/app', +} satisfies CapturedSender; + +beforeEach(() => { + installChrome(); + installCaches(); + cacheStore.clear(); + idbMeta.clear(); + idbNonces.clear(); + idbDpopKey = undefined; +}); + +describe('Phase-2 per-data-class gate (wired into the SW)', () => { + it('a class-only origin READS within its pinned container → ALLOW with egress', async () => { + await seedSession(); + storage.set('solid:class-grants', [classGrant()]); + await loadWorker(); + + const res = await sendFetch( + { url: 'https://alice.pod.example/finance/statement.ttl', stampedOrigin: APPX.origin }, + APPX, + ); + expect(res.status).toBe(200); + expect(fetchCalls.some((c) => c.url.startsWith('https://alice.pod.example/finance/'))).toBe( + true, + ); + }); + + it('DENIES a resource OUTSIDE the pinned targets — distinguishable reason, NO egress', async () => { + await seedSession(); + storage.set('solid:class-grants', [classGrant()]); + await loadWorker(); + fetchCalls.length = 0; + + const res = await sendFetch( + { url: 'https://alice.pod.example/health/record.ttl', stampedOrigin: APPX.origin }, + APPX, + ); + expect(res.status).toBe(403); + expect(res.error).toBe('Out of grant scope'); + expect(fetchCalls.some((c) => c.url.startsWith('https://alice.pod.example'))).toBe(false); + }); + + it('DENIES a WRITE against a read-only grant (mode not satisfied)', async () => { + await seedSession(); + storage.set('solid:class-grants', [classGrant({ modes: ['read'] })]); + await loadWorker(); + fetchCalls.length = 0; + + const res = await sendFetch( + { + url: 'https://alice.pod.example/finance/statement.ttl', + method: 'PUT', + body: ' .', + stampedOrigin: APPX.origin, + }, + APPX, + ); + expect(res.status).toBe(403); + expect(res.error).toBe('Out of grant scope'); + expect(fetchCalls.length).toBe(0); + }); + + it('ALLOWS a WRITE when the grant carries write mode', async () => { + await seedSession(); + storage.set('solid:class-grants', [classGrant({ modes: ['write'] })]); + await loadWorker(); + + const res = await sendFetch( + { + url: 'https://alice.pod.example/finance/statement.ttl', + method: 'PUT', + body: ' .', + stampedOrigin: APPX.origin, + }, + APPX, + ); + expect(res.status).toBe(200); + }); + + it('an EXPIRED grant → DENY (no live grant) and is PRUNED from storage', async () => { + await seedSession(); + storage.set('solid:class-grants', [classGrant({ expiresAt: Date.now() - 1000 })]); + await loadWorker(); + + const res = await sendFetch( + { url: 'https://alice.pod.example/finance/statement.ttl', stampedOrigin: APPX.origin }, + APPX, + ); + // An expired grant no longer makes the origin a granted requester → forbidden-origin. + expect(res.status).toBe(403); + // The expired grant was pruned on the fetch path. + expect(storage.get('solid:class-grants')).toEqual([]); + }); + + it('an UNKNOWN / unresolved class (empty targets) is fail-closed DENY even if unexpired', async () => { + await seedSession(); + storage.set('solid:class-grants', [classGrant({ targets: [] })]); + await loadWorker(); + + const res = await sendFetch( + { url: 'https://alice.pod.example/finance/statement.ttl', stampedOrigin: APPX.origin }, + APPX, + ); + // The origin passes the per-origin gate (it holds a live grant) but the empty-target grant + // covers nothing → out-of-grant-scope. + expect(res.status).toBe(403); + expect(res.error).toBe('Out of grant scope'); + }); + + it('ignores a grant minted under a DIFFERENT WebID (session boundary)', async () => { + await seedSession(); // active WebID = alice + storage.set('solid:class-grants', [classGrant({ webId: 'https://bob.pod.example/card#me' })]); + await loadWorker(); + + const res = await sendFetch( + { url: 'https://alice.pod.example/finance/statement.ttl', stampedOrigin: APPX.origin }, + APPX, + ); + expect(res.status).toBe(403); + }); + + it('BACKWARDS COMPAT: a legacy origin-only grant keeps WHOLE-POD access', async () => { + await seedSession(); + // pm.example is a legacy full grant; NO class grant. It may read ANY pod resource. + storage.set('solid:granted-origins', ['https://pm.example']); + await loadWorker(); + + const pm = { origin: 'https://pm.example', url: 'https://pm.example/app' }; + const a = await sendFetch( + { url: 'https://alice.pod.example/finance/x.ttl', stampedOrigin: pm.origin }, + pm, + ); + const b = await sendFetch( + { url: 'https://alice.pod.example/health/y.ttl', stampedOrigin: pm.origin }, + pm, + ); + expect(a.status).toBe(200); + expect(b.status).toBe(200); + }); + + it('a full-grant origin that ALSO holds a class grant keeps whole-pod access (full wins)', async () => { + await seedSession(); + storage.set('solid:granted-origins', ['https://appx.example']); + storage.set('solid:class-grants', [classGrant()]); // finance-only + await loadWorker(); + + // Outside the class targets, but appx is a FULL grant → still allowed. + const res = await sendFetch( + { url: 'https://alice.pod.example/health/y.ttl', stampedOrigin: APPX.origin }, + APPX, + ); + expect(res.status).toBe(200); + }); + + it('REVOCATION via SOLID_REVOKE_GRANT takes effect on the NEXT request', async () => { + await seedSession(); + storage.set('solid:class-grants', [classGrant()]); + await loadWorker(); + + // Before: allowed. + const before = await sendFetch( + { url: 'https://alice.pod.example/finance/x.ttl', stampedOrigin: APPX.origin }, + APPX, + ); + expect(before.status).toBe(200); + + // Revoke (as the same origin). + const rev = await send( + { + type: 'SOLID_REVOKE_GRANT', + requestId: 'rev1', + grantId: 'grant-1', + stampedOrigin: APPX.origin, + }, + APPX, + ); + expect(rev.ok).toBe(true); + + // After: denied on the very next request. + const after = await sendFetch( + { url: 'https://alice.pod.example/finance/x.ttl', stampedOrigin: APPX.origin }, + APPX, + ); + expect(after.status).toBe(403); + }); + + it('REVOKE purges exactly the revoked class replica slice, leaving other classes resident (residual b)', async () => { + await seedSession(); + storage.set('solid:class-grants', [ + classGrant({ + grantId: 'grant-1', + dataClass: 'https://w3id.org/jeswr/sectors/finance#Transaction', + targets: [{ url: 'https://alice.pod.example/finance/', kind: 'container' }], + }), + classGrant({ + grantId: 'grant-2', + dataClass: 'https://w3id.org/jeswr/sectors/health#Record', + targets: [{ url: 'https://alice.pod.example/health/', kind: 'container' }], + }), + ]); + await loadWorker(); + + // Replicate ONE resource of EACH class → two cached byte entries + two metadata rows. + await sendFetch( + { url: 'https://alice.pod.example/finance/statement.ttl', stampedOrigin: APPX.origin }, + APPX, + ); + await sendFetch( + { url: 'https://alice.pod.example/health/record.ttl', stampedOrigin: APPX.origin }, + APPX, + ); + expect(cacheStore.size).toBe(2); + expect(idbMeta.size).toBe(2); + + // Revoke ONLY the finance grant. + const rev = await send( + { + type: 'SOLID_REVOKE_GRANT', + requestId: 'rv', + grantId: 'grant-1', + stampedOrigin: APPX.origin, + }, + APPX, + ); + expect(rev.ok).toBe(true); + + // ZERO bytes of the finance class remain; the health slice is untouched. + expect(idbMeta.size).toBe(1); + expect([...idbMeta.values()][0]?.resourceUrl).toBe( + 'https://alice.pod.example/health/record.ttl', + ); + expect(cacheStore.size).toBe(1); + }); + + it('RETAINS overlapping cached bytes when a surviving READ grant still covers them (residual b)', async () => { + await seedSession(); + // Two READ grants for the same origin BOTH covering finance/ (overlap). + storage.set('solid:class-grants', [ + classGrant({ grantId: 'read-a', modes: ['read'] }), + classGrant({ grantId: 'read-b', modes: ['read'] }), + ]); + await loadWorker(); + + await sendFetch( + { url: 'https://alice.pod.example/finance/statement.ttl', stampedOrigin: APPX.origin }, + APPX, + ); + expect(idbMeta.size).toBe(1); + + // Revoke ONE read grant; the other READ grant survives and still authorises the bytes → KEPT. + const rev = await send( + { + type: 'SOLID_REVOKE_GRANT', + requestId: 'rv', + grantId: 'read-a', + stampedOrigin: APPX.origin, + }, + APPX, + ); + expect(rev.ok).toBe(true); + expect(idbMeta.size).toBe(1); // retained — the surviving read grant still covers it + expect(cacheStore.size).toBe(1); + }); + + it('PURGES cached read bytes even when an overlapping WRITE-only grant survives (read ≠ write, residual b)', async () => { + await seedSession(); + // A READ grant (revoked) + a WRITE-only grant (survivor), both covering finance/. + storage.set('solid:class-grants', [ + classGrant({ grantId: 'read-grant', modes: ['read'] }), + classGrant({ grantId: 'write-grant', modes: ['write'] }), + ]); + await loadWorker(); + + // Read caches the finance bytes (authorised by the read grant). + await sendFetch( + { url: 'https://alice.pod.example/finance/statement.ttl', stampedOrigin: APPX.origin }, + APPX, + ); + expect(idbMeta.size).toBe(1); + + // Revoke the READ grant. The write-only survivor does NOT authorise READING these bytes, so + // they MUST be purged (a write grant does not imply read in this codebase). + const rev = await send( + { + type: 'SOLID_REVOKE_GRANT', + requestId: 'rv', + grantId: 'read-grant', + stampedOrigin: APPX.origin, + }, + APPX, + ); + expect(rev.ok).toBe(true); + expect(idbMeta.size).toBe(0); // purged — the surviving write-only grant cannot retain read bytes + expect(cacheStore.size).toBe(0); + }); + + it('a PURGE FAILURE on revoke still denies access (fail-closed: removal precedes purge, residual b)', async () => { + await seedSession(); + storage.set('solid:class-grants', [classGrant()]); + // A cache whose delete THROWS (purge cannot complete), but put/match still work so a read caches. + (globalThis as unknown as { caches: unknown }).caches = { + open: async () => ({ + match: async (request: Request) => { + const e = cacheStore.get(request.url); + return e ? new Response(e.body, { status: e.status, headers: e.headers }) : undefined; + }, + put: async (request: Request, response: Response) => { + cacheStore.set(request.url, { + body: await response.text(), + status: response.status, + headers: Object.fromEntries(response.headers.entries()), + }); + }, + delete: async () => { + throw new Error('cache delete unavailable'); + }, + keys: async () => [...cacheStore.keys()].map((url) => new Request(url)), + }), + }; + await loadWorker(); + + const before = await sendFetch( + { url: 'https://alice.pod.example/finance/x.ttl', stampedOrigin: APPX.origin }, + APPX, + ); + expect(before.status).toBe(200); + + const rev = await send( + { + type: 'SOLID_REVOKE_GRANT', + requestId: 'rv', + grantId: 'grant-1', + stampedOrigin: APPX.origin, + }, + APPX, + ); + // The grant was removed (the authoritative deny) EVEN THOUGH the byte purge could not complete. + expect(rev.ok).toBe(true); + + // Access is denied on the very next request — never left live because cleanup failed. + const after = await sendFetch( + { url: 'https://alice.pod.example/finance/x.ttl', stampedOrigin: APPX.origin }, + APPX, + ); + expect(after.status).toBe(403); + }); + + it("a page CANNOT revoke another origin's grant", async () => { + await seedSession(); + storage.set('solid:class-grants', [classGrant({ origin: 'https://appx.example' })]); + await loadWorker(); + + const evil = { origin: 'https://evil.example', url: 'https://evil.example/x' }; + const rev = await send( + { + type: 'SOLID_REVOKE_GRANT', + requestId: 'rev1', + grantId: 'grant-1', + stampedOrigin: evil.origin, + }, + evil, + ); + expect(rev.ok).toBe(false); + expect((storage.get('solid:class-grants') as unknown[]).length).toBe(1); + }); + + it('CONCURRENT save + revoke do not resurrect the revoked grant (serialised store)', async () => { + const { saveClassGrant, revokeClassGrant, loadClassGrants } = await import( + '../src/background/session-store' + ); + storage.set('solid:class-grants', [classGrant({ grantId: 'existing' })]); + // Fire a save (of a NEW grant) and a revoke (of the existing grant) concurrently. Without + // serialisation a stale save could write back an array that resurrects 'existing'. + await Promise.all([ + saveClassGrant(classGrant({ grantId: 'added' })), + revokeClassGrant('existing', 'https://appx.example'), + ]); + const ids = (await loadClassGrants()).map((g) => g.grantId).sort(); + expect(ids).toEqual(['added']); // 'existing' stayed revoked; 'added' landed (no lost update). + }); + + it('CONCURRENT save + clear (logout) is serialised — clear wins, no grant survives', async () => { + const { saveClassGrant, clearClassGrants, loadClassGrants } = await import( + '../src/background/session-store' + ); + // save enqueues first, clear (logout) second → serialised: clear runs after → empty. Without + // the mutex, a stale save could persist a grant AFTER the logout clear. + await Promise.all([saveClassGrant(classGrant({ grantId: 'x' })), clearClassGrants()]); + expect(await loadClassGrants()).toEqual([]); + }); + + it('resolveClass: a class-only origin may resolve ONLY a class it holds a grant for', async () => { + await seedSession(); + storage.set('solid:class-grants', [classGrant()]); // finance grant only + await loadWorker(); + fetchImpl = async (url) => { + if (url.includes('/profile/card')) + return new Response( + `@prefix solid: . + <${WEBID}> solid:publicTypeIndex .`, + { status: 200 }, + ); + if (url.includes('/settings/pub.ttl')) + return new Response( + `@prefix solid: . + <#h> a solid:TypeRegistration ; + solid:forClass ; + solid:instanceContainer .`, + { status: 200 }, + ); + return new Response('ok', { status: 200 }); + }; + + // Its OWN class (finance) → allowed (resolves, even if empty). + const ownClass = await send( + { + type: 'SOLID_RESOLVE_CLASS', + requestId: 'rc1', + classIRI: 'https://w3id.org/jeswr/sectors/finance#Transaction', + stampedOrigin: APPX.origin, + }, + APPX, + ); + expect(ownClass.error).toBeUndefined(); + + // An UNRELATED class (health) it holds no grant for → DENY (no private-structure leak). + const otherClass = await send( + { + type: 'SOLID_RESOLVE_CLASS', + requestId: 'rc2', + classIRI: 'https://w3id.org/jeswr/sectors/health#Record', + stampedOrigin: APPX.origin, + }, + APPX, + ); + expect(otherClass.error).toBe('No access grant for this data class'); + expect(otherClass.targets).toBeUndefined(); + }); + + it('resolveClass does NOT follow a type-index ref on the ISSUER origin (pod-only, SSRF)', async () => { + await seedSession(); // issuer = https://idp.example/ + storage.set('solid:granted-origins', ['https://pm.example']); + await loadWorker(); + const issuerFetches: string[] = []; + fetchImpl = async (url) => { + if (url.startsWith('https://idp.example')) issuerFetches.push(url); + if (url.includes('/profile/card')) + // A hostile profile points the type index at the ISSUER origin. + return new Response( + `@prefix solid: . + <${WEBID}> solid:publicTypeIndex .`, + { status: 200 }, + ); + return new Response('', { status: 200 }); + }; + + const pm = { origin: 'https://pm.example', url: 'https://pm.example/app' }; + const res = await send( + { + type: 'SOLID_RESOLVE_CLASS', + requestId: 'rc4', + classIRI: 'https://w3id.org/jeswr/sectors/finance#Transaction', + stampedOrigin: pm.origin, + }, + pm, + ); + expect(res.targets).toEqual([]); + // The issuer-hosted type-index ref was NEVER fetched. + expect(issuerFetches.filter((u) => u.includes('typeindex'))).toEqual([]); + }); + + it('resolveClass FOLLOWS a same-origin type-index ref when the pod IS the issuer (CSS single-origin)', async () => { + // CSS default: WebID + IdP on ONE origin. The issuer exclusion must NOT drop this origin, + // or class resolution breaks entirely on the most common Solid topology. + await seedSession({ issuer: 'https://alice.pod.example/' }); // issuer === WebID origin + storage.set('solid:granted-origins', ['https://pm.example']); + await loadWorker(); + fetchImpl = async (url) => { + if (url.includes('/profile/card')) + return new Response( + `@prefix solid: . + <${WEBID}> solid:publicTypeIndex .`, + { status: 200 }, + ); + if (url.includes('/settings/pub.ttl')) + return new Response( + `@prefix solid: . + <#fin> a solid:TypeRegistration ; + solid:forClass ; + solid:instanceContainer .`, + { status: 200 }, + ); + return new Response('', { status: 404 }); + }; + + const pm = { origin: 'https://pm.example', url: 'https://pm.example/app' }; + const res = await send( + { + type: 'SOLID_RESOLVE_CLASS', + requestId: 'rc5', + classIRI: 'https://w3id.org/jeswr/sectors/finance#Transaction', + stampedOrigin: pm.origin, + }, + pm, + ); + // The pod-origin ref (== issuer origin here) WAS followed → the target resolved. + expect(res.targets).toEqual([{ url: 'https://alice.pod.example/finance/', kind: 'container' }]); + }); + + it('resolveClass: a FULL-grant origin may resolve any class', async () => { + await seedSession(); + storage.set('solid:granted-origins', ['https://pm.example']); + await loadWorker(); + fetchImpl = async () => new Response('', { status: 404 }); // resolution degrades to [] + + const pm = { origin: 'https://pm.example', url: 'https://pm.example/app' }; + const res = await send( + { + type: 'SOLID_RESOLVE_CLASS', + requestId: 'rc3', + classIRI: 'https://w3id.org/jeswr/sectors/health#Record', + stampedOrigin: pm.origin, + }, + pm, + ); + expect(res.error).toBeUndefined(); + expect(res.targets).toEqual([]); + }); + + it("listGrants returns ONLY this origin's live grants", async () => { + await seedSession(); + storage.set('solid:class-grants', [ + classGrant({ grantId: 'mine', origin: 'https://appx.example' }), + classGrant({ grantId: 'other', origin: 'https://appy.example' }), + ]); + await loadWorker(); + + const res = await send( + { type: 'SOLID_LIST_GRANTS', requestId: 'lg1', stampedOrigin: APPX.origin }, + APPX, + ); + const grants = res.grants as Array<{ grantId: string }>; + expect(grants.map((g) => g.grantId)).toEqual(['mine']); + }); +}); + +describe('Phase-2 requestAccess → consent → grant (wired)', () => { + const PROFILE = ` + @prefix solid: . + <${WEBID}> solid:publicTypeIndex .`; + const INDEX = ` + @prefix solid: . + <#fin> a solid:TypeRegistration ; + solid:forClass ; + solid:instanceContainer .`; + + function routeFetch(url: string): Promise { + if (url.includes('/profile/card')) + return Promise.resolve(new Response(PROFILE, { status: 200 })); + if (url.includes('/settings/pub.ttl')) + return Promise.resolve(new Response(INDEX, { status: 200 })); + return Promise.resolve(new Response('ok', { status: 200 })); + } + + it('resolves the class, opens consent, mints a PINNED grant on approve, and enforces it', async () => { + await seedSession(); + await loadWorker(); + fetchImpl = (url) => routeFetch(url); + + // Fire requestAccess (do not await yet — it awaits the consent decision). + const reqPromise = send( + { + type: 'SOLID_REQUEST_ACCESS', + requestId: 'ra1', + classes: ['https://w3id.org/jeswr/sectors/finance#Transaction'], + modes: ['read'], + purpose: 'https://w3id.org/dpv#ServiceProvision', + duration: 'P30D', + stampedOrigin: APPX.origin, + userActivated: true, + }, + APPX, + ); + + // Wait for the consent window to open, then read the consentId from its URL. + for (let i = 0; i < 50 && createdWindows.length === 0; i++) { + await new Promise((r) => setTimeout(r, 5)); + } + expect(createdWindows.length).toBe(1); + const consentId = new URL(createdWindows[0]).searchParams.get('consentId'); + expect(consentId).toBeTruthy(); + + // The consent window fetches the pending record to render it. + const view = (await send( + { type: 'SOLID_GET_PENDING_CONSENT', consentId }, + EXT_SENDER, + )) as unknown as { classes: Array<{ targets: unknown[] }> } | null; + expect(view?.classes[0].targets).toHaveLength(1); + + // Approve the finance class. + await send( + { + type: 'SOLID_CONSENT_DECISION', + consentId, + approvedClasses: ['https://w3id.org/jeswr/sectors/finance#Transaction'], + }, + EXT_SENDER, + ); + + const res = await reqPromise; + expect(res.ok).toBe(true); + const grants = res.grants as Array<{ targets: Array<{ url: string; kind: string }> }>; + expect(grants).toHaveLength(1); + expect(grants[0].targets).toEqual([ + { url: 'https://alice.pod.example/finance/', kind: 'container' }, + ]); + + // The minted grant now ENFORCES: appx can read finance, not health. + const ok = await sendFetch( + { url: 'https://alice.pod.example/finance/x.ttl', stampedOrigin: APPX.origin }, + APPX, + ); + expect(ok.status).toBe(200); + const denied = await sendFetch( + { url: 'https://alice.pod.example/health/x.ttl', stampedOrigin: APPX.origin }, + APPX, + ); + expect(denied.status).toBe(403); + }); + + it('a DENY-all consent decision mints NO grant', async () => { + await seedSession(); + await loadWorker(); + fetchImpl = (url) => routeFetch(url); + + const reqPromise = send( + { + type: 'SOLID_REQUEST_ACCESS', + requestId: 'ra2', + classes: ['https://w3id.org/jeswr/sectors/finance#Transaction'], + modes: ['read'], + duration: 'P30D', + stampedOrigin: APPX.origin, + userActivated: true, + }, + APPX, + ); + for (let i = 0; i < 50 && createdWindows.length === 0; i++) { + await new Promise((r) => setTimeout(r, 5)); + } + const consentId = new URL(createdWindows[0]).searchParams.get('consentId'); + await send({ type: 'SOLID_CONSENT_DECISION', consentId, approvedClasses: [] }, EXT_SENDER); + + const res = await reqPromise; + expect(res.ok).toBe(false); + expect(res.grants).toEqual([]); + expect(storage.get('solid:class-grants') ?? []).toEqual([]); + }); + + it('a NON-extension sender cannot post a consent decision (fail-closed)', async () => { + await seedSession(); + await loadWorker(); + fetchImpl = (url) => routeFetch(url); + + const reqPromise = send( + { + type: 'SOLID_REQUEST_ACCESS', + requestId: 'ra3', + classes: ['https://w3id.org/jeswr/sectors/finance#Transaction'], + modes: ['read'], + duration: 'P30D', + stampedOrigin: APPX.origin, + userActivated: true, + }, + APPX, + ); + for (let i = 0; i < 50 && createdWindows.length === 0; i++) { + await new Promise((r) => setTimeout(r, 5)); + } + const consentId = new URL(createdWindows[0]).searchParams.get('consentId'); + + // A WEB PAGE tries to self-approve — must be ignored (not the extension's own window). + await send( + { + type: 'SOLID_CONSENT_DECISION', + consentId, + approvedClasses: ['https://w3id.org/jeswr/sectors/finance#Transaction'], + }, + APPX, + ); + // The forged decision did not resolve the request; the grant store stays empty. + await new Promise((r) => setTimeout(r, 20)); + expect(storage.get('solid:class-grants') ?? []).toEqual([]); + void reqPromise; + }); + + it('REJECTS requestAccess with NO user gesture — opens NO consent window (residual c)', async () => { + await seedSession(); + await loadWorker(); + fetchImpl = (url) => routeFetch(url); + + const res = await send( + { + type: 'SOLID_REQUEST_ACCESS', + requestId: 'rg1', + classes: ['https://w3id.org/jeswr/sectors/finance#Transaction'], + modes: ['read'], + duration: 'P30D', + stampedOrigin: APPX.origin, + // userActivated omitted → false (an autonomous, non-gesture call). + }, + APPX, + ); + expect(res.error).toMatch(/user action/i); + // No consent window was opened and no pod fetch was made. + expect(createdWindows.length).toBe(0); + expect(fetchCalls.length).toBe(0); + }); + + it('RATE-LIMITS rapid repeated requestAccess from ONE origin (residual c)', async () => { + await seedSession(); + await loadWorker(); + fetchImpl = (url) => routeFetch(url); + + const base = { + type: 'SOLID_REQUEST_ACCESS', + classes: ['https://w3id.org/jeswr/sectors/finance#Transaction'], + modes: ['read'], + duration: 'P30D', + stampedOrigin: APPX.origin, + userActivated: true, + } as const; + + // Fire the first 3 (the default per-origin/minute cap): each passes the throttle and opens a + // consent window, then AWAITS its decision — so we do NOT await these promises. + void send({ ...base, requestId: 'rl1' }, APPX); + void send({ ...base, requestId: 'rl2' }, APPX); + void send({ ...base, requestId: 'rl3' }, APPX); + for (let i = 0; i < 100 && createdWindows.length < 3; i++) { + await new Promise((r) => setTimeout(r, 5)); + } + expect(createdWindows.length).toBe(3); + + // The 4th within the same window → RATE-LIMITED, resolves immediately, opens NO 4th window. + const r4 = await send({ ...base, requestId: 'rl4' }, APPX); + expect(r4.error).toMatch(/too many/i); + expect(createdWindows.length).toBe(3); + }); +}); diff --git a/apps/extension/test/service-worker-gate.test.ts b/apps/extension/test/service-worker-gate.test.ts index cefcdb9c9..73f69efc9 100644 --- a/apps/extension/test/service-worker-gate.test.ts +++ b/apps/extension/test/service-worker-gate.test.ts @@ -15,10 +15,51 @@ // - SOLID_GET_STATE withholds the WebID (PII) from a non-granted page, exposes to granted import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { exportDpopKeyPair, generateDpopKeyPair } from '../src/background/core/dpop'; +import { generateDpopKeyPair } from '../src/background/core/dpop'; +import type { StoredDpopKeyRecord } from '../src/background/core/dpop-key-db'; import type { ReplicaMetadata } from '../src/background/core/replica-db'; import type { StoredSession } from '../src/background/session-store'; +// --- An in-memory DpopKeyDb double (Phase-2 security hardening) ----------------------- +// The DPoP private key is no longer carried inline on `StoredSession` — it lives as a +// non-extractable `CryptoKeyPair` in its own IndexedDB store (`core/dpop-key-db.ts`). Fake it +// the same way as the replica DB below: a module-level single-record store so a simulated SW +// "restart" (fresh module import) re-opens the SAME durable key. +let idbDpopKey: StoredDpopKeyRecord | undefined; +/** Set to simulate a TRANSIENT failure (IndexedDB open error, a bug) on EVERY `get()` until + * explicitly cleared (not one-shot — a real cold-wake failure would keep failing `get()` + * calls until the underlying condition clears, not just the first one). */ +let idbDpopKeyGetError: Error | null = null; +class FakeDpopKeyDb { + static async open() { + return new FakeDpopKeyDb(); + } + async get() { + if (idbDpopKeyGetError) throw idbDpopKeyGetError; + return idbDpopKey; + } + async put(record: StoredDpopKeyRecord) { + idbDpopKey = record; + } + async clear() { + idbDpopKey = undefined; + } + close() {} +} +vi.mock('../src/background/core/dpop-key-db', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, DpopKeyDb: FakeDpopKeyDb }; +}); + +/** Persist a keypair into the fake DPoP key store, as `mintAndPersistDpopKeyPair` would. */ +async function seedDpopKeyPair(keyPair: CryptoKeyPair): Promise { + idbDpopKey = { + privateKey: keyPair.privateKey, + publicKey: keyPair.publicKey, + publicJwk: (await crypto.subtle.exportKey('jwk', keyPair.publicKey)) as JsonWebKey, + }; +} + // --- In-memory replica stores (Phase 1) ----------------------------------------------- // The fetch path now routes through the shared replica, which opens IndexedDB (metadata + // nonce) and the Cache API. Stub both so the Phase-0 gate assertions below run unchanged: @@ -205,11 +246,11 @@ function sendMessage( async function seedSession(overrides: Partial = {}): Promise { const keyPair = await generateDpopKeyPair(); + await seedDpopKeyPair(keyPair); const session: StoredSession = { webId: 'https://alice.pod.example/profile/card#me', accessToken: 'access-token-xyz', refreshToken: 'refresh-xyz', - dpopKeyPair: await exportDpopKeyPair(keyPair), issuer: 'https://idp.example/', tokenEndpoint: 'https://idp.example/token', clientId: 'https://pm.example/clientid.jsonld', @@ -219,8 +260,17 @@ async function seedSession(overrides: Partial = {}): Promise { + messageListeners.length = 0; vi.resetModules(); await import('../src/background/service-worker'); // Let the import-time ensureSession()/icon promise settle. @@ -233,6 +283,8 @@ beforeEach(() => { cacheStore.clear(); idbMeta.clear(); idbNonces.clear(); + idbDpopKey = undefined; + idbDpopKeyGetError = null; }); describe('service-worker per-requesting-origin gate (wired)', () => { @@ -254,6 +306,41 @@ describe('service-worker per-requesting-origin gate (wired)', () => { expect(headers.Authorization).toBe('DPoP access-token-xyz'); }); + it('a TRANSIENT DPoP-key-store failure fails ONE request soft — no destructive teardown', async () => { + // roborev round-1 (b64367d) Medium: ensureSession() must not conflate an unexpected + // IndexedDB/crypto failure with a genuinely broken session (DpopKeyUnavailableError) — + // the former must fail this one request without clearing the stored session/tokens, so + // a later, working request still succeeds without a fresh login. + await seedSession(); + storage.set('solid:granted-origins', ['https://pm.example']); + await loadWorker(); // normal cold wake: caches the keypair once + + // A fresh SW "restart" (new module instance, empty in-memory caches) that happens to hit + // a transient DB-open failure on its cold-wake key load — set BEFORE the restart so it + // also covers the import-time `ensureSession()` call `loadWorker()` triggers. + idbDpopKeyGetError = new Error('simulated IndexedDB open failure'); + await loadWorker(); + const failed = await sendFetch( + { url: 'https://alice.pod.example/private/notes.ttl', stampedOrigin: 'https://pm.example' }, + { origin: 'https://pm.example', url: 'https://pm.example/app.html' }, + ); + expect(failed.status).not.toBe(200); + // No pod egress happened for the failed attempt. + expect(fetchCalls.some((c) => c.url.startsWith('https://alice.pod.example'))).toBe(false); + // Crucially: the session was NOT torn down — it is still in storage, untouched. + expect(storage.get('solid:session')).toBeDefined(); + + // The transient condition clears (no restart needed — `cachedKeyPair` was never + // populated by the failed attempts, so the very next call retries the DB read). + idbDpopKeyGetError = null; + const ok = await sendFetch( + { url: 'https://alice.pod.example/private/notes.ttl', stampedOrigin: 'https://pm.example' }, + { origin: 'https://pm.example', url: 'https://pm.example/app.html' }, + ); + expect(ok.status).toBe(200); + expect(fetchCalls.some((c) => c.url.startsWith('https://alice.pod.example'))).toBe(true); + }); + it('a credential-origin app (served FROM the pod) reads without a separate grant', async () => { await seedSession(); // no explicit grants — the pod origin is auto-granted as a requester. @@ -470,14 +557,14 @@ describe('login GRANTS the requesting origin ONLY on success (privilege-escalati it('a SUCCESSFUL login GRANTS the verified origin and a subsequent fetch SUCCEEDS', async () => { // Mock initiateLogin to succeed, returning a fresh session, so we exercise the success path - // where the grant SHOULD now be persisted. + // where the grant SHOULD now be persisted. The real `completeLogin` would also have called + // `mintAndPersistDpopKeyPair` — seed the fake DPoP key store the same way. const keyPair = await generateDpopKeyPair(); - const exported = await exportDpopKeyPair(keyPair); + await seedDpopKeyPair(keyPair); const session: StoredSession = { webId: 'https://alice.pod.example/profile/card#me', accessToken: 'access-token-xyz', refreshToken: 'refresh-xyz', - dpopKeyPair: exported, issuer: 'https://idp.example/', tokenEndpoint: 'https://idp.example/token', clientId: 'https://newapp.example/clientid.jsonld', @@ -516,12 +603,11 @@ describe('login GRANTS the requesting origin ONLY on success (privilege-escalati it('does NOT grant a FORGED login origin even on success (sender ≠ stamp)', async () => { const keyPair = await generateDpopKeyPair(); - const exported = await exportDpopKeyPair(keyPair); + await seedDpopKeyPair(keyPair); const session: StoredSession = { webId: 'https://alice.pod.example/profile/card#me', accessToken: 'access-token-xyz', refreshToken: 'refresh-xyz', - dpopKeyPair: exported, issuer: 'https://idp.example/', tokenEndpoint: 'https://idp.example/token', clientId: 'https://newapp.example/clientid.jsonld', diff --git a/apps/extension/test/service-worker-replica.test.ts b/apps/extension/test/service-worker-replica.test.ts index bcb639c55..3aa73e40b 100644 --- a/apps/extension/test/service-worker-replica.test.ts +++ b/apps/extension/test/service-worker-replica.test.ts @@ -13,10 +13,41 @@ // - the cross-user cache-key guard (same URL+ETag, different WebID → not served) import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { exportDpopKeyPair, generateDpopKeyPair } from '../src/background/core/dpop'; +import { generateDpopKeyPair } from '../src/background/core/dpop'; +import type { StoredDpopKeyRecord } from '../src/background/core/dpop-key-db'; import type { ReplicaMetadata } from '../src/background/core/replica-db'; import type { StoredSession } from '../src/background/session-store'; +// --- An in-memory DpopKeyDb double (see service-worker-gate.test.ts for the rationale) -- +let idbDpopKey: StoredDpopKeyRecord | undefined; +class FakeDpopKeyDb { + static async open() { + return new FakeDpopKeyDb(); + } + async get() { + return idbDpopKey; + } + async put(record: StoredDpopKeyRecord) { + idbDpopKey = record; + } + async clear() { + idbDpopKey = undefined; + } + close() {} +} +vi.mock('../src/background/core/dpop-key-db', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, DpopKeyDb: FakeDpopKeyDb }; +}); + +async function seedDpopKeyPair(keyPair: CryptoKeyPair): Promise { + idbDpopKey = { + privateKey: keyPair.privateKey, + publicKey: keyPair.publicKey, + publicJwk: (await crypto.subtle.exportKey('jwk', keyPair.publicKey)) as JsonWebKey, + }; +} + // --- An in-memory ReplicaDb double (mocked module) ------------------------------------ // // A module-level shared store so a "restart" (fresh module import) re-opens the SAME durable @@ -210,11 +241,11 @@ function sendMessage( async function seedSession(overrides: Partial = {}): Promise { const keyPair = await generateDpopKeyPair(); + await seedDpopKeyPair(keyPair); const session: StoredSession = { webId: 'https://alice.pod.example/profile/card#me', accessToken: 'access-token-xyz', refreshToken: 'refresh-xyz', - dpopKeyPair: await exportDpopKeyPair(keyPair), issuer: 'https://idp.example/', tokenEndpoint: 'https://idp.example/token', clientId: 'https://pm.example/clientid.jsonld', @@ -241,6 +272,7 @@ beforeEach(() => { cacheStore.clear(); idbMeta.clear(); idbNonces.clear(); + idbDpopKey = undefined; }); describe('Phase-1 shared replica (wired into the service worker)', () => { diff --git a/apps/extension/test/session-store.test.ts b/apps/extension/test/session-store.test.ts new file mode 100644 index 000000000..f618cc6c6 --- /dev/null +++ b/apps/extension/test/session-store.test.ts @@ -0,0 +1,182 @@ +// AUTHORED-BY Claude Sonnet 5 +// +// `session-store.ts`'s legacy-session migration: security hardening (design roborev +// round-1) moved the DPoP private key OFF `chrome.storage.local` (where it used to live +// inline as an extractable JWK pair) and into a non-extractable IndexedDB `CryptoKeyPair` +// (see `core/dpop-key-db.ts` / `core/dpop-key-store.ts`). `loadSession()` is the one choke +// point that reads a persisted session, so it is where a pre-hardening on-disk record is +// detected and cleaned — these tests drive that path directly against a minimal in-memory +// `chrome.storage.local` stub. + +import { beforeEach, describe, expect, it } from 'vitest'; +import type { ClassGrant } from '../src/background/core/class-grant'; +import { + loadClassGrants, + loadSession, + pruneExpiredClassGrants, + revokeClassGrant, + type StoredSession, + saveSession, +} from '../src/background/session-store'; + +function installChromeStorageStub(): Map { + const backing = new Map(); + ( + globalThis as unknown as { + chrome: { + storage: { local: chrome.storage.LocalStorageArea }; + }; + } + ).chrome = { + storage: { + local: { + get: async (key: string) => (backing.has(key) ? { [key]: backing.get(key) } : {}), + set: async (items: Record) => { + for (const [k, v] of Object.entries(items)) backing.set(k, v); + }, + remove: async (key: string) => { + backing.delete(key); + }, + } as unknown as chrome.storage.LocalStorageArea, + }, + }; + return backing; +} + +const BASE_SESSION: StoredSession = { + webId: 'https://alice.pod.example/profile/card#me', + accessToken: 'access-token-xyz', + refreshToken: 'refresh-xyz', + issuer: 'https://idp.example/', + tokenEndpoint: 'https://idp.example/token', + clientId: 'https://pm.example/clientid.jsonld', + expiresAt: Date.now() + 60 * 60 * 1000, +}; + +describe('loadSession — legacy extractable-JWK migration', () => { + let backing: Map; + + beforeEach(() => { + backing = installChromeStorageStub(); + }); + + it('round-trips a current-shape session unchanged (no legacy field, no migration)', async () => { + await saveSession(BASE_SESSION); + const loaded = await loadSession(); + expect(loaded).toEqual(BASE_SESSION); + // Untouched: no rewrite occurred (same object shape persisted). + expect(backing.get('solid:session')).toEqual(BASE_SESSION); + }); + + it('returns null when no session is stored', async () => { + expect(await loadSession()).toBeNull(); + }); + + it('strips a legacy inline extractable dpopKeyPair and persists the cleaned record', async () => { + const legacyKeyPair = await crypto.subtle.generateKey( + { name: 'ECDSA', namedCurve: 'P-256' }, + true, + ['sign', 'verify'], + ); + const legacyJwk = { + publicKey: await crypto.subtle.exportKey('jwk', legacyKeyPair.publicKey), + privateKey: await crypto.subtle.exportKey('jwk', legacyKeyPair.privateKey), + }; + // Fabricate the PRE-HARDENING on-disk shape directly (bypassing the current StoredSession + // type, which no longer declares this field) — this is exactly what an old install's + // `chrome.storage.local` would still contain the first time it runs the new code. + backing.set('solid:session', { ...BASE_SESSION, dpopKeyPair: legacyJwk }); + + const loaded = await loadSession(); + + // The returned session carries no key material at all. + expect(loaded).not.toBeNull(); + expect(loaded).toEqual(BASE_SESSION); + expect((loaded as unknown as { dpopKeyPair?: unknown }).dpopKeyPair).toBeUndefined(); + + // The MIGRATION WROTE BACK the cleaned record — the extractable JWK no longer lingers in + // storage at rest, not even until the next unrelated save. + const rawAfter = backing.get('solid:session') as Record; + expect(rawAfter.dpopKeyPair).toBeUndefined(); + expect(rawAfter).toEqual(BASE_SESSION); + }); + + it('a second loadSession() call after migration is a stable no-op (idempotent)', async () => { + const legacyKeyPair = await crypto.subtle.generateKey( + { name: 'ECDSA', namedCurve: 'P-256' }, + true, + ['sign', 'verify'], + ); + backing.set('solid:session', { + ...BASE_SESSION, + dpopKeyPair: { + publicKey: await crypto.subtle.exportKey('jwk', legacyKeyPair.publicKey), + privateKey: await crypto.subtle.exportKey('jwk', legacyKeyPair.privateKey), + }, + }); + + await loadSession(); // first call migrates + const second = await loadSession(); // second call: already clean + expect(second).toEqual(BASE_SESSION); + }); +}); + +describe('revokeClassGrant + pruneExpiredClassGrants — removed/expired reporting', () => { + let backing: Map; + beforeEach(() => { + backing = installChromeStorageStub(); + }); + + function grant(overrides: Partial = {}): ClassGrant { + return { + grantId: 'g1', + webId: BASE_SESSION.webId, + origin: 'https://appx.example', + dataClass: 'https://w3id.org/jeswr/sectors/finance#Transaction', + modes: ['read'], + purpose: null, + expiresAt: Date.now() + 60_000, + targets: [{ url: 'https://alice.pod.example/finance/', kind: 'container' }], + createdAt: Date.now(), + ...overrides, + }; + } + + it('returns the removed grant(s) and removes them from the store', async () => { + backing.set('solid:class-grants', [grant({ grantId: 'g1' }), grant({ grantId: 'g2' })]); + const removed = await revokeClassGrant('g1', 'https://appx.example'); + expect(removed.map((g) => g.grantId)).toEqual(['g1']); + expect((await loadClassGrants()).map((g) => g.grantId)).toEqual(['g2']); + }); + + it('removes EVERY row matching (grantId, origin) — no duplicate stays live after a revoke', async () => { + // A store that somehow holds a duplicate id: the revoke must leave NONE matching live. + backing.set('solid:class-grants', [ + grant({ grantId: 'dup' }), + grant({ grantId: 'dup' }), + grant({ grantId: 'other' }), + ]); + const removed = await revokeClassGrant('dup', 'https://appx.example'); + expect(removed).toHaveLength(2); + expect((await loadClassGrants()).map((g) => g.grantId)).toEqual(['other']); + }); + + it('does not revoke another origin’s grant (returns [])', async () => { + backing.set('solid:class-grants', [grant({ grantId: 'g1', origin: 'https://appx.example' })]); + const removed = await revokeClassGrant('g1', 'https://evil.example'); + expect(removed).toEqual([]); + expect(await loadClassGrants()).toHaveLength(1); + }); + + it('pruneExpiredClassGrants returns {live, expired} and persists only the live', async () => { + const now = Date.now(); + backing.set('solid:class-grants', [ + grant({ grantId: 'live', expiresAt: now + 60_000 }), + grant({ grantId: 'dead', expiresAt: now - 1000 }), + ]); + const { live, expired } = await pruneExpiredClassGrants(now); + expect(live.map((g) => g.grantId)).toEqual(['live']); + expect(expired.map((g) => g.grantId)).toEqual(['dead']); + expect((await loadClassGrants()).map((g) => g.grantId)).toEqual(['live']); + }); +}); diff --git a/apps/extension/webpack.config.cjs b/apps/extension/webpack.config.cjs index 4d1a0bda1..8b2b5e4c9 100644 --- a/apps/extension/webpack.config.cjs +++ b/apps/extension/webpack.config.cjs @@ -13,6 +13,7 @@ module.exports = { 'sidepanel/sidepanel': './src/sidepanel/sidepanel.ts', 'content/content-script': './src/content/content-script.ts', 'inject/inject': './src/inject/inject.ts', + 'consent/consent': './src/consent/consent.ts', }, output: { path: path.resolve(__dirname, 'dist'), @@ -42,6 +43,8 @@ module.exports = { { from: 'src/popup/popup.css', to: 'popup/popup.css' }, { from: 'src/sidepanel/sidepanel.html', to: 'sidepanel/sidepanel.html' }, { from: 'src/sidepanel/sidepanel.css', to: 'sidepanel/sidepanel.css' }, + { from: 'src/consent/consent.html', to: 'consent/consent.html' }, + { from: 'src/consent/consent.css', to: 'consent/consent.css' }, { from: 'src/icons', to: 'icons' }, // The static Solid-OIDC Client Identifier Document (see client-id.ts). Shipped so a // build can be hosted with the doc alongside; the in-flow client_id points at the From 8d2a517e148ddadf95011d69ff1711c7983449d4 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Mon, 26 Oct 2026 04:41:40 +0000 Subject: [PATCH 35/50] feat: pinned extension ID and generated Client ID Document (EXT-02) (#6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(access): Phase-2 per-data-class grant gate in the SW (design §4) Widen the grant key from origin to (origin, dataClass, modes, purpose, expiry, webId) and enforce a PINNED, resolved target set on every .fetch — the SW remains the sole security boundary; legacy origin-only grants keep whole-pod access (dual-read backwards compat). - Pure core (exhaustively tested): class-grant (gate logic, fail-closed on every axis), class-request (bounded ISO-8601 duration, IRI/mode validation), class-resolve (Type-Index walk → concrete targets, TOCTOU-pin), consent (pending record + decision → grants). - SW gateAccess: per-origin gate ∪ class-only origins, then a per-data-class gate for pod targets; distinguishable out-of-grant-scope deny; expiry pruned per request; revocation effective next request (no in-memory grant cache); WebID session boundary; grants cleared on logout. - window.solid.requestAccess/listGrants/revokeGrant/resolveClass + content relay + unspoofable extension-chrome consent window (src/consent/*). - +73 adversarial tests (class-grant/request/resolve/consent + wired SW class-gate: allow/deny matrix, out-of-pin, expiry, revocation, unknown-class fail-closed, session boundary, backwards-compat, requestAccess→consent flow). - docs/access-management-seam.md rewritten to the shipped surface + honest revocation/TOCTOU/purpose limits. Model: claude-opus-4-8 Provenance: Opus 4.8 (Fable unavailable) — re-review/upgrade candidate Co-Authored-By: Claude Opus 4.8 * fix(access): roborev round-1 — serialise grant store, scope resolveClass, SSRF-guard resolver - HIGH: class-grant mutations (save/revoke/prune) were unversioned read-modify-write on one array; a stale save could resurrect a just-revoked grant, breaking "revocation effective next request". Route all three through a single promise-chain serialiser that re-reads inside the critical section. - MEDIUM: resolveClass let any class-only origin enumerate targets for ANY class (private-structure leak). Now a class-only origin may resolve ONLY a class it holds a live grant for; full-grant origins keep open introspection. - Defence in depth: the resolver no longer fetches a type-index ref on a foreign origin (a hostile profile pointing publicTypeIndex at an attacker URL could turn resolution into an SW-side SSRF) — off-pod refs are skipped. - +4 tests: concurrent save+revoke no-resurrect, resolveClass class-scoped allow/deny, full-grant open resolve, SSRF ref-skip. Model: claude-opus-4-8 Provenance: Opus 4.8 (Fable unavailable) — re-review/upgrade candidate Co-Authored-By: Claude Opus 4.8 * fix(access): strip stray NUL bytes from class-resolve dedupe keys (text, not binary) Two dedupe-key separators in class-resolve.ts were NUL bytes, which made git treat the file as binary (blocking clean diffs/review). Replace with a space (the dedupe key joins a fixed kind word + a URL, so a space is unambiguous). No behaviour change. Model: claude-opus-4-8 Provenance: Opus 4.8 (Fable unavailable) — re-review/upgrade candidate Co-Authored-By: Claude Opus 4.8 * fix(access): roborev round-2 — pod-only resolver origins + serialise clearClassGrants - MEDIUM: the resolver SSRF guard confined refs/targets to the full credential set, which INCLUDES the issuer origin — a hostile profile could point a type-index ref at the issuer and trigger an authed SW fetch there. Resolve against a POD-ONLY origin set (WebID origin ∪ configured pods, issuer excluded) for both ref fetches and resolved targets. - MEDIUM: clearClassGrants (logout) bypassed the class-grant mutex, so it could interleave a concurrent save/prune and leave a grant persisted after logout. Route it through the same serialiser. - +2 tests: resolveClass skips an issuer-hosted type-index ref; concurrent save+clear is serialised (no grant survives logout). Model: claude-opus-4-8 Provenance: Opus 4.8 (Fable unavailable) — re-review/upgrade candidate Co-Authored-By: Claude Opus 4.8 * fix(access): roborev round-3 — document + test the issuer-origin exclusion (CSS single-origin) roborev flagged that podOriginsFor doesn't unconditionally delete the issuer origin. That is DELIBERATE: unconditionally removing it would drop the pod origin in the ubiquitous CSS single-origin topology (WebID + IdP on ONE origin) and break class resolution entirely. The `includeIssuerOrigin: false` rule already achieves the correct policy — the issuer origin is allowed ONLY when it coincides with a genuine pod origin (WebID origin or a user-configured pod), where token-to-that-origin is already normal pod access (and the token endpoint stays protected by isTokenEndpoint); an issuer origin DISTINCT from every pod origin (the profile-controlled vector) is excluded. - Expanded the rationale in podOriginsFor. - +1 test: CSS single-origin (WebID origin === issuer origin) — a same-origin type-index ref IS followed so resolution works (proving no over-exclusion); the distinct-issuer skip is already covered. Model: claude-opus-4-8 Provenance: Opus 4.8 (Fable unavailable) — re-review/upgrade candidate Co-Authored-By: Claude Opus 4.8 * fix(auth): non-extractable DPoP CryptoKey in IndexedDB (design roborev round-1 F3) Migrates DPoP private-key custody off an extractable JWK in chrome.storage.local onto a non-extractable Web Crypto CryptoKey persisted directly in IndexedDB (core/dpop-key-db.ts). Non-extractable CryptoKeys are structured-clone serialisable, so the key survives the MV3 service worker's aggressive kill/restart cycle without ever being exportable — closing the F3 keystore-advantage finding from the design review while keeping the same session-scoped-keypair behaviour (fresh key per login, reused across refreshes, cleared on logout). - core/dpop.ts: generateDpopKeyPair() now generates extractable:false; removed exportDpopKeyPair/importDpopKeyPair/StoredDpopKeyPair (no longer needed — the key is never exported to JWK for storage). - core/dpop-key-db.ts (new): IndexedDB handle for the keypair record (put/get/clear), modelled on replica-db.ts. - core/dpop-key-store.ts (new): mintAndPersistDpopKeyPair (login) and loadOrRotateDpopKeyPair (reload-or-mint) orchestration. - session-store.ts: StoredSession no longer carries dpopKeyPair; loadSession() detects + strips a pre-hardening inline extractable JWK on read (one-time migration) and writes the cleaned record back immediately. A migrated session's refresh token was DPoP-bound to the old key's jkt and will fail its next refresh, which the existing refresh-failure teardown in ensureSession() already turns into a clean re-login — no new complexity needed there. - auth-flow.ts / service-worker.ts: rewired onto DpopKeyDb + loadOrRotateDpopKeyPair; logout now also clears the persisted key. Tests: dpop.test.ts (non-extractability + still-signs-and-verifies), dpop-key-store.test.ts (new — a real DpopKeyDb against a minimal fake IDBFactory proving the structured-clone round trip, not a mocked module), session-store.test.ts (new — the legacy-migration strip + idempotency), and the three service-worker integration suites updated to seed the fake DpopKeyDb instead of embedding dpopKeyPair on StoredSession fixtures. +11 net new test cases (358 total). Gate green: lint, typecheck, test, build. Docs updated in the same change: README.md, docs/manifest-permissions.md, docs/design/shared-replica-architecture.md (marks the residual-LOW key-custody finding as closed, keeps the original text struck through for the record). NO PUSH — landing policy for this (ODI-transferred) repo is under re-confirmation on issue #9; orchestrator decides. Model: claude-sonnet-5 Co-Authored-By: Claude Sonnet 5 * fix(auth): roborev round-1 — dpopJkt-checked key load + defer persist to login success Two Medium findings from codex's review of b64367d, both closed: 1. loadOrRotateDpopKeyPair() silently minted a fresh key whenever IndexedDB was empty, even for a CURRENT-shape session whose access/refresh tokens were bound to a different (lost/evicted) key's jkt — producing confusing 401s instead of a clean re-login. Fixed by recording dpopJkt (RFC 7638 thumbprint) on StoredSession at mint time; the renamed loadSessionDpopKeyPair(db, session) now verifies the persisted key's thumbprint against session.dpopJkt and throws DpopKeyUnavailableError on a missing/mismatched key for a current-shape session (dpopJkt present). Both service-worker.ts ensureSession() and auth-flow.ts refreshSession() treat that error like an ordinary refresh failure — clean teardown, not a substitute key. The legacy-migration rotation (no recorded dpopJkt) is unchanged. 2. completeLogin() persisted the newly minted key to the single-record IndexedDB store BEFORE the token exchange succeeded, so a failed/retried login could clobber an existing session's still-valid key. Fixed by splitting mintAndPersistDpopKeyPair into generateDpopKeyPair (unchanged, called early — needed to sign the exchange's own DPoP proof) + persistDpopKeyPair (new — called only after the exchange succeeds, alongside saving the session with its dpopJkt). Tests: dpop-key-store.test.ts rewritten/extended for the new loadSessionDpopKeyPair session-aware contract (legacy-rotate, matching-key, missing-key-throws, mismatched-key-throws) + persistDpopKeyPair's deferred-persist safety (10 tests, was 5). 363 total, gate green: lint, typecheck, test, build. roborev: codex review of b64367d found these 2 Medium findings, no Critical/ High; both addressed above. Model: claude-sonnet-5 Co-Authored-By: Claude Sonnet 5 * fix(auth): roborev round-2 — narrow ensureSession's DPoP-key-load teardown codex's review of d83a142 found: ensureSession() caught EVERY error from loadSessionDpopKeyPair() (including a transient IndexedDB-open failure or an unrelated bug) and destructively tore the session down, silently logging the user out on what might be a momentary fault. Fixed by narrowing the catch to teardown ONLY on DpopKeyUnavailableError (the "this session's key is genuinely gone/mismatched" signal). Any other error now fails that one call soft: cachedKeyPair stays null, the stored session/tokens are left untouched, and the next call retries the DB read cleanly (self-heals once the transient condition clears) — matching every caller's existing "no ctx -> not authenticated" handling rather than adding new throw-handling at each call site. Also fixed a latent test-harness bug this surfaced: service-worker-gate.test.ts's loadWorker() didn't clear messageListeners before vi.resetModules(), so a test calling loadWorker() twice (to model a restart mid-test, needed to actually exercise the DB-read path — a keypair cached in one module instance never re-reads on the same instance) left a stale listener from the FIRST module instance handling the message instead of the second. Fixed by clearing messageListeners at the top of loadWorker(), matching the other two service-worker integration test files. Test added: "a TRANSIENT DPoP-key-store failure fails ONE request soft — no destructive teardown" in service-worker-gate.test.ts (364 tests total, was 363). Gate green: lint, typecheck, test, build. roborev: codex review of d83a142 found this 1 Medium finding, no Critical/ High; addressed above. Model: claude-sonnet-5 Co-Authored-By: Claude Sonnet 5 * feat(access-gate): per-class replica-slice purge on revoke/expiry + requestAccess gesture & rate-limit Access-management Phase-2 residuals (bead suite-tracker-uana, items b + c): (b) Per-class replica-slice purge. Revoking (or expiring) a class grant now synchronously purges exactly that class's replicated bytes + metadata from the extension's shared replica — the resources it resolved to (its pinned targets), scoped to (webId, grantScope) — leaving every other class/origin/user resident. Closes the data-retention gap where a revoked class's bytes lingered until logout. FAIL-CLOSED: the grant's REMOVAL is the authoritative deny and precedes the best-effort byte purge, so a purge failure can never leave access live — it only degrades to "bytes linger until logout" with access already revoked. - new purgeReplicaSlice() in core/replica.ts (targetsCover containment, per-row best-effort so one failure can't abort the slice) - revokeClassGrant() now returns the removed grant; pruneExpiredClassGrants() returns {live, expired}; SW purges each removed/expired grant's slice. (c) requestAccess anti-spam. Two independent, fail-closed defences before the consent window opens or any pod fetch is made: (i) user-gesture requirement — the ISOLATED content script stamps the page-unforgeable navigator.userActivation.isActive; an autonomous (non-gesture) requestAccess loop is rejected. (ii) per-origin fixed-window rate limit (default 3/60s) on the browser-attested origin, with a distinguishable denied-reason. - new pure core/request-access-throttle.ts (decideRequestAccess), wired into the SW handler with best-effort module-level per-origin state. Gate: lint + typecheck + 382 tests (18 new) + build all green. Model: claude-opus-4-8 Provenance: Opus-4.8-authored; tag for re-review/upgrade per docs/MODEL-PROVENANCE.md Co-Authored-By: Claude Opus 4.8 * chore: run CI gate on Node 24 Upgrade checkout and setup-node to their Node 24 runtimes and run the full gate before local-server e2e tests. Co-Authored-By: GPT-6.1-Sol (Codex) * docs: describe implemented class grant API Replace the requestAccess stub description and obsolete prototype plan with the current API, consent flow, pinned-target enforcement, replica, and known resolver limits. Co-Authored-By: GPT-6.1-Sol (Codex) * docs: record EXT-01 validation Record the green Node 24 gate, API surface check, missing package check script, and local CSS setup timeout preventing e2e validation. Co-Authored-By: GPT-6.1-Sol (Codex) * docs: record EXT-02 configuration questions Co-Authored-By: GPT-6.1-Sol (Codex) * feat: generate client document and gate DCR mode Add the single document template, public-key validation and rendering, explicit trusted clientIdMode routing, root redirect, and adversarial tests. Manifest pinning remains blocked because the dispatcher public key is missing; its two acceptance checks intentionally fail until it is supplied. Co-Authored-By: GPT-6.1-Sol (Codex) * feat: publish client document with Pages workflow Stage only the generated public document and logo, restrict publication to main, and leave Pages enablement to Jesse. Document the clientIdMode setting and public-key import command. Co-Authored-By: GPT-6.1-Sol (Codex) * docs: record EXT-02 validation Co-Authored-By: GPT-6.1-Sol (Codex) * fix: align development client redirects Use the root Chromium redirect in both development Client ID Documents. Add a mocked harness regression test that reproduces the former callback mismatch. Co-Authored-By: GPT-6.1-Sol (Codex) * docs: record fix-round validation Record the development redirect fix, gate results, and missing dispatcher public key that still blocks finding 1. Co-Authored-By: GPT-6.1-Sol (Codex) * test: cover client ID generation without key overrides Exercise render and check modes in an isolated checkout with no dependencies and EXT_PUBKEY removed. Require the committed manifest and document to remain consistent. Co-Authored-By: GPT-6.1-Sol (Codex) * docs: record second-round key blocker Record clean-environment validation and the missing dispatcher public key that still prevents resolving review finding 1. Co-Authored-By: GPT-6.1-Sol (Codex) * fix: pin supplied extension public key * docs: record third-round pin validation * chore: remove lane report files --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: GPT-6.1-Sol (Codex) --- apps/extension/.github/workflows/pages.yml | 62 +++++ apps/extension/README.md | 37 ++- apps/extension/e2e/setup/global-setup.ts | 2 +- apps/extension/package.json | 5 +- apps/extension/public/clientid.jsonld | 15 +- apps/extension/scripts/dev.ts | 2 +- apps/extension/scripts/render-client-id.ts | 43 +++ apps/extension/src/background/auth-flow.ts | 53 ++-- .../src/background/client-id-template.ts | 23 ++ apps/extension/src/background/client-id.ts | 102 +------ .../extension/src/background/session-store.ts | 15 ++ apps/extension/src/manifest.json | 1 + apps/extension/test/auth-flow.test.ts | 188 +++++++++++++ apps/extension/test/client-id.test.ts | 253 +++++++++--------- apps/extension/test/dev.test.ts | 75 ++++++ apps/extension/webpack.config.cjs | 4 +- 16 files changed, 617 insertions(+), 263 deletions(-) create mode 100644 apps/extension/.github/workflows/pages.yml create mode 100644 apps/extension/scripts/render-client-id.ts create mode 100644 apps/extension/src/background/client-id-template.ts create mode 100644 apps/extension/test/auth-flow.test.ts create mode 100644 apps/extension/test/dev.test.ts diff --git a/apps/extension/.github/workflows/pages.yml b/apps/extension/.github/workflows/pages.yml new file mode 100644 index 000000000..1e75b5ee4 --- /dev/null +++ b/apps/extension/.github/workflows/pages.yml @@ -0,0 +1,62 @@ +name: Client ID Document Pages + +on: + push: + branches: [main] + paths: + - '.github/workflows/pages.yml' + - 'package.json' + - 'public/clientid.jsonld' + - 'scripts/render-client-id.ts' + - 'src/background/client-id-template.ts' + - 'src/icons/icon128.png' + - 'src/manifest.json' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: client-id-pages + cancel-in-progress: false + +jobs: + build: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: '24' + # The renderer uses only Node built-ins; no dependency install is needed. + - name: Render Client ID Document + run: npm run generate:client-id + - name: Configure Pages + uses: actions/configure-pages@v5 + with: + # Enabling Pages is Jesse's repository-settings step (DESIGN §2 D2). + enablement: false + - name: Stage public document and logo + run: | + mkdir -p "$RUNNER_TEMP/client-id-site" + cp public/clientid.jsonld "$RUNNER_TEMP/client-id-site/clientid.jsonld" + cp src/icons/icon128.png "$RUNNER_TEMP/client-id-site/icon128.png" + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v4 + with: + path: ${{ runner.temp }}/client-id-site + + deploy: + needs: build + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy Pages artifact + id: deployment + uses: actions/deploy-pages@v4 diff --git a/apps/extension/README.md b/apps/extension/README.md index 28e972a0b..829d16c5f 100644 --- a/apps/extension/README.md +++ b/apps/extension/README.md @@ -109,15 +109,15 @@ Explicit `solid.fetch` calls retain the worker's denial response. - **Login:** Solid-OIDC **authorization-code + PKCE + DPoP**, driven by `chrome.identity.launchWebAuthFlow` (a service worker has no `window`). The WebID's `solid:oidcIssuer` is resolved by **proper RDF parsing** (N3.js, not regex). The extension's - own published Solid-OIDC **Client Identifier Document** (committed at `public/clientid.jsonld`, - copied into the build, mirrored + shape-tested in `src/background/client-id.ts`) is used as the - `client_id` (stable consent-screen name + `token_endpoint_auth_method: "none"` public-client - behaviour) **when its hosted URL is reachable**; otherwise the flow falls back to **dynamic - client registration**. The hosted URL is a `REPLACE-ME` **placeholder** (`PUBLISHED_CLIENT_ID_URL`) - — actually HOSTING the document at a stable HTTPS URL (and pinning the real extension-id - `chromiumapp.org` redirect into it) is a **needs:user** step; until then the unreachable - placeholder transparently falls back to dynamic registration, so login keeps working. A page - may still declare its own Client ID Document via `solid.setClientId(...)`. + own Solid-OIDC **Client Identifier Document** is generated from + `src/background/client-id-template.ts` and the manifest's public key into + `public/clientid.jsonld` before builds (Node 24 required). The document's `client_id` is + `https://jeswr.github.io/solid-browser-extension/clientid.jsonld`, its redirect is + `https://bicabcdahgfbkgmlidjpmiadmjlppikk.chromiumapp.org/`, and its auth method is `none`. + The build rejects a missing or mismatched public key; the dispatcher-supplied key can be + imported with `EXT_PUBKEY=/path/to/extension-pubkey.b64 npm run generate:client-id`. + The public key belongs in `manifest.key`; the extension signing private key stays with Jesse. + A page may still declare its own Client ID Document via `solid.setClientId(...)`. - **Session:** the access + refresh token are persisted in `chrome.storage.local`; the DPoP keypair is persisted separately as a **non-extractable** `CryptoKey` object directly in IndexedDB (`core/dpop-key-db.ts`) — non-extractable `CryptoKey`s are structured-clone @@ -129,6 +129,25 @@ Explicit `solid.fetch` calls retain the worker's denial response. [`@jeswr/solid-session-restore`](https://github.com/jeswr/solid-session-restore) (fail-closed, WebID-scoped). +The trusted `clientIdMode` setting is stored as `solid:client-id-mode` in +`chrome.storage.local`. It accepts `document` or `dcr`, defaulting to `dcr` while Pages +enablement is pending. `dcr` uses dynamic client registration without probing the hosted +document. `document` uses the published URL and fails login if the document cannot be +fetched or does not self-reference; it never silently registers a different client. +Malformed settings fail closed. A page-declared Client ID Document keeps precedence in +either mode, and page messages cannot set the mode. + +`.github/workflows/pages.yml` renders and publishes only the public document and its logo +on `main` (or manual dispatch from `main`). **Jesse must enable GitHub Pages with GitHub +Actions as its source**; the workflow does not enable Pages. Once hosting is available, +set the mode from an extension context's DevTools console: + +```js +await chrome.storage.local.set({ 'solid:client-id-mode': 'document' }); +``` + +Use `dcr` in the same setting to retain dynamic registration during local development. + ## The pinned toolbar identity The `chrome.action` icon is rendered (off-DOM, `OffscreenCanvas`) to the signed-in WebID's diff --git a/apps/extension/e2e/setup/global-setup.ts b/apps/extension/e2e/setup/global-setup.ts index 807f66d5f..7eae931b6 100644 --- a/apps/extension/e2e/setup/global-setup.ts +++ b/apps/extension/e2e/setup/global-setup.ts @@ -32,7 +32,7 @@ async function globalSetup() { console.log('Discovering extension ID...'); const extensionId = await discoverExtensionId(); - const redirectUri = `https://${extensionId}.chromiumapp.org/callback`; + const redirectUri = `https://${extensionId}.chromiumapp.org/`; console.log(` Extension ID: ${extensionId}`); // Port 8080: basic test site + extension's Client ID Document diff --git a/apps/extension/package.json b/apps/extension/package.json index 17c876577..34e41e169 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -12,8 +12,9 @@ "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", - "build": "webpack --config webpack.config.cjs", - "build:dev": "webpack --config webpack.config.cjs --mode development --watch", + "generate:client-id": "node scripts/render-client-id.ts", + "build": "npm run generate:client-id && webpack --config webpack.config.cjs", + "build:dev": "npm run generate:client-id && webpack --config webpack.config.cjs --mode development --watch", "gate": "npm run lint && npm run typecheck && npm test && npm run build", "test:e2e": "npm run build && playwright test --config e2e/playwright.config.ts", "clean": "rm -rf dist" diff --git a/apps/extension/public/clientid.jsonld b/apps/extension/public/clientid.jsonld index 1cc114b61..1f6ebdfd0 100644 --- a/apps/extension/public/clientid.jsonld +++ b/apps/extension/public/clientid.jsonld @@ -1,14 +1,19 @@ { "@context": "https://www.w3.org/ns/solid/oidc-context.jsonld", - "client_id": "https://REPLACE-ME.example/solid-browser-extension/clientid.jsonld", + "client_id": "https://jeswr.github.io/solid-browser-extension/clientid.jsonld", "client_name": "Solid Browser Extension", "client_uri": "https://github.com/jeswr/solid-browser-extension", - "logo_uri": "https://REPLACE-ME.example/solid-browser-extension/icon128.png", + "logo_uri": "https://jeswr.github.io/solid-browser-extension/icon128.png", "redirect_uris": [ - "https://REPLACE-ME-EXTENSION-ID.chromiumapp.org/callback" + "https://bicabcdahgfbkgmlidjpmiadmjlppikk.chromiumapp.org/" + ], + "grant_types": [ + "authorization_code", + "refresh_token" + ], + "response_types": [ + "code" ], - "grant_types": ["authorization_code", "refresh_token"], - "response_types": ["code"], "scope": "openid webid offline_access", "token_endpoint_auth_method": "none", "application_type": "web" diff --git a/apps/extension/scripts/dev.ts b/apps/extension/scripts/dev.ts index 6ca40c0e0..ba846a132 100644 --- a/apps/extension/scripts/dev.ts +++ b/apps/extension/scripts/dev.ts @@ -119,7 +119,7 @@ function pinExtension(userDataDir: string, extensionId: string) { pinExtension(userDataDir, extensionId); // 4. Start two test site servers, both configured with the extension's redirect URI - const redirectUri = `https://${extensionId}.chromiumapp.org/callback`; + const redirectUri = `https://${extensionId}.chromiumapp.org/`; const appClientIdUrl = `http://localhost:${CLIENT_ID_SITE_PORT}/client-id`; console.log('Starting test sites...'); diff --git a/apps/extension/scripts/render-client-id.ts b/apps/extension/scripts/render-client-id.ts new file mode 100644 index 000000000..0f6aac1f7 --- /dev/null +++ b/apps/extension/scripts/render-client-id.ts @@ -0,0 +1,43 @@ +// AUTHORED-BY GPT-6.1-Sol (Codex) +import { createHash, createPublicKey } from 'node:crypto'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { renderClientIdDocument } from '../src/background/client-id-template.ts'; + +const manifestPath = new URL('../src/manifest.json', import.meta.url); +const documentPath = new URL('../public/clientid.jsonld', import.meta.url); +const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + +// EXT_PUBKEY is optional after initial pinning: clean builds use the committed PUBLIC key. +// This script never reads, generates, or exports a private key. +const key: unknown = process.env.EXT_PUBKEY + ? readFileSync(process.env.EXT_PUBKEY, 'utf8').trim() + : manifest.key; +if (typeof key !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(key)) { + throw new Error( + 'Missing/invalid manifest public key; set EXT_PUBKEY to the supplied base64 DER file.', + ); +} +const der = Buffer.from(key, 'base64'); +if (der.toString('base64') !== key) throw new Error('Public key must be canonical base64 DER.'); +createPublicKey({ key: der, format: 'der', type: 'spki' }); +const extensionId = createHash('sha256') + .update(der) + .digest('hex') + .slice(0, 32) + .replace(/[0-9a-f]/g, (digit) => String.fromCharCode(97 + Number.parseInt(digit, 16))); +if (extensionId !== 'bicabcdahgfbkgmlidjpmiadmjlppikk') { + throw new Error('Public key does not match the dispatcher-pinned extension ID.'); +} + +const rendered = `${JSON.stringify(renderClientIdDocument(extensionId), null, 2)}\n`; +if (process.argv.includes('--check')) { + if (manifest.key !== key || readFileSync(documentPath, 'utf8') !== rendered) { + throw new Error('Pinned manifest/document is stale; run npm run generate:client-id.'); + } +} else { + if (manifest.key !== key) { + manifest.key = key; + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + } + writeFileSync(documentPath, rendered); +} diff --git a/apps/extension/src/background/auth-flow.ts b/apps/extension/src/background/auth-flow.ts index 82b7a6754..641f739d1 100644 --- a/apps/extension/src/background/auth-flow.ts +++ b/apps/extension/src/background/auth-flow.ts @@ -9,13 +9,17 @@ * * Login uses a PUBLISHED Solid-OIDC Client Identifier Document URL as the `client_id` * (so the consent screen shows a stable name and `token_endpoint_auth_method=none` - * public-client behaviour is correct), falling back to dynamic client registration only - * when no Client ID Document is configured (dev). A page may supply its OWN Client ID + * public-client behaviour is correct). Dynamic registration is used only when the trusted + * clientIdMode setting is 'dcr'. A page may supply its OWN Client ID * Document via `window.solid.setClientId(...)` to identify itself to the pod. */ import * as jose from 'jose'; -import { isPublishedClientIdReachable, PUBLISHED_CLIENT_ID_URL } from './client-id'; +import { + CLIENT_ID_SCOPE, + isPublishedClientIdReachable, + PUBLISHED_CLIENT_ID_URL, +} from './client-id'; import { createDpopProof, dpopThumbprint, generateDpopKeyPair } from './core/dpop'; import { DpopKeyDb } from './core/dpop-key-db'; import { loadSessionDpopKeyPair, persistDpopKeyPair } from './core/dpop-key-store'; @@ -25,22 +29,13 @@ import { type AuthParams, clearAuthParams, loadAuthParams, + loadClientIdMode, type StoredSession, saveAuthParams, saveSession, } from './session-store'; -/** - * The extension's own published Client Identifier Document URL — see client-id.ts (the - * static doc is committed at `public/clientid.jsonld`, copied into the build). This is a - * PLACEHOLDER (`REPLACE-ME.example`) until the maintainer hosts the document and pins the - * real URL (a needs:user step — see client-id.ts). Because the placeholder is unreachable, - * the flow auto-falls-back to dynamic client registration (see initiateLogin), so shipping - * the placeholder does NOT break login. - */ -const DEFAULT_CLIENT_ID = PUBLISHED_CLIENT_ID_URL; - -const SCOPE = 'openid webid offline_access'; +const SCOPE = CLIENT_ID_SCOPE; interface OidcConfig { authorization_endpoint: string; @@ -74,7 +69,7 @@ async function fetchOidcConfig(issuer: string): Promise { /** The fixed redirect URI this extension is registered with (chrome.identity). */ function getRedirectUri(): string { - return chrome.identity.getRedirectURL('callback'); + return chrome.identity.getRedirectURL(); } async function fetchProfile(webId: string): Promise { @@ -136,6 +131,7 @@ export async function initiateLogin( options: InitiateLoginOptions, ): Promise<{ session: StoredSession; name: string | null; photoUrl: string | null }> { const { webId } = options; + const clientIdMode = await loadClientIdMode(); const turtle = await fetchProfile(webId); const profile = parseWebIdProfile(webId, turtle); const issuer = await selectIssuer( @@ -147,24 +143,21 @@ export async function initiateLogin( const config = await fetchOidcConfig(issuer); const redirectUri = getRedirectUri(); - // Client-id resolution order: - // 1. a page-supplied Client ID Document URL (the page identifying itself), used as-is; - // 2. otherwise the extension's OWN published Client ID Document — but ONLY if it is - // actually reachable + self-consistent (the IdP will need to dereference it). The - // shipped value is a placeholder until hosted (needs:user), so this probe fails and - // we fall through to (3) — keeping login working with no published client-id; - // 3. dynamic client registration (the fallback), when there is no usable client-id. + // A page-declared document keeps precedence. Only the extension's trusted setting can + // select DCR; document failure must never silently change the client's identity. let clientId = options.clientId || ''; - if (!clientId && DEFAULT_CLIENT_ID && (await isPublishedClientIdReachable(DEFAULT_CLIENT_ID))) { - clientId = DEFAULT_CLIENT_ID; - } if (!clientId) { - if (!config.registration_endpoint) { - throw new Error( - 'No client identifier configured and the issuer offers no dynamic registration endpoint.', - ); + if (clientIdMode === 'document') { + if (!(await isPublishedClientIdReachable(PUBLISHED_CLIENT_ID_URL))) { + throw new Error('The published Client ID Document is unavailable or invalid.'); + } + clientId = PUBLISHED_CLIENT_ID_URL; + } else { + if (!config.registration_endpoint) { + throw new Error('clientIdMode=dcr but the issuer offers no dynamic registration endpoint.'); + } + clientId = await dynamicClientRegistration(config.registration_endpoint, redirectUri); } - clientId = await dynamicClientRegistration(config.registration_endpoint, redirectUri); } const codeVerifier = randomB64Url(32); diff --git a/apps/extension/src/background/client-id-template.ts b/apps/extension/src/background/client-id-template.ts new file mode 100644 index 000000000..e74dfdef1 --- /dev/null +++ b/apps/extension/src/background/client-id-template.ts @@ -0,0 +1,23 @@ +// AUTHORED-BY GPT-6.1-Sol (Codex) +/** The single template used to render the public Solid-OIDC Client Identifier Document. */ +export const PUBLISHED_CLIENT_ID_URL = + 'https://jeswr.github.io/solid-browser-extension/clientid.jsonld'; +export const SOLID_OIDC_CONTEXT = 'https://www.w3.org/ns/solid/oidc-context.jsonld'; +export const CLIENT_ID_SCOPE = 'openid webid offline_access'; + +export function renderClientIdDocument(extensionId: string) { + if (!/^[a-p]{32}$/.test(extensionId)) throw new Error('Invalid Chromium extension ID.'); + return { + '@context': SOLID_OIDC_CONTEXT, + client_id: PUBLISHED_CLIENT_ID_URL, + client_name: 'Solid Browser Extension', + client_uri: 'https://github.com/jeswr/solid-browser-extension', + logo_uri: new URL('icon128.png', PUBLISHED_CLIENT_ID_URL).href, + redirect_uris: [`https://${extensionId}.chromiumapp.org/`], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + scope: CLIENT_ID_SCOPE, + token_endpoint_auth_method: 'none', + application_type: 'web', + }; +} diff --git a/apps/extension/src/background/client-id.ts b/apps/extension/src/background/client-id.ts index c226e272a..3d6ca02eb 100644 --- a/apps/extension/src/background/client-id.ts +++ b/apps/extension/src/background/client-id.ts @@ -1,107 +1,25 @@ -// AUTHORED-BY Claude Opus 4.8 -/** - * The extension's own published Solid-OIDC Client Identifier Document. - * - * Logging in with a STABLE, published Client ID Document (instead of throwaway dynamic - * client registration) makes the IdP consent screen show a stable name + the - * `token_endpoint_auth_method: "none"` public-client behaviour, and lets the pod recognise - * the app across logins. The document is a dereferenceable HTTPS URL; the IdP fetches it - * during the authorization request and reads `redirect_uris`, `client_name`, etc. - * - * The static document lives at `public/clientid.jsonld` (committed; copied into the build - * by webpack). {@link CLIENT_ID_DOCUMENT} below is its canonical in-code mirror — the test - * suite asserts the two agree, so the shipped JSON can never silently drift from the shape - * the spec + this flow require. - * - * ┌──────────────────────────────────────────────────────────────────────────────────┐ - * │ needs:user — HOSTING. {@link PUBLISHED_CLIENT_ID_URL} is a PLACEHOLDER. To go │ - * │ live the maintainer must: │ - * │ 1. host `public/clientid.jsonld` at a stable HTTPS URL, │ - * │ 2. set its `client_id` member to THAT URL (it must self-reference), │ - * │ 3. set `redirect_uris` to the packed extension's real callback │ - * │ (`https://.chromiumapp.org/callback` — must equal │ - * │ `chrome.identity.getRedirectURL('callback')` in auth-flow.ts), and │ - * │ 4. replace the `REPLACE-ME...` placeholder URL below with the hosted URL. │ - * │ Until then the URL is unreachable, so initiateLogin() falls back to dynamic │ - * │ client registration — the extension keeps working with no published client-id. │ - * └──────────────────────────────────────────────────────────────────────────────────┘ - */ - -/** - * The hosted URL of the extension's Client ID Document. PLACEHOLDER — `REPLACE-ME.example` - * is deliberately not a real domain (see the needs:user box above). Because it is - * unreachable as-shipped, the auth flow auto-falls-back to dynamic client registration, so - * shipping this placeholder does NOT break login. A page may still supply its own client-id. - * - * Overridable at runtime via `chrome.storage` (the e2e harness injects a localhost doc). - */ -export const PUBLISHED_CLIENT_ID_URL = - 'https://REPLACE-ME.example/solid-browser-extension/clientid.jsonld'; - -/** The OIDC context every Solid Client Identifier Document must declare. */ -export const SOLID_OIDC_CONTEXT = 'https://www.w3.org/ns/solid/oidc-context.jsonld'; - -/** The OAuth scope the extension requests (kept in sync with auth-flow.ts SCOPE). */ -export const CLIENT_ID_SCOPE = 'openid webid offline_access'; - -/** - * The canonical Client Identifier Document shape. Mirrors `public/clientid.jsonld`; the - * test suite asserts byte-for-byte agreement so the published asset can't drift. - * - * NOTE the placeholders: `client_id` must self-reference {@link PUBLISHED_CLIENT_ID_URL}, - * and `redirect_uris[0]` must be the packed extension's real chromiumapp.org callback. Both - * are needs:user values the maintainer fills in at hosting time. - */ -export const CLIENT_ID_DOCUMENT = { - '@context': SOLID_OIDC_CONTEXT, - client_id: PUBLISHED_CLIENT_ID_URL, - client_name: 'Solid Browser Extension', - client_uri: 'https://github.com/jeswr/solid-browser-extension', - logo_uri: 'https://REPLACE-ME.example/solid-browser-extension/icon128.png', - redirect_uris: ['https://REPLACE-ME-EXTENSION-ID.chromiumapp.org/callback'], - grant_types: ['authorization_code', 'refresh_token'], - response_types: ['code'], - scope: CLIENT_ID_SCOPE, - token_endpoint_auth_method: 'none', - application_type: 'web', -} as const; - -/** - * Whether `url` looks like the still-unconfigured placeholder hosted URL (the `REPLACE-ME` - * sentinel). Used to skip a doomed reachability probe when the maintainer has not yet - * hosted the document — fall straight through to dynamic registration. - */ -export function isPlaceholderClientId(url: string): boolean { - return url.includes('REPLACE-ME'); -} +// AUTHORED-BY GPT-6.1-Sol (Codex) +/** Client ID Document metadata comes from the build's single template. */ +export { CLIENT_ID_SCOPE, PUBLISHED_CLIENT_ID_URL } from './client-id-template'; /** - * Is the published Client ID Document actually reachable + well-formed (so the IdP will be - * able to dereference it)? A HEAD/GET to the URL that resolves 2xx with a JSON-ish body - * whose `client_id` self-references the URL. ANY failure (placeholder, network error, - * non-2xx, malformed, mismatched self-reference) returns false → the caller falls back to - * dynamic client registration. Never throws. - * - * `fetchImpl` is injectable for tests; defaults to the global `fetch`. + * Check that the hosted HTTPS document self-references its exact URL. Failure never + * selects DCR: auth-flow.ts reports an error when clientIdMode is 'document'. */ export async function isPublishedClientIdReachable( url: string, fetchImpl: typeof fetch = fetch, ): Promise { - if (!url || isPlaceholderClientId(url)) return false; try { - const parsed = new URL(url); - if (parsed.protocol !== 'https:') return false; // the published doc must be HTTPS. + if (new URL(url).protocol !== 'https:') return false; const response = await fetchImpl(url, { headers: { Accept: 'application/ld+json, application/json' }, + credentials: 'omit', + redirect: 'error', }); if (!response.ok) return false; - const doc = (await response.json()) as { client_id?: unknown }; - // Spec: the document's client_id MUST equal the URL it is served from. - return ( - typeof doc.client_id === 'string' && - doc.client_id.replace(/\/$/, '') === url.replace(/\/$/, '') - ); + const doc: unknown = await response.json(); + return typeof doc === 'object' && doc !== null && 'client_id' in doc && doc.client_id === url; } catch { return false; } diff --git a/apps/extension/src/background/session-store.ts b/apps/extension/src/background/session-store.ts index 41ed1b598..3b41d9df5 100644 --- a/apps/extension/src/background/session-store.ts +++ b/apps/extension/src/background/session-store.ts @@ -24,6 +24,7 @@ const KEYS = { profile: 'solid:profile', recentAccounts: 'solid:recent-accounts', clientIds: 'solid:client-ids', + clientIdMode: 'solid:client-id-mode', podOrigins: 'solid:pod-origins', grantedOrigins: 'solid:granted-origins', classGrants: 'solid:class-grants', @@ -148,6 +149,20 @@ async function rememberAccount(profile: StoredProfile): Promise { // --- Per-origin client IDs + pod origins (credential-boundary inputs) ------------------ +export type ClientIdMode = 'document' | 'dcr'; + +/** Keep login working until Jesse enables Pages (DESIGN §2 D2). */ +export const DEFAULT_CLIENT_ID_MODE: ClientIdMode = 'dcr'; + +/** Only extension contexts configure this setting; page messages cannot override it. */ +export async function loadClientIdMode(): Promise { + const result = await chrome.storage.local.get(KEYS.clientIdMode); + const mode: unknown = result[KEYS.clientIdMode]; + if (mode === undefined) return DEFAULT_CLIENT_ID_MODE; + if (mode === 'document' || mode === 'dcr') return mode; + throw new Error('Invalid clientIdMode setting; expected document or dcr.'); +} + export async function loadClientIds(): Promise> { return (await get>(KEYS.clientIds)) ?? {}; } diff --git a/apps/extension/src/manifest.json b/apps/extension/src/manifest.json index 88cbd5cb2..8d5340be6 100644 --- a/apps/extension/src/manifest.json +++ b/apps/extension/src/manifest.json @@ -2,6 +2,7 @@ "manifest_version": 3, "name": "Solid", "version": "0.2.0", + "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAivtuQP2w3AxZ/8dBdOYDSwtknAIMzs1TE2hmiCHXe9Zndt/zrn/MsBybc9E27jwZVn41hNKHI1zsFpIyaKezVBUQrSMzE/i6z3Qto4/oz2TBSWkiaz/f18V6qskPBZASoB3/slTTS+7Yx5JpaUOHbB7pN2lgLlKtyxHJ1H4+4htpPNltseZFPu/ID4yVGuLeGgfNUpM3IxMf6Ttt2syq+s24tCrNZY5YJoYPog2+9wTIOgBPvmar099++gxim84WfTtopyz2252cJRl/nkVk/fygGZFZ1PkbERmUWdgQ8XXl7uprfHAwyObCGdUbLnBEVNVVgburf/CsfI8G/WXK+wIDAQAB", "description": "Sign in to Solid at the browser level — injects window.solid (DPoP-authenticated fetch) and a pinned account menu.", "permissions": ["storage", "identity", "sidePanel", "contextMenus", "scripting"], "host_permissions": [""], diff --git a/apps/extension/test/auth-flow.test.ts b/apps/extension/test/auth-flow.test.ts new file mode 100644 index 000000000..486e1ece4 --- /dev/null +++ b/apps/extension/test/auth-flow.test.ts @@ -0,0 +1,188 @@ +// AUTHORED-BY GPT-6.1-Sol (Codex) +// Exercise the real auth entry point with fixture discovery, storage and chrome.identity. +// Cancel at the browser prompt: no real accounts, tokens, or external network calls. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { initiateLogin } from '../src/background/auth-flow'; +import { PUBLISHED_CLIENT_ID_URL } from '../src/background/client-id'; +import { renderClientIdDocument } from '../src/background/client-id-template'; +import type { AuthParams } from '../src/background/session-store'; + +const webId = 'https://alice.example/profile/card#me'; +const issuer = 'https://issuer.example/'; +const registrationEndpoint = `${issuer}register`; +const redirectUri = 'https://bicabcdahgfbkgmlidjpmiadmjlppikk.chromiumapp.org/'; +let storage: Map; +let fetchSpy: ReturnType>; +let launch: ReturnType; +let getRedirectURL: ReturnType; +let publishedResponse: () => Response; +let registrationResponse: () => Response; +let registrationEnabled: boolean; + +beforeEach(() => { + storage = new Map(); + publishedResponse = () => + Response.json(renderClientIdDocument('bicabcdahgfbkgmlidjpmiadmjlppikk')); + registrationResponse = () => Response.json({ client_id: 'fixture-dcr-client' }); + registrationEnabled = true; + getRedirectURL = vi.fn(() => redirectUri); + launch = vi.fn((_options, callback) => callback(undefined)); + vi.stubGlobal('chrome', { + runtime: {}, + identity: { getRedirectURL, launchWebAuthFlow: launch }, + storage: { + local: { + get: async (key: string) => (storage.has(key) ? { [key]: storage.get(key) } : {}), + set: async (items: Record) => { + for (const [key, value] of Object.entries(items)) storage.set(key, value); + }, + }, + }, + }); + fetchSpy = vi.fn(async (input) => { + const url = String(input); + if (url === webId) { + return new Response(`<${webId}> <${issuer}>.`); + } + if (url === `${issuer}.well-known/openid-configuration`) { + return Response.json({ + issuer, + authorization_endpoint: `${issuer}authorize`, + token_endpoint: `${issuer}token`, + ...(registrationEnabled ? { registration_endpoint: registrationEndpoint } : {}), + }); + } + if (url === PUBLISHED_CLIENT_ID_URL) return publishedResponse(); + if (url === registrationEndpoint) return registrationResponse(); + throw new Error(`Unexpected fixture fetch: ${url}`); + }); + vi.stubGlobal('fetch', fetchSpy); +}); + +afterEach(() => vi.unstubAllGlobals()); + +function registrationCalls() { + return fetchSpy.mock.calls.filter(([url]) => String(url) === registrationEndpoint); +} + +async function expectAuthorizationClient(clientId: string, pageClientId?: string) { + await expect(initiateLogin({ webId, clientId: pageClientId })).rejects.toThrow( + 'Authorization was cancelled', + ); + expect(launch).toHaveBeenCalledOnce(); + expect(getRedirectURL).toHaveBeenCalledWith(); + const authorization = new URL(launch.mock.calls[0][0].url); + expect(authorization.searchParams.get('client_id')).toBe(clientId); + expect(authorization.searchParams.get('redirect_uri')).toBe(redirectUri); + expect(authorization.searchParams.get('code_challenge_method')).toBe('S256'); + const params = storage.get('solid:auth-params') as AuthParams; + expect(params.clientId).toBe(clientId); + expect(params.redirectUri).toBe(redirectUri); +} + +describe('clientIdMode routing (real initiateLogin)', () => { + it('uses DCR by default while Pages enablement is pending, without probing the document', async () => { + await expectAuthorizationClient('fixture-dcr-client'); + expect(registrationCalls()).toHaveLength(1); + expect(fetchSpy.mock.calls.some(([url]) => url === PUBLISHED_CLIENT_ID_URL)).toBe(false); + expect(JSON.parse(String(registrationCalls()[0][1]?.body))).toMatchObject({ + redirect_uris: [redirectUri], + token_endpoint_auth_method: 'none', + scope: 'openid webid offline_access', + }); + }); + + it('uses the published document only in document mode, without DCR', async () => { + storage.set('solid:client-id-mode', 'document'); + await expectAuthorizationClient(PUBLISHED_CLIENT_ID_URL); + expect(registrationCalls()).toHaveLength(0); + }); + + it('uses DCR only when explicitly configured, even if the document is available', async () => { + storage.set('solid:client-id-mode', 'dcr'); + await expectAuthorizationClient('fixture-dcr-client'); + expect(registrationCalls()).toHaveLength(1); + expect(fetchSpy.mock.calls.some(([url]) => url === PUBLISHED_CLIENT_ID_URL)).toBe(false); + }); + + it.each([ + 'document', + 'dcr', + ])('preserves a page-declared Client ID Document in %s mode without registration', async (mode) => { + storage.set('solid:client-id-mode', mode); + await expectAuthorizationClient( + 'https://app.example/clientid.jsonld', + 'https://app.example/clientid.jsonld', + ); + expect(registrationCalls()).toHaveLength(0); + expect(fetchSpy.mock.calls.some(([url]) => url === PUBLISHED_CLIENT_ID_URL)).toBe(false); + }); + + it.each([ + () => new Response('not found', { status: 404 }), + () => new Response('not json'), + () => Response.json(null), + () => Response.json({ client_id: 'https://attacker.example/clientid.jsonld' }), + () => { + throw new Error('offline'); + }, + ])('never registers on document failure or changes an existing session', async (response) => { + storage.set('solid:client-id-mode', 'document'); + const existing = { marker: 'existing-session-fixture' }; + storage.set('solid:session', existing); + publishedResponse = response; + await expect(initiateLogin({ webId })).rejects.toThrow( + 'published Client ID Document is unavailable or invalid', + ); + expect(registrationCalls()).toHaveLength(0); + expect(launch).not.toHaveBeenCalled(); + expect(storage.has('solid:auth-params')).toBe(false); + expect(storage.get('solid:session')).toBe(existing); + }); + + it.each([ + null, + '', + 'DCR', + 'automatic', + false, + { mode: 'dcr' }, + ])('fails closed on malformed trusted settings (%j), without network or authorization', async (mode) => { + storage.set('solid:client-id-mode', mode); + await expect(initiateLogin({ webId })).rejects.toThrow('Invalid clientIdMode'); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(launch).not.toHaveBeenCalled(); + }); + + it('ignores a page-supplied clientIdMode field', async () => { + storage.set('solid:client-id-mode', 'document'); + publishedResponse = () => new Response('not found', { status: 404 }); + const pageOptions = { webId, clientIdMode: 'dcr' }; + await expect(initiateLogin(pageOptions)).rejects.toThrow( + 'published Client ID Document is unavailable or invalid', + ); + expect(registrationCalls()).toHaveLength(0); + expect(launch).not.toHaveBeenCalled(); + }); + + it('fails in DCR mode when discovery has no registration endpoint', async () => { + storage.set('solid:client-id-mode', 'dcr'); + registrationEnabled = false; + await expect(initiateLogin({ webId })).rejects.toThrow( + 'issuer offers no dynamic registration endpoint', + ); + expect(registrationCalls()).toHaveLength(0); + expect(launch).not.toHaveBeenCalled(); + }); + + it('surfaces DCR failure without trying another client identity', async () => { + storage.set('solid:client-id-mode', 'dcr'); + registrationResponse = () => new Response('rejected', { status: 400 }); + await expect(initiateLogin({ webId })).rejects.toThrow( + 'Dynamic client registration failed (400)', + ); + expect(registrationCalls()).toHaveLength(1); + expect(fetchSpy.mock.calls.some(([url]) => url === PUBLISHED_CLIENT_ID_URL)).toBe(false); + expect(launch).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/extension/test/client-id.test.ts b/apps/extension/test/client-id.test.ts index 734bfd673..03189fa3b 100644 --- a/apps/extension/test/client-id.test.ts +++ b/apps/extension/test/client-id.test.ts @@ -1,144 +1,157 @@ -// AUTHORED-BY Claude Opus 4.8 -/** - * Tests for the static Solid-OIDC Client Identifier Document + its in-code mirror. - * - * The published Client ID Document (`public/clientid.jsonld`, copied into the build) lets - * the extension log in with a STABLE named identity instead of throwaway dynamic - * registration. These assert: - * (1) the shipped JSON has every member Solid-OIDC requires, with the right values; - * (2) the in-code mirror CLIENT_ID_DOCUMENT agrees byte-for-byte with the shipped JSON - * (so the asset can never silently drift from what the flow expects); - * (3) the document's redirect_uri shape matches auth-flow.ts's chromiumapp.org callback; - * (4) the reachability probe is fail-closed: the shipped placeholder URL is treated as - * unreachable so initiateLogin() falls back to dynamic client registration. - */ - -import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { describe, expect, it } from 'vitest'; +// AUTHORED-BY GPT-6.1-Sol (Codex) +import { spawnSync } from 'node:child_process'; +import { createHash, createPublicKey } from 'node:crypto'; +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import { isPublishedClientIdReachable } from '../src/background/client-id'; import { - CLIENT_ID_DOCUMENT, CLIENT_ID_SCOPE, - isPlaceholderClientId, - isPublishedClientIdReachable, PUBLISHED_CLIENT_ID_URL, + renderClientIdDocument, SOLID_OIDC_CONTEXT, -} from '../src/background/client-id'; - -const shippedJson = JSON.parse( - readFileSync(fileURLToPath(new URL('../public/clientid.jsonld', import.meta.url)), 'utf8'), -) as Record; - -describe('the shipped Client Identifier Document (public/clientid.jsonld)', () => { - it('declares the Solid-OIDC @context', () => { +} from '../src/background/client-id-template'; + +const expectedId = 'bicabcdahgfbkgmlidjpmiadmjlppikk'; +const documentPath = new URL('../public/clientid.jsonld', import.meta.url); +const shippedJson = JSON.parse(readFileSync(documentPath, 'utf8')); + +// Compute independently of the build script, from the actual committed DER public key. +function extensionIdFromKey(key: string): string { + const der = Buffer.from(key, 'base64'); + expect(der.toString('base64')).toBe(key); + expect(createPublicKey({ key: der, format: 'der', type: 'spki' }).type).toBe('public'); + return [...createHash('sha256').update(der).digest().subarray(0, 16)] + .flatMap((byte) => [byte >> 4, byte & 15]) + .map((nibble) => String.fromCharCode(97 + nibble)) + .join(''); +} + +describe('generated Client Identifier Document', () => { + it('renders the required public-client fields from one template', () => { + expect(renderClientIdDocument(expectedId)).toEqual({ + '@context': 'https://www.w3.org/ns/solid/oidc-context.jsonld', + client_id: 'https://jeswr.github.io/solid-browser-extension/clientid.jsonld', + client_name: 'Solid Browser Extension', + client_uri: 'https://github.com/jeswr/solid-browser-extension', + logo_uri: 'https://jeswr.github.io/solid-browser-extension/icon128.png', + redirect_uris: [`https://${expectedId}.chromiumapp.org/`], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + scope: 'openid webid offline_access', + token_endpoint_auth_method: 'none', + application_type: 'web', + }); + expect(shippedJson).toEqual(renderClientIdDocument(expectedId)); + expect(shippedJson.client_id).toBe(PUBLISHED_CLIENT_ID_URL); expect(shippedJson['@context']).toBe(SOLID_OIDC_CONTEXT); - expect(SOLID_OIDC_CONTEXT).toBe('https://www.w3.org/ns/solid/oidc-context.jsonld'); - }); - - it('has the spec-required members for a public-client app', () => { - expect(typeof shippedJson.client_id).toBe('string'); - expect(shippedJson.client_name).toBe('Solid Browser Extension'); expect(shippedJson.scope).toBe(CLIENT_ID_SCOPE); - expect(shippedJson.scope).toBe('openid webid offline_access'); - // A browser-extension app is a PUBLIC client → no client secret → auth method "none". - expect(shippedJson.token_endpoint_auth_method).toBe('none'); - expect(shippedJson.grant_types).toEqual(['authorization_code', 'refresh_token']); - expect(shippedJson.response_types).toEqual(['code']); + expect(JSON.stringify(shippedJson)).not.toContain('REPLACE-ME'); }); - it('redirect_uris is the chromiumapp.org callback shape (matches auth-flow getRedirectURL("callback"))', () => { - const uris = shippedJson.redirect_uris as string[]; - expect(Array.isArray(uris)).toBe(true); - expect(uris).toHaveLength(1); - // chrome.identity.getRedirectURL('callback') => https://.chromiumapp.org/callback - expect(uris[0]).toMatch(/^https:\/\/[^/]+\.chromiumapp\.org\/callback$/); + it('computes the pinned ID from the manifest public key and matches the redirect URI', () => { + const manifest = JSON.parse( + readFileSync(new URL('../src/manifest.json', import.meta.url), 'utf8'), + ); + expect(manifest.key, 'The dispatcher public key must be pinned in the manifest').toBeTypeOf( + 'string', + ); + const id = extensionIdFromKey(manifest.key); + expect(id).toBe(expectedId); + expect(shippedJson.redirect_uris).toEqual([`https://${id}.chromiumapp.org/`]); }); - it('client_id self-references the published URL (spec: the doc client_id MUST equal its URL)', () => { - // Both are the placeholder today — the maintainer sets them to the real hosted URL. - expect(shippedJson.client_id).toBe(PUBLISHED_CLIENT_ID_URL); + it.each([ + { mode: 'render', args: [] }, + { mode: 'check', args: ['--check'] }, + ])('$mode uses the committed key without EXT_PUBKEY or dependencies', ({ args }) => { + const directory = mkdtempSync(join(tmpdir(), 'solid-client-id-')); + const paths = [ + 'package.json', + 'scripts/render-client-id.ts', + 'src/background/client-id-template.ts', + 'src/manifest.json', + 'public/clientid.jsonld', + ]; + const env = { ...process.env }; + delete env.EXT_PUBKEY; + try { + for (const path of paths) { + const destination = join(directory, path); + mkdirSync(dirname(destination), { recursive: true }); + copyFileSync(new URL(`../${path}`, import.meta.url), destination); + } + const result = spawnSync( + process.execPath, + [join(directory, 'scripts/render-client-id.ts'), ...args], + { cwd: directory, encoding: 'utf8', env }, + ); + expect(result.stderr).toBe(''); + expect(result.status).toBe(0); + for (const path of ['src/manifest.json', 'public/clientid.jsonld']) { + expect(readFileSync(join(directory, path), 'utf8')).toBe( + readFileSync(new URL(`../${path}`, import.meta.url), 'utf8'), + ); + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } }); - it('the in-code mirror CLIENT_ID_DOCUMENT agrees byte-for-byte with the shipped JSON', () => { - // The single source of truth check: if either side changes, this fails loudly. - expect(JSON.parse(JSON.stringify(CLIENT_ID_DOCUMENT))).toEqual(shippedJson); + it('substitutes a different valid ID rather than hardcoding the redirect', () => { + const id = 'abcdefghijklmnopabcdefghijklmnop'; + expect(renderClientIdDocument(id).redirect_uris).toEqual([`https://${id}.chromiumapp.org/`]); }); -}); -describe('PUBLISHED_CLIENT_ID_URL placeholder (needs:user — hosting)', () => { - it('is a clearly-marked, non-real placeholder so the maintainer must fill it in', () => { - expect(PUBLISHED_CLIENT_ID_URL).toContain('REPLACE-ME'); - expect(isPlaceholderClientId(PUBLISHED_CLIENT_ID_URL)).toBe(true); - // It IS at least a well-formed https: URL (so wiring/validation paths are exercised). - expect(() => new URL(PUBLISHED_CLIENT_ID_URL)).not.toThrow(); - expect(new URL(PUBLISHED_CLIENT_ID_URL).protocol).toBe('https:'); - }); + it.each([ + '', + 'a'.repeat(31), + 'a'.repeat(33), + 'z'.repeat(32), + '../evil', + expectedId.toUpperCase(), + ])('rejects an invalid extension ID (%s)', (id) => + expect(() => renderClientIdDocument(id)).toThrow('Invalid Chromium extension ID')); }); -describe('isPublishedClientIdReachable — fail-closed fallback to dynamic registration', () => { - it('returns false for the placeholder WITHOUT making a network call', async () => { - let called = false; - const fetchSpy = (async () => { - called = true; - return new Response('{}'); - }) as unknown as typeof fetch; - expect(await isPublishedClientIdReachable(PUBLISHED_CLIENT_ID_URL, fetchSpy)).toBe(false); - expect(called).toBe(false); // short-circuited on the placeholder sentinel +describe('Client ID Document reachability (fixture fetches only)', () => { + it.each([ + '', + 'invalid', + 'http://example.com/clientid.jsonld', + 'file:///clientid.jsonld', + ])('rejects invalid/non-HTTPS URLs before fetching: %s', async (url) => { + const fetchSpy = vi.fn(); + expect(await isPublishedClientIdReachable(url, fetchSpy)).toBe(false); + expect(fetchSpy).not.toHaveBeenCalled(); }); - it('returns false for an empty URL', async () => { - expect(await isPublishedClientIdReachable('')).toBe(false); - }); - - it('returns false for a non-HTTPS URL (the published doc must be HTTPS)', async () => { - const fetchSpy = (async () => new Response('{}')) as unknown as typeof fetch; - expect(await isPublishedClientIdReachable('http://example.com/cid.jsonld', fetchSpy)).toBe( - false, - ); - }); - - it('returns false on a non-2xx response', async () => { - const fetchSpy = (async () => - new Response('not found', { status: 404 })) as unknown as typeof fetch; - expect(await isPublishedClientIdReachable('https://real.example/cid.jsonld', fetchSpy)).toBe( - false, - ); - }); - - it('returns false when the doc client_id does NOT self-reference its URL', async () => { - const fetchSpy = (async () => - new Response(JSON.stringify({ client_id: 'https://other.example/x' }), { - status: 200, - })) as unknown as typeof fetch; - expect(await isPublishedClientIdReachable('https://real.example/cid.jsonld', fetchSpy)).toBe( - false, - ); - }); - - it('returns false on a network error (never throws)', async () => { - const fetchSpy = (async () => { - throw new Error('network down'); - }) as unknown as typeof fetch; - await expect( - isPublishedClientIdReachable('https://real.example/cid.jsonld', fetchSpy), - ).resolves.toBe(false); + it.each([ + () => new Response('not found', { status: 404 }), + () => new Response('not JSON'), + () => Response.json(null), + () => Response.json({}), + () => Response.json({ client_id: 42 }), + () => Response.json({ client_id: 'https://other.example/clientid.jsonld' }), + () => Response.json({ client_id: `${PUBLISHED_CLIENT_ID_URL}/` }), + ])('fails closed for an unavailable or invalid document', async (response) => { + const fetchSpy = vi.fn().mockResolvedValue(response()); + expect(await isPublishedClientIdReachable(PUBLISHED_CLIENT_ID_URL, fetchSpy)).toBe(false); }); - it('returns TRUE for a reachable, self-referencing https doc', async () => { - const url = 'https://real.example/cid.jsonld'; - const fetchSpy = (async () => - new Response(JSON.stringify({ client_id: url, client_name: 'X' }), { - status: 200, - })) as unknown as typeof fetch; - expect(await isPublishedClientIdReachable(url, fetchSpy)).toBe(true); + it('fails closed on network errors', async () => { + const fetchSpy = vi.fn().mockRejectedValue(new Error('network down')); + expect(await isPublishedClientIdReachable(PUBLISHED_CLIENT_ID_URL, fetchSpy)).toBe(false); }); - it('tolerates a trailing-slash difference in the self-reference', async () => { - const url = 'https://real.example/cid.jsonld/'; - const fetchSpy = (async () => - new Response(JSON.stringify({ client_id: 'https://real.example/cid.jsonld' }), { - status: 200, - })) as unknown as typeof fetch; - expect(await isPublishedClientIdReachable(url, fetchSpy)).toBe(true); + it('accepts an exact self-reference, omitting cookies and refusing redirects', async () => { + const fetchSpy = vi.fn().mockResolvedValue(Response.json(shippedJson)); + expect(await isPublishedClientIdReachable(PUBLISHED_CLIENT_ID_URL, fetchSpy)).toBe(true); + expect(fetchSpy).toHaveBeenCalledWith(PUBLISHED_CLIENT_ID_URL, { + headers: { Accept: 'application/ld+json, application/json' }, + credentials: 'omit', + redirect: 'error', + }); }); }); diff --git a/apps/extension/test/dev.test.ts b/apps/extension/test/dev.test.ts new file mode 100644 index 000000000..b3d09826c --- /dev/null +++ b/apps/extension/test/dev.test.ts @@ -0,0 +1,75 @@ +// AUTHORED-BY GPT-6.1-Sol (Codex) +import { EventEmitter } from 'node:events'; +import { afterEach, expect, it, vi } from 'vitest'; + +const { startTestSiteServer, goto, contextOn } = vi.hoisted(() => ({ + startTestSiteServer: vi.fn(async (_options: unknown) => ({ close: vi.fn() })), + goto: vi.fn(async (_url: string) => {}), + contextOn: vi.fn(), +})); + +// Execute the real interactive harness without starting processes, browsers, or listeners. +vi.mock('node:child_process', () => ({ + spawn: () => { + const child = Object.assign(new EventEmitter(), { stderr: new EventEmitter() }); + queueMicrotask(() => child.emit('close', 0)); + return child; + }, +})); +vi.mock('node:fs', () => ({ + default: { + mkdtempSync: () => '/tmp/solid-ext-dev-fixture', + readFileSync: () => '{}', + writeFileSync: vi.fn(), + }, +})); +vi.mock('node:http', () => ({ + default: { + get: (_url: string, callback: (response: { resume: () => void }) => void) => { + callback({ resume: vi.fn() }); + return Object.assign(new EventEmitter(), { setTimeout: vi.fn() }); + }, + }, +})); +vi.mock('playwright', () => ({ + chromium: { + launchPersistentContext: async () => ({ + serviceWorkers: () => [ + { + url: () => + 'chrome-extension://bicabcdahgfbkgmlidjpmiadmjlppikk/background/service-worker.js', + }, + ], + close: async () => {}, + pages: () => [{ goto }], + newPage: async () => ({ goto }), + on: contextOn, + }), + }, +})); +vi.mock('../test-site/server', () => ({ startTestSiteServer })); + +afterEach(() => vi.restoreAllMocks()); + +it('configures both development Client ID Documents with the root Chromium redirect', async () => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(process, 'on').mockReturnValue(process); + + await import('../scripts/dev'); + await vi.waitFor(() => expect(contextOn).toHaveBeenCalledWith('close', expect.any(Function))); + + expect(startTestSiteServer.mock.calls.map(([options]) => options)).toEqual([ + { + port: 8080, + redirectUri: 'https://bicabcdahgfbkgmlidjpmiadmjlppikk.chromiumapp.org/', + clientName: 'Solid Browser Extension', + }, + { + port: 8081, + redirectUri: 'https://bicabcdahgfbkgmlidjpmiadmjlppikk.chromiumapp.org/', + clientName: 'My Test App', + clientIdUrl: 'http://localhost:8081/client-id', + }, + ]); + expect(goto).toHaveBeenCalledWith('http://localhost:8081/with-client-id.html'); +}); diff --git a/apps/extension/webpack.config.cjs b/apps/extension/webpack.config.cjs index 8b2b5e4c9..43fbb0810 100644 --- a/apps/extension/webpack.config.cjs +++ b/apps/extension/webpack.config.cjs @@ -46,9 +46,7 @@ module.exports = { { from: 'src/consent/consent.html', to: 'consent/consent.html' }, { from: 'src/consent/consent.css', to: 'consent/consent.css' }, { from: 'src/icons', to: 'icons' }, - // The static Solid-OIDC Client Identifier Document (see client-id.ts). Shipped so a - // build can be hosted with the doc alongside; the in-flow client_id points at the - // eventual HOSTED URL (PUBLISHED_CLIENT_ID_URL), which is a needs:user placeholder. + // Generated from client-id-template.ts and the manifest's pinned public key. { from: 'public/clientid.jsonld', to: 'clientid.jsonld' }, ], }), From d861a3284b5873b30c4e17d5d6bfe73e05207f09 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:16:32 +0000 Subject: [PATCH 36/50] feat: ProfileStore with several WebIDs and per-profile sessions (EXT-03) (#7) * docs: record conservative profile semantics Co-Authored-By: GPT-6.1-Sol (Codex) * feat: add durable profile credential store Persist up to eight isolated profile sessions and non-extractable keys atomically. Migrate legacy state and recover interrupted removal. Enforce complete core coverage. Co-Authored-By: GPT-6.1-Sol (Codex) * feat: route worker sessions through profiles Migrate the legacy credentials once, scope grants and refresh to the owning profile, and expose trusted profile management messages. Co-Authored-By: GPT-6.1-Sol (Codex) * fix: keep profile worker startup free of DOM imports Compute the existing avatar initials locally so the worker can register its profile API without importing HTMLElement-based UI components. Co-Authored-By: GPT-6.1-Sol (Codex) * test: verify profile isolation and migration Exercise trusted profile messages, independent refresh and DPoP signatures, in-flight removal, delayed consent, legacy migration, and real-browser IndexedDB key custody. Co-Authored-By: GPT-6.1-Sol (Codex) * docs: describe profile storage and worker messages Document per-profile persistence, migration, default and logout behavior, and the extension-only switcher API. Update storage permission rationale. Co-Authored-By: GPT-6.1-Sol (Codex) * docs: record EXT-03 lane status Record delivered profile behavior, lane commits, final gate and browser results, unavailable package/review checks, and conservative interpretations. Co-Authored-By: GPT-6.1-Sol (Codex) * fix: keep async profile state consistent Bind broadcast identity and authorization to one profile context, recheck refresh credentials inside each flight, and correlate asynchronous failures with request IDs. Co-Authored-By: GPT-6.1-Sol (Codex) * fix: cancel profile fetches during lifecycle changes Track requests concurrently, decide passthrough before replica access, and abort outstanding work on logout, removal, or credential replacement. Keep network bodies outside the profile queue and commit replica bytes with metadata before purging. Co-Authored-By: GPT-6.1-Sol (Codex) * docs: record review fix validation Co-Authored-By: GPT-6.1-Sol (Codex) * fix: avoid replaying cancelled profile requests Track native fetch egress separately from cancellation so sent requests return an error while unsent automatic diversions retain passthrough. Cover POST cancellation by logout, removal and re-login before headers and during bodies. Co-Authored-By: GPT-6.1-Sol (Codex) * fix: serialize refresh failure credential checks Compare stored credential generation inside the profile lifecycle queue before cancellation and sign-out. Preserve re-login sessions and grants when an older refresh fails, including when only the refresh token changed. Co-Authored-By: GPT-6.1-Sol (Codex) * fix: discard stale profile toolbar updates Use the state-change generation to guard icon, badge and title mutations after image preparation and asynchronous toolbar calls. Route logout display updates through the same guarded path and cover delayed photos, fallback rendering and signed-out transitions. Co-Authored-By: GPT-6.1-Sol (Codex) * docs: record round 2 validation Record the three lifecycle fixes, 469-test package gate, preserved coverage, repeated existing popup e2e failures and unavailable auxiliary checks. Co-Authored-By: GPT-6.1-Sol (Codex) * fix: retain replica purge retries on removal Co-Authored-By: GPT-6.1-Sol (Codex) * fix: reset toolbar after refresh sign-out Co-Authored-By: GPT-6.1-Sol (Codex) * docs: record round 3 validation Co-Authored-By: GPT-6.1-Sol (Codex) * fix: publish sign-out when replica cleanup fails Co-Authored-By: GPT-6.1-Sol (Codex) * docs: record round 4 validation Co-Authored-By: GPT-6.1-Sol (Codex) * chore: remove lane report files * docs: drop lane QUESTIONS link --------- Co-authored-by: GPT-6.1-Sol (Codex) --- apps/extension/.gitignore | 1 + apps/extension/README.md | 49 +- apps/extension/docs/manifest-permissions.md | 2 +- apps/extension/e2e/tests/profiles.spec.ts | 132 ++ apps/extension/package-lock.json | 602 +++++++- apps/extension/package.json | 1 + apps/extension/src/background/action-icon.ts | 34 +- apps/extension/src/background/auth-flow.ts | 64 +- .../src/background/core/profile-store.ts | 284 ++++ apps/extension/src/background/core/replica.ts | 65 +- .../src/background/profile-authority.ts | 70 + apps/extension/src/background/profile-db.ts | 65 + .../src/background/service-worker.ts | 663 ++++++--- .../extension/src/background/session-store.ts | 6 +- apps/extension/src/shared/messages.ts | 27 + apps/extension/test/profile-auth-flow.test.ts | 143 ++ apps/extension/test/profile-db.test.ts | 156 ++ apps/extension/test/profile-fixtures.ts | 58 + apps/extension/test/profile-store.test.ts | 322 ++++ apps/extension/test/replica.test.ts | 29 +- .../test/service-worker-class-gate.test.ts | 73 +- .../test/service-worker-gate.test.ts | 1310 ++++++++++++++++- .../test/service-worker-replica.test.ts | 11 + apps/extension/vitest.config.ts | 7 + 24 files changed, 3813 insertions(+), 361 deletions(-) create mode 100644 apps/extension/e2e/tests/profiles.spec.ts create mode 100644 apps/extension/src/background/core/profile-store.ts create mode 100644 apps/extension/src/background/profile-authority.ts create mode 100644 apps/extension/src/background/profile-db.ts create mode 100644 apps/extension/test/profile-auth-flow.test.ts create mode 100644 apps/extension/test/profile-db.test.ts create mode 100644 apps/extension/test/profile-fixtures.ts create mode 100644 apps/extension/test/profile-store.test.ts diff --git a/apps/extension/.gitignore b/apps/extension/.gitignore index 7a1884dc0..ba811d906 100644 --- a/apps/extension/.gitignore +++ b/apps/extension/.gitignore @@ -4,3 +4,4 @@ dist/ .DS_Store test-results/ playwright-report/ +coverage/ diff --git a/apps/extension/README.md b/apps/extension/README.md index 829d16c5f..5043f954e 100644 --- a/apps/extension/README.md +++ b/apps/extension/README.md @@ -24,16 +24,16 @@ Four contexts, with the access token held in exactly one of them (the service wo (no credential) (trust boundary: (sole token ├─ core/authenticated-fetch.ts (the boundary) stamps real origin) holder) ├─ core/dpop.ts (RFC 9449 proofs, Web Crypto) ├─ core/origin-policy.ts (fail-closed gate) - Popup = the account UI (@jeswr/solid-elements) ⇄ chrome.runtime ├─ session-store.ts (chrome.storage) + Popup = the account UI (@jeswr/solid-elements) ⇄ chrome.runtime ├─ core/profile-store.ts + profile-db.ts (IndexedDB) └─ action-icon.ts (avatar toolbar icon) ``` - **The page never sees a credential.** `window.solid` (MAIN world) can only `postMessage` to the content script, which relays to the worker. The page gets back the WebID and proxied `Response`s — never the access or refresh token. -- **The service worker is the sole token holder.** Tokens live in `chrome.storage.local` - and the DPoP private key lives as a **non-extractable** `CryptoKey` in the worker's own - IndexedDB (`core/dpop-key-db.ts`) — neither is reachable from any web page, satisfying +- **The service worker is the sole token holder.** Each profile's tokens and DPoP keypair + live together in the worker's IndexedDB (`core/profile-store.ts`, `profile-db.ts`), with + the private key held as a **non-extractable** `CryptoKey` — neither is reachable from any web page, satisfying the suite's "DPoP refresh token in non-page-reachable secure storage" invariant, and the private key additionally cannot be exported to JWK by ANY code (not even a compromised extension context — `crypto.subtle.exportKey` on it always rejects). @@ -118,16 +118,37 @@ Explicit `solid.fetch` calls retain the worker's denial response. imported with `EXT_PUBKEY=/path/to/extension-pubkey.b64 npm run generate:client-id`. The public key belongs in `manifest.key`; the extension signing private key stays with Jesse. A page may still declare its own Client ID Document via `solid.setClientId(...)`. -- **Session:** the access + refresh token are persisted in `chrome.storage.local`; the - DPoP keypair is persisted separately as a **non-extractable** `CryptoKey` object directly - in IndexedDB (`core/dpop-key-db.ts`) — non-extractable `CryptoKey`s are structured-clone - serialisable, so the object survives the MV3 worker's aggressive kill/restart cycle - without ever being exported to JWK. The worker proactively refreshes ~30 s before expiry - via the DPoP refresh grant, reusing the **same** persisted keypair (`jkt`-bound refresh). -- **Silent restore:** reopening the browser re-hydrates the session from the persisted - refresh token (no popup). The restore decision logic is the suite's audited - [`@jeswr/solid-session-restore`](https://github.com/jeswr/solid-session-restore) (fail-closed, - WebID-scoped). +- **Profiles:** `ProfileStore` supports up to eight distinct WebIDs. Each profile has its own + access/refresh token set, non-extractable DPoP keypair, pod settings and grants, persisted + atomically in IndexedDB under the profile id. The current single session migrates to profile + `1`, keeping its matching key. A missing/mismatched key retains the display entry and requires + re-login. Legacy token/key storage is cleared only after migration commits. +- **Default and restore:** the default profile supplies the existing account surface and page + APIs. Requests capture that profile's id; a default switch cannot substitute another + profile's credentials mid-request. Refresh is single-flight per profile, reuses its persisted + key, and commits rotated tokens before use. Reopening the worker restores from IndexedDB. +- **Removal and logout:** removing a profile revokes its grants, cancels pending replica fetches, + finishes local replica commits, purges its WebID's replica slices and deletes its credentials. + Interrupted removal resumes on worker wake. Logout signs out only the default profile, retaining its display entry; other + profiles keep their own sessions. One default exists whenever the store is nonempty. + +The popup/side-panel profile switcher is a separate UI task. Its worker API accepts the +following messages **only from browser-attested extension contexts**, returning display-only +`{ ok, profiles, defaultProfileId }` or `{ error }` (never tokens or key objects): + +| Message | Fields | +|---|---| +| `SOLID_PROFILES_LIST` | none | +| `SOLID_PROFILE_ADD` | `webId` (interactive login; keeps the existing default) | +| `SOLID_PROFILE_RENAME` | `profileId`, `label` | +| `SOLID_PROFILE_REMOVE` | `profileId` | +| `SOLID_PROFILE_SET_DEFAULT` | `profileId` | + +A successful legacy `SOLID_LOGIN` selects the logged-in profile as default. Re-login updates +only the same WebID. Removing the default selects the oldest survivor; an empty store has no +default. Per-origin +profile bindings and the consent profile picker belong to EXT-05; this lane scopes the existing +grants to each profile without adding that origin-grant model. The trusted `clientIdMode` setting is stored as `solid:client-id-mode` in `chrome.storage.local`. It accepts `document` or `dcr`, defaulting to `dcr` while Pages diff --git a/apps/extension/docs/manifest-permissions.md b/apps/extension/docs/manifest-permissions.md index a243e4e66..b2328dbae 100644 --- a/apps/extension/docs/manifest-permissions.md +++ b/apps/extension/docs/manifest-permissions.md @@ -11,7 +11,7 @@ Reviewed against the shared-replica design `docs/design/shared-replica-architect | Permission | Why it is required | Could it be narrowed? | |---|---|---| -| `storage` | The home of the session tokens, profile, recent accounts, per-origin client-ids, and the **granted-origins grant store** (`chrome.storage.local`, page-unreachable). The DPoP **private key** itself no longer lives here — it is a non-extractable `CryptoKey` in the worker's own IndexedDB (`core/dpop-key-db.ts`, an unpermissioned Web Platform API always available to extension contexts); only the public JWK ever touches storage, and only inside the (page-unreachable) IndexedDB record. | No — load-bearing for the credential boundary. | +| `storage` | Stores recent-account display metadata, per-origin client ids, transient PKCE parameters and onboarding settings in `chrome.storage.local`. Legacy session/grant/pod settings are read once for migration. Per-profile tokens, grants, pod settings and non-extractable DPoP `CryptoKey`s now live atomically in worker-owned IndexedDB (`profile-db.ts`, an unpermissioned Web Platform API); legacy credentials and the singleton key record are cleared after that transaction commits. | No — still needed for settings and the auth redirect. | | `identity` | `chrome.identity.launchWebAuthFlow` drives the Solid-OIDC auth-code + PKCE redirect (a service worker has no `window`). | No. | | `sidePanel` | The persistent side-panel account surface. | No. | | `contextMenus` | The right-click "Open Solid side panel" entry. | No. | diff --git a/apps/extension/e2e/tests/profiles.spec.ts b/apps/extension/e2e/tests/profiles.spec.ts new file mode 100644 index 000000000..ffb386664 --- /dev/null +++ b/apps/extension/e2e/tests/profiles.spec.ts @@ -0,0 +1,132 @@ +/// +// AUTHORED-BY GPT-6.1-Sol (Codex) +import type { ProfileRecord } from '../../src/background/core/profile-store'; +import { expect, test } from '../fixtures'; + +/** Browser fixtures stay inside the worker; no fixture credential crosses the message API. */ +test('profile management persists isolated non-extractable credentials in worker IndexedDB', async ({ + context, + extensionId, +}) => { + const page = await context.newPage(); + await page.goto(`chrome-extension://${extensionId}/popup/popup.html`); + const message = (request: Record) => + page.evaluate((value) => chrome.runtime.sendMessage(value), request); + expect((await message({ type: 'SOLID_PROFILES_LIST' })).profiles).toEqual([]); + + const worker = context.serviceWorkers()[0]; + await worker.evaluate(async () => { + const records: ProfileRecord[] = []; + for (const id of ['1', '2']) { + const keyPair = await crypto.subtle.generateKey( + { name: 'ECDSA', namedCurve: 'P-256' }, + false, + ['sign', 'verify'], + ); + const webId = `https://fixture.invalid/profile-${id}#me`; + records.push({ + profile: { + id, + webId, + issuer: 'https://fixture.invalid/', + label: `Profile ${id}`, + sessionState: 'active', + dpopKeyRef: id, + }, + session: { + webId, + issuer: 'https://fixture.invalid/', + accessToken: `fixture-access-${id}`, + refreshToken: `fixture-refresh-${id}`, + tokenEndpoint: 'https://fixture.invalid/token', + clientId: 'https://fixture.invalid/client', + expiresAt: Date.now() + 3_600_000, + }, + keyPair, + grantedOrigins: [], + podOrigins: [], + classGrants: [], + }); + } + const db = await new Promise((resolve, reject) => { + const req = indexedDB.open('solid-ext-profiles', 1); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + try { + await new Promise((resolve, reject) => { + const tx = db.transaction(['profiles', 'metadata'], 'readwrite'); + for (const record of records) tx.objectStore('profiles').put(record, record.profile.id); + tx.objectStore('metadata').put( + { defaultProfileId: '1', nextId: 3, migrated: true }, + 'state', + ); + tx.oncomplete = () => resolve(); + tx.onabort = tx.onerror = () => reject(tx.error); + }); + } finally { + db.close(); + } + }); + + const list = await message({ type: 'SOLID_PROFILES_LIST' }); + expect(list.profiles).toHaveLength(2); + expect(list.defaultProfileId).toBe('1'); + expect(JSON.stringify(list)).not.toMatch( + /accessToken|refreshToken|privateKey|fixture-access|fixture-refresh/, + ); + expect((await message({ type: 'SOLID_PROFILE_RENAME', profileId: '2', label: 'Work' })).ok).toBe( + true, + ); + expect( + (await message({ type: 'SOLID_PROFILE_SET_DEFAULT', profileId: '2' })).defaultProfileId, + ).toBe('2'); + await page.reload(); + expect((await message({ type: 'SOLID_GET_STATE' })).name).toBe('Work'); + expect((await message({ type: 'SOLID_PROFILE_REMOVE', profileId: '1' })).profiles).toHaveLength( + 1, + ); + + // A real IndexedDB close/reopen returns a usable, non-exportable private CryptoKey. + const custody = await worker.evaluate(async () => { + const db = await new Promise((resolve, reject) => { + const req = indexedDB.open('solid-ext-profiles', 1); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + try { + const record = await new Promise<{ keyPair: CryptoKeyPair }>((resolve, reject) => { + const req = db.transaction('profiles', 'readonly').objectStore('profiles').get('2'); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + const data = new TextEncoder().encode('fixture-signature'); + const signature = await crypto.subtle.sign( + { name: 'ECDSA', hash: 'SHA-256' }, + record.keyPair.privateKey, + data, + ); + let exportRejected = false; + try { + await crypto.subtle.exportKey('jwk', record.keyPair.privateKey); + } catch { + exportRejected = true; + } + return { + exportRejected, + valid: await crypto.subtle.verify( + { name: 'ECDSA', hash: 'SHA-256' }, + record.keyPair.publicKey, + signature, + data, + ), + }; + } finally { + db.close(); + } + }); + expect(custody).toEqual({ exportRejected: true, valid: true }); + expect( + (await message({ type: 'SOLID_PROFILE_REMOVE', profileId: '2' })).defaultProfileId, + ).toBeNull(); +}); diff --git a/apps/extension/package-lock.json b/apps/extension/package-lock.json index f63f81598..ee865401f 100644 --- a/apps/extension/package-lock.json +++ b/apps/extension/package-lock.json @@ -23,6 +23,7 @@ "@solid/community-server": "^7.1.3", "@types/chrome": "^0.0.287", "@types/node": "^24.0.0", + "@vitest/coverage-v8": "2.1.9", "copy-webpack-plugin": "^12.0.2", "http-server": "^14.1.1", "jsdom": "^25.0.1", @@ -34,6 +35,31 @@ "webpack-cli": "^6.0.1" } }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@ampproject/remapping/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", @@ -48,6 +74,63 @@ "lru-cache": "^10.4.3" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.11.tgz", + "integrity": "sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.11.tgz", + "integrity": "sha512-OZ6+oaK7GMyg3IfCpj8JOmn8n+w2rJbDT0LLfVDTLb1ZFeKDh3AiNELgCMUw175IJHSkLl74hG0M0VLsvx+gag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.11" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.11.tgz", + "integrity": "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.11", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, "node_modules/@bergos/jsonparse": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/@bergos/jsonparse/-/jsonparse-1.4.2.tgz", @@ -3139,7 +3222,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -3163,7 +3245,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -3798,6 +3879,109 @@ "dev": true, "license": "MIT" }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/@isaacs/ttlcache": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz", @@ -3808,6 +3992,16 @@ "node": ">=12" } }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@jeswr/prefixcc": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@jeswr/prefixcc/-/prefixcc-1.2.1.tgz", @@ -3833,7 +4027,7 @@ }, "node_modules/@jeswr/solid-dpop": { "version": "0.1.0", - "resolved": "git+https://github.com/jeswr/solid-dpop.git#4bc5309fab6fe837a942581d331da90be2d552e3", + "resolved": "git+ssh://git@github.com/jeswr/solid-dpop.git#4bc5309fab6fe837a942581d331da90be2d552e3", "integrity": "sha512-WgfFz+Cqq3R2WwH4XuFOs5SXaumB4B0Aq/tApnVM9TLLaw1azhYB0Q820/Js0K0PR7n/HBNw1TT3AUwlTj8h2g==", "license": "MIT", "dependencies": { @@ -3854,7 +4048,7 @@ }, "node_modules/@jeswr/solid-elements": { "version": "0.1.0", - "resolved": "git+https://github.com/jeswr/solid-elements.git#06c12e9383e3e4be45ec236b30f02fd8fdc53dbc", + "resolved": "git+ssh://git@github.com/jeswr/solid-elements.git#06c12e9383e3e4be45ec236b30f02fd8fdc53dbc", "integrity": "sha512-lTBLslxVknu9f0b1urqqkw5HHviwFqjvu5PEyf8VmehRtaMBdPmFOhfjycRRpO5SoFRsV4cVnAsCmiI19Up4kQ==", "license": "MIT", "dependencies": { @@ -3907,10 +4101,9 @@ }, "node_modules/@jeswr/solid-session-restore": { "version": "0.1.0", - "resolved": "git+https://github.com/jeswr/solid-session-restore.git#1a6aedc52cd4b6cbd61859e0098034cf4a7e8282", + "resolved": "git+ssh://git@github.com/jeswr/solid-session-restore.git#1a6aedc52cd4b6cbd61859e0098034cf4a7e8282", "integrity": "sha512-e9JtZumGrdeU4chsoVapv0kGhw2BabKWBn60md/03qaERReOwv4X9sapIzlV63oQSVS2CGU/bb3YQVfQ0k9vrA==", "license": "MIT", - "peer": true, "dependencies": { "dpop": "^2.1.1", "oauth4webapi": "^3.8.6" @@ -4083,6 +4276,17 @@ "node": ">= 8" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@playwright/test": { "version": "1.61.0", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz", @@ -4771,7 +4975,6 @@ "resolved": "https://registry.npmjs.org/@solid/object/-/object-0.6.0.tgz", "integrity": "sha512-Ybme2HjbeWKZ6jOyYxacsFnAC6wPaifnSNO39bYW7PdbTEwgT56jG0Rp3TAWzXJvRjBzqMErUwvOMpqd8sVeOw==", "license": "MIT", - "peer": true, "dependencies": { "@rdfjs/wrapper": "^0.34.0" }, @@ -5147,7 +5350,6 @@ "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -5371,6 +5573,39 @@ "dev": true, "license": "MIT" }, + "node_modules/@vitest/coverage-v8": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.9.tgz", + "integrity": "sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^0.2.3", + "debug": "^4.3.7", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.12", + "magicast": "^0.3.5", + "std-env": "^3.8.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "2.1.9", + "vitest": "2.1.9" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", @@ -5738,7 +5973,6 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6063,7 +6297,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -6695,7 +6928,8 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/data-urls": { "version": "5.0.0", @@ -6921,7 +7155,6 @@ "resolved": "https://registry.npmjs.org/dpop/-/dpop-2.1.1.tgz", "integrity": "sha512-J0Of2JTiM4h5si0tlbPQ/lkqfZ5wAEVkKYBhkwyyANnPJfWH4VsR5uIkZ+T+OSPIwDYUg1fbd5Mmodd25HjY1w==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/panva" } @@ -6941,6 +7174,13 @@ "node": ">= 0.4" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -7176,7 +7416,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7693,6 +7932,36 @@ } } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/form-data": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", @@ -7842,6 +8111,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -7862,6 +8153,32 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -8088,6 +8405,13 @@ "node": ">=12" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/htmlparser2": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", @@ -8591,6 +8915,87 @@ "node": ">=0.10.0" } }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jake": { "version": "10.9.4", "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", @@ -9183,6 +9588,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", @@ -9367,6 +9800,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -9379,7 +9822,6 @@ "resolved": "https://registry.npmjs.org/n3/-/n3-2.0.4.tgz", "integrity": "sha512-d2Sr4vvqombySfOARjw5jQky0EYMwkEXuQQjYe2glasDdI3hAT3l00aJZbAmR+AA59eFNjJbDkHCq9l5Dm2DIg==", "license": "MIT", - "peer": true, "dependencies": { "buffer": "^6.0.3", "readable-stream": "^4.0.0" @@ -9538,7 +9980,6 @@ "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz", "integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/panva" } @@ -9730,6 +10171,13 @@ "node": ">=6" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -9806,6 +10254,23 @@ "dev": true, "license": "MIT" }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/path-to-regexp": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", @@ -10872,7 +11337,6 @@ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -11470,6 +11934,22 @@ "node": ">=8" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -11483,6 +11963,20 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -11549,7 +12043,6 @@ "integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -11635,6 +12128,60 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", @@ -11930,7 +12477,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12318,7 +12864,6 @@ "integrity": "sha512-v7RhXaJbpMlV0D7hC7lb2EbnxkoeUqf9qhKr6lozx3Q48pmFrqqNRmZFUEGmi7pSwm6fCQ2H1IjvCkHqdpVdjQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", @@ -12673,6 +13218,25 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/apps/extension/package.json b/apps/extension/package.json index 34e41e169..6b07c9c52 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -34,6 +34,7 @@ "@solid/community-server": "^7.1.3", "@types/chrome": "^0.0.287", "@types/node": "^24.0.0", + "@vitest/coverage-v8": "2.1.9", "copy-webpack-plugin": "^12.0.2", "http-server": "^14.1.1", "jsdom": "^25.0.1", diff --git a/apps/extension/src/background/action-icon.ts b/apps/extension/src/background/action-icon.ts index a19ea0a0c..504e419e3 100644 --- a/apps/extension/src/background/action-icon.ts +++ b/apps/extension/src/background/action-icon.ts @@ -10,7 +10,15 @@ * broken. The badge is a tiny dot indicating signed-in (green) / signed-out (cleared). */ -import { initials } from '@jeswr/solid-elements'; +// Keep the worker free of the UI entry point: importing Lit components requires +// HTMLElement and aborts worker startup before the profile message handlers register. +// Match solid-elements' initials fallback without importing its component registry. +function initials(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return '?'; + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); +} const SIZES = [16, 32, 48, 128] as const; @@ -69,11 +77,14 @@ async function drawPhoto(size: number, photoUrl: string): Promise { * Set the toolbar action icon to the signed-in user's avatar (photo → initials fallback) * and a green status dot. Best-effort: failures leave the previous/default icon intact. */ -export async function setSignedInIcon(profile: { - webId: string; - name: string | null; - photoUrl: string | null; -}): Promise { +export async function setSignedInIcon( + profile: { + webId: string; + name: string | null; + photoUrl: string | null; + }, + isCurrent: () => boolean, +): Promise { const label = profile.name ?? profile.webId; const imageData: Record = {}; @@ -94,9 +105,15 @@ export async function setSignedInIcon(profile: { } try { + // Image preparation can outlive a default change. Recheck after every await + // before mutating any part of the toolbar's captured identity. + if (!isCurrent()) return; await chrome.action.setIcon({ imageData }); + if (!isCurrent()) return; await chrome.action.setBadgeBackgroundColor({ color: '#2e9e57' }); + if (!isCurrent()) return; await chrome.action.setBadgeText({ text: ' ' }); + if (!isCurrent()) return; await chrome.action.setTitle({ title: `Signed in as ${label}` }); } catch { // ignore — icon update is cosmetic. @@ -104,8 +121,9 @@ export async function setSignedInIcon(profile: { } /** Reset the action icon + badge to the signed-out (default) state. */ -export async function setSignedOutIcon(): Promise { +export async function setSignedOutIcon(isCurrent: () => boolean): Promise { try { + if (!isCurrent()) return; await chrome.action.setIcon({ path: { 16: 'icons/icon16.png', @@ -114,7 +132,9 @@ export async function setSignedOutIcon(): Promise { 128: 'icons/icon128.png', }, }); + if (!isCurrent()) return; await chrome.action.setBadgeText({ text: '' }); + if (!isCurrent()) return; await chrome.action.setTitle({ title: 'Sign in to Solid' }); } catch { // ignore. diff --git a/apps/extension/src/background/auth-flow.ts b/apps/extension/src/background/auth-flow.ts index 641f739d1..715d6f12c 100644 --- a/apps/extension/src/background/auth-flow.ts +++ b/apps/extension/src/background/auth-flow.ts @@ -21,8 +21,6 @@ import { PUBLISHED_CLIENT_ID_URL, } from './client-id'; import { createDpopProof, dpopThumbprint, generateDpopKeyPair } from './core/dpop'; -import { DpopKeyDb } from './core/dpop-key-db'; -import { loadSessionDpopKeyPair, persistDpopKeyPair } from './core/dpop-key-store'; import { isLoopbackHost } from './core/origin-policy'; import { parseWebIdProfile, selectIssuer } from './core/webid'; import { @@ -32,7 +30,6 @@ import { loadClientIdMode, type StoredSession, saveAuthParams, - saveSession, } from './session-store'; const SCOPE = CLIENT_ID_SCOPE; @@ -124,12 +121,15 @@ export interface InitiateLoginOptions { } /** - * Run the interactive authorization-code (DPoP) login for a WebID and persist the - * resulting session. Returns the WebID + display profile parsed from the same fetch. + * Run the interactive authorization-code (DPoP) login for a WebID. The worker commits the + * returned token set and key together in its ProfileStore, alongside display metadata. */ -export async function initiateLogin( - options: InitiateLoginOptions, -): Promise<{ session: StoredSession; name: string | null; photoUrl: string | null }> { +export async function initiateLogin(options: InitiateLoginOptions): Promise<{ + session: StoredSession; + keyPair: CryptoKeyPair; + name: string | null; + photoUrl: string | null; +}> { const { webId } = options; const clientIdMode = await loadClientIdMode(); const turtle = await fetchProfile(webId); @@ -194,12 +194,14 @@ export async function initiateLogin( }); }); - const session = await completeLogin(callbackUrl); - return { session, name: profile.name, photoUrl: profile.photoUrl }; + const credentials = await completeLogin(callbackUrl); + return { ...credentials, name: profile.name, photoUrl: profile.photoUrl }; } /** Exchange the authorization code (from the callback URL) for a DPoP-bound token set. */ -async function completeLogin(callbackUrl: string): Promise { +async function completeLogin( + callbackUrl: string, +): Promise<{ session: StoredSession; keyPair: CryptoKeyPair }> { const callback = new URL(callbackUrl).searchParams; const code = callback.get('code'); const returnedState = callback.get('state'); @@ -222,9 +224,8 @@ async function completeLogin(callbackUrl: string): Promise { } // Generate the keypair EARLY (needed to sign the token-exchange DPoP proof itself), but do - // NOT persist it until the exchange actually succeeds — see below. A failed/retried login - // must never clobber an existing, still-valid session's key in this single-record store - // (roborev round-1 Medium). + // return it only after the exchange succeeds. The worker commits it with its token set; + // a failed login never overwrites any existing profile's key. const keyPair = await generateDpopKeyPair(); const dpopProof = await createDpopProof({ keyPair, @@ -258,12 +259,7 @@ async function completeLogin(callbackUrl: string): Promise { const webId = (idToken.webid as string) || (idToken.sub as string); if (!webId) throw new Error('No WebID in the ID token.'); - // The exchange succeeded — `keyPair` is now genuinely bound to a real token set. ONLY now - // persist it (overwriting whatever was there) and record its thumbprint on the session. - const keyDb = await DpopKeyDb.open(); - await persistDpopKeyPair(keyDb, keyPair); - keyDb.close(); - + // The worker persists this key and its token set atomically under the owning profile id. const session: StoredSession = { webId, accessToken: tokens.access_token, @@ -275,28 +271,19 @@ async function completeLogin(callbackUrl: string): Promise { expiresAt: Date.now() + (tokens.expires_in ?? 600) * 1000, }; - await saveSession(session); await clearAuthParams(); - return session; + return { session, keyPair }; } -/** - * Refresh an expiring session via the DPoP-bound refresh-token grant, reusing the SAME - * persisted keypair (CSS binds the refresh token to the original `jkt`, so a regenerated - * key fails) — `loadSessionDpopKeyPair` verifies the persisted key's thumbprint against - * `session.dpopJkt` and returns it unchanged in the normal case. It throws - * `DpopKeyUnavailableError` (propagated to the caller, handled like any other refresh - * failure — see `service-worker.ts` `ensureSession`) if the persisted key is missing or - * doesn't match a CURRENT-shape session; it mints a NEW key ONLY for the one-time legacy - * migration (a pre-hardening session with no recorded `dpopJkt`), which deliberately - * invalidates this grant — see `dpop-key-store.ts`. One retry on a server DPoP-nonce - * challenge. - */ -export async function refreshSession(session: StoredSession): Promise { +/** Refresh only the supplied profile's token set using its own persisted DPoP key. */ +export async function refreshSession( + session: StoredSession, + keyPair: CryptoKeyPair, +): Promise { if (!session.refreshToken) throw new Error('No refresh token; re-login required.'); - const keyDb = await DpopKeyDb.open(); - const keyPair = await loadSessionDpopKeyPair(keyDb, session); - keyDb.close(); + if (!session.dpopJkt || session.dpopJkt !== (await dpopThumbprint(keyPair.publicKey))) { + throw new Error('Session does not match the profile DPoP key.'); + } const grant = async (nonce?: string): Promise => { const proof = await createDpopProof({ @@ -338,6 +325,5 @@ export async function refreshSession(session: StoredSession): Promise; + write(state: ProfileSnapshot): Promise; +} + +export interface LegacyProfile { + session: StoredSession | null; + display: StoredProfile | null; + keyPair: CryptoKeyPair | null; + grantedOrigins: string[]; + podOrigins: string[]; + classGrants: ClassGrant[]; +} + +export const MAX_PROFILES = 8; + +export class ProfileStore { + private tail: Promise = Promise.resolve(); + + constructor( + private readonly persistence: ProfilePersistence, + private readonly purge: (webId: string) => Promise, + ) {} + + /** All read/modify/write sequences share one worker-owned writer, including failures. */ + private locked(op: () => Promise): Promise { + const run = this.tail.then(op); + this.tail = run.catch(() => {}); + return run; + } + + /** Commit migration before deleting legacy storage; cleanup is safe to retry on wake. */ + initialise(loadLegacy: () => Promise, clearLegacy: () => Promise) { + return this.locked(async () => { + const state = await this.persistence.read(); + if (!state.migrated) { + const legacy = await loadLegacy(); + if (legacy.session) { + const { session, keyPair } = legacy; + const valid = await this.keyMatches(session, keyPair); + const migrated = valid + ? { ...session, dpopJkt: await dpopThumbprint((keyPair as CryptoKeyPair).publicKey) } + : session; + state.records.push(this.record('1', migrated, valid ? keyPair : null, legacy.display)); + const record = state.records[0]; + if (valid) { + record.grantedOrigins = legacy.grantedOrigins; + record.podOrigins = legacy.podOrigins; + record.classGrants = legacy.classGrants.filter((g) => g.webId === session.webId); + } + state.defaultProfileId = '1'; + state.nextId = 2; + } + state.migrated = true; + await this.persistence.write(state); + } + await clearLegacy(); + // A worker may have died between revocation and the byte purge. Finish before serving. + for (const record of state.records) { + if (record.profile.sessionState === 'removing') await this.finishRemoval(state, record); + else if (record.profile.sessionState === 'signed-out') + await this.purge(record.profile.webId); + } + }); + } + + async list(): Promise<{ profiles: Profile[]; defaultProfileId: string | null }> { + const state = await this.persistence.read(); + return { + profiles: state.records.map(({ profile: p }) => ({ + id: p.id, + webId: p.webId, + issuer: p.issuer, + label: p.label, + avatar: p.avatar, + sessionState: p.sessionState, + dpopKeyRef: p.dpopKeyRef, + })), + defaultProfileId: state.defaultProfileId, + }; + } + + async get(id: string): Promise { + return this.find(await this.persistence.read(), id); + } + + async getDefault(): Promise { + const state = await this.persistence.read(); + return state.defaultProfileId === null ? null : this.find(state, state.defaultProfileId); + } + + /** The supplied id is mandatory: never fall back to a different profile's credentials. */ + async session(id: string): Promise<{ session: StoredSession; keyPair: CryptoKeyPair } | null> { + const record = (await this.persistence.read()).records.find((r) => r.profile.id === id); + if (record?.profile.sessionState !== 'active' || !record.session) return null; + if ( + record.session.webId !== record.profile.webId || + record.session.issuer !== record.profile.issuer || + !(await this.keyMatches(record.session, record.keyPair)) + ) { + await this.signOut(id); + return null; + } + return { session: record.session, keyPair: record.keyPair as CryptoKeyPair }; + } + + /** Re-login replaces only the same WebID. New WebIDs get fresh, empty capability scopes. */ + login(session: StoredSession, keyPair: CryptoKeyPair, display: StoredProfile): Promise { + return this.locked(async () => { + if (!(await this.keyMatches(session, keyPair))) throw new Error('Invalid profile DPoP key.'); + const state = await this.persistence.read(); + const existing = state.records.find((r) => r.profile.webId === session.webId); + if (existing?.profile.sessionState === 'removing') + throw new Error('Profile removal pending.'); + if (!existing && state.records.length >= MAX_PROFILES) throw new Error('At most 8 profiles.'); + if (existing?.profile.sessionState === 'signed-out') await this.purge(existing.profile.webId); + const id = existing ? existing.profile.id : String(state.nextId++); + const bound = { ...session, dpopJkt: await dpopThumbprint(keyPair.publicKey) }; + const record = this.record(id, bound, keyPair, display); + if (existing) { + record.profile = { ...record.profile, label: existing.profile.label }; + record.grantedOrigins = existing.grantedOrigins; + record.podOrigins = existing.podOrigins; + record.classGrants = existing.classGrants; + state.records[state.records.indexOf(existing)] = record; + } else { + state.records.push(record); + } + state.defaultProfileId ??= id; + await this.persistence.write(state); + return { ...record.profile }; + }); + } + + rename(id: string, label: string): Promise { + return this.change(id, (record) => { + if (typeof label !== 'string' || !label.trim() || label.length > 200) { + throw new Error('Profile label must contain 1–200 characters.'); + } + record.profile = { ...record.profile, label: label.trim() }; + }); + } + + setDefault(id: string): Promise { + return this.locked(async () => { + const state = await this.persistence.read(); + const record = this.find(state, id); + if (record.profile.sessionState === 'removing') throw new Error('Profile removal pending.'); + state.defaultProfileId = id; + await this.persistence.write(state); + }); + } + + updateSession(id: string, session: StoredSession): Promise { + return this.change(id, async (record) => { + if ( + record.profile.sessionState !== 'active' || + session.webId !== record.profile.webId || + session.issuer !== record.profile.issuer || + !(await this.keyMatches(session, record.keyPair)) + ) { + throw new Error('Session does not belong to this profile.'); + } + record.session = session; + }); + } + + /** Capability writes are internal to the SW; no page-facing message accepts these arrays. */ + capabilities(id: string, update: (record: ProfileRecord) => void | Promise): Promise { + return this.change(id, (record) => { + if (record.profile.sessionState !== 'active') throw new Error('Profile is not active.'); + return update(record); + }); + } + + signOut(id: string): Promise { + return this.locked(async () => { + const state = await this.persistence.read(); + const record = this.find(state, id); + record.profile = { ...record.profile, sessionState: 'signed-out' }; + record.session = null; + record.keyPair = null; + record.grantedOrigins = []; + record.classGrants = []; + await this.persistence.write(state); + await this.purge(record.profile.webId); + }); + } + + remove(id: string): Promise { + return this.locked(async () => { + const state = await this.persistence.read(); + const record = this.find(state, id); + record.profile = { ...record.profile, sessionState: 'removing' }; + record.session = null; + record.keyPair = null; + record.grantedOrigins = []; + record.classGrants = []; + await this.persistence.write(state); + await this.finishRemoval(state, record); + }); + } + + private async finishRemoval(state: ProfileSnapshot, record: ProfileRecord): Promise { + await this.purge(record.profile.webId); + state.records = state.records.filter((r) => r !== record); + if (state.defaultProfileId === record.profile.id) { + state.defaultProfileId = state.records[0]?.profile.id ?? null; + } + await this.persistence.write(state); + } + + private change(id: string, op: (record: ProfileRecord) => void | Promise): Promise { + return this.locked(async () => { + const state = await this.persistence.read(); + await op(this.find(state, id)); + await this.persistence.write(state); + }); + } + + private find(state: ProfileSnapshot, id: string): ProfileRecord { + const record = state.records.find((r) => r.profile.id === id); + if (!record) throw new Error('Unknown profile.'); + return record; + } + + private async keyMatches( + session: StoredSession, + keyPair: CryptoKeyPair | null, + ): Promise { + if (!keyPair || keyPair.privateKey.extractable) return false; + return ( + session.dpopJkt === undefined || session.dpopJkt === (await dpopThumbprint(keyPair.publicKey)) + ); + } + + private record( + id: string, + session: StoredSession, + keyPair: CryptoKeyPair | null, + display: StoredProfile | null, + ): ProfileRecord { + const sameDisplay = display?.webId === session.webId; + return { + profile: { + id, + webId: session.webId, + issuer: session.issuer, + label: (sameDisplay && display.name) || session.webId, + avatar: (sameDisplay && display.photoUrl) || undefined, + sessionState: keyPair ? 'active' : 'signed-out', + dpopKeyRef: id, + }, + session: keyPair ? session : null, + keyPair, + grantedOrigins: [], + podOrigins: [], + classGrants: [], + }; + } +} diff --git a/apps/extension/src/background/core/replica.ts b/apps/extension/src/background/core/replica.ts index a00505a26..b132259f7 100644 --- a/apps/extension/src/background/core/replica.ts +++ b/apps/extension/src/background/core/replica.ts @@ -153,6 +153,8 @@ export interface ReplicaConfig { readonly db: ReplicaMetaStore; /** The active session's WebID (the security key dimension). */ readonly webId: string | null; + /** Guard/serialize local mutations against lifecycle purges. Egress must return buffered bodies. */ + readonly commit?: (task: () => Promise) => Promise; } /** @@ -162,6 +164,10 @@ export interface ReplicaConfig { export class SharedReplica { constructor(private readonly cfg: ReplicaConfig) {} + private commit(task: () => Promise): Promise { + return this.cfg.commit ? this.cfg.commit(task) : task(); + } + /** * Read a resource through the replica (GET/HEAD): gate FIRST (incl. on a cache hit), then * serve-from-cache + revalidate (never-authoritative SWR). Returns the gate's DENY Response @@ -216,7 +222,7 @@ export class SharedReplica { // 304 Not Modified: the provisional cached body is confirmed fresh — serve it, touch it. // For a HEAD, serve the headers/status only (no cached body — Low #5). if (response.status === 304 && cachedBytes && meta) { - await this.cfg.db.putMetadata({ ...meta, fetchedAt: Date.now() }); + await this.commit(() => this.cfg.db.putMetadata({ ...meta, fetchedAt: Date.now() })); return { response: responseFromCache(cachedBytes.clone(), meta, isHead), fromCache: true, @@ -314,35 +320,39 @@ export class SharedReplica { ): Promise { // Clone BEFORE any await so the caller's live body is preserved even if the persist throws. const toStore = response.clone(); - try { - // Store the clone UN-mutated under the key. - await this.cfg.cache.put(keyReq, toStore); - const record: ReplicaMetadata = { - key: metaKey, - webId: scope.webId as string, - grantScope: scope.grantScope as string, - resourceUrl: url, - varyKey, - etag: response.headers.get('etag') ?? undefined, - contentType: response.headers.get('content-type') ?? undefined, - status: response.status, - fetchedAt: Date.now(), - }; - await this.cfg.db.putMetadata(record); - } catch { - // Non-cacheable 2xx (206 / Vary:* / quota) — skip persistence, serve the live response. - // Best-effort defence: if the bytes landed but the metadata write failed, the orphaned - // byte entry would be un-keyable on a later read (no metadata ⇒ no conditional ⇒ treated - // as a cold miss + overwritten), so leaving it is harmless; we still try to drop it. - await this.cfg.cache.delete(keyReq, IGNORE_VARY).catch(() => false); - } + await this.commit(async () => { + try { + // Store the clone UN-mutated under the key. + await this.cfg.cache.put(keyReq, toStore); + const record: ReplicaMetadata = { + key: metaKey, + webId: scope.webId as string, + grantScope: scope.grantScope as string, + resourceUrl: url, + varyKey, + etag: response.headers.get('etag') ?? undefined, + contentType: response.headers.get('content-type') ?? undefined, + status: response.status, + fetchedAt: Date.now(), + }; + await this.cfg.db.putMetadata(record); + } catch { + // Non-cacheable 2xx (206 / Vary:* / quota) — skip persistence, serve the live response. + // Best-effort defence: if the bytes landed but the metadata write failed, the orphaned + // byte entry would be un-keyable on a later read (no metadata ⇒ no conditional ⇒ treated + // as a cold miss + overwritten), so leaving it is harmless; we still try to drop it. + await this.cfg.cache.delete(keyReq, IGNORE_VARY).catch(() => false); + } + }); return response; } /** Drop one entry's bytes + metadata (a no-leak partial-write sweep). */ private async invalidate(metaKey: string, keyReq: Request): Promise { - await this.cfg.cache.delete(keyReq, IGNORE_VARY).catch(() => false); - await this.cfg.db.deleteMetadata(metaKey).catch(() => {}); + await this.commit(async () => { + await this.cfg.cache.delete(keyReq, IGNORE_VARY).catch(() => false); + await this.cfg.db.deleteMetadata(metaKey).catch(() => {}); + }); } } @@ -381,14 +391,15 @@ export async function purgeReplica( let bytesDropped = 0; if (webId !== null) { - // Drop exactly this identity's rows + their byte entries. + // Drop exactly this identity's bytes before deleting any retry metadata. Propagate + // deletion failures so ProfileStore keeps its removal marker for the next wake. const rows = await db.getMetadataByWebId(webId); for (const row of rows) { const keyReq = new Request( // Reconstruct the exact synthetic key from the stored scope (injective — see replica-key.ts). replicaKeyUrlFor(row), ); - if (await cache.delete(keyReq, IGNORE_VARY).catch(() => false)) bytesDropped++; + if (await cache.delete(keyReq, IGNORE_VARY)) bytesDropped++; } await db.deleteMetadataByWebId(webId); metaDropped = rows.length; diff --git a/apps/extension/src/background/profile-authority.ts b/apps/extension/src/background/profile-authority.ts new file mode 100644 index 000000000..e368b6202 --- /dev/null +++ b/apps/extension/src/background/profile-authority.ts @@ -0,0 +1,70 @@ +// AUTHORED-BY GPT-6.1-Sol (Codex) +/** Worker-owned migration and lazy IndexedDB authority. No caller falls back to legacy tokens. */ +import { DpopKeyDb } from './core/dpop-key-db'; +import { ProfileStore } from './core/profile-store'; +import { ProfileDb } from './profile-db'; +import { + loadClassGrants, + loadGrantedOrigins, + loadPodOrigins, + loadProfile, + loadSession, +} from './session-store'; + +export function profileAuthority( + purge: (webId: string) => Promise, +): () => Promise { + let ready: Promise | undefined; + return () => { + ready ??= (async () => { + const db = await ProfileDb.open(); + const store = new ProfileStore(db, purge); + try { + await store.initialise( + async () => { + const session = await loadSession(); + const keyDb = await DpopKeyDb.open(); + try { + const keyPair = await keyDb.get(); + return { + session, + keyPair: keyPair ?? null, + display: await loadProfile(), + grantedOrigins: await loadGrantedOrigins(), + podOrigins: await loadPodOrigins(), + classGrants: await loadClassGrants(), + }; + } finally { + keyDb.close(); + } + }, + async () => { + // Only after the profile transaction commits. Retrying on wake is idempotent. + for (const key of [ + 'solid:session', + 'solid:profile', + 'solid:granted-origins', + 'solid:pod-origins', + 'solid:class-grants', + ]) + await chrome.storage.local.remove(key); + const keyDb = await DpopKeyDb.open(); + try { + await keyDb.clear(); + } finally { + keyDb.close(); + } + }, + ); + return store; + } catch (error) { + db.close(); + throw error; + } + })().catch((error) => { + ready = undefined; + throw error; + }); + return ready; + }; +} diff --git a/apps/extension/src/background/profile-db.ts b/apps/extension/src/background/profile-db.ts new file mode 100644 index 000000000..514494dee --- /dev/null +++ b/apps/extension/src/background/profile-db.ts @@ -0,0 +1,65 @@ +// AUTHORED-BY GPT-6.1-Sol (Codex) +import type { ProfilePersistence, ProfileRecord, ProfileSnapshot } from './core/profile-store'; + +const RECORDS = 'profiles'; +const META = 'metadata'; + +function request(req: IDBRequest): Promise { + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +function complete(tx: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onabort = tx.onerror = () => reject(tx.error); + }); +} + +/** Each token set and structured-cloned non-extractable key is keyed by profile id. */ +export class ProfileDb implements ProfilePersistence { + private constructor(private readonly db: IDBDatabase) {} + + static async open(factory: IDBFactory = globalThis.indexedDB): Promise { + if (!factory) throw new Error('ProfileStore requires IndexedDB.'); + const req = factory.open('solid-ext-profiles', 1); + req.onupgradeneeded = () => { + req.result.createObjectStore(RECORDS); + req.result.createObjectStore(META); + }; + return new ProfileDb(await request(req)); + } + + async read(): Promise { + const tx = this.db.transaction([RECORDS, META], 'readonly'); + const done = complete(tx); + const [records, meta] = await Promise.all([ + request(tx.objectStore(RECORDS).getAll()), + request | undefined>(tx.objectStore(META).get('state')), + done, + ]); + return { + records: records.sort((a, b) => Number(a.profile.id) - Number(b.profile.id)), + defaultProfileId: null, + nextId: 1, + migrated: false, + ...meta, + }; + } + + async write({ records, ...meta }: ProfileSnapshot): Promise { + const tx = this.db.transaction([RECORDS, META], 'readwrite'); + const done = complete(tx); + const store = tx.objectStore(RECORDS); + store.clear(); + for (const record of records) store.put(record, record.profile.id); + tx.objectStore(META).put(meta, 'state'); + await done; + } + + close(): void { + this.db.close(); + } +} diff --git a/apps/extension/src/background/service-worker.ts b/apps/extension/src/background/service-worker.ts index 2b5c02b9e..09fc09872 100644 --- a/apps/extension/src/background/service-worker.ts +++ b/apps/extension/src/background/service-worker.ts @@ -14,7 +14,7 @@ * token-attaching path, and the access token is only ever sent to a vetted resource * origin. * - * The MV3 worker is killed aggressively; all durable state lives in chrome.storage and is + * The MV3 worker is killed aggressively; profile credentials live in IndexedDB and settings in chrome.storage, * re-hydrated on wake (the in-memory caches below are best-effort accelerators only). */ @@ -30,6 +30,8 @@ import type { ListGrantsRequest, LoginRequest, PendingConsentView, + ProfileRequest, + ProfilesResponse, RequestAccessRequest, ResolveClassRequest, RevokeGrantRequest, @@ -48,12 +50,11 @@ import { modeSatisfies, type PinnedTarget, pruneExpiredGrants, + sanitiseGrants, } from './core/class-grant'; import { normaliseAccessRequest } from './core/class-request'; import { resolveClassTargets } from './core/class-resolve'; import { buildPendingConsent, consentDecisionToGrants, type PendingConsent } from './core/consent'; -import { DpopKeyDb } from './core/dpop-key-db'; -import { DpopKeyUnavailableError, loadSessionDpopKeyPair } from './core/dpop-key-store'; import { computeAllowedOrigins, isValidClientIdUrl } from './core/origin-policy'; import { type EgressPort, @@ -79,24 +80,13 @@ import { filterResponseHeaders } from './core/response-headers'; import { SingleFlight } from './core/single-flight'; import { parseWebIdProfile } from './core/webid'; import { registerGlobalFetchInjection } from './global-fetch-register'; +import { profileAuthority } from './profile-authority'; import { - clearClassGrants, - clearProfile, - clearSession, - grantOrigin, - loadClassGrants, loadClientIds, - loadGrantedOrigins, - loadPodOrigins, - loadProfile, loadRecentAccounts, - loadSession, - pruneExpiredClassGrants, - revokeClassGrant, + rememberAccount, type StoredSession, - saveClassGrant, saveClientId, - saveProfile, } from './session-store'; import { registerSidePanel } from './side-panel'; @@ -106,10 +96,6 @@ const EXPIRY_BUFFER_MS = 30_000; /** The Cache API bucket holding the shared replica's bytes (one physical store, see replica.ts). */ const REPLICA_CACHE_NAME = 'solid-ext-replica-bytes'; -/** Best-effort in-memory caches (re-hydrated from storage on SW wake). */ -let cachedSession: StoredSession | null = null; -let cachedKeyPair: CryptoKeyPair | null = null; - /** * The replica IndexedDB handle (metadata + the DPoP nonce cache). Re-derivable on every SW * wake — a best-effort accelerator, never load-bearing in-memory state (MV3 invariant). The @@ -124,18 +110,6 @@ async function getReplicaDb(): Promise { return replicaDbHandle; } -/** - * The DPoP keypair IndexedDB handle — the non-extractable `CryptoKeyPair`'s durable home - * (see `core/dpop-key-db.ts`). Re-derivable on every SW wake, same as the replica DB above. - */ -let dpopKeyDbHandle: DpopKeyDb | null = null; - -/** Lazily open (once per wake) the DPoP key DB. */ -async function getDpopKeyDb(): Promise { - if (!dpopKeyDbHandle) dpopKeyDbHandle = await DpopKeyDb.open(); - return dpopKeyDbHandle; -} - /** The named Cache bucket for replica bytes (re-opened lazily; available in the SW context). */ function getReplicaCache(): Promise { return caches.open(REPLICA_CACHE_NAME) as Promise; @@ -183,117 +157,163 @@ async function purgeGrantSlices(gone: readonly ClassGrant[], retainPool: readonl * best-effort. The surviving `live` grants are the retain-pool so an expiring grant that overlaps a * still-live one does not evict the survivor's data. */ -async function pruneAndPurgeExpiredGrants(now: number): Promise { - const { live, expired } = await pruneExpiredClassGrants(now); +async function pruneAndPurgeExpiredGrants(profileId: string, now: number): Promise { + const store = await getProfiles(); + let live: ClassGrant[] = []; + let expired: ClassGrant[] = []; + await store.capabilities(profileId, (record) => { + const all = sanitiseGrants(record.classGrants); + live = pruneExpiredGrants(all, now); + expired = all.filter((g) => !live.includes(g)); + record.classGrants = live; + }); await purgeGrantSlices(expired, live); return live; } -/** - * Single-flight refresh: concurrent near-expiry requests must NOT each start a refresh - * with the same (rotation-bound) refresh token — that races, so one grant succeeds and the - * others fail with invalid_grant and would wrongly tear down the freshly-refreshed session. - * All callers await the one in-flight refresh instead. - */ -const refreshGate = new SingleFlight(); - -/** Refresh `session` exactly once even under concurrency; rejects on a genuine failure. */ -function refreshOnce(session: StoredSession): Promise { - return refreshGate.run(async () => { - const updated = await refreshSession(session); - cachedSession = updated; - cachedKeyPair = null; - return updated; + +const getProfiles = profileAuthority(async (webId) => { + const [cache, db] = await Promise.all([getReplicaCache(), getReplicaDb()]); + await purgeReplica(cache, db, webId); +}); + +/** Serialize only lifecycle changes and short replica commits, never network/body reads. */ +const profileTasks = new Map>(); +function withProfileTask(id: string, task: () => Promise): Promise { + const run = (profileTasks.get(id) ?? Promise.resolve()).then(task); + const tail = run.catch(() => {}); + profileTasks.set(id, tail); + void tail.then(() => { + if (profileTasks.get(id) === tail) profileTasks.delete(id); + }); + return run; +} + +/** Concurrent requests retain their own cancellation scope; lifecycle changes cancel all of them. */ +const profileOperations = new Map>(); + +async function cancellable(signal: AbortSignal, task: () => Promise): Promise { + signal.throwIfAborted(); + let onAbort = () => {}; + const aborted = new Promise((_resolve, reject) => { + onAbort = () => reject(signal.reason); + signal.addEventListener('abort', onAbort, { once: true }); }); + try { + return await Promise.race([aborted, task()]); + } finally { + signal.removeEventListener('abort', onAbort); + } +} + +async function withProfileOperation( + id: string, + task: (signal: AbortSignal) => Promise, +): Promise { + const controller = new AbortController(); + const operations = profileOperations.get(id) ?? new Set(); + operations.add(controller); + profileOperations.set(id, operations); + try { + await profileTasks.get(id); + return await cancellable(controller.signal, () => task(controller.signal)); + } catch (error) { + if (controller.signal.aborted) return null; + throw error; + } finally { + operations.delete(controller); + if (operations.size === 0) profileOperations.delete(id); + } } -/** Load + refresh-if-needed the active session and its (imported) keypair. */ -async function ensureSession(): Promise<{ session: StoredSession; keyPair: CryptoKeyPair } | null> { - if (!cachedSession) cachedSession = await loadSession(); - if (!cachedSession) return null; +function cancelProfileOperations(id: string): void { + for (const operation of profileOperations.get(id) ?? []) operation.abort(); +} + +function withProfileLifecycle(id: string, task: () => Promise): Promise { + return withProfileTask(id, async () => { + cancelProfileOperations(id); + return task(); + }); +} - if (cachedSession.expiresAt < Date.now() + EXPIRY_BUFFER_MS) { - const toRefresh = cachedSession; +const refreshes = new Map>(); +function refreshOnce(id: string): Promise { + let flight = refreshes.get(id); + if (!flight) { + flight = new SingleFlight(); + refreshes.set(id, flight); + } + return flight.run(async () => { + const store = await getProfiles(); + // A delayed reader can enter after the previous flight committed rotated tokens. + // Only the credentials read INSIDE this flight may be redeemed or signed out. + const ctx = await store.session(id); + if (!ctx) return null; + if (ctx.session.expiresAt >= Date.now() + EXPIRY_BUFFER_MS) return ctx.session; try { - await refreshOnce(toRefresh); + const updated = await refreshSession(ctx.session, ctx.keyPair); + await store.updateSession(id, updated); + return updated; } catch { - // Only tear down if nothing newer has landed: a concurrent single-flight refresh (or - // a fresh login) may already have replaced the session this caller tried to refresh. - if (cachedSession === toRefresh) { - await teardownSession(); + return withProfileTask(id, async () => { + // Read and compare under the same lifecycle lock as re-login. A stale failure + // must neither sign out replacement credentials nor cancel their requests. + const current = await store.session(id); + if ( + current?.session.accessToken === ctx.session.accessToken && + current.session.refreshToken === ctx.session.refreshToken && + current.session.dpopJkt === ctx.session.dpopJkt + ) { + cancelProfileOperations(id); + try { + await store.signOut(id); + } finally { + // Revocation commits before replica cleanup, which may fail and need a retry. + // A toolbar update may await this refresh; schedule its guarded repaint. + void updateDefaultIconAndBroadcast().catch(() => {}); + } + } else if (current && current.session.expiresAt >= Date.now() + EXPIRY_BUFFER_MS) { + return current.session; + } return null; - } + }); } - } + }); +} - if (!cachedSession) return null; - if (!cachedKeyPair) { - try { - cachedKeyPair = await loadSessionDpopKeyPair(await getDpopKeyDb(), cachedSession); - } catch (err) { - if (err instanceof DpopKeyUnavailableError) { - // The persisted DPoP key is missing or doesn't match THIS session specifically - // (IndexedDB eviction, a browser bug, or a stale in-memory session object) — - // unrecoverable in place. Tear down for a clean re-login rather than silently - // signing with the wrong (or a freshly-minted, orphaned) key, which would just - // produce confusing 401s. - await teardownSession(); - } - // Either the confirmed-broken-session teardown above, or a TRANSIENT/unexpected - // failure (IndexedDB open error, a bug) — in the transient case we deliberately do - // NOT tear down (the stored session/tokens are left intact), we just fail THIS one - // call soft, matching every caller's existing "no ctx ⇒ not authenticated" handling. - // `cachedKeyPair` stays null either way, so the very next call retries cleanly. - return null; - } +type SessionContext = { profileId: string; session: StoredSession; keyPair: CryptoKeyPair }; + +/** Resolve the default once per request. Never re-select it after an await. */ +async function ensureSession(profileId?: string): Promise { + const store = await getProfiles(); + const id = profileId ?? (await store.getDefault())?.profile.id; + if (!id) return null; + let ctx = await store.session(id); + if (!ctx) return null; + if (ctx.session.expiresAt < Date.now() + EXPIRY_BUFFER_MS) { + if (!(await refreshOnce(id))) return null; + ctx = await store.session(id); } - return { session: cachedSession, keyPair: cachedKeyPair }; + return ctx ? { ...ctx, profileId: id } : null; } -/** - * Tear down the session AND SYNCHRONOUSLY purge the shared replica BEFORE returning, so no - * subsequent (returning / different-user) read can be served a single departed-identity byte - * (design §4.3 "Cross-USER stale bytes", §5.4 point 5). The logout handler AWAITS this; the - * purge (replica bytes + metadata + cached DPoP nonces) completes before any further serve. - * `priorWebId` scopes the purge to exactly that identity's rows; a missing prior WebID purges - * everything (fail-closed). The WebID-in-key already makes a cross-user collision impossible; - * this removes the prior rows entirely so even a same-key re-provision cannot collide. - */ async function teardownSession(): Promise { - const priorWebId = (cachedSession ?? (await loadSession()))?.webId ?? null; - cachedSession = null; - cachedKeyPair = null; - - // SYNCHRONOUS-BEFORE-SERVE replica purge. Best-effort on the stores (a missing DB must not - // block sign-out), but AWAITED so it precedes any new serve. - try { + const store = await getProfiles(); + const record = await store.getDefault(); + if (record) await withProfileLifecycle(record.profile.id, () => store.signOut(record.profile.id)); + else if ((await store.list()).profiles.length === 0) { const [cache, db] = await Promise.all([getReplicaCache(), getReplicaDb()]); - await purgeReplica(cache, db, priorWebId); - } catch { - // If the replica stores are unavailable, sign-out still proceeds (no bytes to leak when - // the store never opened). The WebID-scoped key means a future session cannot read them. + await purgeReplica(cache, db, null); } - - // Class grants are bound to the departing session's WebID (ClassGrant.webId) and its resolved, - // pinned pod targets — they MUST NOT survive into a different identity's session. Clear them on - // logout (fail-closed; the WebID binding already prevents cross-identity use, this removes them - // entirely). The coarse per-origin grant store (login opt-ins) intentionally survives logout. - // The persisted DPoP keypair is likewise session-bound (a fresh one is minted per login / - // legacy-migration rotation) — drop it too so no departed session's key lingers in IndexedDB. - await Promise.all([ - clearSession(), - clearProfile(), - clearClassGrants(), - getDpopKeyDb().then((db) => db.clear()), - ]); - await setSignedOutIcon(); } /** Build the per-request fetch session (the credential boundary) from the stored session. */ async function buildFetchSession( session: StoredSession, keyPair: CryptoKeyPair, + profileId: string, ): Promise { - const podOrigins = await loadPodOrigins(); + const { podOrigins } = await (await getProfiles()).get(profileId); const allowedOrigins = computeAllowedOrigins({ webId: session.webId, issuer: session.issuer, @@ -365,12 +385,13 @@ async function gateAccess( sender: MessageSender | undefined, allowedTargetOrigins: ReadonlySet, webId: string, + profileId: string, ): Promise<{ grantScope: string } | { deny: Response }> { const now = Date.now(); - const explicitGrants = await loadGrantedOrigins(); + const { grantedOrigins: explicitGrants } = await (await getProfiles()).get(profileId); // Opportunistically prune expired grants on the fetch path (design item 3: expired → pruned) AND // purge each expired grant's replica slice (residual b — no bytes linger past expiry). - const liveGrants = await pruneAndPurgeExpiredGrants(now); + const liveGrants = await pruneAndPurgeExpiredGrants(profileId, now); const classOrigins = [...liveGrantOrigins(liveGrants, now, webId)]; const decision = decideRequestingOrigin(sender, message.stampedOrigin, message.url, { @@ -435,7 +456,31 @@ async function handleFetch( }; } - const ctx = await ensureSession(); + const selected = await (await getProfiles()).getDefault(); + if (selected) { + let egressStarted = false; + const result = await withProfileOperation(selected.profile.id, async (signal) => { + const ctx = await ensureSession(selected.profile.id); + signal.throwIfAborted(); + return handleProfileFetch(message, sender, requestingOrigin, ctx, signal, () => { + egressStarted = true; + }); + }); + if (result) return result; + // Native fallback would replay a request the pod may already have processed. + if (egressStarted) return { requestId: message.requestId, error: 'Fetch cancelled' }; + } + return handleProfileFetch(message, sender, requestingOrigin, null); +} + +async function handleProfileFetch( + message: FetchRequest, + sender: MessageSender | undefined, + requestingOrigin: string, + ctx: SessionContext | null, + signal = new AbortController().signal, + onEgress = () => {}, +): Promise { if (!ctx) { // No session: the explicit path reports "Not authenticated"; an autoDivert request gets the // passthrough sentinel so the page does the plain native fetch it would have done itself (no @@ -445,15 +490,37 @@ async function handleFetch( } try { - const fetchSession = await buildFetchSession(ctx.session, ctx.keyPair); + const fetchSession = await buildFetchSession(ctx.session, ctx.keyPair, ctx.profileId); // The INJECTED gate the replica runs as the FIRST step on EVERY read AND write — including // on a CACHE HIT (design §5.4 point 3). It re-runs the SAME audited `gateRequestingOrigin` // decision (idempotent) and returns the verified grant-scope on ALLOW; the replica never // touches the cache or the pod before this resolves to ALLOW, so a warm cache can never // bypass the per-origin gate. - const gate = (): Promise<{ grantScope: string } | { deny: Response }> => - gateAccess(message, requestingOrigin, sender, fetchSession.allowedOrigins, ctx.session.webId); + const gate = async (): Promise<{ grantScope: string } | { deny: Response }> => { + signal.throwIfAborted(); + const decision = await gateAccess( + message, + requestingOrigin, + sender, + fetchSession.allowedOrigins, + ctx.session.webId, + ctx.profileId, + ); + signal.throwIfAborted(); + return decision; + }; + + // Decide passthrough before opening replica stores, independently of other requests. + const decision = await gate(); + if ('deny' in decision) { + if (message.autoDivert) return passthroughSentinel(message); + return { + requestId: message.requestId, + status: decision.deny.status, + error: await decision.deny.text(), + }; + } const origin = originOf(message.url); const db = await getReplicaDb(); @@ -472,16 +539,38 @@ async function handleFetch( headers, body, nonce: n, + fetchImpl: (input, init) => + cancellable(signal, () => { + onEgress(); + return fetch(input, { ...init, signal }); + }), }); - return { response: result.response, nonce: result.nonce }; + // Consume network bodies outside the lifecycle queue, including cache.put's body. + const responseBody = result.response.body + ? await cancellable(signal, () => result.response.arrayBuffer()) + : null; + const response = new Response(responseBody, { + status: result.response.status, + statusText: result.response.statusText, + headers: result.response.headers, + }); + return { response, nonce: result.nonce }; }, }; + // Commit bytes and metadata together before a lifecycle purge can enter the queue. + // Aborted work cannot persist anything after that purge. + const persist = (task: () => Promise): Promise => + withProfileTask(ctx.profileId, async () => { + signal.throwIfAborted(); + return task(); + }); const replica = new SharedReplica({ gate, egress, cache, db, + commit: persist, webId: ctx.session.webId, }); @@ -518,7 +607,8 @@ async function handleFetch( } // Persist the freshest server nonce to IndexedDB so it survives an SW restart. - if (origin && result.nonce) await db.setNonce(origin, result.nonce); + const serverNonce = result.nonce; + if (origin && serverNonce) await persist(() => db.setNonce(origin, serverNonce)); // Defense-in-depth: relay only the allowlisted, app-relevant response headers back to // the page rather than every header the server emitted (see response-headers.ts). @@ -530,7 +620,7 @@ async function handleFetch( status: result.response.status, statusText: result.response.statusText, headers, - body: isHead ? undefined : await result.response.text(), + body: isHead ? undefined : await cancellable(signal, () => result.response.text()), }; } catch (err) { return { @@ -565,16 +655,17 @@ function passthroughSentinel(message: FetchRequest): FetchResponse { return { requestId: message.requestId, passthrough: true }; } -/** Best-effort: fetch + parse the WebID profile (name/photo) and persist it. */ -async function refreshProfile(webId: string): Promise { +/** Best-effort display refresh; it never changes a user-assigned label or another profile. */ +async function refreshProfile(profileId: string, webId: string): Promise { try { const response = await fetch(webId, { headers: { Accept: 'text/turtle' } }); if (!response.ok) return; - const { name, photoUrl } = parseWebIdProfile(webId, await response.text()); - await saveProfile({ webId, name, photoUrl }); - await setSignedInIcon({ webId, name, photoUrl }); + const { photoUrl } = parseWebIdProfile(webId, await response.text()); + await (await getProfiles()).capabilities(profileId, (record) => { + record.profile = { ...record.profile, avatar: photoUrl ?? undefined }; + }); } catch { - // cosmetic — leave whatever profile/icon we have. + // Cosmetic. } } @@ -585,20 +676,32 @@ async function refreshProfile(webId: string): Promise { * (`webId === null`) is sent to every tab so granted apps clear their state. The popup/side * panel (extension contexts) always get the real value via the runtime broadcast. */ -async function broadcastStateChange(webId: string | null): Promise { +let stateChangeVersion = 0; + +async function broadcastStateChange(ctx: SessionContext | null, version: number): Promise { + const webId = ctx?.session.webId ?? null; let granted: ReadonlySet = new Set(); - if (webId !== null) { - const ctx = await ensureSession(); - if (ctx) { - const explicitGrants = await loadGrantedOrigins(); - granted = computeGrantedOrigins({ - explicitGrants, - credentialOrigins: await sessionCredentialOrigins(ctx.session, ctx.keyPair), - }); - } + if (ctx) { + const { grantedOrigins: explicitGrants } = await (await getProfiles()).get(ctx.profileId); + granted = computeGrantedOrigins({ + explicitGrants, + credentialOrigins: await sessionCredentialOrigins(ctx.session, ctx.keyPair, ctx.profileId), + }); } const tabs = await chrome.tabs.query({}); + // Tab discovery and grant reads can overlap a default switch. Discard the whole stale + // notification, including its trusted-context copy, before sending any captured identity. + const current = await (await getProfiles()).getDefault(); + if (version !== stateChangeVersion) return; + if (ctx) { + if ( + current?.profile.id !== ctx.profileId || + current.profile.sessionState !== 'active' || + current.profile.webId !== webId + ) + return; + } else if (current?.profile.sessionState === 'active') return; for (const tab of tabs) { if (tab.id === undefined) continue; const tabOrigin = tab.url ? originOf(tab.url) : null; @@ -613,49 +716,111 @@ async function broadcastStateChange(webId: string | null): Promise { chrome.runtime.sendMessage({ type: 'SOLID_STATE_CHANGED', webId }).catch(() => {}); } -async function handleLogin( +// The legacy PKCE params slot admits one interactive auth flow at a time. +let loginTail: Promise = Promise.resolve(); +function handleLogin( message: LoginRequest, sender: MessageSender | undefined, + makeDefault = true, ): Promise { - try { - // A login driven FROM a web page is a deliberate owner opt-in. Resolve + REMEMBER the - // verified requesting origin now (to bind the flow), but do NOT grant it yet — verify the - // origin via the same dual-origin agreement (browser-attested ∧ page stamp) and never trust - // the page-supplied field alone. A popup/side-panel login has no web sender → nothing to grant. - // - // SECURITY (privilege-escalation guard): the grant is persisted ONLY AFTER initiateLogin - // succeeds (the success path below). Granting BEFORE login would let any page send - // SOLID_LOGIN, have its origin persisted as granted even if the auth flow is cancelled or - // fails, and then — if a session already existed — read the existing credentials via - // SOLID_FETCH_REQUEST. A failed/cancelled login must leave the grant store UNCHANGED. - const requestingOrigin = resolveRequestingOrigin(sender, message.origin); + const run = loginTail.then(async () => { + try { + const store = await getProfiles(); + const { profiles } = await store.list(); + if (profiles.length >= 8 && !profiles.some((p) => p.webId === message.webId)) { + throw new Error('At most 8 profiles.'); + } + const requestingOrigin = resolveRequestingOrigin(sender, message.origin); + const clientIds = await loadClientIds(); + const candidate = + message.clientId || (requestingOrigin ? clientIds[requestingOrigin] : undefined); + const clientId = candidate && isValidClientIdUrl(candidate) ? candidate : undefined; + const { session, keyPair, name, photoUrl } = await initiateLogin({ + webId: message.webId, + clientId, + }); + const existing = profiles.find((p) => p.webId === session.webId); + const commit = async () => { + const profile = await store.login(session, keyPair, { + webId: session.webId, + name, + photoUrl, + }); + if (requestingOrigin !== null) + await store.capabilities(profile.id, (record) => { + if (!record.grantedOrigins.includes(requestingOrigin)) + record.grantedOrigins.push(requestingOrigin); + }); + if (makeDefault) await store.setDefault(profile.id); + await rememberAccount({ webId: session.webId, name, photoUrl }); + void refreshProfile(profile.id, session.webId); + }; + if (existing) await withProfileLifecycle(existing.id, commit); + else await commit(); + await updateDefaultIconAndBroadcast(); + return { ok: true, webId: session.webId }; + } catch (err) { + return { error: err instanceof Error ? err.message : 'Login failed' }; + } + }); + loginTail = run.catch(() => {}); + return run; +} - const clientIds = await loadClientIds(); - const candidate = - message.clientId || (message.origin ? clientIds[message.origin] : undefined) || undefined; - // A page-declared client-id is only honoured if it is a valid (https / loopback) URL; - // an invalid one is ignored (fall back to the extension's own client / dynamic reg) - // rather than passing a tamperable plaintext client-id into the auth flow. - const clientId = candidate && isValidClientIdUrl(candidate) ? candidate : undefined; - - const { session, name, photoUrl } = await initiateLogin({ - webId: message.webId, - clientId, - }); - // Login succeeded (owner-approved): NOW grant the verified requesting origin so its - // subsequent window.solid.fetch calls pass the per-origin gate. - if (requestingOrigin !== null) await grantOrigin(requestingOrigin); - cachedSession = session; - cachedKeyPair = null; - await saveProfile({ webId: session.webId, name, photoUrl }); - await setSignedInIcon({ webId: session.webId, name, photoUrl }); - // best-effort fresher profile from the post-auth WebID (handles webid != input). - void refreshProfile(session.webId); - await broadcastStateChange(session.webId); - return { ok: true, webId: session.webId }; +async function updateDefaultIconAndBroadcast(): Promise { + const version = ++stateChangeVersion; + const isCurrent = () => version === stateChangeVersion; + const ctx = await ensureSession(); + const record = ctx ? await (await getProfiles()).get(ctx.profileId) : null; + const webId = ctx?.session.webId ?? null; + if (!isCurrent()) return; + if (webId && record) + await setSignedInIcon( + { + webId, + name: record.profile.label, + photoUrl: record.profile.avatar ?? null, + }, + isCurrent, + ); + else await setSignedOutIcon(isCurrent); + await broadcastStateChange(ctx, version); +} + +async function handleProfiles( + message: ProfileRequest, + sender: MessageSender | undefined, +): Promise { + if (!isExtensionContext(sender)) + return { error: 'Profile management requires extension context.' }; + try { + const store = await getProfiles(); + switch (message.type) { + case 'SOLID_PROFILE_ADD': { + const result = await handleLogin( + { type: 'SOLID_LOGIN', webId: message.webId }, + sender, + false, + ); + if (result.error) return { error: result.error }; + break; + } + case 'SOLID_PROFILE_RENAME': + await store.rename(message.profileId, message.label); + break; + case 'SOLID_PROFILE_REMOVE': + await withProfileLifecycle(message.profileId, () => store.remove(message.profileId)); + break; + case 'SOLID_PROFILE_SET_DEFAULT': + await store.setDefault(message.profileId); + break; + case 'SOLID_PROFILES_LIST': + return { ok: true, ...(await store.list()) }; + } + await updateDefaultIconAndBroadcast(); + return { ok: true, ...(await store.list()) }; } catch (err) { - // A cancelled / failed login persists NO grant — the grant store is untouched above. - return { error: err instanceof Error ? err.message : 'Login failed' }; + return { error: err instanceof Error ? err.message : 'Profile operation failed' }; } } @@ -668,7 +833,11 @@ async function handleLogin( */ function isExtensionContext(sender: MessageSender | undefined): boolean { const extensionOrigin = `chrome-extension://${chrome.runtime.id}`; - if (sender?.origin && sender.origin === extensionOrigin) return true; + if (sender?.origin) + return ( + sender.origin === extensionOrigin && + (!sender.url || sender.url.startsWith(`${extensionOrigin}/`)) + ); if (sender?.url?.startsWith(`${extensionOrigin}/`)) return true; return false; } @@ -684,15 +853,15 @@ async function handleGetState( sender: MessageSender | undefined, ): Promise { const ctx = await ensureSession(); - const profile = await loadProfile(); + const profile = ctx ? (await (await getProfiles()).get(ctx.profileId)).profile : null; if (isExtensionContext(sender)) { const recentAccounts = await loadRecentAccounts(); return { webId: ctx?.session.webId ?? null, isActive: ctx !== null, - name: profile?.name ?? null, - photoUrl: profile?.photoUrl ?? null, + name: profile?.label ?? null, + photoUrl: profile?.avatar ?? null, recentAccounts, }; } @@ -701,10 +870,10 @@ async function handleGetState( const requestingOrigin = resolveRequestingOrigin(sender, message.stampedOrigin); let mayExpose = false; if (requestingOrigin !== null && ctx) { - const explicitGrants = await loadGrantedOrigins(); + const { grantedOrigins: explicitGrants } = await (await getProfiles()).get(ctx.profileId); const granted = computeGrantedOrigins({ explicitGrants, - credentialOrigins: await sessionCredentialOrigins(ctx.session, ctx.keyPair), + credentialOrigins: await sessionCredentialOrigins(ctx.session, ctx.keyPair, ctx.profileId), }); mayExpose = isRequestingOriginGranted(granted, requestingOrigin); } @@ -712,8 +881,8 @@ async function handleGetState( return { webId: mayExpose ? (ctx?.session.webId ?? null) : null, isActive: mayExpose && ctx !== null, - name: mayExpose ? (profile?.name ?? null) : null, - photoUrl: mayExpose ? (profile?.photoUrl ?? null) : null, + name: mayExpose ? (profile?.label ?? null) : null, + photoUrl: mayExpose ? (profile?.avatar ?? null) : null, // Never leak the cross-pod recent-accounts list to a web page. recentAccounts: [], }; @@ -723,8 +892,9 @@ async function handleGetState( async function sessionCredentialOrigins( session: StoredSession, keyPair: CryptoKeyPair, + profileId: string, ): Promise> { - const fetchSession = await buildFetchSession(session, keyPair); + const fetchSession = await buildFetchSession(session, keyPair, profileId); return fetchSession.allowedOrigins; } @@ -748,7 +918,12 @@ async function handleSetClientId( } await saveClientId(requestingOrigin, message.clientId); // Declaring a client-id is a deliberate owner opt-in from this app → grant it access. - await grantOrigin(requestingOrigin); + const store = await getProfiles(); + const record = await store.getDefault(); + if (record?.profile.sessionState === 'active') + await store.capabilities(record.profile.id, (r) => { + if (!r.grantedOrigins.includes(requestingOrigin)) r.grantedOrigins.push(requestingOrigin); + }); return { ok: true }; } catch (err) { return { error: err instanceof Error ? err.message : 'Invalid client identifier' }; @@ -824,8 +999,11 @@ function isHttpIri(value: unknown): value is string { * authenticated-fetch). A issuer origin DISTINCT from every pod origin is excluded, which is the * profile-controlled attack this guards. (Verified by the CSS-single-origin + distinct-issuer tests.) */ -async function podOriginsFor(session: StoredSession): Promise> { - const podOrigins = await loadPodOrigins(); +async function podOriginsFor( + session: StoredSession, + profileId: string, +): Promise> { + const { podOrigins } = await (await getProfiles()).get(profileId); return computeAllowedOrigins({ webId: session.webId, podOrigins, @@ -923,9 +1101,9 @@ async function handleRequestAccess( // Resolve each requested class to its concrete, pinned targets (confined to POD origins — // excludes the issuer, so a hostile profile can't steer resolution at the issuer). - const fetchSession = await buildFetchSession(ctx.session, ctx.keyPair); + const fetchSession = await buildFetchSession(ctx.session, ctx.keyPair, ctx.profileId); const resolveFetch = makeResolveFetch(fetchSession); - const podOrigins = await podOriginsFor(ctx.session); + const podOrigins = await podOriginsFor(ctx.session, ctx.profileId); const resolved = new Map(); for (const cls of spec.classes) { const targets = await resolveClassTargets(cls, { @@ -956,7 +1134,15 @@ async function handleRequestAccess( } const grants = consentDecisionToGrants(pending, approvedClasses, () => crypto.randomUUID()); - for (const g of grants) await saveClassGrant(g); + try { + await (await getProfiles()).capabilities(ctx.profileId, (record) => { + if (record.session?.dpopJkt !== ctx.session.dpopJkt) + throw new Error('Profile session changed.'); + record.classGrants.push(...grants); + }); + } catch { + return { requestId, error: 'Profile session changed.' }; + } return { requestId, ok: grants.length > 0, grants: grants.map(grantSummary) }; } @@ -1009,7 +1195,7 @@ async function handleListGrants( const ctx = await ensureSession(); if (!ctx) return { requestId, grants: [] }; const now = Date.now(); - const live = await pruneAndPurgeExpiredGrants(now); + const live = await pruneAndPurgeExpiredGrants(ctx.profileId, now); const mine = grantsForSession(live, requestingOrigin, ctx.session.webId); return { requestId, grants: mine.map(grantSummary) }; } @@ -1023,20 +1209,20 @@ async function handleRevokeGrant( if (requestingOrigin === null) { return { requestId, ok: false, error: 'Could not verify the requesting origin.' }; } - // A page may revoke only its OWN origin's grant (revokeClassGrant enforces the origin match). - // The REMOVAL is the authoritative deny (fail-closed) and happens FIRST; the replica-slice purge - // below is best-effort AFTER it — so a purge failure can never leave access live (residual b). - const removed = await revokeClassGrant(message.grantId, requestingOrigin); - if (removed.length > 0) { - // Synchronously purge exactly the revoked class's replicated bytes + metadata (the resources it - // resolved to), leaving every other class/origin intact — and RETAINING any resource still - // covered by another live grant of the same origin (the surviving grants are the retain-pool). - // Access is already revoked above, so a failure here only leaves now-grant-less bytes for the - // logout purge — never live access. - const now = Date.now(); - const survivors = pruneExpiredGrants(await loadClassGrants(), now); - await purgeGrantSlices(removed, survivors); - } + const ctx = await ensureSession(); + if (!ctx) return { requestId, ok: false }; + const removed: ClassGrant[] = []; + let survivors: ClassGrant[] = []; + await (await getProfiles()).capabilities(ctx.profileId, (record) => { + removed.push( + ...record.classGrants.filter( + (g) => g.grantId === message.grantId && g.origin === requestingOrigin, + ), + ); + record.classGrants = record.classGrants.filter((g) => !removed.includes(g)); + survivors = pruneExpiredGrants(record.classGrants, Date.now()); + }); + await purgeGrantSlices(removed, survivors); return { requestId, ok: removed.length > 0 }; } @@ -1055,12 +1241,12 @@ async function handleResolveClass( const ctx = await ensureSession(); if (!ctx) return { requestId, error: 'Not authenticated' }; - const fetchSession = await buildFetchSession(ctx.session, ctx.keyPair); + const fetchSession = await buildFetchSession(ctx.session, ctx.keyPair, ctx.profileId); // Only a GRANTED origin (full OR class) may introspect — never leak the pod's type-index // layout to an arbitrary, un-opted-in page. const now = Date.now(); - const explicitGrants = await loadGrantedOrigins(); - const live = await pruneAndPurgeExpiredGrants(now); + const { grantedOrigins: explicitGrants } = await (await getProfiles()).get(ctx.profileId); + const live = await pruneAndPurgeExpiredGrants(ctx.profileId, now); const classOrigins = [...liveGrantOrigins(live, now, ctx.session.webId)]; const granted = computeGrantedOrigins({ explicitGrants: [...explicitGrants, ...classOrigins], @@ -1087,45 +1273,60 @@ async function handleResolveClass( const targets = await resolveClassTargets(message.classIRI, { webId: ctx.session.webId, fetch: makeResolveFetch(fetchSession), - allowedOrigins: await podOriginsFor(ctx.session), + allowedOrigins: await podOriginsFor(ctx.session, ctx.profileId), }); return { requestId, shape: null, targets: targets.map((t) => ({ url: t.url, kind: t.kind })) }; } chrome.runtime.onMessage.addListener( (message: WorkerRequest, sender, sendResponse: (r: unknown) => void) => { + const respond = (task: Promise) => { + task.then(sendResponse).catch(() => + sendResponse({ + ...('requestId' in message ? { requestId: message.requestId } : {}), + error: 'Profile storage unavailable.', + }), + ); + }; switch (message.type) { + case 'SOLID_PROFILES_LIST': + case 'SOLID_PROFILE_ADD': + case 'SOLID_PROFILE_RENAME': + case 'SOLID_PROFILE_REMOVE': + case 'SOLID_PROFILE_SET_DEFAULT': + respond(handleProfiles(message, sender)); + return true; case 'SOLID_FETCH_REQUEST': // Pass the browser-ATTESTED sender (origin/url set by Chrome, not the page) so the // per-requesting-origin gate can cross-check it against the page-supplied stamp. - handleFetch(message, sender).then(sendResponse); + respond(handleFetch(message, sender)); return true; case 'SOLID_LOGIN': - handleLogin(message, sender).then(sendResponse); + respond(handleLogin(message, sender)); return true; case 'SOLID_LOGOUT': teardownSession() - .then(() => broadcastStateChange(null)) + .then(() => updateDefaultIconAndBroadcast()) .then(() => sendResponse({ ok: true })) .catch((err) => sendResponse({ error: String(err) })); return true; case 'SOLID_GET_STATE': - handleGetState(message, sender).then(sendResponse); + respond(handleGetState(message, sender)); return true; case 'SOLID_SET_CLIENT_ID': - handleSetClientId(message, sender).then(sendResponse); + respond(handleSetClientId(message, sender)); return true; case 'SOLID_REQUEST_ACCESS': - handleRequestAccess(message, sender).then(sendResponse); + respond(handleRequestAccess(message, sender)); return true; case 'SOLID_LIST_GRANTS': - handleListGrants(message, sender).then(sendResponse); + respond(handleListGrants(message, sender)); return true; case 'SOLID_REVOKE_GRANT': - handleRevokeGrant(message, sender).then(sendResponse); + respond(handleRevokeGrant(message, sender)); return true; case 'SOLID_RESOLVE_CLASS': - handleResolveClass(message, sender).then(sendResponse); + respond(handleResolveClass(message, sender)); return true; case 'SOLID_GET_PENDING_CONSENT': sendResponse(handleGetPendingConsent(message, sender)); @@ -1156,16 +1357,4 @@ chrome.runtime.onInstalled.addListener((details) => { registerSidePanel(); // Re-hydrate the session + icon on every SW wake. -ensureSession().then(async (ctx) => { - if (ctx) { - const profile = await loadProfile(); - await setSignedInIcon({ - webId: ctx.session.webId, - name: profile?.name ?? null, - photoUrl: profile?.photoUrl ?? null, - }); - await broadcastStateChange(ctx.session.webId); - } else { - await setSignedOutIcon(); - } -}); +void updateDefaultIconAndBroadcast().catch(() => {}); diff --git a/apps/extension/src/background/session-store.ts b/apps/extension/src/background/session-store.ts index 3b41d9df5..e6e143d27 100644 --- a/apps/extension/src/background/session-store.ts +++ b/apps/extension/src/background/session-store.ts @@ -1,6 +1,8 @@ // AUTHORED-BY Claude Opus 4.8 /** - * The extension's durable auth state, backed by `chrome.storage.local`. + * Legacy session migration plus extension settings in `chrome.storage.local`. + * New sessions, keys, grants and pod settings belong to ProfileStore in IndexedDB; + * the singleton session/grant helpers below remain for migration and compatibility tests. * * What lives here, and why this store and not the page: * - The active session (access + refresh token, the DPoP keypair as JWK, issuer/token @@ -138,7 +140,7 @@ export async function loadRecentAccounts(): Promise { } /** Promote/insert an account at the front of the recent list (logout-surviving memory). */ -async function rememberAccount(profile: StoredProfile): Promise { +export async function rememberAccount(profile: StoredProfile): Promise { const list = await loadRecentAccounts(); const next: RecentAccount[] = [ { webId: profile.webId, name: profile.name, photoUrl: profile.photoUrl }, diff --git a/apps/extension/src/shared/messages.ts b/apps/extension/src/shared/messages.ts index 7873f451b..dee951336 100644 --- a/apps/extension/src/shared/messages.ts +++ b/apps/extension/src/shared/messages.ts @@ -77,6 +77,32 @@ export interface SetClientIdRequest { readonly clientId: string; } +/** Display-only profile metadata. Tokens and CryptoKeys have no message representation. */ +export interface Profile { + readonly id: string; + readonly webId: string; + readonly issuer: string; + readonly label: string; + readonly avatar?: string; + readonly sessionState: 'active' | 'signed-out' | 'removing'; + readonly dpopKeyRef: string; +} + +/** Accepted only from browser-attested popup/side-panel contexts. */ +export type ProfileRequest = + | { readonly type: 'SOLID_PROFILES_LIST' } + | { readonly type: 'SOLID_PROFILE_ADD'; readonly webId: string } + | { readonly type: 'SOLID_PROFILE_RENAME'; readonly profileId: string; readonly label: string } + | { readonly type: 'SOLID_PROFILE_REMOVE'; readonly profileId: string } + | { readonly type: 'SOLID_PROFILE_SET_DEFAULT'; readonly profileId: string }; + +export interface ProfilesResponse { + readonly ok?: boolean; + readonly profiles?: readonly Profile[]; + readonly defaultProfileId?: string | null; + readonly error?: string; +} + // --- Per-data-class access management (Phase 2, design §4.2) --------------------------- /** @@ -173,6 +199,7 @@ export interface PendingConsentView { } export type WorkerRequest = + | ProfileRequest | FetchRequest | LoginRequest | LogoutRequest diff --git a/apps/extension/test/profile-auth-flow.test.ts b/apps/extension/test/profile-auth-flow.test.ts new file mode 100644 index 000000000..b4e94c262 --- /dev/null +++ b/apps/extension/test/profile-auth-flow.test.ts @@ -0,0 +1,143 @@ +// AUTHORED-BY GPT-6.1-Sol (Codex) +import * as jose from 'jose'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { initiateLogin, refreshSession } from '../src/background/auth-flow'; +import { profileCredentials } from './profile-fixtures'; + +const WEBID = 'https://pod.example/alice/profile#me'; +const ISSUER = 'https://issuer.example/'; +const TOKEN = `${ISSUER}token`; +const CLIENT = 'https://app.example/clientid'; +let storage: Map; +let fetchMock: ReturnType>; +let exchangeSucceeds: boolean; + +beforeEach(() => { + storage = new Map([['solid:session', { accessToken: 'fixture-legacy-unchanged' }]]); + exchangeSucceeds = true; + vi.stubGlobal('chrome', { + runtime: { lastError: undefined }, + storage: { + local: { + get: async (key: string) => ({ [key]: storage.get(key) }), + set: async (entries: Record) => { + for (const [key, value] of Object.entries(entries)) storage.set(key, value); + }, + remove: async (key: string) => { + storage.delete(key); + }, + }, + }, + identity: { + getRedirectURL: () => 'https://fixture.chromiumapp.org/callback', + launchWebAuthFlow: ({ url }: { url: string }, callback: (url: string) => void) => { + const state = new URL(url).searchParams.get('state')!; + callback( + `https://fixture.chromiumapp.org/callback?code=fixture-code&state=${state}&iss=${encodeURIComponent(ISSUER)}`, + ); + }, + }, + }); + fetchMock = vi.fn(async (input, init) => { + const url = input.toString(); + if (url === WEBID) + return new Response(`<${WEBID}> <${ISSUER}> .`); + if (url === `${ISSUER}.well-known/openid-configuration`) + return Response.json({ + issuer: ISSUER, + authorization_endpoint: `${ISSUER}authorize`, + token_endpoint: TOKEN, + }); + if (url === TOKEN) { + expect(new URLSearchParams(init?.body as URLSearchParams).get('grant_type')).toBe( + 'authorization_code', + ); + expect( + jose.decodeProtectedHeader(new Headers(init?.headers).get('dpop')!).jwk, + ).not.toHaveProperty('d'); + if (!exchangeSucceeds) return new Response('fixture denied', { status: 401 }); + return Response.json({ + access_token: 'fixture-access', + refresh_token: 'fixture-refresh', + id_token: new jose.UnsecuredJWT({ webid: WEBID }).encode(), + expires_in: 3600, + }); + } + throw new Error('Unexpected fixture URL'); + }); + vi.stubGlobal('fetch', fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('profile-owned auth credentials', () => { + it('returns the successful token set with its own non-extractable key for one atomic profile commit', async () => { + const { session, keyPair } = await initiateLogin({ webId: WEBID, clientId: CLIENT }); + expect(session).toMatchObject({ + webId: WEBID, + issuer: ISSUER, + accessToken: 'fixture-access', + refreshToken: 'fixture-refresh', + }); + expect(keyPair.privateKey.extractable).toBe(false); + await expect(crypto.subtle.exportKey('jwk', keyPair.privateKey)).rejects.toThrow(); + const tokenCall = fetchMock.mock.calls.find(([url]) => url.toString() === TOKEN)!; + await expect( + jose.jwtVerify(new Headers(tokenCall[1]?.headers).get('dpop')!, keyPair.publicKey), + ).resolves.toBeDefined(); + expect(storage.get('solid:session')).toEqual({ accessToken: 'fixture-legacy-unchanged' }); + expect(storage.has('solid:auth-params')).toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it('leaves the existing credentials untouched after a failed exchange', async () => { + exchangeSucceeds = false; + await expect(initiateLogin({ webId: WEBID, clientId: CLIENT })).rejects.toThrow( + 'Token exchange failed', + ); + expect(storage.get('solid:session')).toEqual({ accessToken: 'fixture-legacy-unchanged' }); + }); + + it('refuses refresh with another profile key before any token egress', async () => { + const a = await profileCredentials(); + const b = await profileCredentials('bob'); + await expect(refreshSession(a.session, b.keyPair)).rejects.toThrow('does not match'); + await expect(refreshSession({ ...a.session, dpopJkt: undefined }, a.keyPair)).rejects.toThrow( + 'does not match', + ); + await expect(refreshSession({ ...a.session, refreshToken: null }, a.keyPair)).rejects.toThrow( + 'No refresh token', + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('reuses only the owning key for a nonce retry and returns rotated tokens without global persistence', async () => { + const a = await profileCredentials(); + fetchMock.mockImplementation(async (_url, init) => { + const proof = new Headers(init?.headers).get('dpop')!; + const { payload } = await jose.jwtVerify(proof, a.keyPair.publicKey); + expect(new URLSearchParams(init?.body as URLSearchParams).get('refresh_token')).toBe( + a.session.refreshToken, + ); + if (!payload.nonce) + return new Response('', { status: 400, headers: { 'dpop-nonce': 'fixture-nonce' } }); + expect(payload.nonce).toBe('fixture-nonce'); + return Response.json({ + access_token: 'fixture-rotated-access', + refresh_token: 'fixture-rotated-refresh', + expires_in: 3600, + }); + }); + const updated = await refreshSession(a.session, a.keyPair); + expect(updated).toMatchObject({ + webId: a.session.webId, + dpopJkt: a.session.dpopJkt, + accessToken: 'fixture-rotated-access', + refreshToken: 'fixture-rotated-refresh', + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(storage.get('solid:session')).toEqual({ accessToken: 'fixture-legacy-unchanged' }); + }); +}); diff --git a/apps/extension/test/profile-db.test.ts b/apps/extension/test/profile-db.test.ts new file mode 100644 index 000000000..4fb8601a4 --- /dev/null +++ b/apps/extension/test/profile-db.test.ts @@ -0,0 +1,156 @@ +// AUTHORED-BY GPT-6.1-Sol (Codex) +import { describe, expect, it } from 'vitest'; +import { createDpopProof } from '../src/background/core/dpop'; +import { ProfileStore } from '../src/background/core/profile-store'; +import { ProfileDb } from '../src/background/profile-db'; +import { noLegacy, profileCredentials } from './profile-fixtures'; + +/** Minimal fixture IndexedDB: named stores, async transactions and structured-cloned values. */ +function fixtureIdb() { + const stores = new Map>(); + let created = false; + let abortNext = false; + let failRead = false; + let closed = 0; + const db = { + createObjectStore: (name: string) => stores.set(name, new Map()), + close: () => closed++, + transaction: (_names: string[], mode: string) => { + const snapshot = structuredClone(stores); + let pending = 0; + let settled = false; + const tx = { + oncomplete: null as (() => void) | null, + onerror: null as (() => void) | null, + onabort: null as (() => void) | null, + error: new Error('fixture transaction aborted'), + objectStore: (name: string) => { + const backing = snapshot.get(name)!; + const req = (op: () => T) => { + pending++; + const r = { + result: undefined as T | undefined, + error: new Error('fixture read failed'), + onsuccess: null as (() => void) | null, + onerror: null as (() => void) | null, + }; + queueMicrotask(() => { + if (failRead && mode === 'readonly') r.onerror?.(); + else { + r.result = op(); + r.onsuccess?.(); + } + pending--; + queueMicrotask(() => { + if (pending !== 0 || settled) return; + settled = true; + if (abortNext) { + abortNext = false; + tx.onabort?.(); + } else { + if (mode === 'readwrite') + for (const [key, value] of snapshot) stores.set(key, value); + tx.oncomplete?.(); + } + }); + }); + return r; + }; + return { + get: (key: IDBValidKey) => req(() => structuredClone(backing.get(key))), + getAll: () => req(() => structuredClone([...backing.values()])), + put: (value: unknown, key: IDBValidKey) => + req(() => { + backing.set(key, structuredClone(value)); + return key; + }), + clear: () => req(() => backing.clear()), + }; + }, + }; + return tx; + }, + }; + const factory = { + open: () => { + const req = { + result: db, + onsuccess: null as (() => void) | null, + onerror: null as (() => void) | null, + onupgradeneeded: null as (() => void) | null, + }; + queueMicrotask(() => { + if (!created) { + created = true; + req.onupgradeneeded?.(); + } + req.onsuccess?.(); + }); + return req; + }, + } as unknown as IDBFactory; + return { + factory, + stores, + abort: () => { + abortNext = true; + }, + readError: () => { + failRead = true; + }, + closed: () => closed, + }; +} + +describe('ProfileDb IndexedDB adapter', () => { + it('persists profile-keyed tokens, default and non-extractable keys across close/reopen', async () => { + const fixture = fixtureIdb(); + const db = await ProfileDb.open(fixture.factory); + const store = new ProfileStore(db, async () => {}); + await store.initialise(noLegacy, async () => {}); + const a = await profileCredentials(); + const b = await profileCredentials('bob'); + await store.login(a.session, a.keyPair, a.display); + await store.login(b.session, b.keyPair, b.display); + await store.setDefault('2'); + db.close(); + expect(fixture.closed()).toBe(1); + const reopened = await ProfileDb.open(fixture.factory); + const restored = new ProfileStore(reopened, async () => {}); + expect((await restored.getDefault())?.session?.accessToken).toBe(b.session.accessToken); + const aKey = (await restored.session('1'))!.keyPair; + expect(aKey.privateKey.extractable).toBe(false); + await expect(crypto.subtle.exportKey('jwk', aKey.privateKey)).rejects.toThrow(); + expect( + await createDpopProof({ keyPair: aKey, htm: 'GET', htu: 'https://pod.example/private/' }), + ).toBeTypeOf('string'); + expect([...fixture.stores.get('profiles')!.keys()]).toEqual(['1', '2']); + expect((await restored.list()).profiles.map((p) => p.id)).toEqual(['1', '2']); + }); + + it('waits for commit and preserves the previous snapshot on transaction abort', async () => { + const fixture = fixtureIdb(); + const db = await ProfileDb.open(fixture.factory); + const before = await db.read(); + fixture.abort(); + await expect(db.write({ ...before, migrated: true })).rejects.toThrow('aborted'); + expect(await db.read()).toEqual(before); + fixture.readError(); + await expect(db.read()).rejects.toThrow('read failed'); + }); + + it('fails closed when IndexedDB is unavailable or opening fails', async () => { + await expect(ProfileDb.open(undefined)).rejects.toThrow('requires IndexedDB'); + const factory = { + open: () => { + const req = { + error: new Error('fixture open failure'), + onerror: null as (() => void) | null, + }; + queueMicrotask(() => req.onerror?.()); + return req; + }, + } as unknown as IDBFactory; + await expect(ProfileDb.open(factory)).rejects.toThrow('open failure'); + }); +}); diff --git a/apps/extension/test/profile-fixtures.ts b/apps/extension/test/profile-fixtures.ts new file mode 100644 index 000000000..d4e50ed31 --- /dev/null +++ b/apps/extension/test/profile-fixtures.ts @@ -0,0 +1,58 @@ +// AUTHORED-BY GPT-6.1-Sol (Codex) +import type { ClassGrant } from '../src/background/core/class-grant'; +import { dpopThumbprint, generateDpopKeyPair } from '../src/background/core/dpop'; +import type { LegacyProfile, ProfileSnapshot } from '../src/background/core/profile-store'; +import type { StoredSession } from '../src/background/session-store'; + +export class MemoryProfileDb { + state: ProfileSnapshot = { records: [], nextId: 1, defaultProfileId: null, migrated: false }; + error: Error | null = null; + async read(): Promise { + if (this.error) throw this.error; + return structuredClone(this.state); + } + async write(state: ProfileSnapshot): Promise { + this.state = structuredClone(state); + } + close() {} +} + +export async function profileCredentials(name = 'alice') { + const keyPair = await generateDpopKeyPair(); + const session: StoredSession = { + webId: `https://pod.example/${name}/profile#me`, + issuer: 'https://issuer.example/', + tokenEndpoint: 'https://issuer.example/token', + clientId: 'https://app.example/clientid', + accessToken: `fixture-access-${name}`, + refreshToken: `fixture-refresh-${name}`, + dpopJkt: await dpopThumbprint(keyPair.publicKey), + expiresAt: Date.now() + 3600_000, + }; + const display = { webId: session.webId, name, photoUrl: `https://pod.example/${name}.png` }; + return { session, keyPair, display }; +} + +export function fixtureGrant(webId: string): ClassGrant { + return { + grantId: 'fixture-grant', + origin: 'https://app.example', + webId, + dataClass: 'https://schema.org/Person', + modes: ['read'], + purpose: null, + createdAt: 0, + expiresAt: Date.now() + 3600_000, + targets: [{ url: 'https://pod.example/private/', kind: 'container' }], + }; +} + +export const noLegacy = (): Promise => + Promise.resolve({ + session: null, + display: null, + keyPair: null, + grantedOrigins: [], + podOrigins: [], + classGrants: [], + }); diff --git a/apps/extension/test/profile-store.test.ts b/apps/extension/test/profile-store.test.ts new file mode 100644 index 000000000..310ab0c46 --- /dev/null +++ b/apps/extension/test/profile-store.test.ts @@ -0,0 +1,322 @@ +// AUTHORED-BY GPT-6.1-Sol (Codex) +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { authenticatedFetch } from '../src/background/core/authenticated-fetch'; +import { dpopThumbprint, generateDpopKeyPair } from '../src/background/core/dpop'; +import { ProfileStore } from '../src/background/core/profile-store'; +import { fixtureGrant, MemoryProfileDb, noLegacy, profileCredentials } from './profile-fixtures'; + +let db: MemoryProfileDb; +let purge: ReturnType Promise>>; +let store: ProfileStore; + +beforeEach(() => { + db = new MemoryProfileDb(); + purge = vi.fn(async () => {}); + store = new ProfileStore(db, purge); +}); + +async function login(name = 'alice') { + const credentials = await profileCredentials(name); + const profile = await store.login(credentials.session, credentials.keyPair, credentials.display); + return { ...credentials, profile }; +} + +describe('ProfileStore migration', () => { + it('migrates the legacy session to profile 1 exactly once, preserving its key and grants', async () => { + const a = await profileCredentials(); + const grants = [fixtureGrant(a.session.webId), fixtureGrant('https://other.example/#me')]; + const load = vi.fn(async () => ({ + ...a, + grantedOrigins: ['https://app.example'], + podOrigins: ['https://pod.example'], + classGrants: grants, + })); + const clear = vi.fn(async () => { + expect(db.state.migrated).toBe(true); + expect(db.state.records[0].session?.accessToken).toBe(a.session.accessToken); + }); + await store.initialise(load, clear); + const record = await store.get('1'); + expect(record.session).toEqual(a.session); + expect(record.grantedOrigins).toEqual(['https://app.example']); + expect(record.podOrigins).toEqual(['https://pod.example']); + expect(record.classGrants).toEqual([grants[0]]); + expect((await store.list()).defaultProfileId).toBe('1'); + expect(await dpopThumbprint(record.keyPair!.publicKey)).toBe(a.session.dpopJkt); + expect(record.keyPair!.privateKey.extractable).toBe(false); + await expect(crypto.subtle.exportKey('jwk', record.keyPair!.privateKey)).rejects.toThrow(); + await new ProfileStore(db, purge).initialise(load, clear); + expect(load).toHaveBeenCalledTimes(1); + expect(clear).toHaveBeenCalledTimes(2); + }); + + it('handles an empty install and retries cleanup after a committed migration', async () => { + const clear = vi + .fn() + .mockRejectedValueOnce(new Error('interrupted')) + .mockResolvedValue(undefined); + await expect(store.initialise(noLegacy, clear)).rejects.toThrow('interrupted'); + await store.initialise(() => { + throw new Error('must not re-import'); + }, clear); + expect(await store.list()).toEqual({ profiles: [], defaultProfileId: null }); + expect(await store.getDefault()).toBeNull(); + }); + + it.each([ + 'missing', + 'mismatch', + 'extractable', + ] as const)('requires re-login for a %s legacy key', async (failure) => { + const a = await profileCredentials(); + const keyPair = + failure === 'missing' + ? null + : failure === 'mismatch' + ? await generateDpopKeyPair() + : await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [ + 'sign', + 'verify', + ]); + await store.initialise( + async () => ({ + ...a, + keyPair, + grantedOrigins: ['https://app.example'], + podOrigins: [], + classGrants: [], + }), + async () => {}, + ); + expect((await store.get('1')).profile.sessionState).toBe('signed-out'); + expect(await store.session('1')).toBeNull(); + expect((await store.get('1')).session).toBeNull(); + expect((await store.get('1')).grantedOrigins).toEqual([]); + expect(purge).toHaveBeenCalledWith(a.session.webId); + }); + + it('binds a pre-thumbprint session and ignores a mismatched display profile', async () => { + const a = await profileCredentials(); + await store.initialise( + async () => ({ + ...a, + session: { ...a.session, dpopJkt: undefined }, + display: { ...a.display, webId: 'https://other.example/#me' }, + grantedOrigins: [], + podOrigins: [], + classGrants: [], + }), + async () => {}, + ); + expect((await store.get('1')).session?.dpopJkt).toBe(a.session.dpopJkt); + expect((await store.get('1')).profile).toMatchObject({ + label: a.session.webId, + avatar: undefined, + }); + }); + + it('uses the WebID label when the legacy display is absent', async () => { + const a = await profileCredentials(); + await store.initialise( + async () => ({ ...a, display: null, grantedOrigins: [], podOrigins: [], classGrants: [] }), + async () => {}, + ); + expect((await store.get('1')).profile.label).toBe(a.session.webId); + }); +}); + +describe('ProfileStore lifecycle and adversarial isolation', () => { + it('keeps defaults, labels and credentials across worker restarts without exposing secrets', async () => { + const a = await login(); + const b = await login('bob'); + expect((await store.getDefault())?.profile.id).toBe(a.profile.id); + await store.rename(b.profile.id, ' Bob personal '); + await store.setDefault(b.profile.id); + const reopened = new ProfileStore(db, purge); + expect((await reopened.getDefault())?.profile.label).toBe('Bob personal'); + expect((await reopened.session(a.profile.id))?.session.accessToken).toBe(a.session.accessToken); + const view = JSON.stringify(await reopened.list()); + expect(view).not.toContain('accessToken'); + expect(view).not.toContain('refreshToken'); + expect(view).not.toContain('privateKey'); + expect(await reopened.session('missing')).toBeNull(); + await expect(reopened.get('missing')).rejects.toThrow('Unknown profile'); + }); + + it('never uses P1 tokens or signing keys for P2, even when both share a pod origin', async () => { + const a = await login(); + const b = await login('bob'); + const seen: string[] = []; + for (const profile of [a, b]) { + const ctx = (await store.session(profile.profile.id))!; + const result = await authenticatedFetch( + { + accessToken: ctx.session.accessToken, + dpopKeyPair: ctx.keyPair, + allowedOrigins: new Set(['https://pod.example']), + tokenEndpoint: ctx.session.tokenEndpoint, + }, + { + url: 'https://pod.example/shared', + method: 'GET', + headers: { Authorization: 'attacker', DPoP: 'attacker' }, + body: null, + fetchImpl: async (_url, init) => { + const headers = new Headers(init?.headers); + seen.push(headers.get('authorization')!); + const header = JSON.parse( + atob(headers.get('dpop')!.split('.')[0].replace(/-/g, '+').replace(/_/g, '/')), + ); + expect(header.jwk.d).toBeUndefined(); + expect(header.jwk.x).toBe( + (await crypto.subtle.exportKey('jwk', ctx.keyPair.publicKey)).x, + ); + return new Response('fixture'); + }, + }, + ); + expect(result.response.status).toBe(200); + } + expect(seen).toEqual([`DPoP ${a.session.accessToken}`, `DPoP ${b.session.accessToken}`]); + }); + + it('rejects swapped sessions and mismatched, missing or exportable keys', async () => { + const a = await login(); + const b = await login('bob'); + await expect(store.updateSession(a.profile.id, b.session)).rejects.toThrow('does not belong'); + await expect( + store.updateSession(a.profile.id, { ...a.session, issuer: 'https://other.example' }), + ).rejects.toThrow('does not belong'); + await expect( + store.updateSession(a.profile.id, { ...a.session, dpopJkt: b.session.dpopJkt }), + ).rejects.toThrow('does not belong'); + await expect(store.login(a.session, b.keyPair, a.display)).rejects.toThrow('Invalid profile'); + db.state.records[0].keyPair = b.keyPair; + expect(await store.session(a.profile.id)).toBeNull(); + expect((await store.session(b.profile.id))?.session).toEqual(b.session); + await expect(store.updateSession(a.profile.id, a.session)).rejects.toThrow('does not belong'); + await expect(store.capabilities(a.profile.id, () => {})).rejects.toThrow('not active'); + const newA = await login(); + db.state.records[0].keyPair = null; + expect(await store.session(newA.profile.id)).toBeNull(); + }); + + it.each([ + 'webId', + 'issuer', + ] as const)('rejects a persisted session whose %s was swapped across profiles', async (field) => { + const a = await login(); + db.state.records[0].session = { ...a.session, [field]: 'https://other.example/#me' }; + expect(await store.session('1')).toBeNull(); + }); + + it('returns only the allowlisted profile fields even if a stored display object has extra properties', async () => { + await login(); + Object.assign(db.state.records[0].profile, { accessToken: 'fixture-must-not-leak' }); + expect(JSON.stringify(await store.list())).not.toContain('fixture-must-not-leak'); + }); + + it('updates refreshed tokens only for their owning profile', async () => { + const a = await login(); + const b = await login('bob'); + await store.updateSession(a.profile.id, { ...a.session, accessToken: 'fixture-rotated' }); + expect((await store.session(a.profile.id))?.session.accessToken).toBe('fixture-rotated'); + expect((await store.session(b.profile.id))?.session.accessToken).toBe(b.session.accessToken); + }); + + it('enforces eight profiles under concurrent additions and permits re-login at the limit', async () => { + const credentials = await Promise.all( + Array.from({ length: 9 }, (_, i) => profileCredentials(String(i))), + ); + const results = await Promise.allSettled( + credentials.map((c) => store.login(c.session, c.keyPair, c.display)), + ); + expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(8); + expect(results[8].status).toBe('rejected'); + const a = credentials[0]; + await store.rename('1', 'Personal'); + await store.capabilities('1', (r) => { + r.grantedOrigins = ['https://app.example']; + }); + await store.login(a.session, a.keyPair, { ...a.display, name: null, photoUrl: null }); + expect((await store.list()).profiles).toHaveLength(8); + expect((await store.get('1')).profile.label).toBe('Personal'); + expect((await store.get('1')).grantedOrigins).toEqual(['https://app.example']); + }); + + it.each([ + '', + ' ', + 'x'.repeat(201), + null, + ])('rejects invalid labels without damaging the writer: %s', async (label) => { + await login(); + await expect(store.rename('1', label as string)).rejects.toThrow('label'); + await store.rename('1', 'Valid'); + expect((await store.get('1')).profile.label).toBe('Valid'); + }); + + it('revokes before purging, removes only the chosen profile and assigns a surviving default', async () => { + const a = await login(); + const b = await login('bob'); + await store.capabilities('1', (r) => { + r.grantedOrigins = ['https://app.example']; + r.classGrants = [fixtureGrant(a.session.webId)]; + }); + purge.mockImplementation(async () => { + const removing = await store.get('1'); + expect(removing.profile.sessionState).toBe('removing'); + expect(removing.session).toBeNull(); + expect(removing.keyPair).toBeNull(); + expect(removing.grantedOrigins).toEqual([]); + expect(removing.classGrants).toEqual([]); + expect(await store.session('1')).toBeNull(); + }); + await store.remove('1'); + expect(purge).toHaveBeenCalledWith(a.session.webId); + expect((await store.getDefault())?.profile.id).toBe('2'); + expect((await store.session('2'))?.session).toEqual(b.session); + purge.mockResolvedValue(undefined); + await store.remove('2'); + expect(await store.getDefault()).toBeNull(); + expect((await login('carol')).profile.id).toBe('3'); + }); + + it('retains a different default when removing a non-default profile', async () => { + await login(); + await login('bob'); + await store.remove('2'); + expect((await store.list()).defaultProfileId).toBe('1'); + }); + + it('fails closed on purge failure and resumes removal after suspension', async () => { + const a = await login(); + db.state.migrated = true; + purge.mockRejectedValueOnce(new Error('cache unavailable')); + await expect(store.remove('1')).rejects.toThrow('cache unavailable'); + expect(await store.session('1')).toBeNull(); + await expect(store.setDefault('1')).rejects.toThrow('removal pending'); + await expect(store.login(a.session, a.keyPair, a.display)).rejects.toThrow('removal pending'); + await new ProfileStore(db, purge).initialise(noLegacy, async () => {}); + expect((await store.list()).profiles).toEqual([]); + }); + + it('signs out one profile and purges before re-login while retaining its display entry', async () => { + const a = await login(); + const b = await login('bob'); + await store.signOut('1'); + expect((await store.get('1')).profile.sessionState).toBe('signed-out'); + expect((await store.session('2'))?.session).toEqual(b.session); + await store.login(a.session, a.keyPair, a.display); + expect(purge).toHaveBeenCalledTimes(2); + expect((await store.session('1'))?.session).toEqual(a.session); + }); + + it('rejects unknown profile ids rather than selecting the default', async () => { + await login(); + await expect(store.rename('absent', 'X')).rejects.toThrow('Unknown profile'); + await expect(store.remove('absent')).rejects.toThrow('Unknown profile'); + await expect(store.setDefault('absent')).rejects.toThrow('Unknown profile'); + await expect(store.signOut('absent')).rejects.toThrow('Unknown profile'); + }); +}); diff --git a/apps/extension/test/replica.test.ts b/apps/extension/test/replica.test.ts index 586e388b9..a140c1eca 100644 --- a/apps/extension/test/replica.test.ts +++ b/apps/extension/test/replica.test.ts @@ -7,7 +7,7 @@ // - the gate is the FIRST step on EVERY read AND write, INCLUDING a cache hit; // - the security-scoped key blocks a cross-user / cross-grant serve off a warm cache. -import { beforeEach, describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { type EgressPort, purgeReplica, @@ -419,6 +419,33 @@ describe('SharedReplica.write — synchronous write-through with If-Match', () = }); describe('purgeReplica — synchronous-before-serve logout purge', () => { + it.each([0, 1])('retains retry metadata when byte deletion %i fails', async (failureIndex) => { + const cache = new FakeCache(); + const db = new FakeMetaStore(); + await seedResource(cache, db, WEBID_A, SCOPE_PM, URL); + await seedResource(cache, db, WEBID_A, SCOPE_B, URL); + await seedResource(cache, db, WEBID_B, SCOPE_PM, URL); + const rows = [...db.rows.values()]; + const remove = cache.delete.bind(cache); + const failedKey = [...cache.bytes.keys()][failureIndex]; + const deletion = vi.spyOn(cache, 'delete').mockImplementation(async (request) => { + if (request.url === failedKey) throw new Error('fixture cache deletion failed'); + return remove(request); + }); + + await expect(purgeReplica(cache, db, WEBID_A)).rejects.toThrow('cache deletion failed'); + expect([...db.rows.values()]).toEqual(rows); + expect(cache.bytes.has(failedKey)).toBe(true); + expect(db.noncesCleared).toBe(0); + + deletion.mockRestore(); + // Already-deleted bytes return false on retry; absence still permits completion. + await purgeReplica(cache, db, WEBID_A); + expect([...db.rows.values()]).toEqual(rows.filter((row) => row.webId === WEBID_B)); + expect(cache.bytes.size).toBe(1); + expect(db.noncesCleared).toBe(1); + }); + it('drops exactly the prior identity rows + clears nonces (scoped purge)', async () => { const cache = new FakeCache(); const db = new FakeMetaStore(); diff --git a/apps/extension/test/service-worker-class-gate.test.ts b/apps/extension/test/service-worker-class-gate.test.ts index 09b45158c..ef6c4f998 100644 --- a/apps/extension/test/service-worker-class-gate.test.ts +++ b/apps/extension/test/service-worker-class-gate.test.ts @@ -17,8 +17,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { ClassGrant } from '../src/background/core/class-grant'; import { generateDpopKeyPair } from '../src/background/core/dpop'; import type { StoredDpopKeyRecord } from '../src/background/core/dpop-key-db'; +import { ProfileStore } from '../src/background/core/profile-store'; import type { ReplicaMetadata } from '../src/background/core/replica-db'; import type { StoredSession } from '../src/background/session-store'; +import { MemoryProfileDb, profileCredentials } from './profile-fixtures'; + +let profileDb: MemoryProfileDb; +vi.mock('../src/background/profile-db', () => ({ + ProfileDb: { open: async () => profileDb }, +})); // --- An in-memory DpopKeyDb double (see service-worker-gate.test.ts for the rationale) -- let idbDpopKey: StoredDpopKeyRecord | undefined; @@ -277,6 +284,7 @@ const APPX = { } satisfies CapturedSender; beforeEach(() => { + profileDb = new MemoryProfileDb(); installChrome(); installCaches(); cacheStore.clear(); @@ -365,7 +373,7 @@ describe('Phase-2 per-data-class gate (wired into the SW)', () => { // An expired grant no longer makes the origin a granted requester → forbidden-origin. expect(res.status).toBe(403); // The expired grant was pruned on the fetch path. - expect(storage.get('solid:class-grants')).toEqual([]); + expect(profileDb.state.records[0]?.classGrants).toEqual([]); }); it('an UNKNOWN / unresolved class (empty targets) is fail-closed DENY even if unexpired', async () => { @@ -637,7 +645,7 @@ describe('Phase-2 per-data-class gate (wired into the SW)', () => { evil, ); expect(rev.ok).toBe(false); - expect((storage.get('solid:class-grants') as unknown[]).length).toBe(1); + expect((profileDb.state.records[0]?.classGrants as unknown[]).length).toBe(1); }); it('CONCURRENT save + revoke do not resurrect the revoked grant (serialised store)', async () => { @@ -904,6 +912,63 @@ describe('Phase-2 requestAccess → consent → grant (wired)', () => { expect(denied.status).toBe(403); }); + it.each([ + 'switch', + 'remove', + 'signout', + 'relogin', + ] as const)('binds delayed consent to its original profile through %s', async (action) => { + await seedSession(); + await loadWorker(); + const store = new ProfileStore(profileDb, async () => {}); + const b = await profileCredentials('bob'); + await store.login(b.session, b.keyPair, { ...b.display, photoUrl: null }); + fetchImpl = (url) => routeFetch(url); + const request = send( + { + type: 'SOLID_REQUEST_ACCESS', + requestId: 'delayed', + classes: ['https://w3id.org/jeswr/sectors/finance#Transaction'], + modes: ['read'], + duration: 'P30D', + stampedOrigin: APPX.origin, + userActivated: true, + }, + APPX, + ); + for (let i = 0; i < 50 && createdWindows.length === 0; i++) + await new Promise((r) => setTimeout(r, 5)); + expect(createdWindows).toHaveLength(1); + const consentId = new URL(createdWindows[0]).searchParams.get('consentId'); + if (action === 'switch') + await send({ type: 'SOLID_PROFILE_SET_DEFAULT', profileId: '2' }, EXT_SENDER); + if (action === 'remove') + await send({ type: 'SOLID_PROFILE_REMOVE', profileId: '1' }, EXT_SENDER); + if (action === 'signout') await send({ type: 'SOLID_LOGOUT' }, EXT_SENDER); + if (action === 'relogin') { + const a = await profileCredentials('alice'); + const session = { ...a.session, webId: profileDb.state.records[0].profile.webId }; + await store.login(session, a.keyPair, { ...a.display, webId: session.webId, photoUrl: null }); + } + await send( + { + type: 'SOLID_CONSENT_DECISION', + consentId, + approvedClasses: ['https://w3id.org/jeswr/sectors/finance#Transaction'], + }, + EXT_SENDER, + ); + const result = await request; + expect((await store.get('2')).classGrants).toEqual([]); + if (action === 'switch') { + expect(result.ok).toBe(true); + expect((await store.get('1')).classGrants).toHaveLength(1); + } else { + expect(result.error).toMatch(/session changed/); + expect(profileDb.state.records.every((r) => r.classGrants.length === 0)).toBe(true); + } + }); + it('a DENY-all consent decision mints NO grant', async () => { await seedSession(); await loadWorker(); @@ -930,7 +995,7 @@ describe('Phase-2 requestAccess → consent → grant (wired)', () => { const res = await reqPromise; expect(res.ok).toBe(false); expect(res.grants).toEqual([]); - expect(storage.get('solid:class-grants') ?? []).toEqual([]); + expect(profileDb.state.records[0]?.classGrants ?? []).toEqual([]); }); it('a NON-extension sender cannot post a consent decision (fail-closed)', async () => { @@ -966,7 +1031,7 @@ describe('Phase-2 requestAccess → consent → grant (wired)', () => { ); // The forged decision did not resolve the request; the grant store stays empty. await new Promise((r) => setTimeout(r, 20)); - expect(storage.get('solid:class-grants') ?? []).toEqual([]); + expect(profileDb.state.records[0]?.classGrants ?? []).toEqual([]); void reqPromise; }); diff --git a/apps/extension/test/service-worker-gate.test.ts b/apps/extension/test/service-worker-gate.test.ts index 73f69efc9..be162f3ce 100644 --- a/apps/extension/test/service-worker-gate.test.ts +++ b/apps/extension/test/service-worker-gate.test.ts @@ -14,11 +14,19 @@ // - login from an app origin GRANTS that origin (the opt-in), unlocking subsequent fetches // - SOLID_GET_STATE withholds the WebID (PII) from a non-granted page, exposes to granted -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as jose from 'jose'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { generateDpopKeyPair } from '../src/background/core/dpop'; import type { StoredDpopKeyRecord } from '../src/background/core/dpop-key-db'; +import { ProfileStore } from '../src/background/core/profile-store'; import type { ReplicaMetadata } from '../src/background/core/replica-db'; import type { StoredSession } from '../src/background/session-store'; +import { fixtureGrant, MemoryProfileDb, profileCredentials } from './profile-fixtures'; + +let profileDb: MemoryProfileDb; +vi.mock('../src/background/profile-db', () => ({ + ProfileDb: { open: async () => profileDb }, +})); // --- An in-memory DpopKeyDb double (Phase-2 security hardening) ----------------------- // The DPoP private key is no longer carried inline on `StoredSession` — it lives as a @@ -278,6 +286,8 @@ async function loadWorker(): Promise { } beforeEach(() => { + vi.doUnmock('../src/background/auth-flow'); + profileDb = new MemoryProfileDb(); installChrome(); installCaches(); cacheStore.clear(); @@ -287,6 +297,42 @@ beforeEach(() => { idbDpopKeyGetError = null; }); +afterEach(() => { + vi.unstubAllGlobals(); +}); + +/** Exercise the real asynchronous icon renderer without a browser canvas. */ +function installIconCanvas(): void { + vi.stubGlobal( + 'OffscreenCanvas', + class { + constructor( + private readonly width: number, + private readonly height: number, + ) {} + getContext() { + return { + beginPath() {}, + roundRect() {}, + fill() {}, + fillText() {}, + save() {}, + arc() {}, + clip() {}, + drawImage() {}, + restore() {}, + getImageData: () => ({ + width: this.width, + height: this.height, + data: new Uint8ClampedArray(4), + }), + }; + } + }, + ); + vi.stubGlobal('createImageBitmap', async () => ({ width: 1, height: 1, close() {} })); +} + describe('service-worker per-requesting-origin gate (wired)', () => { it('the legitimate owner path: a GRANTED app reads its pod → SUCCEEDS with egress', async () => { await seedSession(); @@ -318,7 +364,7 @@ describe('service-worker per-requesting-origin gate (wired)', () => { // A fresh SW "restart" (new module instance, empty in-memory caches) that happens to hit // a transient DB-open failure on its cold-wake key load — set BEFORE the restart so it // also covers the import-time `ensureSession()` call `loadWorker()` triggers. - idbDpopKeyGetError = new Error('simulated IndexedDB open failure'); + profileDb.error = new Error('simulated IndexedDB open failure'); await loadWorker(); const failed = await sendFetch( { url: 'https://alice.pod.example/private/notes.ttl', stampedOrigin: 'https://pm.example' }, @@ -328,11 +374,11 @@ describe('service-worker per-requesting-origin gate (wired)', () => { // No pod egress happened for the failed attempt. expect(fetchCalls.some((c) => c.url.startsWith('https://alice.pod.example'))).toBe(false); // Crucially: the session was NOT torn down — it is still in storage, untouched. - expect(storage.get('solid:session')).toBeDefined(); + expect(profileDb.state.records[0].session).not.toBeNull(); // The transient condition clears (no restart needed — `cachedKeyPair` was never // populated by the failed attempts, so the very next call retries the DB read). - idbDpopKeyGetError = null; + profileDb.error = null; const ok = await sendFetch( { url: 'https://alice.pod.example/private/notes.ttl', stampedOrigin: 'https://pm.example' }, { origin: 'https://pm.example', url: 'https://pm.example/app.html' }, @@ -511,7 +557,7 @@ describe('login GRANTS the requesting origin ONLY on success (privilege-escalati // The login failed (no IdP) ... expect(res.error).toBeDefined(); // ... so NO grant was persisted (the privilege-escalation fix). - const grants = (storage.get('solid:granted-origins') as string[]) ?? []; + const grants = (profileDb.state.records[0]?.grantedOrigins as string[]) ?? []; expect(grants).not.toContain('https://newapp.example'); }); @@ -533,7 +579,7 @@ describe('login GRANTS the requesting origin ONLY on success (privilege-escalati { origin: 'https://hostile.example', url: 'https://hostile.example/x' }, ); expect(loginRes.error).toBeDefined(); - const grants = (storage.get('solid:granted-origins') as string[]) ?? []; + const grants = (profileDb.state.records[0]?.grantedOrigins as string[]) ?? []; expect(grants).not.toContain('https://hostile.example'); // The failing OIDC flow itself may make network calls (discovery/profile) on the raw fetch; @@ -571,7 +617,7 @@ describe('login GRANTS the requesting origin ONLY on success (privilege-escalati expiresAt: Date.now() + 60 * 60 * 1000, }; vi.doMock('../src/background/auth-flow', () => ({ - initiateLogin: vi.fn(async () => ({ session, name: 'Alice', photoUrl: null })), + initiateLogin: vi.fn(async () => ({ session, keyPair, name: 'Alice', photoUrl: null })), refreshSession: vi.fn(async (s: StoredSession) => s), })); await loadWorker(); @@ -585,7 +631,7 @@ describe('login GRANTS the requesting origin ONLY on success (privilege-escalati { origin: 'https://newapp.example', url: 'https://newapp.example/x' }, ); expect(loginRes.ok).toBe(true); - const grants = (storage.get('solid:granted-origins') as string[]) ?? []; + const grants = (profileDb.state.records[0]?.grantedOrigins as string[]) ?? []; expect(grants).toContain('https://newapp.example'); // The now-granted origin can read its pod. @@ -614,7 +660,7 @@ describe('login GRANTS the requesting origin ONLY on success (privilege-escalati expiresAt: Date.now() + 60 * 60 * 1000, }; vi.doMock('../src/background/auth-flow', () => ({ - initiateLogin: vi.fn(async () => ({ session, name: 'Alice', photoUrl: null })), + initiateLogin: vi.fn(async () => ({ session, keyPair, name: 'Alice', photoUrl: null })), refreshSession: vi.fn(async (s: StoredSession) => s), })); await loadWorker(); @@ -627,7 +673,7 @@ describe('login GRANTS the requesting origin ONLY on success (privilege-escalati }, { origin: 'https://attacker.example', url: 'https://attacker.example/x' }, // real sender ); - const grants = (storage.get('solid:granted-origins') as string[]) ?? []; + const grants = (profileDb.state.records[0]?.grantedOrigins as string[]) ?? []; // The forged origin resolves to null → no grant for victim OR attacker, even on success. expect(grants).not.toContain('https://victim.example'); expect(grants).not.toContain('https://attacker.example'); @@ -862,3 +908,1247 @@ describe('Round-2 HIGH #1: autoDivert returns a PASSTHROUGH SENTINEL — the SW expect(fetchCalls.some((c) => c.url.startsWith('https://api.thirdparty.example'))).toBe(false); }); }); + +const PROFILE_OWNER = { + origin: `chrome-extension://${EXTENSION_ID}`, + url: `chrome-extension://${EXTENSION_ID}/popup/popup.html`, +}; +const PROFILE_APP_A = { origin: 'https://appa.example', url: 'https://appa.example/app' }; +const PROFILE_APP_B = { origin: 'https://appb.example', url: 'https://appb.example/app' }; + +async function twoProfiles() { + profileDb.state.migrated = true; + const store = new ProfileStore(profileDb, async () => {}); + const a = await profileCredentials(); + const b = await profileCredentials('bob'); + await store.login(a.session, a.keyPair, { ...a.display, photoUrl: null }); + await store.login(b.session, b.keyPair, { ...b.display, photoUrl: null }); + await store.capabilities('1', (r) => { + r.grantedOrigins = [PROFILE_APP_A.origin]; + r.podOrigins = ['https://alice-only.example']; + r.classGrants = [fixtureGrant(a.session.webId)]; + }); + await store.capabilities('2', (r) => { + r.grantedOrigins = [PROFILE_APP_B.origin]; + r.classGrants = [fixtureGrant(b.session.webId)]; + }); + return { store, a, b }; +} + +function profileFetch(sender = PROFILE_APP_A, extra: Record = {}) { + return sendFetch( + { url: 'https://pod.example/private/notes', stampedOrigin: sender.origin, ...extra }, + sender, + ); +} + +function profileMessage(type: string, extra: Record = {}) { + return sendMessage({ type, ...extra }, PROFILE_OWNER); +} + +describe('ProfileStore runtime API and adversarial isolation', () => { + const actions = [ + { type: 'SOLID_PROFILES_LIST' }, + { type: 'SOLID_PROFILE_ADD', webId: 'https://hostile.example/#me' }, + { type: 'SOLID_PROFILE_RENAME', profileId: '1', label: 'Hijacked' }, + { type: 'SOLID_PROFILE_REMOVE', profileId: '1' }, + { type: 'SOLID_PROFILE_SET_DEFAULT', profileId: '2' }, + ]; + + it.each( + actions, + )('rejects untrusted $type messages before any state or network change', async (action) => { + await twoProfiles(); + await loadWorker(); + const before = structuredClone(profileDb.state); + const callsBefore = fetchCalls.length; + for (const sender of [ + PROFILE_APP_A, + {}, + { origin: 'null' }, + { origin: PROFILE_APP_A.origin, url: PROFILE_OWNER.url }, + { origin: PROFILE_OWNER.origin, url: PROFILE_APP_A.url }, + { url: `${PROFILE_OWNER.origin}.evil/popup.html` }, + ]) { + const result = await sendMessage(action, sender); + expect(result.error).toMatch(/extension context/); + expect(result.profiles).toBeUndefined(); + } + expect(profileDb.state).toEqual(before); + expect(fetchCalls).toHaveLength(callsBefore); + }); + + it('lists, renames and switches with token-free metadata, persisting the default across wake', async () => { + await twoProfiles(); + await loadWorker(); + const list = await profileMessage('SOLID_PROFILES_LIST'); + expect(list.defaultProfileId).toBe('1'); + expect(list.profiles).toHaveLength(2); + const serialized = JSON.stringify(list); + for (const forbidden of [ + 'accessToken', + 'refreshToken', + 'privateKey', + 'fixture-access', + 'fixture-refresh', + ]) { + expect(serialized).not.toContain(forbidden); + } + expect( + (await profileMessage('SOLID_PROFILE_RENAME', { profileId: '2', label: 'Work' })).ok, + ).toBe(true); + const switched = await profileMessage('SOLID_PROFILE_SET_DEFAULT', { profileId: '2' }); + expect(switched.defaultProfileId).toBe('2'); + await loadWorker(); + const view = await sendMessage({ type: 'SOLID_GET_STATE' }, PROFILE_OWNER); + expect(view).toMatchObject({ + webId: 'https://pod.example/bob/profile#me', + name: 'Work', + isActive: true, + }); + expect( + (await profileMessage('SOLID_PROFILE_RENAME', { profileId: '2', label: '' })).error, + ).toMatch(/label/); + for (const type of [ + 'SOLID_PROFILE_RENAME', + 'SOLID_PROFILE_REMOVE', + 'SOLID_PROFILE_SET_DEFAULT', + ]) { + expect((await profileMessage(type, { profileId: 'absent', label: 'X' })).error).toMatch( + /Unknown profile/, + ); + } + // Chrome also supplies a URL-only sender on some extension surfaces. + expect( + (await sendMessage({ type: 'SOLID_PROFILES_LIST' }, { url: PROFILE_OWNER.url })).ok, + ).toBe(true); + }); + + it('adds profiles through login, retaining the old default and refusing a ninth before opening auth', async () => { + const credentials = await profileCredentials('first'); + let next = credentials; + const initiateLogin = vi.fn(async () => ({ + ...next, + name: next.display.name, + photoUrl: next.display.photoUrl, + })); + vi.doMock('../src/background/auth-flow', () => ({ initiateLogin, refreshSession: vi.fn() })); + await loadWorker(); + expect( + (await profileMessage('SOLID_PROFILE_ADD', { webId: credentials.session.webId })) + .defaultProfileId, + ).toBe('1'); + for (let i = 2; i <= 8; i++) { + next = await profileCredentials(String(i)); + const result = await profileMessage('SOLID_PROFILE_ADD', { webId: next.session.webId }); + expect(result.ok).toBe(true); + expect(result.defaultProfileId).toBe('1'); + } + expect(profileDb.state.records).toHaveLength(8); + expect(profileDb.state.records[1].grantedOrigins).toEqual([]); + const rejected = await profileMessage('SOLID_PROFILE_ADD', { + webId: 'https://ninth.example/#me', + }); + expect(rejected.error).toMatch(/8 profiles/); + expect(initiateLogin).toHaveBeenCalledTimes(8); + next = credentials; + expect((await profileMessage('SOLID_PROFILE_ADD', { webId: next.session.webId })).ok).toBe( + true, + ); + expect(profileDb.state.records).toHaveLength(8); + initiateLogin.mockRejectedValueOnce(new Error('fixture cancelled')); + expect((await profileMessage('SOLID_PROFILE_ADD', { webId: next.session.webId })).error).toBe( + 'fixture cancelled', + ); + expect(storage.has('solid:session')).toBe(false); + expect(idbDpopKey).toBeUndefined(); + }); + + it('broadcasts each identity only to its own granted tabs', async () => { + const { a, b } = await twoProfiles(); + const tabs = [ + { id: 1, url: PROFILE_APP_A.url }, + { id: 2, url: PROFILE_APP_B.url }, + { id: 3, url: 'https://ungranted.example/' }, + ]; + chrome.tabs.query = vi.fn().mockResolvedValue(tabs); + const send = vi.spyOn(chrome.tabs, 'sendMessage'); + await loadWorker(); + expect(send.mock.calls).toEqual([ + [1, { type: 'SOLID_STATE_CHANGED', webId: a.session.webId }], + [2, { type: 'SOLID_STATE_CHANGED', webId: null }], + [3, { type: 'SOLID_STATE_CHANGED', webId: null }], + ]); + send.mockClear(); + await profileMessage('SOLID_PROFILE_SET_DEFAULT', { profileId: '2' }); + expect(send.mock.calls).toEqual([ + [1, { type: 'SOLID_STATE_CHANGED', webId: null }], + [2, { type: 'SOLID_STATE_CHANGED', webId: b.session.webId }], + [3, { type: 'SOLID_STATE_CHANGED', webId: null }], + ]); + }); + + it('never authorizes an earlier identity with a newly selected profile grant', async () => { + const { b } = await twoProfiles(); + await loadWorker(); + chrome.tabs.query = vi.fn().mockResolvedValue([{ id: 2, url: PROFILE_APP_B.url }]); + const send = vi.spyOn(chrome.tabs, 'sendMessage'); + const { ProfileStore: WorkerStore } = await import('../src/background/core/profile-store'); + const selected = structuredClone(profileDb.state.records[0]); + let release!: () => void; + let entered!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + const started = new Promise((resolve) => { + entered = resolve; + }); + vi.spyOn(WorkerStore.prototype, 'getDefault').mockImplementationOnce(async () => { + entered(); + await held; + return selected; + }); + const old = profileMessage('SOLID_PROFILE_RENAME', { profileId: '1', label: 'Personal' }); + await started; + await profileMessage('SOLID_PROFILE_SET_DEFAULT', { profileId: '2' }); + release(); + await old; + expect(send.mock.calls).toEqual([[2, { type: 'SOLID_STATE_CHANGED', webId: b.session.webId }]]); + }); + + it('discards a broadcast held across a default change, including the runtime copy', async () => { + const { b } = await twoProfiles(); + await loadWorker(); + let release!: (tabs: Array<{ id: number; url: string }>) => void; + let entered!: () => void; + const started = new Promise((resolve) => { + entered = resolve; + }); + const held = new Promise>((resolve) => { + release = resolve; + }); + const tabs = [{ id: 2, url: PROFILE_APP_B.url }]; + chrome.tabs.query = vi + .fn() + .mockImplementationOnce(() => { + entered(); + return held; + }) + .mockResolvedValue(tabs); + const send = vi.spyOn(chrome.tabs, 'sendMessage'); + const runtime = vi.spyOn(chrome.runtime, 'sendMessage'); + const old = profileMessage('SOLID_PROFILE_RENAME', { profileId: '1', label: 'Personal' }); + await started; + await profileMessage('SOLID_PROFILE_SET_DEFAULT', { profileId: '2' }); + release(tabs); + await old; + expect(send.mock.calls).toEqual([[2, { type: 'SOLID_STATE_CHANGED', webId: b.session.webId }]]); + expect(runtime.mock.calls).toEqual([[{ type: 'SOLID_STATE_CHANGED', webId: b.session.webId }]]); + }); + + it.each([ + { next: 'profile', photoOk: true }, + { next: 'profile', photoOk: false }, + { next: 'logout', photoOk: true }, + ])('discards delayed avatar mutations after $next (photo success: $photoOk)', async ({ + next, + photoOk, + }) => { + await twoProfiles(); + await loadWorker(); + installIconCanvas(); + const avatar = 'https://pod.example/alice.png'; + profileDb.state.records[0].profile = { ...profileDb.state.records[0].profile, avatar }; + let entered!: () => void; + let release!: () => void; + const started = new Promise((resolve) => { + entered = resolve; + }); + const held = new Promise((resolve) => { + release = resolve; + }); + fetchImpl = async (url) => { + expect(url).toBe(avatar); + entered(); + await held; + return new Response('fixture photo', { status: photoOk ? 200 : 404 }); + }; + const icon = vi.spyOn(chrome.action, 'setIcon'); + const badge = vi.spyOn(chrome.action, 'setBadgeText'); + const background = vi.spyOn(chrome.action, 'setBadgeBackgroundColor'); + const title = vi.spyOn(chrome.action, 'setTitle'); + const old = profileMessage('SOLID_PROFILE_RENAME', { profileId: '1', label: 'Personal' }); + await started; + if (next === 'profile') await profileMessage('SOLID_PROFILE_SET_DEFAULT', { profileId: '2' }); + else await profileMessage('SOLID_LOGOUT'); + const latest = { + icon: [...icon.mock.calls], + badge: [...badge.mock.calls], + background: [...background.mock.calls], + title: [...title.mock.calls], + }; + expect(title).toHaveBeenLastCalledWith({ + title: next === 'profile' ? 'Signed in as bob' : 'Sign in to Solid', + }); + expect(badge).toHaveBeenLastCalledWith({ text: next === 'profile' ? ' ' : '' }); + release(); + expect((await old).ok).toBe(true); + expect(icon.mock.calls).toEqual(latest.icon); + expect(badge.mock.calls).toEqual(latest.badge); + expect(background.mock.calls).toEqual(latest.background); + expect(title.mock.calls).toEqual(latest.title); + }); + + it.each([ + 'active', + 'signed-out', + ])('stops an obsolete %s toolbar update after an awaited mutation', async (state) => { + await twoProfiles(); + await loadWorker(); + installIconCanvas(); + let entered!: () => void; + let release!: () => void; + const started = new Promise((resolve) => { + entered = resolve; + }); + const held = new Promise((resolve) => { + release = resolve; + }); + vi.spyOn(chrome.action, 'setIcon').mockImplementationOnce(async () => { + entered(); + await held; + }); + const badge = vi.spyOn(chrome.action, 'setBadgeText'); + const title = vi.spyOn(chrome.action, 'setTitle'); + const old = + state === 'active' + ? profileMessage('SOLID_PROFILE_RENAME', { profileId: '1', label: 'Personal' }) + : profileMessage('SOLID_LOGOUT'); + await started; + await profileMessage('SOLID_PROFILE_SET_DEFAULT', { profileId: '2' }); + const latestBadge = [...badge.mock.calls]; + const latestTitle = [...title.mock.calls]; + release(); + expect((await old).ok).toBe(true); + expect(badge.mock.calls).toEqual(latestBadge); + expect(title.mock.calls).toEqual(latestTitle); + expect(title).toHaveBeenLastCalledWith({ title: 'Signed in as bob' }); + }); + + it('never uses P1 grants, tokens, keys or pod settings for P2, even on a shared pod origin', async () => { + const { a, b } = await twoProfiles(); + await loadWorker(); + expect((await profileFetch()).status).toBe(200); + await profileMessage('SOLID_PROFILE_SET_DEFAULT', { profileId: '2' }); + const before = fetchCalls.length; + expect((await profileFetch(PROFILE_APP_A, { profileId: '1' })).status).toBe(403); + expect(fetchCalls).toHaveLength(before); + const hidden = await sendMessage( + { type: 'SOLID_GET_STATE', stampedOrigin: PROFILE_APP_A.origin }, + PROFILE_APP_A, + ); + expect(hidden.webId).toBeNull(); + expect((await profileFetch(PROFILE_APP_B, { profileId: '1' })).status).toBe(200); + const podCalls = fetchCalls.filter((c) => c.url === 'https://pod.example/private/notes'); + expect(podCalls).toHaveLength(2); + for (const [call, credentials] of [ + [podCalls[0], a], + [podCalls[1], b], + ] as const) { + const headers = new Headers(call.init?.headers); + expect(headers.get('authorization')).toBe(`DPoP ${credentials.session.accessToken}`); + const proof = headers.get('dpop')!; + const publicJwk = await crypto.subtle.exportKey('jwk', credentials.keyPair.publicKey); + expect(jose.decodeProtectedHeader(proof).jwk).toMatchObject({ + x: publicJwk.x, + y: publicJwk.y, + }); + await expect(jose.jwtVerify(proof, credentials.keyPair.publicKey)).resolves.toBeDefined(); + } + expect( + (await profileFetch(PROFILE_APP_B, { url: 'https://alice-only.example/private' })).status, + ).toBe(403); + }); + + it('keeps an in-flight P1 request bound to P1 when the default changes to P2', async () => { + const { a } = await twoProfiles(); + await loadWorker(); + let release!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + let entered!: () => void; + const started = new Promise((resolve) => { + entered = resolve; + }); + fetchImpl = async () => { + entered(); + await held; + return new Response('P1-only'); + }; + const pending = profileFetch(); + await started; + await profileMessage('SOLID_PROFILE_SET_DEFAULT', { profileId: '2' }); + release(); + expect((await pending).body).toBe('P1-only'); + expect(new Headers(fetchCalls.at(-1)?.init?.headers).get('authorization')).toBe( + `DPoP ${a.session.accessToken}`, + ); + expect(profileDb.state.defaultProfileId).toBe('2'); + }); + + it('cancels in-flight reads and purges only the removed profile', async () => { + const { a, b } = await twoProfiles(); + await loadWorker(); + fetchImpl = async () => new Response('fixture bytes', { headers: { etag: 'fixture-etag' } }); + await profileFetch(); + await profileMessage('SOLID_PROFILE_SET_DEFAULT', { profileId: '2' }); + await profileFetch(PROFILE_APP_B); + expect(idbMeta.size).toBe(2); + await profileMessage('SOLID_PROFILE_SET_DEFAULT', { profileId: '1' }); + let release!: () => void; + let entered!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + const started = new Promise((resolve) => { + entered = resolve; + }); + fetchImpl = async () => { + entered(); + await held; + return new Response('late P1 bytes'); + }; + const read = profileFetch(); + await started; + const signal = fetchCalls.at(-1)?.init?.signal; + const removal = await profileMessage('SOLID_PROFILE_REMOVE', { profileId: '1' }); + expect(removal.defaultProfileId).toBe('2'); + expect(await read).toEqual({ requestId: 'r1', error: 'Fetch cancelled' }); + expect(signal?.aborted).toBe(true); + release(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(profileDb.state.records.map((r) => r.profile.id)).toEqual(['2']); + expect([...idbMeta.values()].every((m) => m.webId === b.session.webId)).toBe(true); + expect([...idbMeta.values()].some((m) => m.webId === a.session.webId)).toBe(false); + expect(cacheStore.size).toBe(1); + expect(profileDb.state.records[0].session).toEqual(b.session); + expect(profileDb.state.records[0].classGrants).toHaveLength(1); + await loadWorker(); + expect((await profileFetch(PROFILE_APP_B)).status).toBe(200); + expect( + (await profileMessage('SOLID_PROFILE_REMOVE', { profileId: '2' })).defaultProfileId, + ).toBeNull(); + expect(cacheStore.size).toBe(0); + }); + + it('serves concurrent reads and passthrough decisions while another request is stalled', async () => { + await twoProfiles(); + await loadWorker(); + let release!: () => void; + let entered!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + const started = new Promise((resolve) => { + entered = resolve; + }); + fetchImpl = async (url) => { + if (url.endsWith('/slow')) { + entered(); + await held; + } + return new Response('fixture bytes'); + }; + const slow = profileFetch(PROFILE_APP_A, { url: 'https://pod.example/slow' }); + await started; + const openCache = vi.spyOn(caches, 'open'); + expect( + await profileFetch(PROFILE_APP_A, { + url: 'https://thirdparty.example/api', + autoDivert: true, + requestId: 'passthrough', + }), + ).toEqual({ requestId: 'passthrough', passthrough: true }); + expect(openCache).not.toHaveBeenCalled(); + expect((await profileFetch(PROFILE_APP_A, { url: 'https://pod.example/fast' })).status).toBe( + 200, + ); + expect(fetchCalls).toHaveLength(2); + release(); + expect((await slow).status).toBe(200); + }); + + it.each([ + { lifecycle: 'SOLID_LOGOUT', method: 'GET' }, + { lifecycle: 'SOLID_PROFILE_REMOVE', method: 'GET' }, + { lifecycle: 'SOLID_LOGOUT', method: 'PUT' }, + { lifecycle: 'SOLID_PROFILE_REMOVE', method: 'PUT' }, + ])('cancels a stalled $method response body during $lifecycle without late persistence', async ({ + lifecycle, + method, + }) => { + const { a, b } = await twoProfiles(); + await loadWorker(); + let stream!: ReadableStreamDefaultController; + let entered!: () => void; + const started = new Promise((resolve) => { + entered = resolve; + }); + fetchImpl = async () => { + const body = new ReadableStream({ + start(controller) { + stream = controller; + }, + pull() { + entered(); + }, + }); + return new Response(body, { headers: { etag: 'fixture-etag', 'dpop-nonce': 'late-nonce' } }); + }; + const pending = profileFetch(PROFILE_APP_A, { + method, + body: method === 'PUT' ? 'fixture write' : null, + }); + await started; + expect((await profileMessage(lifecycle, { profileId: '1' })).ok).toBe(true); + expect(await pending).toEqual({ requestId: 'r1', error: 'Fetch cancelled' }); + expect(fetchCalls[0].init?.signal?.aborted).toBe(true); + stream.enqueue(new TextEncoder().encode('late private bytes')); + stream.close(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(cacheStore.size).toBe(0); + expect([...idbMeta.values()].some((record) => record.webId === a.session.webId)).toBe(false); + expect(idbNonces.size).toBe(0); + expect(profileDb.state.records.find((record) => record.profile.id === '2')?.session).toEqual( + b.session, + ); + }); + + it.each([ + 'SOLID_LOGOUT', + 'SOLID_PROFILE_REMOVE', + ])('does not wait for a refresh flight during %s or restore its late tokens', async (lifecycle) => { + const { a, b } = await twoProfiles(); + await loadWorker(); + profileDb.state.records[0].session = { ...a.session, expiresAt: 0 }; + let entered!: () => void; + let release!: () => void; + const started = new Promise((resolve) => { + entered = resolve; + }); + const held = new Promise((resolve) => { + release = resolve; + }); + fetchImpl = async (url) => { + expect(url).toBe(a.session.tokenEndpoint); + entered(); + await held; + return Response.json({ + access_token: 'late-access', + refresh_token: 'late-refresh', + expires_in: 3600, + }); + }; + const pending = profileFetch(); + await started; + expect((await profileMessage(lifecycle, { profileId: '1' })).ok).toBe(true); + expect(await pending).toEqual({ requestId: 'r1', error: 'Not authenticated' }); + release(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect( + profileDb.state.records.find((record) => record.profile.id === '1')?.session ?? null, + ).toBeNull(); + expect(profileDb.state.records.find((record) => record.profile.id === '2')?.session).toEqual( + b.session, + ); + expect(fetchCalls).toHaveLength(1); + expect(cacheStore.size).toBe(0); + expect(idbMeta.size).toBe(0); + }); + + it.each([ + { lifecycle: 'SOLID_LOGOUT', phase: 'headers' }, + { lifecycle: 'SOLID_PROFILE_REMOVE', phase: 'headers' }, + { lifecycle: 'SOLID_LOGIN', phase: 'headers' }, + { lifecycle: 'SOLID_LOGOUT', phase: 'body' }, + { lifecycle: 'SOLID_PROFILE_REMOVE', phase: 'body' }, + { lifecycle: 'SOLID_LOGIN', phase: 'body' }, + ])('never replays an automatic POST cancelled during $phase by $lifecycle', async ({ + lifecycle, + phase, + }) => { + const { a } = await twoProfiles(); + const replacement = await profileCredentials(); + vi.doMock('../src/background/auth-flow', () => ({ + initiateLogin: vi.fn(async () => ({ ...replacement, name: 'alice', photoUrl: null })), + refreshSession: vi.fn(), + })); + await loadWorker(); + let entered!: () => void; + let release!: () => void; + const started = new Promise((resolve) => { + entered = resolve; + }); + const held = new Promise((resolve) => { + release = resolve; + }); + fetchImpl = async (url) => { + if (url !== 'https://pod.example/private/notes') return new Response(''); + if (phase === 'headers') { + entered(); + await held; + return new Response('created', { status: 201 }); + } + return new Response( + new ReadableStream({ + async start(controller) { + entered(); + await held; + controller.enqueue(new TextEncoder().encode('created')); + controller.close(); + }, + }), + { status: 201 }, + ); + }; + const pending = profileFetch(PROFILE_APP_A, { + method: 'POST', + body: 'fixture mutation', + autoDivert: true, + requestId: 'cancelled-diversion', + }); + await started; + const signal = fetchCalls[0].init?.signal; + expect((await profileMessage(lifecycle, { profileId: '1', webId: a.session.webId })).ok).toBe( + true, + ); + expect(await pending).toEqual({ requestId: 'cancelled-diversion', error: 'Fetch cancelled' }); + expect(signal?.aborted).toBe(true); + release(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect( + fetchCalls.filter((call) => call.url === 'https://pod.example/private/notes'), + ).toHaveLength(1); + expect(cacheStore.size).toBe(0); + expect(idbMeta.size).toBe(0); + }); + + it('returns passthrough when logout cancels an automatic POST before egress', async () => { + await twoProfiles(); + await loadWorker(); + let entered!: () => void; + let release!: () => void; + const started = new Promise((resolve) => { + entered = resolve; + }); + const held = new Promise((resolve) => { + release = resolve; + }); + vi.spyOn(FakeReplicaDb.prototype, 'getNonce').mockImplementationOnce(async () => { + entered(); + await held; + return undefined; + }); + const pending = profileFetch(PROFILE_APP_A, { + method: 'POST', + body: 'fixture mutation', + autoDivert: true, + requestId: 'unsent-diversion', + }); + await started; + expect((await profileMessage('SOLID_LOGOUT')).ok).toBe(true); + expect(await pending).toEqual({ requestId: 'unsent-diversion', passthrough: true }); + release(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(fetchCalls).toHaveLength(0); + expect(cacheStore.size).toBe(0); + expect(idbMeta.size).toBe(0); + }); + + it('finishes an atomic local replica commit before purging its bytes and metadata', async () => { + await twoProfiles(); + await loadWorker(); + const cache = await caches.open('solid-ext-replica-bytes'); + const put = cache.put.bind(cache); + let release!: () => void; + let entered!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + const started = new Promise((resolve) => { + entered = resolve; + }); + vi.spyOn(cache, 'put').mockImplementation(async (request, response) => { + await put(request, response); + entered(); + await held; + }); + vi.spyOn(caches, 'open').mockResolvedValue(cache); + const pending = profileFetch(); + await started; + expect(cacheStore.size).toBe(1); + expect(idbMeta.size).toBe(0); + const removal = profileMessage('SOLID_PROFILE_REMOVE', { profileId: '1' }); + release(); + expect((await removal).ok).toBe(true); + expect(await pending).toEqual({ requestId: 'r1', error: 'Fetch cancelled' }); + expect(cacheStore.size).toBe(0); + expect(idbMeta.size).toBe(0); + }); + + it('keeps failed removal retryable until replica bytes are deleted on wake', async () => { + const { a, b } = await twoProfiles(); + await loadWorker(); + expect((await profileFetch()).status).toBe(200); + expect( + (await profileFetch(PROFILE_APP_A, { url: 'https://pod.example/private/second' })).status, + ).toBe(200); + const failedKey = [...cacheStore.keys()][1]; + await profileMessage('SOLID_PROFILE_SET_DEFAULT', { profileId: '2' }); + expect((await profileFetch(PROFILE_APP_B)).status).toBe(200); + await profileMessage('SOLID_PROFILE_SET_DEFAULT', { profileId: '1' }); + const cache = await caches.open('solid-ext-replica-bytes'); + const remove = cache.delete.bind(cache); + let fail = true; + vi.spyOn(cache, 'delete').mockImplementation(async (request, options) => { + if ((request as Request).url === failedKey && fail) + throw new Error('fixture cache deletion failed'); + return remove(request, options); + }); + vi.spyOn(caches, 'open').mockResolvedValue(cache); + + const result = await profileMessage('SOLID_PROFILE_REMOVE', { profileId: '1' }); + expect(result.error).toBe('fixture cache deletion failed'); + expect(profileDb.state.defaultProfileId).toBe('1'); + expect(profileDb.state.records[0]).toMatchObject({ + profile: { sessionState: 'removing' }, + session: null, + keyPair: null, + grantedOrigins: [], + classGrants: [], + }); + expect(profileDb.state.records[1].session).toEqual(b.session); + expect([...idbMeta.values()].filter((row) => row.webId === a.session.webId)).toHaveLength(2); + expect(cacheStore.has(failedKey)).toBe(true); + expect(cacheStore.size).toBe(2); + expect((await profileFetch()).error).toBe('Not authenticated'); + + await loadWorker(); + expect((await profileMessage('SOLID_PROFILES_LIST')).error).toBe( + 'fixture cache deletion failed', + ); + expect(profileDb.state.records[0].profile.sessionState).toBe('removing'); + expect(cacheStore.has(failedKey)).toBe(true); + + fail = false; + await loadWorker(); + expect(await profileMessage('SOLID_PROFILES_LIST')).toMatchObject({ + ok: true, + defaultProfileId: '2', + profiles: [{ id: '2', webId: b.session.webId, sessionState: 'active' }], + }); + expect([...idbMeta.values()].every((row) => row.webId === b.session.webId)).toBe(true); + expect(cacheStore.has(failedKey)).toBe(false); + expect(cacheStore.size).toBe(1); + expect((await profileFetch(PROFILE_APP_B)).status).toBe(200); + }); + + it('signs out only the default profile and never substitutes an active survivor', async () => { + const { a, b } = await twoProfiles(); + await loadWorker(); + expect((await profileMessage('SOLID_LOGOUT')).ok).toBe(true); + expect(profileDb.state.records[0]).toMatchObject({ + session: null, + keyPair: null, + grantedOrigins: [], + classGrants: [], + }); + expect(profileDb.state.records[0].profile.sessionState).toBe('signed-out'); + expect(profileDb.state.records[1].session).toEqual(b.session); + expect((await profileFetch()).error).toBe('Not authenticated'); + expect(fetchCalls).toHaveLength(0); + expect(profileDb.state.records[0].profile.webId).toBe(a.session.webId); + await profileMessage('SOLID_PROFILE_SET_DEFAULT', { profileId: '2' }); + expect((await profileFetch(PROFILE_APP_B)).status).toBe(200); + }); + + it('refreshes each profile with its own key and atomically retains rotated tokens across wake', async () => { + const { a, b } = await twoProfiles(); + for (const record of profileDb.state.records) + record.session = { ...record.session!, expiresAt: 0 }; + let failA = false; + fetchImpl = async (url, init) => { + if (url === a.session.tokenEndpoint) { + const body = init?.body as URLSearchParams; + const isA = body.get('refresh_token') === a.session.refreshToken; + const credentials = isA ? a : b; + const proof = new Headers(init?.headers).get('dpop')!; + await expect(jose.jwtVerify(proof, credentials.keyPair.publicKey)).resolves.toBeDefined(); + if (isA && failA) return new Response('fixture refresh denied', { status: 401 }); + return Response.json({ + access_token: `rotated-${isA ? 'alice' : 'bob'}`, + refresh_token: `refresh-${isA ? 'alice' : 'bob'}`, + expires_in: 3600, + }); + } + return new Response('pod fixture'); + }; + await loadWorker(); + await Promise.all([profileFetch(), sendMessage({ type: 'SOLID_GET_STATE' }, PROFILE_OWNER)]); + expect(fetchCalls.filter((c) => c.url === a.session.tokenEndpoint)).toHaveLength(1); + await profileMessage('SOLID_PROFILE_SET_DEFAULT', { profileId: '2' }); + await profileFetch(PROFILE_APP_B); + expect(profileDb.state.records[0].session?.accessToken).toBe('rotated-alice'); + expect(profileDb.state.records[1].session?.accessToken).toBe('rotated-bob'); + await loadWorker(); + expect(profileDb.state.records[0].session?.refreshToken).toBe('refresh-alice'); + // A genuine failure tears down only its own profile. + profileDb.state.records[0].session = { ...a.session, expiresAt: 0 }; + failA = true; + await profileMessage('SOLID_PROFILE_SET_DEFAULT', { profileId: '1' }); + expect(profileDb.state.records[0].session).toBeNull(); + expect(profileDb.state.records[1].session?.accessToken).toBe('rotated-bob'); + }); + + it.each([ + 'state', + 'fetch', + ])('resets the signed-in toolbar after a %s refresh failure', async (request) => { + const { a, b } = await twoProfiles(); + installIconCanvas(); + const icon = vi.spyOn(chrome.action, 'setIcon'); + const badge = vi.spyOn(chrome.action, 'setBadgeText'); + const background = vi.spyOn(chrome.action, 'setBadgeBackgroundColor'); + const title = vi.spyOn(chrome.action, 'setTitle'); + await loadWorker(); + expect(icon.mock.lastCall?.[0]).toHaveProperty('imageData'); + expect(background).toHaveBeenLastCalledWith({ color: '#2e9e57' }); + expect(badge).toHaveBeenLastCalledWith({ text: ' ' }); + expect(title).toHaveBeenLastCalledWith({ title: 'Signed in as alice' }); + icon.mockClear(); + badge.mockClear(); + title.mockClear(); + const broadcast = vi.spyOn(chrome.runtime, 'sendMessage'); + profileDb.state.records[0].session = { ...a.session, expiresAt: 0 }; + fetchImpl = async () => new Response('fixture refresh denied', { status: 401 }); + + if (request === 'state') { + expect(await profileMessage('SOLID_GET_STATE')).toMatchObject({ + webId: null, + isActive: false, + }); + } else { + expect(await profileFetch()).toMatchObject({ requestId: 'r1', error: 'Not authenticated' }); + } + await vi.waitFor(() => { + expect(icon).toHaveBeenLastCalledWith({ + path: { + 16: 'icons/icon16.png', + 32: 'icons/icon32.png', + 48: 'icons/icon48.png', + 128: 'icons/icon128.png', + }, + }); + expect(badge).toHaveBeenLastCalledWith({ text: '' }); + expect(title).toHaveBeenLastCalledWith({ title: 'Sign in to Solid' }); + expect(broadcast).toHaveBeenLastCalledWith({ type: 'SOLID_STATE_CHANGED', webId: null }); + }); + expect(profileDb.state.records[0]).toMatchObject({ + profile: { sessionState: 'signed-out' }, + session: null, + keyPair: null, + }); + expect(profileDb.state.records[1].session).toEqual(b.session); + expect(fetchCalls.map((call) => call.url)).toEqual([a.session.tokenEndpoint]); + }); + + it.each([ + 'state', + 'fetch', + ])('publishes a %s refresh sign-out when replica cleanup fails and retries on wake', async (request) => { + const { a, b } = await twoProfiles(); + installIconCanvas(); + const icon = vi.spyOn(chrome.action, 'setIcon'); + const badge = vi.spyOn(chrome.action, 'setBadgeText'); + const title = vi.spyOn(chrome.action, 'setTitle'); + await loadWorker(); + expect((await profileFetch()).status).toBe(200); + await profileMessage('SOLID_PROFILE_SET_DEFAULT', { profileId: '2' }); + expect((await profileFetch(PROFILE_APP_B)).status).toBe(200); + await profileMessage('SOLID_PROFILE_SET_DEFAULT', { profileId: '1' }); + expect(icon.mock.lastCall?.[0]).toHaveProperty('imageData'); + expect(badge).toHaveBeenLastCalledWith({ text: ' ' }); + expect(title).toHaveBeenLastCalledWith({ title: 'Signed in as alice' }); + const metadata = [...idbMeta.values()]; + const bytes = [...cacheStore.keys()]; + const survivor = structuredClone(profileDb.state.records[1]); + const cache = await caches.open('solid-ext-replica-bytes'); + const remove = cache.delete.bind(cache); + let fail = true; + vi.spyOn(cache, 'delete').mockImplementation(async (entry, options) => { + if (fail) throw new Error('fixture cache deletion failed'); + return remove(entry, options); + }); + vi.spyOn(caches, 'open').mockResolvedValue(cache); + chrome.tabs.query = vi.fn().mockResolvedValue([ + { id: 1, url: PROFILE_APP_A.url }, + { id: 2, url: PROFILE_APP_B.url }, + ]); + const tabBroadcast = vi.spyOn(chrome.tabs, 'sendMessage'); + const broadcast = vi.spyOn(chrome.runtime, 'sendMessage'); + profileDb.state.records[0].session = { ...a.session, expiresAt: 0 }; + fetchImpl = async () => new Response('fixture refresh denied', { status: 401 }); + + const result = + request === 'state' ? await profileMessage('SOLID_GET_STATE') : await profileFetch(); + expect(result).toEqual( + request === 'state' + ? { error: 'Profile storage unavailable.' } + : { requestId: 'r1', error: 'Not authenticated' }, + ); + await vi.waitFor(() => { + expect(icon).toHaveBeenLastCalledWith({ + path: { + 16: 'icons/icon16.png', + 32: 'icons/icon32.png', + 48: 'icons/icon48.png', + 128: 'icons/icon128.png', + }, + }); + expect(badge).toHaveBeenLastCalledWith({ text: '' }); + expect(title).toHaveBeenLastCalledWith({ title: 'Sign in to Solid' }); + expect(broadcast).toHaveBeenLastCalledWith({ type: 'SOLID_STATE_CHANGED', webId: null }); + for (const tabId of [1, 2]) + expect(tabBroadcast).toHaveBeenCalledWith(tabId, { + type: 'SOLID_STATE_CHANGED', + webId: null, + }); + }); + expect(profileDb.state.records[0]).toMatchObject({ + profile: { sessionState: 'signed-out' }, + session: null, + keyPair: null, + grantedOrigins: [], + classGrants: [], + }); + expect(profileDb.state.records[1]).toEqual(survivor); + expect([...idbMeta.values()]).toEqual(metadata); + expect([...cacheStore.keys()]).toEqual(bytes); + expect((await profileFetch()).error).toBe('Not authenticated'); + + fail = false; + await loadWorker(); + expect(await profileMessage('SOLID_GET_STATE')).toMatchObject({ + webId: null, + isActive: false, + }); + expect([...idbMeta.values()]).toEqual(metadata.filter((row) => row.webId === b.session.webId)); + expect(cacheStore.size).toBe(1); + expect(profileDb.state.records[1]).toEqual(survivor); + await profileMessage('SOLID_PROFILE_SET_DEFAULT', { profileId: '2' }); + expect((await profileMessage('SOLID_GET_STATE')).webId).toBe(b.session.webId); + }); + + it.each([ + 'profile', + 'login', + ])('discards a refresh-failure toolbar reset superseded by %s', async (next) => { + const { a } = await twoProfiles(); + vi.doMock('../src/background/auth-flow', () => ({ + initiateLogin: vi.fn(async () => ({ ...a, name: 'alice', photoUrl: null })), + refreshSession: vi.fn().mockRejectedValue(new Error('fixture refresh denied')), + })); + installIconCanvas(); + await loadWorker(); + let entered!: () => void; + let release!: () => void; + const started = new Promise((resolve) => { + entered = resolve; + }); + const held = new Promise((resolve) => { + release = resolve; + }); + const icon = vi.spyOn(chrome.action, 'setIcon').mockImplementationOnce(async () => { + expect(profileDb.state.records[0].profile.sessionState).toBe('signed-out'); + entered(); + await held; + }); + const badge = vi.spyOn(chrome.action, 'setBadgeText'); + const background = vi.spyOn(chrome.action, 'setBadgeBackgroundColor'); + const title = vi.spyOn(chrome.action, 'setTitle'); + const broadcast = vi.spyOn(chrome.runtime, 'sendMessage'); + profileDb.state.records[0].session = { ...a.session, expiresAt: 0 }; + const pending = profileMessage('SOLID_GET_STATE'); + await started; + // Scheduling the repaint must not block the state request on toolbar I/O. + expect((await pending).isActive).toBe(false); + expect( + next === 'profile' + ? (await profileMessage('SOLID_PROFILE_SET_DEFAULT', { profileId: '2' })).ok + : (await profileMessage('SOLID_LOGIN', { webId: a.session.webId })).ok, + ).toBe(true); + const latest = { + icon: [...icon.mock.calls], + badge: [...badge.mock.calls], + background: [...background.mock.calls], + title: [...title.mock.calls], + broadcast: [...broadcast.mock.calls], + }; + release(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(icon.mock.calls).toEqual(latest.icon); + expect(badge.mock.calls).toEqual(latest.badge); + expect(background.mock.calls).toEqual(latest.background); + expect(title.mock.calls).toEqual(latest.title); + expect(broadcast.mock.calls).toEqual(latest.broadcast); + expect(title).toHaveBeenLastCalledWith({ + title: `Signed in as ${next === 'profile' ? 'bob' : 'alice'}`, + }); + expect(badge).toHaveBeenLastCalledWith({ text: ' ' }); + }); + + it('checks refresh failure credentials after a queued re-login commits', async () => { + const { a, b } = await twoProfiles(); + const replacement = await profileCredentials(); + const grants = structuredClone(profileDb.state.records[0].classGrants); + let refreshEntered!: () => void; + let rejectRefresh!: (error: Error) => void; + const refreshing = new Promise((resolve) => { + refreshEntered = resolve; + }); + const refresh = new Promise((_resolve, reject) => { + rejectRefresh = reject; + }); + vi.doMock('../src/background/auth-flow', () => ({ + initiateLogin: vi.fn(async () => ({ ...replacement, name: 'alice', photoUrl: null })), + refreshSession: vi.fn(() => { + refreshEntered(); + return refresh; + }), + })); + await loadWorker(); + profileDb.state.records[0].session = { ...a.session, expiresAt: 0 }; + const pending = sendMessage({ type: 'SOLID_GET_STATE' }, PROFILE_OWNER); + await refreshing; + + const { ProfileStore: WorkerStore } = await import('../src/background/core/profile-store'); + const login = WorkerStore.prototype.login; + let loginEntered!: () => void; + let releaseLogin!: () => void; + const committing = new Promise((resolve) => { + loginEntered = resolve; + }); + const held = new Promise((resolve) => { + releaseLogin = resolve; + }); + vi.spyOn(WorkerStore.prototype, 'login').mockImplementationOnce(async function ( + this: ProfileStore, + ...args + ) { + loginEntered(); + await held; + return login.apply(this, args); + }); + const relogin = sendMessage( + { type: 'SOLID_LOGIN', webId: a.session.webId, origin: 'https://relogin.example' }, + { origin: 'https://relogin.example', url: 'https://relogin.example/app' }, + ); + await committing; + // The lifecycle queue is held, but its login has not replaced the old tokens yet. + // A refresh failure must queue its credential read behind this login too. + rejectRefresh(new Error('fixture old refresh denied')); + await new Promise((resolve) => setTimeout(resolve, 0)); + releaseLogin(); + expect((await relogin).ok).toBe(true); + expect((await pending).isActive).toBe(true); + expect(profileDb.state.records[0].session).toEqual(replacement.session); + expect(profileDb.state.records[0].profile.sessionState).toBe('active'); + expect(profileDb.state.records[0].grantedOrigins).toContain('https://relogin.example'); + expect(profileDb.state.records[0].classGrants).toEqual(grants); + expect(profileDb.state.records[1].session).toEqual(b.session); + }); + + it.each([ + 'key', + 'refresh token', + ])('ignores an old refresh failure after re-login changes the %s', async (generation) => { + const { a } = await twoProfiles(); + const replacement = + generation === 'key' + ? await profileCredentials() + : { + ...a, + session: { ...a.session, refreshToken: 'fixture-relogin-refresh' }, + }; + let entered!: () => void; + let rejectRefresh!: (error: Error) => void; + const started = new Promise((resolve) => { + entered = resolve; + }); + const refresh = new Promise((_resolve, reject) => { + rejectRefresh = reject; + }); + vi.doMock('../src/background/auth-flow', () => ({ + initiateLogin: vi.fn(async () => ({ ...replacement, name: 'alice', photoUrl: null })), + refreshSession: vi.fn(() => { + entered(); + return refresh; + }), + })); + await loadWorker(); + profileDb.state.records[0].session = { ...a.session, expiresAt: 0 }; + const pending = sendMessage({ type: 'SOLID_GET_STATE' }, PROFILE_OWNER); + await started; + expect((await profileMessage('SOLID_LOGIN', { webId: a.session.webId })).ok).toBe(true); + const committed = structuredClone(profileDb.state.records[0]); + rejectRefresh(new Error('fixture old refresh denied')); + expect((await pending).isActive).toBe(true); + expect(profileDb.state.records[0]).toEqual(committed); + expect((await profileFetch()).status).toBe(200); + }); + + it('rechecks credentials when an expired snapshot outlives a completed refresh flight', async () => { + const { a } = await twoProfiles(); + await loadWorker(); + profileDb.state.records[0].session = { ...a.session, expiresAt: 0 }; + const { ProfileStore: WorkerStore } = await import('../src/background/core/profile-store'); + const session = WorkerStore.prototype.session; + let release!: () => void; + let entered!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + const started = new Promise((resolve) => { + entered = resolve; + }); + vi.spyOn(WorkerStore.prototype, 'session').mockImplementationOnce(async function ( + this: ProfileStore, + id, + ) { + const stale = await session.call(this, id); + entered(); + await held; + return stale; + }); + fetchImpl = async (url, init) => { + if (url === a.session.tokenEndpoint) { + expect((init?.body as URLSearchParams).get('refresh_token')).toBe(a.session.refreshToken); + return Response.json({ + access_token: 'fixture-rotated', + refresh_token: 'fixture-rotated-refresh', + expires_in: 3600, + }); + } + return new Response('pod fixture'); + }; + const delayed = sendMessage({ type: 'SOLID_GET_STATE' }, PROFILE_OWNER); + await started; + expect((await sendMessage({ type: 'SOLID_GET_STATE' }, PROFILE_OWNER)).isActive).toBe(true); + release(); + expect((await delayed).isActive).toBe(true); + expect(fetchCalls.filter((call) => call.url === a.session.tokenEndpoint)).toHaveLength(1); + expect(profileDb.state.records[0].session?.refreshToken).toBe('fixture-rotated-refresh'); + expect((await profileFetch()).status).toBe(200); + expect(new Headers(fetchCalls.at(-1)?.init?.headers).get('authorization')).toBe( + 'DPoP fixture-rotated', + ); + }); + + it('returns a correlated unauthenticated response when the captured profile is deleted', async () => { + await twoProfiles(); + await loadWorker(); + const { ProfileStore: WorkerStore } = await import('../src/background/core/profile-store'); + const selected = structuredClone(profileDb.state.records[0]); + let release!: () => void; + let entered!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + const started = new Promise((resolve) => { + entered = resolve; + }); + vi.spyOn(WorkerStore.prototype, 'getDefault').mockImplementationOnce(async () => { + entered(); + await held; + return selected; + }); + const delayed = profileFetch(PROFILE_APP_A, { requestId: 'deleted-profile-fetch' }); + await started; + expect((await profileMessage('SOLID_PROFILE_REMOVE', { profileId: '1' })).ok).toBe(true); + release(); + expect(await delayed).toEqual({ + requestId: 'deleted-profile-fetch', + error: 'Not authenticated', + }); + expect(fetchCalls).toHaveLength(0); + }); + + it('preserves the fetch request ID on asynchronous storage failures', async () => { + await twoProfiles(); + await loadWorker(); + profileDb.error = new Error('fixture storage unavailable'); + expect(await profileFetch(PROFILE_APP_A, { requestId: 'storage-error-fetch' })).toEqual({ + requestId: 'storage-error-fetch', + error: 'Profile storage unavailable.', + }); + expect(fetchCalls).toHaveLength(0); + }); + + it('migrates legacy session 1 once and clears legacy credentials only after commit', async () => { + await seedSession(); + storage.set('solid:profile', { + webId: 'https://alice.pod.example/profile/card#me', + name: 'Alice', + photoUrl: null, + }); + storage.set('solid:granted-origins', [PROFILE_APP_A.origin]); + storage.set('solid:pod-origins', ['https://extra.example']); + await loadWorker(); + const migrated = profileDb.state.records[0]; + expect(migrated.profile).toMatchObject({ + id: '1', + label: 'Alice', + sessionState: 'active', + dpopKeyRef: '1', + }); + expect(migrated.keyPair?.privateKey.extractable).toBe(false); + expect(migrated.session?.dpopJkt).toBeTruthy(); + expect(migrated.grantedOrigins).toEqual([PROFILE_APP_A.origin]); + expect(migrated.podOrigins).toEqual(['https://extra.example']); + expect(idbDpopKey).toBeUndefined(); + for (const key of ['session', 'profile', 'pod-origins', 'granted-origins', 'class-grants']) + expect(storage.has(`solid:${key}`)).toBe(false); + await loadWorker(); + expect(profileDb.state.records).toEqual([migrated]); + expect((await profileMessage('SOLID_PROFILES_LIST')).defaultProfileId).toBe('1'); + }); + + it('preserves a missing-key legacy profile for re-login without serving or refreshing its tokens', async () => { + await seedSession(); + idbDpopKey = undefined; + await loadWorker(); + expect(profileDb.state.records[0].profile.sessionState).toBe('signed-out'); + expect(profileDb.state.records[0].session).toBeNull(); + expect((await profileFetch()).error).toBe('Not authenticated'); + expect(fetchCalls).toHaveLength(0); + }); + + it('retries interrupted legacy cleanup without importing a second copy', async () => { + await seedSession(); + const remove = chrome.storage.local.remove; + let fail = true; + chrome.storage.local.remove = vi.fn(async (key: string | string[]) => { + if (fail) throw new Error('fixture interrupted cleanup'); + await remove(key); + }); + await loadWorker(); + expect(profileDb.state.migrated).toBe(true); + expect(profileDb.state.records).toHaveLength(1); + expect((await profileFetch()).error).toBeDefined(); + fail = false; + await loadWorker(); + expect(profileDb.state.records).toHaveLength(1); + expect(storage.has('solid:session')).toBe(false); + }); +}); diff --git a/apps/extension/test/service-worker-replica.test.ts b/apps/extension/test/service-worker-replica.test.ts index 3aa73e40b..6c5457109 100644 --- a/apps/extension/test/service-worker-replica.test.ts +++ b/apps/extension/test/service-worker-replica.test.ts @@ -17,6 +17,12 @@ import { generateDpopKeyPair } from '../src/background/core/dpop'; import type { StoredDpopKeyRecord } from '../src/background/core/dpop-key-db'; import type { ReplicaMetadata } from '../src/background/core/replica-db'; import type { StoredSession } from '../src/background/session-store'; +import { MemoryProfileDb } from './profile-fixtures'; + +let profileDb: MemoryProfileDb; +vi.mock('../src/background/profile-db', () => ({ + ProfileDb: { open: async () => profileDb }, +})); // --- An in-memory DpopKeyDb double (see service-worker-gate.test.ts for the rationale) -- let idbDpopKey: StoredDpopKeyRecord | undefined; @@ -267,6 +273,7 @@ async function loadWorker(): Promise { } beforeEach(() => { + profileDb = new MemoryProfileDb(); installChrome(); installCaches(); cacheStore.clear(); @@ -457,8 +464,11 @@ describe('Phase-1 shared replica (wired into the service worker)', () => { expect(aBytes).toBe('ALICE-BYTES'); // Now user B is the active session (same device, same granted origin, same URL + ETag). + profileDb = new MemoryProfileDb(); storage.delete('solid:session'); await seedSession({ webId: 'https://bob.pod.example/profile/card#me' }); + storage.set('solid:pod-origins', ['https://shared.pod.example']); + storage.set('solid:granted-origins', ['https://pm.example']); // The pod returns B's OWN bytes (even at the same ETag) — B's read must MISS A's cache. fetchImpl = async () => new Response('BOB-BYTES', { status: 200, headers: { etag: 'W/"shared"' } }); @@ -511,6 +521,7 @@ describe('Phase-1 shared replica (wired into the service worker)', () => { // (fresh module → in-memory cachedSession is null). On the cold worker, teardown's // loadSession() returns null ⇒ priorWebId null ⇒ the null-WebID purge runs. That branch must // still enumerate + delete the CacheStorage bytes (the Medium #4 leak), not just metadata. + profileDb = new MemoryProfileDb(); storage.delete('solid:session'); await loadWorker(); const out = await sendMessage( diff --git a/apps/extension/vitest.config.ts b/apps/extension/vitest.config.ts index 070a738a5..b7a725c11 100644 --- a/apps/extension/vitest.config.ts +++ b/apps/extension/vitest.config.ts @@ -13,5 +13,12 @@ export default defineConfig({ include: ['test/**/*.test.ts'], exclude: ['node_modules/**', 'dist/**', 'e2e/**'], globals: false, + coverage: { + enabled: true, + provider: 'v8', + include: ['src/background/core/profile-store.ts'], + reporter: ['text', 'json'], + thresholds: { branches: 100, functions: 100, lines: 100, statements: 100 }, + }, }, }); From e1df9d196384d05494744a9d226997a57dfe8952 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:56:23 +0000 Subject: [PATCH 37/50] fix(e2e): make the popup-login E2E suite pass again (EXT-CI) (#10) * chore: run extension E2E in headless Chromium * fix: reach nested popup controls in E2E * fix: grant the E2E app access after popup login * docs: record EXT-CI reproduction and passing gates * chore: drop the EXT-CI lane report Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01AAzZz6QwQyvsDB1fCCYrCv * fix(e2e): fail fast on a busy CSS port and stop the server itself A Community Solid Server left on port 3000 answered the readiness probe while the new one exited with EADDRINUSE, so later runs silently used stale state. Spawning through npx also meant SIGTERM never reached the server. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01AAzZz6QwQyvsDB1fCCYrCv * docs: E2E runs in headless Chromium Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01AAzZz6QwQyvsDB1fCCYrCv --------- Co-authored-by: Claude Opus 5.5 (1M context) --- apps/extension/.github/workflows/ci.yml | 2 +- apps/extension/CLAUDE.md | 2 +- apps/extension/README.md | 5 +- apps/extension/e2e/README.md | 39 +++++++++++ apps/extension/e2e/fixtures.ts | 4 +- apps/extension/e2e/playwright.config.ts | 2 +- apps/extension/e2e/setup/css-server.ts | 70 +++++++++++-------- apps/extension/e2e/setup/global-setup.ts | 9 ++- .../e2e/tests/authenticated-fetch.spec.ts | 19 ++++- .../e2e/tests/client-registration.spec.ts | 7 +- apps/extension/e2e/tests/helpers.ts | 35 +++++++++- .../e2e/tests/session-restore.spec.ts | 3 +- 12 files changed, 151 insertions(+), 46 deletions(-) create mode 100644 apps/extension/e2e/README.md diff --git a/apps/extension/.github/workflows/ci.yml b/apps/extension/.github/workflows/ci.yml index 5ecdeec5e..34b27e7cf 100644 --- a/apps/extension/.github/workflows/ci.yml +++ b/apps/extension/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: run: npx playwright install chromium - name: Run E2E tests - run: xvfb-run npm run test:e2e + run: npm run test:e2e - name: Upload extension artifact uses: actions/upload-artifact@v4 diff --git a/apps/extension/CLAUDE.md b/apps/extension/CLAUDE.md index 99bebc012..bf79b4279 100644 --- a/apps/extension/CLAUDE.md +++ b/apps/extension/CLAUDE.md @@ -61,7 +61,7 @@ npm run test:e2e # Build + run Playwright e2e tests ### E2E Testing -- Requires headed Chrome (extensions don't work in headless) +- Runs in headless Chromium (`channel: 'chromium'`, the new headless mode, loads extensions); no display server needed - Uses Community Solid Server on localhost:3000 with in-memory storage - Test site served on a separate port - Extension loaded from `dist/` via `--load-extension` diff --git a/apps/extension/README.md b/apps/extension/README.md index 5043f954e..eb63f5831 100644 --- a/apps/extension/README.md +++ b/apps/extension/README.md @@ -278,8 +278,9 @@ npm run test:e2e # build + Playwright against a LOCAL Community Solid Serv The unit suite stubs `fetch` / `chrome.*` and needs no server. The Playwright e2e suite boots a **local** Community Solid Server (`e2e/setup`) and a local test site — **never** the -live deploy — and drives the real extension in headed Chromium. CI uses Node.js 24 and -runs the gate before the local-server e2e suite. +live deploy — and drives the real extension in headless Chromium. CI uses Node.js 24 and +runs the gate before the local-server e2e suite. See [e2e/README.md](e2e/README.md) for +setup and runner troubleshooting. ## License diff --git a/apps/extension/e2e/README.md b/apps/extension/e2e/README.md new file mode 100644 index 000000000..9ec1f11a6 --- /dev/null +++ b/apps/extension/e2e/README.md @@ -0,0 +1,39 @@ +# Extension E2E tests + +Use Node.js 24 and the locked dependencies: + +```sh +npm ci +npx playwright install chromium +npm run test:e2e +``` + +CI runs the same E2E command after `npm run gate`. The command builds `dist/`, then +Playwright's global setup starts an in-memory Community Solid Server on port 3000, +seeded from `setup/seed.json` and `setup/templates/pod/`. It discovers the unpacked +extension's ID and starts the test sites on ports 8080 and 8081 with matching Client +ID Documents. No separately running server or live pod is needed. + +Each test loads the real extension in a fresh persistent context using headless +Chromium (`channel: 'chromium'`, which supports extensions). No display server is +needed. The suite has zero retries. + +Popup sign-in does not grant unrelated web origins access. The fetch/navigation +tests explicitly opt the test site in using `SOLID_SET_CLIENT_ID` from its +browser-attested isolated world, await the worker's acknowledgement, and reload to +read the granted state. The fetch test also checks that the ungranted site receives +no WebID and its private fetch is denied. The app-login scenario separately drives +the public `window.solid.setClientId` API. + +On runners with a read-only home directory, Chromium can fail before any test with +`chrome_crashpad_handler: --database is required`. The launches disable crash +reporting and Breakpad; if the error persists, give Chromium a writable config +directory (without changing where Playwright finds the installed browser): + +```sh +mkdir -p /tmp/solid-extension-chromium-config +XDG_CONFIG_HOME=/tmp/solid-extension-chromium-config npm run test:e2e +``` + +Likewise, if npm's default cache is read-only, set `npm_config_cache` to a writable +directory when installing and running the suite. diff --git a/apps/extension/e2e/fixtures.ts b/apps/extension/e2e/fixtures.ts index 073e153f1..bc6200c77 100644 --- a/apps/extension/e2e/fixtures.ts +++ b/apps/extension/e2e/fixtures.ts @@ -15,8 +15,10 @@ export const test = base.extend<{ const pathToExtension = path.resolve(__dirname, '../dist'); const context = await chromium.launchPersistentContext('', { channel: 'chromium', - headless: false, + headless: true, args: [ + '--disable-crash-reporter', + '--disable-breakpad', `--disable-extensions-except=${pathToExtension}`, `--load-extension=${pathToExtension}`, ], diff --git a/apps/extension/e2e/playwright.config.ts b/apps/extension/e2e/playwright.config.ts index 82e8bbe38..49c5803f4 100644 --- a/apps/extension/e2e/playwright.config.ts +++ b/apps/extension/e2e/playwright.config.ts @@ -6,7 +6,7 @@ export default defineConfig({ fullyParallel: true, retries: 0, use: { - headless: false, + headless: true, }, // Paths are resolved relative to this config file's directory. This package is // `"type": "module"`, so Playwright loads these `.ts` files as ESM — `require` is not diff --git a/apps/extension/e2e/setup/css-server.ts b/apps/extension/e2e/setup/css-server.ts index 34291b929..17c57ad39 100644 --- a/apps/extension/e2e/setup/css-server.ts +++ b/apps/extension/e2e/setup/css-server.ts @@ -1,5 +1,6 @@ import { type ChildProcess, spawn } from 'node:child_process'; import http from 'node:http'; +import { createRequire } from 'node:module'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -16,19 +17,17 @@ export async function startCSSServer(): Promise { const seedConfig = path.resolve(__dirname, 'seed.json'); const cssConfig = path.resolve(__dirname, 'css-config.json'); + // A server left on the port (e.g. by an aborted run) would answer the readiness probe + // while ours exits with EADDRINUSE, so the suite would silently run against stale state. + if (await isServerUp(CSS_BASE_URL)) { + throw new Error(`Port ${CSS_PORT} is already in use; stop the server holding it first.`); + } + + // Run the CSS entry point directly (not via npx) so SIGTERM reaches the server itself. + const cssBin = createRequire(import.meta.url).resolve('@solid/community-server/bin/server.js'); serverProcess = spawn( - 'npx', - [ - '@solid/community-server', - '-p', - String(CSS_PORT), - '-c', - cssConfig, - '--seedConfig', - seedConfig, - '-l', - 'warn', - ], + process.execPath, + [cssBin, '-p', String(CSS_PORT), '-c', cssConfig, '--seedConfig', seedConfig, '-l', 'warn'], { stdio: 'pipe', env: { ...process.env }, @@ -42,8 +41,13 @@ export async function startCSSServer(): Promise { } }); + let exitCode: number | null = null; + serverProcess.on('exit', (code) => { + exitCode = code ?? -1; + }); + // Wait for the server to be ready - await waitForServer(CSS_BASE_URL, 30_000); + await waitForServer(CSS_BASE_URL, 30_000, () => exitCode); } export async function stopCSSServer(): Promise { @@ -53,26 +57,32 @@ export async function stopCSSServer(): Promise { } } -async function waitForServer(url: string, timeoutMs: number): Promise { +function isServerUp(url: string): Promise { + return new Promise((resolve) => { + const req = http.get(url, (res) => { + res.resume(); + resolve(true); + }); + req.on('error', () => resolve(false)); + req.setTimeout(1000, () => { + req.destroy(); + resolve(false); + }); + }); +} + +async function waitForServer( + url: string, + timeoutMs: number, + exited: () => number | null, +): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { - try { - await new Promise((resolve, reject) => { - const req = http.get(url, (res) => { - res.resume(); - resolve(); - }); - req.on('error', reject); - req.setTimeout(1000, () => { - req.destroy(); - reject(new Error('timeout')); - }); - }); - return; - } catch { - await new Promise((r) => setTimeout(r, 500)); - } + const code = exited(); + if (code !== null) throw new Error(`Community Solid Server exited early (code ${code})`); + if (await isServerUp(url)) return; + await new Promise((r) => setTimeout(r, 500)); } throw new Error(`Server at ${url} did not start within ${timeoutMs}ms`); diff --git a/apps/extension/e2e/setup/global-setup.ts b/apps/extension/e2e/setup/global-setup.ts index 7eae931b6..1a52e1f20 100644 --- a/apps/extension/e2e/setup/global-setup.ts +++ b/apps/extension/e2e/setup/global-setup.ts @@ -15,8 +15,13 @@ async function discoverExtensionId(): Promise { const extPath = path.resolve(__dirname, '../../dist'); const ctx = await chromium.launchPersistentContext('', { channel: 'chromium', - headless: false, - args: [`--disable-extensions-except=${extPath}`, `--load-extension=${extPath}`], + headless: true, + args: [ + '--disable-crash-reporter', + '--disable-breakpad', + `--disable-extensions-except=${extPath}`, + `--load-extension=${extPath}`, + ], }); let sw = ctx.serviceWorkers()[0]; if (!sw) sw = await ctx.waitForEvent('serviceworker'); diff --git a/apps/extension/e2e/tests/authenticated-fetch.spec.ts b/apps/extension/e2e/tests/authenticated-fetch.spec.ts index 3d5d4dd69..8a2426f47 100644 --- a/apps/extension/e2e/tests/authenticated-fetch.spec.ts +++ b/apps/extension/e2e/tests/authenticated-fetch.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from '../fixtures'; -import { performLogin } from './helpers'; +import { grantTestSite, performLogin, TEST_WEBID } from './helpers'; const TEST_SITE = 'http://localhost:8080'; @@ -12,6 +12,23 @@ test('authenticated fetch retrieves a private resource via window.solid', async const page = await context.newPage(); await page.goto(TEST_SITE); + // Popup sign-in cannot disclose identity or pod bytes to an ungranted app. + const denied = await page.evaluate(async (privateUrl) => { + const solid = ( + window as unknown as { + solid: { webId: string | null; fetch(url: string): Promise }; + } + ).solid; + try { + await solid.fetch(privateUrl); + return { webId: solid.webId, error: null }; + } catch (error) { + return { webId: solid.webId, error: (error as Error).message }; + } + }, new URL('../private/notes', TEST_WEBID).href); + expect(denied).toEqual({ webId: null, error: 'Origin not granted access' }); + await grantTestSite(context, page); + // The test site reads window.solid.webId + window.solid.fetch(private resource). await expect(page.locator('#webid')).toContainText('test-pod', { timeout: 15_000 }); await expect(page.locator('#fetch-result')).toContainText('Private Note', { timeout: 15_000 }); diff --git a/apps/extension/e2e/tests/client-registration.spec.ts b/apps/extension/e2e/tests/client-registration.spec.ts index 4527e1db3..27c0d210d 100644 --- a/apps/extension/e2e/tests/client-registration.spec.ts +++ b/apps/extension/e2e/tests/client-registration.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from '../fixtures'; -import { completeOidcLogin, TEST_WEBID } from './helpers'; +import { completeOidcLogin, grantTestSite, TEST_WEBID } from './helpers'; const TEST_SITE = 'http://localhost:8080'; const APP_SITE = 'http://localhost:8081'; @@ -11,10 +11,10 @@ test('default popup login (dynamic registration) authenticates a private fetch', const popupPage = await context.newPage(); await popupPage.goto(`chrome-extension://${extensionId}/popup/popup.html`); // The signed-out surface is ; target its shadow parts. - await popupPage.fill('#login-panel >>> [part="webid-input"]', TEST_WEBID); + await popupPage.fill('#login-panel [part="webid-input"]', TEST_WEBID); const loginPagePromise = context.waitForEvent('page'); - await popupPage.click('#login-panel >>> [part="login-button"]'); + await popupPage.click('#login-panel [part="login-button"]'); await completeOidcLogin(await loginPagePromise); await popupPage.goto(`chrome-extension://${extensionId}/popup/popup.html`); @@ -23,6 +23,7 @@ test('default popup login (dynamic registration) authenticates a private fetch', const page = await context.newPage(); await page.goto(TEST_SITE); + await grantTestSite(context, page); await expect(page.locator('#fetch-result')).toContainText('Private Note', { timeout: 15_000 }); }); diff --git a/apps/extension/e2e/tests/helpers.ts b/apps/extension/e2e/tests/helpers.ts index 35ea6e5bf..a9cfae4f7 100644 --- a/apps/extension/e2e/tests/helpers.ts +++ b/apps/extension/e2e/tests/helpers.ts @@ -1,8 +1,36 @@ import type { BrowserContext, Page } from '@playwright/test'; +import { expect } from '../fixtures'; /** The seeded CSS test WebID (see e2e/setup/seed.json). */ export const TEST_WEBID = 'http://localhost:3000/test-pod/profile/card#me'; +/** + * Opt a test app into the popup's session through the real, origin-verified content + * script path. Popup login alone deliberately grants no unrelated website access. + * Await the worker's acknowledgement before loading the app again for its state read. + */ +export async function grantTestSite(context: BrowserContext, page: Page): Promise { + const origin = new URL(page.url()).origin; + const result = await context.serviceWorkers()[0].evaluate(async (appOrigin) => { + const tabs = await chrome.tabs.query({ url: `${appOrigin}/*` }); + const tabId = tabs[0]?.id; + if (tabId === undefined) throw new Error('Test app tab not found'); + const [reply] = await chrome.scripting.executeScript({ + target: { tabId }, + world: 'ISOLATED', + func: () => + chrome.runtime.sendMessage({ + type: 'SOLID_SET_CLIENT_ID', + origin: location.origin, + clientId: `${location.origin}/client-id`, + }), + }); + return reply.result; + }, origin); + expect(result).toEqual({ ok: true }); + await page.reload(); +} + /** * Complete the CSS OIDC login + consent flow on the window * `chrome.identity.launchWebAuthFlow` opened. CSS auto-redirects to the password page, @@ -34,11 +62,12 @@ export async function performLogin( const popupPage = await context.newPage(); await popupPage.goto(`chrome-extension://${extensionId}/popup/popup.html`); // The signed-out surface is now ; drive its shadow parts. - // Playwright's CSS engine pierces the (open) shadow root, so `part=` selectors work. - await popupPage.fill('#login-panel >>> [part="webid-input"]', webId); + // CSS descendants pierce the open shadow root and reach controls nested in the form. + // `>>>` is parsed as `>>` then `>` and incorrectly requires a direct child. + await popupPage.fill('#login-panel [part="webid-input"]', webId); const loginPagePromise = context.waitForEvent('page'); - await popupPage.click('#login-panel >>> [part="login-button"]'); + await popupPage.click('#login-panel [part="login-button"]'); await completeOidcLogin(await loginPagePromise); await popupPage.goto(`chrome-extension://${extensionId}/popup/popup.html`); diff --git a/apps/extension/e2e/tests/session-restore.spec.ts b/apps/extension/e2e/tests/session-restore.spec.ts index c9759af21..e061633f0 100644 --- a/apps/extension/e2e/tests/session-restore.spec.ts +++ b/apps/extension/e2e/tests/session-restore.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from '../fixtures'; -import { performLogin } from './helpers'; +import { grantTestSite, performLogin } from './helpers'; const TEST_SITE = 'http://localhost:8080'; @@ -11,6 +11,7 @@ test('session persists across navigation (the worker holds the session)', async const page = await context.newPage(); await page.goto(TEST_SITE); + await grantTestSite(context, page); await expect(page.locator('#webid')).toContainText('test-pod', { timeout: 15_000 }); await page.goto('about:blank'); From 60fe7b53d43f894cffa50ab3baa32b17b2534ab2 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:40:09 +0000 Subject: [PATCH 38/50] feat: profile switcher in popup and side panel (EXT-04) (#11) * feat: add profile UI controller Co-Authored-By: GPT-6.1-Sol (Codex) * feat: share accessible profile controls across surfaces Co-Authored-By: GPT-6.1-Sol (Codex) * test: cover profile switching in both account surfaces Co-Authored-By: GPT-6.1-Sol (Codex) * docs: record profile switcher lane results Co-Authored-By: GPT-6.1-Sol (Codex) * fix: keep pending profile removals retryable Co-Authored-By: GPT-6.1-Sol (Codex) * test: cover failed profile removal recovery Co-Authored-By: GPT-6.1-Sol (Codex) * docs: record removal recovery validation Co-Authored-By: GPT-6.1-Sol (Codex) * chore: run extension E2E in headless Chromium * fix: reach nested popup controls in E2E * fix: grant the E2E app access after popup login * docs: record EXT-CI reproduction and passing gates * chore: drop the EXT-CI lane report Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01AAzZz6QwQyvsDB1fCCYrCv * fix(e2e): fail fast on a busy CSS port and stop the server itself A Community Solid Server left on port 3000 answered the readiness probe while the new one exited with EADDRINUSE, so later runs silently used stale state. Spawning through npx also meant SIGTERM never reached the server. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01AAzZz6QwQyvsDB1fCCYrCv * docs: E2E runs in headless Chromium Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01AAzZz6QwQyvsDB1fCCYrCv * fix(profiles): preserve pending removal recovery Co-Authored-By: Codex GPT-6.1-Sol * test(profiles): cover partial removal and logout recovery Co-Authored-By: Codex GPT-6.1-Sol * fix(popup): restore profile focus after updating controls Co-Authored-By: Codex GPT-6.1-Sol * test(popup): cover removal state and focus at capacity Co-Authored-By: Codex GPT-6.1-Sol * chore: remove profile switcher lane reports Co-Authored-By: Codex GPT-6.1-Sol * fix(popup): keep focus visible when Retry profiles hides itself Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01AAzZz6QwQyvsDB1fCCYrCv * test(e2e): attach profile-switcher screenshots instead of pixel baselines The committed baselines came from a headed Xvfb run and fail under headless Chromium and on CI fonts. Screenshots are kept as review artifacts; the axe, layout and target-size assertions stay. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01AAzZz6QwQyvsDB1fCCYrCv * fix(popup): restore focus lost while a profile operation is busy Disabling or hiding the focused control during an operation drops focus to (lazily, via focus fixup). Remember the control focused when the operation started and, once idle, refocus it if usable or fall back to Add / the first enabled control. Replaces the Retry-profiles special case with one guard covering every control. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01AAzZz6QwQyvsDB1fCCYrCv * fix(popup): recover focus lost by any render, never steal it Track the focused control before each render: if that render disables, hides or replaces it (including a successful refresh hiding Retry profiles, which is not a busy operation), recover focus once idle. Focus the user moved to a usable control outside the switcher is left alone. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01AAzZz6QwQyvsDB1fCCYrCv * fix(popup): closing the editor returns focus only if the editor owned it Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01AAzZz6QwQyvsDB1fCCYrCv --------- Co-authored-by: GPT-6.1-Sol (Codex) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/extension/README.md | 16 +- apps/extension/e2e/setup/seed.json | 12 + apps/extension/e2e/tests/helpers.ts | 7 +- .../e2e/tests/profile-switcher.spec.ts | 127 +++++++ apps/extension/package-lock.json | 27 ++ apps/extension/package.json | 1 + .../src/background/core/profile-store.ts | 4 +- .../src/background/service-worker.ts | 4 + apps/extension/src/popup/account-surface.ts | 9 +- apps/extension/src/popup/popup.css | 60 +-- apps/extension/src/popup/popup.html | 4 +- .../extension/src/popup/profile-controller.ts | 156 ++++++++ apps/extension/src/popup/profile-switcher.ts | 320 ++++++++++++++++ apps/extension/src/popup/profiles.css | 36 ++ apps/extension/src/sidepanel/sidepanel.css | 57 +-- apps/extension/src/sidepanel/sidepanel.html | 4 +- .../test/account-surface-cleanup.test.ts | 65 ++++ .../extension/test/profile-controller.test.ts | 254 +++++++++++++ apps/extension/test/profile-store.test.ts | 26 ++ apps/extension/test/profile-switcher.test.ts | 350 ++++++++++++++++++ .../test/service-worker-gate.test.ts | 2 + apps/extension/webpack.config.cjs | 1 + 22 files changed, 1482 insertions(+), 60 deletions(-) create mode 100644 apps/extension/e2e/tests/profile-switcher.spec.ts create mode 100644 apps/extension/src/popup/profile-controller.ts create mode 100644 apps/extension/src/popup/profile-switcher.ts create mode 100644 apps/extension/src/popup/profiles.css create mode 100644 apps/extension/test/profile-controller.test.ts create mode 100644 apps/extension/test/profile-switcher.test.ts diff --git a/apps/extension/README.md b/apps/extension/README.md index eb63f5831..317261430 100644 --- a/apps/extension/README.md +++ b/apps/extension/README.md @@ -132,8 +132,14 @@ Explicit `solid.fetch` calls retain the worker's denial response. Interrupted removal resumes on worker wake. Logout signs out only the default profile, retaining its display entry; other profiles keep their own sessions. One default exists whenever the store is nonempty. -The popup/side-panel profile switcher is a separate UI task. Its worker API accepts the -following messages **only from browser-attested extension contexts**, returning display-only +The popup and side panel share a profile switcher: list accounts with photos or initials, +add an account through sign-in, rename, remove with confirmation, and choose the default. +Adding keeps the existing default. Signed-out entries offer re-login; all eight slots stay +visible at capacity. Native controls support Tab, Enter and Space; arrows move between the +same action on adjacent profiles, Escape cancels edits, and results/errors are announced. +Removal revokes that profile's app access and clears its local replica while keeping its pod. + +The worker API accepts the following messages **only from browser-attested extension contexts**, returning display-only `{ ok, profiles, defaultProfileId }` or `{ error }` (never tokens or key objects): | Message | Fields | @@ -185,7 +191,7 @@ the extension also ships an MV3 **side panel** (`sidepanel/sidepanel.html`). It **identical** signed-in/signed-out surface as the popup — the SAME `@jeswr/solid-elements` components driven by the SAME token-free `MessageBridgeLoginController` seam — via the shared `mountAccountSurface()` (`src/popup/account-surface.ts`), so there is exactly one copy of the -view-switching + auth-bridge logic. The popup is unchanged. **Open it** by right-clicking the +view-switching + auth-bridge logic. Both surfaces also share the profile controls. **Open it** by right-clicking the toolbar icon → **"Open Solid side panel"** (a left-click still opens the quick popup; Chrome ignores open-on-action-click while a `default_popup` is set, so both surfaces coexist via the context-menu entry). @@ -280,7 +286,9 @@ The unit suite stubs `fetch` / `chrome.*` and needs no server. The Playwright e2 boots a **local** Community Solid Server (`e2e/setup`) and a local test site — **never** the live deploy — and drives the real extension in headless Chromium. CI uses Node.js 24 and runs the gate before the local-server e2e suite. See [e2e/README.md](e2e/README.md) for -setup and runner troubleshooting. +setup and runner troubleshooting. The profile-switcher scenario uses two +seeded CSS accounts, keyboard controls, axe WCAG checks and screenshots of both surfaces +in light/dark themes. ## License diff --git a/apps/extension/e2e/setup/seed.json b/apps/extension/e2e/setup/seed.json index b5896d20b..201aff795 100644 --- a/apps/extension/e2e/setup/seed.json +++ b/apps/extension/e2e/setup/seed.json @@ -10,5 +10,17 @@ } } ] + }, + { + "email": "work@example.com", + "password": "test-password-123", + "pods": [ + { + "name": "work-pod", + "settings": { + "name": "Morgan Chen" + } + } + ] } ] diff --git a/apps/extension/e2e/tests/helpers.ts b/apps/extension/e2e/tests/helpers.ts index a9cfae4f7..f55a73be4 100644 --- a/apps/extension/e2e/tests/helpers.ts +++ b/apps/extension/e2e/tests/helpers.ts @@ -36,10 +36,13 @@ export async function grantTestSite(context: BrowserContext, page: Page): Promis * `chrome.identity.launchWebAuthFlow` opened. CSS auto-redirects to the password page, * then to consent; both have JS-enabled submit buttons we wait on. */ -export async function completeOidcLogin(loginPage: Page): Promise { +export async function completeOidcLogin( + loginPage: Page, + email = 'test@example.com', +): Promise { await loginPage.waitForLoadState('networkidle'); await loginPage.waitForSelector('button[type="submit"]:not([disabled])', { timeout: 10_000 }); - await loginPage.fill('#email', 'test@example.com'); + await loginPage.fill('#email', email); await loginPage.fill('#password', 'test-password-123'); await loginPage.click('button[type="submit"]'); diff --git a/apps/extension/e2e/tests/profile-switcher.spec.ts b/apps/extension/e2e/tests/profile-switcher.spec.ts new file mode 100644 index 000000000..851a2efc1 --- /dev/null +++ b/apps/extension/e2e/tests/profile-switcher.spec.ts @@ -0,0 +1,127 @@ +// AUTHORED-BY GPT-6.1-Sol (Codex) +import AxeBuilder from '@axe-core/playwright'; +import type { Page } from '@playwright/test'; +import { expect, test } from '../fixtures'; +import { completeOidcLogin, TEST_WEBID } from './helpers'; + +async function checkAccessibility(page: Page): Promise { + const { violations } = await new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa']) + .analyze(); + expect(violations.filter(({ impact }) => impact === 'serious' || impact === 'critical')).toEqual( + [], + ); +} + +test('add two local profiles, switch default across surfaces and manage them by keyboard', async ({ + context, + extensionId, +}) => { + const popup = await context.newPage(); + await popup.setViewportSize({ width: 340, height: 600 }); + await popup.goto(`chrome-extension://${extensionId}/popup/popup.html`); + const panel = await context.newPage(); + await panel.setViewportSize({ width: 400, height: 800 }); + await panel.goto(`chrome-extension://${extensionId}/sidepanel/sidepanel.html`); + + for (const [surface, webId, email, count] of [ + [popup, TEST_WEBID, 'test@example.com', 1], + [panel, 'http://localhost:3000/work-pod/profile/card#me', 'work@example.com', 2], + ] as const) { + // Start both sign-ins through the shared profile UI, including the empty-store state. + // Clearing only browser cookies avoids reusing the first account's CSS IdP session. + await context.clearCookies(); + await surface.getByRole('button', { name: 'Add profile', exact: true }).focus(); + await surface.keyboard.press('Enter'); + const input = surface.getByRole('textbox', { name: 'Account ID', exact: true }); + await expect(input).toBeFocused(); + await checkAccessibility(surface); + await input.fill(webId); + const loginWindow = context.waitForEvent('page'); + await input.press('Enter'); + await completeOidcLogin(await loginWindow, email); + await expect(surface.locator('#profile-list > li')).toHaveCount(count); + await expect(surface.locator('#profile-status')).toHaveText('Profile added.'); + } + + const personal = popup.getByRole('button', { name: 'Set Alex Wilder as default', exact: true }); + const work = popup.getByRole('button', { name: 'Set Morgan Chen as default', exact: true }); + await expect(personal).toHaveAttribute('aria-pressed', 'true'); + await expect(work).toHaveAttribute('aria-pressed', 'false'); + await personal.focus(); + await popup.keyboard.press('ArrowDown'); + await expect(work).toBeFocused(); + await popup.keyboard.press('Space'); + await expect(work).toHaveAttribute('aria-pressed', 'true'); + await expect( + panel.getByRole('button', { name: 'Set Morgan Chen as default', exact: true }), + ).toHaveAttribute('aria-pressed', 'true'); + await expect(panel.locator('#account-menu')).toHaveAttribute('name', 'Morgan Chen'); + await popup.reload(); + await expect( + popup.getByRole('button', { name: 'Set Morgan Chen as default', exact: true }), + ).toHaveAttribute('aria-pressed', 'true'); + await expect(popup.locator('.profile-avatar img')).toHaveCount(2); + await expect(panel.locator('.profile-avatar img')).toHaveCount(2); + + // Both surfaces, both themes, at their actual narrow widths. + for (const [surface, name] of [ + [popup, 'popup'], + [panel, 'sidepanel'], + ] as const) { + for (const theme of ['light', 'dark']) { + await surface.evaluate( + (dark) => document.documentElement.classList.toggle('dark', dark), + theme === 'dark', + ); + await checkAccessibility(surface); + // Review artifacts, not pixel baselines: fonts and rasterisation differ between + // runners, and committed baselines would fail on every environment but the author's. + await test.info().attach(`profiles-${name}-${theme}.png`, { + body: await surface.screenshot({ fullPage: true, animations: 'disabled' }), + contentType: 'image/png', + }); + } + await surface.setViewportSize({ width: 320, height: 800 }); + expect( + await surface.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), + ).toBe(true); + expect( + await surface.locator('#profiles button').evaluateAll((buttons) => + buttons + .filter((button) => button.getClientRects().length > 0) + .every((button) => { + const box = button.getBoundingClientRect(); + return box.width >= 24 && box.height >= 24; + }), + ), + ).toBe(true); + } + + const rename = panel.getByRole('button', { name: 'Rename Morgan Chen', exact: true }); + await rename.focus(); + await panel.keyboard.press('Enter'); + await checkAccessibility(panel); + await panel.getByRole('textbox', { name: 'Profile name' }).fill('Work'); + await panel.keyboard.press('Enter'); + await expect(panel.getByRole('button', { name: 'Rename Work', exact: true })).toBeFocused(); + await expect( + popup.getByRole('button', { name: 'Set Work as default', exact: true }), + ).toHaveAttribute('aria-pressed', 'true'); + + // Escape cancels removal and returns to its trigger; confirmation then selects the survivor. + await panel.getByRole('button', { name: 'Remove Work', exact: true }).focus(); + await panel.keyboard.press('Enter'); + await expect(panel.getByRole('button', { name: 'Cancel', exact: true })).toBeFocused(); + await panel.keyboard.press('Escape'); + await expect(panel.getByRole('button', { name: 'Remove Work', exact: true })).toBeFocused(); + await panel.keyboard.press('Enter'); + await checkAccessibility(panel); + await panel.keyboard.press('Shift+Tab'); + await panel.keyboard.press('Enter'); + await expect(panel.locator('#profile-list > li')).toHaveCount(1); + await expect( + panel.getByRole('button', { name: 'Set Alex Wilder as default', exact: true }), + ).toHaveAttribute('aria-pressed', 'true'); + await expect(panel.getByRole('button', { name: 'Add profile', exact: true })).toBeFocused(); +}); diff --git a/apps/extension/package-lock.json b/apps/extension/package-lock.json index ee865401f..15a1c1869 100644 --- a/apps/extension/package-lock.json +++ b/apps/extension/package-lock.json @@ -18,6 +18,7 @@ "n3": "^2.0.3" }, "devDependencies": { + "@axe-core/playwright": "^4.14.0", "@biomejs/biome": "^2.5.0", "@playwright/test": "^1.49.0", "@solid/community-server": "^7.1.3", @@ -74,6 +75,22 @@ "lru-cache": "^10.4.3" } }, + "node_modules/@axe-core/playwright": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.14.0.tgz", + "integrity": "sha512-SGUhG5PIHd2eMdpgBF2PyQLOhX+qyDR2PT4cmoS4Qk2ak+jX/rrSvst9l0PGv5WTHolKpUMvVmoHpr4cZgf1RQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.14.0" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.29.11", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.11.tgz", @@ -6183,6 +6200,16 @@ "dev": true, "license": "MIT" }, + "node_modules/axe-core": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.14.0.tgz", + "integrity": "sha512-9WTZxEjsZ7b13TH8JPmbV2z8CHbl80/2hm3XPEG4JgNdQLK81IBRXmSxHfMAOkSqQeRxT/0dwNDz2GOm3zzpcQ==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", diff --git a/apps/extension/package.json b/apps/extension/package.json index 6b07c9c52..61a634e2b 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -29,6 +29,7 @@ "n3": "^2.0.3" }, "devDependencies": { + "@axe-core/playwright": "^4.14.0", "@biomejs/biome": "^2.5.0", "@playwright/test": "^1.49.0", "@solid/community-server": "^7.1.3", diff --git a/apps/extension/src/background/core/profile-store.ts b/apps/extension/src/background/core/profile-store.ts index 610a2c09e..4cf7f01c5 100644 --- a/apps/extension/src/background/core/profile-store.ts +++ b/apps/extension/src/background/core/profile-store.ts @@ -200,7 +200,9 @@ export class ProfileStore { return this.locked(async () => { const state = await this.persistence.read(); const record = this.find(state, id); - record.profile = { ...record.profile, sessionState: 'signed-out' }; + // Logout must not cancel the durable intent to finish a partially committed removal. + if (record.profile.sessionState !== 'removing') + record.profile = { ...record.profile, sessionState: 'signed-out' }; record.session = null; record.keyPair = null; record.grantedOrigins = []; diff --git a/apps/extension/src/background/service-worker.ts b/apps/extension/src/background/service-worker.ts index 09fc09872..8271ed723 100644 --- a/apps/extension/src/background/service-worker.ts +++ b/apps/extension/src/background/service-worker.ts @@ -820,6 +820,10 @@ async function handleProfiles( await updateDefaultIconAndBroadcast(); return { ok: true, ...(await store.list()) }; } catch (err) { + // Removal persists revocation before purging bytes. Both account surfaces must re-read + // that state even when cleanup fails; a repaint failure must not hide the original error. + if (message.type === 'SOLID_PROFILE_REMOVE') + await updateDefaultIconAndBroadcast().catch(() => {}); return { error: err instanceof Error ? err.message : 'Profile operation failed' }; } } diff --git a/apps/extension/src/popup/account-surface.ts b/apps/extension/src/popup/account-surface.ts index e1813db56..d936592e5 100644 --- a/apps/extension/src/popup/account-surface.ts +++ b/apps/extension/src/popup/account-surface.ts @@ -25,6 +25,7 @@ import type { LoginDetail, SessionChangeDetail } from '@jeswr/solid-elements'; import type { SessionState } from '../shared/messages'; import { MessageBridgeLoginController } from './message-bridge-controller'; +import { mountProfileSwitcher } from './profile-switcher'; /** The `` element surface this module drives (see popup.ts notes). */ export interface LoginPanelElement extends HTMLElement { @@ -181,7 +182,8 @@ export function mountAccountSurface(els: AccountSurfaceElements): MountedAccount // Track every detach so cleanup() removes ALL listeners this mount added (the long-lived // side panel relies on this; the ephemeral popup may ignore it). - const detachers: Array<() => void> = []; + const profiles = mountProfileSwitcher(signedIn.parentElement!); + const detachers: Array<() => void> = [profiles.cleanup]; // The panel drives login via the bridge (chrome.identity flow in the worker). const onLogin = (e: Event): void => { @@ -214,7 +216,10 @@ export function mountAccountSurface(els: AccountSurfaceElements): MountedAccount // listener: the side panel persists across navigation, so a remount without cleanup would // accumulate duplicate onMessage listeners (and duplicate refresh() calls). const onRuntimeMessage = (message: { type?: string }): void => { - if (message?.type === 'SOLID_STATE_CHANGED') void refresh(); + if (message?.type === 'SOLID_STATE_CHANGED') { + void refresh(); + void profiles.controller.refresh(); + } }; chrome.runtime.onMessage.addListener(onRuntimeMessage); detachers.push(() => chrome.runtime?.onMessage?.removeListener?.(onRuntimeMessage)); diff --git a/apps/extension/src/popup/popup.css b/apps/extension/src/popup/popup.css index 4f83ce4ec..5e5a56862 100644 --- a/apps/extension/src/popup/popup.css +++ b/apps/extension/src/popup/popup.css @@ -4,39 +4,47 @@ * tokens (--background/--foreground/--primary/...) through the shadow boundary, flipped * by the `.dark` class jeswr-theme-toggle sets on . We declare the SAME token set * here (light :root + .dark) so the popup's own chrome themes in lockstep with the - * components. These mirror app-shell's light/dark values so the look matches Pod Manager. + * components. Values follow Solid Suite design system §2.1. */ :root { - --background: oklch(0.992 0.003 210); - --foreground: oklch(0.21 0.022 235); - --border: oklch(0.91 0.012 220); - --muted-foreground: oklch(0.5 0.018 230); - --primary: oklch(0.52 0.094 205); - --primary-foreground: oklch(0.99 0.005 200); + --background: oklch(0.985 0.004 85); + --foreground: oklch(0.20 0.02 240); + --border: oklch(0.90 0.008 85); + --muted-foreground: var(--foreground-muted); + --primary: oklch(0.46 0.085 205); + --primary-foreground: oklch(0.99 0.004 200); --popover: oklch(1 0 0); - --popover-foreground: oklch(0.21 0.022 235); - --destructive: oklch(0.55 0.2 27); - --accent: oklch(0.94 0.03 195); - --accent-foreground: oklch(0.31 0.05 215); - --ring: oklch(0.52 0.094 205); - --radius: 0.7rem; + --popover-foreground: oklch(0.20 0.02 240); + --destructive: oklch(0.52 0.19 27); + --accent: oklch(0.94 0.03 200); + --accent-foreground: oklch(0.30 0.06 210); + --ring: oklch(0.60 0.12 205); + --radius: 6px; + --surface: oklch(1 0 0); + --surface-2: oklch(0.965 0.006 85); + --foreground-muted: oklch(0.47 0.018 240); + --input: oklch(0.63 0.012 240); color-scheme: light; } html.dark { - --background: oklch(0.18 0.02 240); - --foreground: oklch(0.95 0.01 220); - --border: oklch(0.32 0.02 235); - --muted-foreground: oklch(0.68 0.02 230); - --primary: oklch(0.7 0.12 200); - --primary-foreground: oklch(0.16 0.02 240); - --popover: oklch(0.22 0.02 240); - --popover-foreground: oklch(0.95 0.01 220); - --destructive: oklch(0.62 0.2 27); - --accent: oklch(0.3 0.04 215); - --accent-foreground: oklch(0.9 0.03 200); - --ring: oklch(0.7 0.12 200); + --background: oklch(0.16 0.012 240); + --foreground: oklch(0.95 0.008 90); + --border: oklch(1 0 0 / 12%); + --muted-foreground: var(--foreground-muted); + --primary: oklch(0.78 0.10 200); + --primary-foreground: oklch(0.16 0.03 235); + --popover: oklch(0.20 0.014 240); + --popover-foreground: oklch(0.95 0.008 90); + --destructive: oklch(0.72 0.17 22); + --accent: oklch(0.30 0.045 205); + --accent-foreground: oklch(0.92 0.03 200); + --ring: oklch(0.80 0.11 200); + --surface: oklch(0.20 0.014 240); + --surface-2: oklch(0.24 0.016 240); + --foreground-muted: oklch(0.72 0.014 240); + --input: oklch(1 0 0 / 36%); color-scheme: dark; } @@ -47,6 +55,7 @@ html.dark { body { margin: 0; width: 340px; + max-width: 100vw; background: var(--background); color: var(--foreground); font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, @@ -77,6 +86,7 @@ body { } .brand-name { + margin: 0; font-weight: 600; font-size: 15px; } diff --git a/apps/extension/src/popup/popup.html b/apps/extension/src/popup/popup.html index 309eef4aa..873575cd9 100644 --- a/apps/extension/src/popup/popup.html +++ b/apps/extension/src/popup/popup.html @@ -5,8 +5,10 @@ Solid + +
@@ -17,7 +19,7 @@ - Solid +

Solid

diff --git a/apps/extension/src/popup/profile-controller.ts b/apps/extension/src/popup/profile-controller.ts new file mode 100644 index 000000000..ea2786fa0 --- /dev/null +++ b/apps/extension/src/popup/profile-controller.ts @@ -0,0 +1,156 @@ +// AUTHORED-BY GPT-6.1-Sol (Codex) +import type { Profile, ProfileRequest, ProfilesResponse } from '../shared/messages'; + +export interface ProfileViewState { + readonly profiles: readonly Profile[]; + readonly defaultProfileId: string | null; + readonly ready: boolean; + readonly busy: boolean; + readonly error: string; + readonly status: string; +} + +/** Display metadata only; auth, capacity, default selection and purging stay in the worker. */ +export class ProfileController { + state: ProfileViewState = { + profiles: [], + defaultProfileId: null, + ready: false, + busy: false, + error: '', + status: '', + }; + private generation = 0; + private disposed = false; + private refreshQueued = false; + + constructor( + private readonly render: (state: ProfileViewState) => void, + private readonly send: ( + message: ProfileRequest, + ) => Promise = sendProfileMessage, + ) {} + + async refresh(): Promise { + if (this.disposed) return; + if (this.state.busy) { + this.refreshQueued = true; + return; + } + const generation = ++this.generation; + try { + const reply = await this.send({ type: 'SOLID_PROFILES_LIST' }); + if (this.disposed || generation !== this.generation) return; + this.apply(reply); + } catch (error) { + if (this.disposed || generation !== this.generation) return; + this.update({ error: errorMessage(error) }); + } + } + + async retry(): Promise { + if (this.disposed || this.state.busy) return; + // A failed removal may have revoked the session before returning an error. + await this.refresh(); + if (this.disposed || this.state.error) return; + for (const profile of this.state.profiles) { + if (profile.sessionState === 'removing' && !(await this.remove(profile.id))) return; + } + } + + add(webId: string): Promise { + return this.mutate({ type: 'SOLID_PROFILE_ADD', webId: webId.trim() }, 'Profile added.'); + } + + rename(profileId: string, label: string): Promise { + return this.mutate( + { type: 'SOLID_PROFILE_RENAME', profileId, label: label.trim() }, + 'Profile renamed.', + ); + } + + remove(profileId: string): Promise { + return this.mutate({ type: 'SOLID_PROFILE_REMOVE', profileId }, 'Profile removed.'); + } + + setDefault(profileId: string): Promise { + return this.mutate( + { type: 'SOLID_PROFILE_SET_DEFAULT', profileId }, + 'Default profile changed.', + ); + } + + dispose(): void { + this.disposed = true; + this.generation++; + } + + private async mutate(message: ProfileRequest, status: string): Promise { + if (this.disposed || this.state.busy) return false; + // Fence older list reads. Broadcasts during this mutation cannot replace its result. + this.generation++; + this.update({ busy: true, error: '', status: 'Updating profiles…' }); + try { + const reply = await this.send(message); + if (this.disposed) return false; + this.apply(reply); + this.update({ busy: false, status }); + return true; + } catch (error) { + if (!this.disposed && message.type === 'SOLID_PROFILE_REMOVE') { + try { + // Revocation may already be committed. Reconcile queued broadcasts while still + // busy, then keep the removal error visible alongside the worker's current list. + do { + this.refreshQueued = false; + const reply = await this.send({ type: 'SOLID_PROFILES_LIST' }); + if (this.disposed) return false; + this.apply(reply); + } while (this.refreshQueued); + } catch { + // If storage is also unavailable, retain the last list and the removal error. + } + } + if (!this.disposed) this.update({ busy: false, status: '', error: errorMessage(error) }); + return false; + } finally { + // A different open surface may have changed profiles while this reply was in flight. + if (this.refreshQueued) { + this.refreshQueued = false; + void this.refresh(); + } + } + } + + private apply(reply: ProfilesResponse): void { + if (reply?.error) throw new Error(reply.error); + if (!reply?.ok || !Array.isArray(reply.profiles) || reply.defaultProfileId === undefined) { + throw new Error('Could not load profiles. Try again.'); + } + this.update({ + profiles: reply.profiles, + defaultProfileId: reply.defaultProfileId, + ready: true, + error: '', + }); + } + + private update(patch: Partial): void { + this.state = { ...this.state, ...patch }; + this.render(this.state); + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : 'Profile operation failed. Try again.'; +} + +function sendProfileMessage(message: ProfileRequest): Promise { + return new Promise((resolve, reject) => { + chrome.runtime.sendMessage(message, (reply: ProfilesResponse) => { + const error = chrome.runtime.lastError; + if (error) reject(new Error(error.message ?? 'Could not contact the extension.')); + else resolve(reply); + }); + }); +} diff --git a/apps/extension/src/popup/profile-switcher.ts b/apps/extension/src/popup/profile-switcher.ts new file mode 100644 index 000000000..6940222cc --- /dev/null +++ b/apps/extension/src/popup/profile-switcher.ts @@ -0,0 +1,320 @@ +// AUTHORED-BY GPT-6.1-Sol (Codex) +import type { Profile } from '../shared/messages'; +import { ProfileController, type ProfileViewState } from './profile-controller'; + +/** One native, labelled form shared by the popup and persistent side panel. */ +export function mountProfileSwitcher(parent: HTMLElement): { + controller: ProfileController; + cleanup: () => void; +} { + const doc = parent.ownerDocument; + const root = doc.createElement('section'); + root.id = 'profiles'; + root.setAttribute('aria-labelledby', 'profiles-heading'); + root.innerHTML = ` +

Profiles

+
+

Choose the default account for Solid.

+
    + + + + +

    Loading profiles…

    + + `; + parent.append(root); + const get = (id: string): T => root.querySelector(`#${id}`) as T; + const list = get('profile-list'); + const editor = get('profile-editor'); + const input = get('profile-input'); + const add = get('profile-add'); + const save = get('profile-save'); + let editing: { action: 'add' | 'rename' | 'remove'; profile?: Profile; trigger: string } | null = + null; + let listSignature = ''; + let pendingFocus: string | null = null; + // A focused control that a render disabled, hid or removed; restored (or replaced by a + // usable fallback) once the surface is idle. + let busyFocus: HTMLElement | null = null; + let disposed = false; + + function button(text: string, key: string, label: string, click: () => void): HTMLButtonElement { + const el = doc.createElement('button'); + el.type = 'button'; + el.textContent = text; + el.dataset.focus = key; + el.setAttribute('aria-label', label); + el.addEventListener('click', () => { + if (!controller.state.busy) click(); + }); + return el; + } + + function restoreFocus(key: string): void { + pendingFocus = key; + if (controller.state.busy) return; + const controls = Array.from(list.querySelectorAll('button')).filter( + (el) => !el.disabled, + ); + const target = controls.find((el) => el.dataset.focus === key); + (target ?? (add.disabled ? controls[0] : add))?.focus(); + pendingFocus = null; + } + + function closeEditor(): void { + const key = editing?.trigger ?? ''; + editing = null; + editor.hidden = true; + input.setCustomValidity(''); + // Return focus to the trigger only when the editor owned it (or it was lost); never pull + // focus back from a usable control the user moved to while an operation completed. + const active = doc.activeElement; + if (active === null || active === doc.body || root.contains(active)) restoreFocus(key); + } + + function openEditor(action: 'add' | 'rename' | 'remove', profile?: Profile): void { + editing = { action, profile, trigger: profile ? `${action}:${profile.id}` : '' }; + editor.hidden = false; + get('profile-editor-heading').textContent = + action === 'add' + ? 'Add profile' + : `${action === 'rename' ? 'Rename' : 'Remove'} ${profile?.label}`; + get('profile-editor-help').textContent = + action === 'remove' + ? 'Removing this profile revokes its app access and deletes its saved data from this extension. Your pod is kept.' + : action === 'add' + ? 'Enter your account ID to sign in. Your current default stays selected.' + : 'Choose a name to help you recognise this account.'; + const removal = action === 'remove'; + input.hidden = removal; + input.required = !removal; + get('profile-input-label').hidden = removal; + get('profile-input-label').textContent = action === 'add' ? 'Account ID' : 'Profile name'; + input.type = action === 'add' ? 'url' : 'text'; + input.maxLength = action === 'rename' ? 200 : 2048; + input.value = action === 'rename' ? (profile?.label ?? '') : ''; + input.setCustomValidity(''); + save.textContent = removal + ? 'Remove profile' + : action === 'add' + ? 'Sign in and add' + : 'Save name'; + save.classList.toggle('profile-destructive', removal); + // Destructive confirmation starts on Cancel; Enter on opening cannot remove a profile. + if (removal) get('profile-cancel').focus(); + else input.focus(); + } + + function focusable(el: HTMLElement): boolean { + return ( + el.isConnected && + root.contains(el) && + !(el as HTMLButtonElement).disabled && + el.closest('[hidden]') === null + ); + } + + function render(state: ProfileViewState): void { + root.setAttribute('aria-busy', String(state.busy)); + // The control focused before this render; if the render disables, hides or replaces it, + // focus is lost (browsers fix it up to lazily) and is recovered once idle. + const before = doc.activeElement as HTMLElement | null; + const hadFocus = before !== null && root.contains(before); + const signature = JSON.stringify([state.profiles, state.defaultProfileId]); + if (signature !== listSignature) { + listSignature = signature; + const active = doc.activeElement as HTMLElement | null; + const focusKey = list.contains(active) ? active?.dataset.focus : undefined; + list.replaceChildren(); + for (const profile of state.profiles) { + const row = doc.createElement('li'); + row.dataset.profileId = profile.id; + const identity = doc.createElement('div'); + identity.className = 'profile-identity'; + const avatar = doc.createElement('span'); + avatar.className = 'profile-avatar'; + avatar.setAttribute('aria-hidden', 'true'); + avatar.textContent = + profile.label + .trim() + .split(/\s+/) + .map((part) => part[0]) + .slice(0, 2) + .join('') + .toUpperCase() || '?'; + if (profile.avatar && safeAvatar(profile.avatar)) { + const image = doc.createElement('img'); + image.alt = ''; + image.referrerPolicy = 'no-referrer'; + image.src = profile.avatar; + image.addEventListener('error', () => image.remove(), { once: true }); + avatar.append(image); + } + const text = doc.createElement('div'); + text.className = 'profile-text'; + const name = doc.createElement('strong'); + name.textContent = profile.label; + const id = doc.createElement('small'); + id.textContent = `Account ID: ${profile.webId}`; + id.className = 'profile-id'; + id.title = id.textContent; + const session = doc.createElement('small'); + session.textContent = + profile.sessionState === 'active' + ? 'Signed in' + : profile.sessionState === 'removing' + ? 'Removal pending' + : 'Sign-in needed'; + text.append(name, id, session); + identity.append(avatar, text); + const actions = doc.createElement('div'); + actions.className = 'profile-actions'; + const selected = state.defaultProfileId === profile.id; + const select = button( + selected ? 'Default' : 'Set default', + `default:${profile.id}`, + `Set ${profile.label} as default`, + () => { + void controller.setDefault(profile.id); + }, + ); + select.setAttribute('aria-pressed', String(selected)); + actions.append(select); + if (profile.sessionState === 'signed-out') { + actions.append( + button('Sign in', `login:${profile.id}`, `Sign in to ${profile.label}`, () => { + void controller.add(profile.webId); + }), + ); + } + for (const action of ['rename', 'remove'] as const) { + const retryRemoval = action === 'remove' && profile.sessionState === 'removing'; + actions.append( + button( + retryRemoval ? 'Retry removal' : action === 'rename' ? 'Rename' : 'Remove', + `${action}:${profile.id}`, + `${retryRemoval ? 'Retry removal of' : action === 'rename' ? 'Rename' : 'Remove'} ${profile.label}`, + () => { + if (retryRemoval) void controller.remove(profile.id); + else openEditor(action, profile); + }, + ), + ); + } + row.append(identity, actions); + list.append(row); + if (profile.sessionState === 'removing') { + for (const control of row.querySelectorAll('button')) + control.disabled = control.dataset.focus !== `remove:${profile.id}`; + } + } + if (focusKey) pendingFocus = focusKey; + } + get('profiles-count').textContent = `${state.profiles.length} / 8`; + get('profiles-empty').hidden = !state.ready || state.profiles.length !== 0; + add.disabled = !state.ready || state.busy || state.profiles.length >= 8; + get('profile-limit').hidden = state.profiles.length < 8; + for (const control of list.querySelectorAll('button')) { + control.setAttribute('aria-disabled', String(state.busy || control.disabled)); + } + input.readOnly = state.busy; + save.disabled = state.busy; + get('profile-cancel').disabled = state.busy; + get('profile-status').textContent = + state.status || (state.ready || state.error ? '' : 'Loading profiles…'); + get('profile-error').textContent = state.error; + get('profile-retry').hidden = !state.error || state.busy; + // Apply disabled states before moving focus, including the busy → idle render. + if (editing?.profile && !state.profiles.some((p) => p.id === editing?.profile?.id)) + closeEditor(); + if (hadFocus && busyFocus === null && !focusable(before)) busyFocus = before; + if (pendingFocus !== null) restoreFocus(pendingFocus); + else if (!state.busy && busyFocus !== null) { + // Only recover genuinely lost focus: never pull focus back from a usable control the + // user moved to (e.g. the theme toggle) while the operation was running. + const active = doc.activeElement as HTMLElement | null; + if (active === null || active === doc.body || (root.contains(active) && !focusable(active))) { + if (focusable(busyFocus)) busyFocus.focus(); + else restoreFocus(''); + } + } + if (!state.busy) busyFocus = null; + } + const controller = new ProfileController(render); + add.addEventListener('click', () => openEditor('add')); + get('profile-cancel').addEventListener('click', closeEditor); + get('profile-retry').addEventListener('click', () => { + void controller.retry(); + }); + input.addEventListener('input', () => input.setCustomValidity('')); + editor.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && !controller.state.busy) { + event.preventDefault(); + closeEditor(); + } + }); + editor.addEventListener('submit', (event) => { + event.preventDefault(); + if (!editing || controller.state.busy) return; + const { action, profile } = editing; + if (action !== 'remove' && !input.value.trim()) { + input.setCustomValidity('Enter a value.'); + input.reportValidity(); + return; + } + const operation = + action === 'add' + ? controller.add(input.value) + : action === 'rename' + ? controller.rename(profile!.id, input.value) + : controller.remove(profile!.id); + void operation.then((ok) => { + if (ok && !disposed) closeEditor(); + }); + }); + // Arrow keys move between profiles at the same action; native Tab/Enter/Space also work. + list.addEventListener('keydown', (event) => { + if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return; + const active = doc.activeElement as HTMLElement; + const action = active.dataset.focus?.split(':')[0]; + if (!action) return; + const buttons = Array.from(list.querySelectorAll('button')).filter( + (el) => el.dataset.focus?.startsWith(`${action}:`) && !el.disabled, + ); + const next = + (buttons.indexOf(active as HTMLButtonElement) + + (event.key === 'ArrowDown' ? 1 : -1) + + buttons.length) % + buttons.length; + event.preventDefault(); + buttons[next]?.focus(); + }); + render(controller.state); + void controller.refresh(); + return { + controller, + cleanup: () => { + disposed = true; + controller.dispose(); + root.remove(); + }, + }; +} + +function safeAvatar(url: string): boolean { + try { + return ['https:', 'http:'].includes(new URL(url).protocol); + } catch { + return false; + } +} diff --git a/apps/extension/src/popup/profiles.css b/apps/extension/src/popup/profiles.css new file mode 100644 index 000000000..a7e8f5e3b --- /dev/null +++ b/apps/extension/src/popup/profiles.css @@ -0,0 +1,36 @@ +/* AUTHORED-BY GPT-6.1-Sol (Codex) — shared Solid Suite account controls. */ +[hidden] { display: none !important; } +:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } +.skip-link { position: absolute; top: -100px; color: var(--primary); background: var(--surface); padding: 8px; z-index: 10; } +.skip-link:focus { top: 4px; } +#profiles { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); } +.profiles-heading { display: flex; justify-content: space-between; align-items: center; gap: 8px; } +#profiles h2 { font-size: 18px; line-height: 26px; font-weight: 600; margin: 0; } +#profiles h3 { font-size: 14px; line-height: 22px; margin: 0 0 8px; overflow-wrap: anywhere; } +#profiles-count, .profile-hint, #profile-status { color: var(--foreground-muted); font-size: 13px; line-height: 20px; } +.profile-hint { margin: 4px 0 12px; } +#profile-list { list-style: none; padding: 0; margin: 0 0 12px; max-height: 352px; overflow-y: auto; scrollbar-gutter: stable; } +#profile-list li { border: 1px solid var(--border); border-radius: 10px; background: var(--surface); padding: 12px; margin-bottom: 8px; } +.profile-identity { display: flex; align-items: flex-start; gap: 8px; } +.profile-avatar { position: relative; display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 50%; flex: none; overflow: hidden; color: var(--accent-foreground); background: var(--accent); font-size: 13px; font-weight: 600; } +.profile-avatar img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; } +.profile-text { min-width: 0; } +.profile-text strong { display: block; font-size: 14px; line-height: 22px; overflow-wrap: anywhere; } +.profile-text small { display: block; color: var(--foreground-muted); font-size: 12px; line-height: 16px; overflow-wrap: anywhere; } +.profile-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; } +#profiles button, #profiles input { font: inherit; min-height: 36px; border-radius: 6px; padding: 6px 8px; } +#profiles button { border: 1px solid var(--input); color: var(--foreground); background: var(--surface); cursor: pointer; } +#profiles button:hover { background: var(--surface-2); } +#profiles button[aria-pressed="true"] { background: var(--accent); color: var(--accent-foreground); } +#profiles button:disabled, #profiles button[aria-disabled="true"] { opacity: 0.6; cursor: default; } +#profiles .profile-destructive { color: var(--destructive); } +#profile-editor { border: 1px solid var(--border); background: var(--surface-2); border-radius: 10px; padding: 12px; margin-top: 12px; } +#profile-input-label { display: block; margin-bottom: 4px; } +#profile-input { width: 100%; border: 1px solid var(--input); background: var(--surface); color: var(--foreground); } +#profile-error { color: var(--destructive); font-size: 13px; line-height: 20px; overflow-wrap: anywhere; } +#profile-error:empty, #profile-status:empty { margin: 0; } +#profiles button, #profiles input { scroll-margin: 8px; } +@media (pointer: coarse) { #profiles button, #profiles input { min-height: 44px; } } +@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } } + +.profile-text .profile-id { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } diff --git a/apps/extension/src/sidepanel/sidepanel.css b/apps/extension/src/sidepanel/sidepanel.css index 344797b91..c9e87dd99 100644 --- a/apps/extension/src/sidepanel/sidepanel.css +++ b/apps/extension/src/sidepanel/sidepanel.css @@ -7,35 +7,43 @@ */ :root { - --background: oklch(0.992 0.003 210); - --foreground: oklch(0.21 0.022 235); - --border: oklch(0.91 0.012 220); - --muted-foreground: oklch(0.5 0.018 230); - --primary: oklch(0.52 0.094 205); - --primary-foreground: oklch(0.99 0.005 200); + --background: oklch(0.985 0.004 85); + --foreground: oklch(0.20 0.02 240); + --border: oklch(0.90 0.008 85); + --muted-foreground: var(--foreground-muted); + --primary: oklch(0.46 0.085 205); + --primary-foreground: oklch(0.99 0.004 200); --popover: oklch(1 0 0); - --popover-foreground: oklch(0.21 0.022 235); - --destructive: oklch(0.55 0.2 27); - --accent: oklch(0.94 0.03 195); - --accent-foreground: oklch(0.31 0.05 215); - --ring: oklch(0.52 0.094 205); - --radius: 0.7rem; + --popover-foreground: oklch(0.20 0.02 240); + --destructive: oklch(0.52 0.19 27); + --accent: oklch(0.94 0.03 200); + --accent-foreground: oklch(0.30 0.06 210); + --ring: oklch(0.60 0.12 205); + --radius: 6px; + --surface: oklch(1 0 0); + --surface-2: oklch(0.965 0.006 85); + --foreground-muted: oklch(0.47 0.018 240); + --input: oklch(0.63 0.012 240); color-scheme: light; } html.dark { - --background: oklch(0.18 0.02 240); - --foreground: oklch(0.95 0.01 220); - --border: oklch(0.32 0.02 235); - --muted-foreground: oklch(0.68 0.02 230); - --primary: oklch(0.7 0.12 200); - --primary-foreground: oklch(0.16 0.02 240); - --popover: oklch(0.22 0.02 240); - --popover-foreground: oklch(0.95 0.01 220); - --destructive: oklch(0.62 0.2 27); - --accent: oklch(0.3 0.04 215); - --accent-foreground: oklch(0.9 0.03 200); - --ring: oklch(0.7 0.12 200); + --background: oklch(0.16 0.012 240); + --foreground: oklch(0.95 0.008 90); + --border: oklch(1 0 0 / 12%); + --muted-foreground: var(--foreground-muted); + --primary: oklch(0.78 0.10 200); + --primary-foreground: oklch(0.16 0.03 235); + --popover: oklch(0.20 0.014 240); + --popover-foreground: oklch(0.95 0.008 90); + --destructive: oklch(0.72 0.17 22); + --accent: oklch(0.30 0.045 205); + --accent-foreground: oklch(0.92 0.03 200); + --ring: oklch(0.80 0.11 200); + --surface: oklch(0.20 0.014 240); + --surface-2: oklch(0.24 0.016 240); + --foreground-muted: oklch(0.72 0.014 240); + --input: oklch(1 0 0 / 36%); color-scheme: dark; } @@ -81,6 +89,7 @@ body { } .brand-name { + margin: 0; font-weight: 600; font-size: 15px; } diff --git a/apps/extension/src/sidepanel/sidepanel.html b/apps/extension/src/sidepanel/sidepanel.html index 99efb8b21..4e2086e6e 100644 --- a/apps/extension/src/sidepanel/sidepanel.html +++ b/apps/extension/src/sidepanel/sidepanel.html @@ -5,8 +5,10 @@ Solid + +
    @@ -17,7 +19,7 @@ - Solid +

    Solid

    diff --git a/apps/extension/test/account-surface-cleanup.test.ts b/apps/extension/test/account-surface-cleanup.test.ts index ee60cde1e..b5db71d5a 100644 --- a/apps/extension/test/account-surface-cleanup.test.ts +++ b/apps/extension/test/account-surface-cleanup.test.ts @@ -20,6 +20,7 @@ import { mountAccountSurface, resolveAccountSurfaceElements, } from '../src/popup/account-surface'; +import type { Profile } from '../src/shared/messages'; let onMessageListeners: Array<(message: { type?: string }) => void>; @@ -94,6 +95,70 @@ afterEach(() => { }); describe('mountAccountSurface — listener lifecycle (long-lived side panel)', () => { + it('refreshes both account surfaces and profile lists after a partially committed removal', async () => { + let profile: Profile = { + id: '1', + webId: 'https://fixture.invalid/me', + issuer: 'https://fixture.invalid/', + label: 'Personal', + sessionState: 'active', + dpopKeyRef: '1', + }; + vi.stubGlobal('chrome', { + runtime: { + ...chrome.runtime, + sendMessage: (message: { type: string }, callback: (response: unknown) => void) => { + if (message.type === 'SOLID_PROFILES_LIST') { + callback({ ok: true, profiles: [profile], defaultProfileId: '1' }); + } else if (message.type === 'SOLID_GET_STATE') { + const active = profile.sessionState === 'active'; + callback({ + isActive: active, + webId: active ? profile.webId : null, + name: active ? profile.label : null, + photoUrl: null, + recentAccounts: [], + }); + } else if (message.type === 'SOLID_PROFILE_REMOVE') { + profile = { ...profile, sessionState: 'removing' }; + // The worker broadcasts the persisted revocation even when its purge fails. + for (const listener of onMessageListeners) listener({ type: 'SOLID_STATE_CHANGED' }); + callback({ error: 'Cache unavailable.' }); + } else callback({ ok: true }); + }, + }, + }); + const panel = document.implementation.createHTMLDocument(); + panel.body.innerHTML = document.body.innerHTML; + mount(); + liveMounts.push(mountAccountSurface(resolveAccountSurfaceElements(panel))); + await vi.waitFor(() => { + for (const doc of [document, panel]) { + expect(doc.getElementById('signed-in')?.hidden).toBe(false); + expect(doc.getElementById('profile-list')?.textContent).toContain('Signed in'); + } + }); + + document.querySelector('[data-focus="remove:1"]')!.click(); + document + .getElementById('profile-editor')! + .dispatchEvent(new Event('submit', { cancelable: true })); + await vi.waitFor(() => { + for (const doc of [document, panel]) { + expect(doc.getElementById('signed-in')?.hidden).toBe(true); + expect(doc.getElementById('signed-out')?.hidden).toBe(false); + expect(doc.getElementById('profile-list')?.textContent).toContain('Removal pending'); + expect(doc.querySelector('[data-focus="default:1"]')?.disabled).toBe( + true, + ); + expect(doc.querySelector('[data-focus="remove:1"]')?.disabled).toBe( + false, + ); + } + expect(document.getElementById('profile-error')?.textContent).toBe('Cache unavailable.'); + }); + }); + it('registers exactly one onMessage listener on mount', () => { mount(); expect(onMessageListeners).toHaveLength(1); diff --git a/apps/extension/test/profile-controller.test.ts b/apps/extension/test/profile-controller.test.ts new file mode 100644 index 000000000..30acab4d6 --- /dev/null +++ b/apps/extension/test/profile-controller.test.ts @@ -0,0 +1,254 @@ +// AUTHORED-BY GPT-6.1-Sol (Codex) +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ProfileController } from '../src/popup/profile-controller'; +import type { Profile, ProfilesResponse } from '../src/shared/messages'; + +const profile: Profile = { + id: '1', + webId: 'https://fixture.invalid/me', + issuer: 'https://fixture.invalid/', + label: 'Personal', + sessionState: 'active', + dpopKeyRef: '1', +}; +const snapshot: ProfilesResponse = { ok: true, profiles: [profile], defaultProfileId: '1' }; +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} +afterEach(() => vi.unstubAllGlobals()); + +describe('profile UI controller', () => { + it('lists display metadata including an empty store', async () => { + const send = vi.fn().mockResolvedValue(snapshot); + const render = vi.fn(); + const controller = new ProfileController(render, send); + await controller.refresh(); + expect(send).toHaveBeenCalledWith({ type: 'SOLID_PROFILES_LIST' }); + expect(controller.state).toMatchObject({ + profiles: [profile], + defaultProfileId: '1', + ready: true, + }); + send.mockResolvedValue({ ok: true, profiles: [], defaultProfileId: null }); + await controller.refresh(); + expect(controller.state.profiles).toEqual([]); + expect(controller.state.defaultProfileId).toBeNull(); + }); + + it('uses only the existing worker operations and applies returned defaults', async () => { + const send = vi.fn().mockResolvedValue(snapshot); + const controller = new ProfileController(vi.fn(), send); + expect(await controller.add(' https://fixture.invalid/me ')).toBe(true); + expect(send).toHaveBeenLastCalledWith({ type: 'SOLID_PROFILE_ADD', webId: profile.webId }); + await controller.rename('1', ' Work '); + expect(send).toHaveBeenLastCalledWith({ + type: 'SOLID_PROFILE_RENAME', + profileId: '1', + label: 'Work', + }); + await controller.setDefault('1'); + expect(send).toHaveBeenLastCalledWith({ type: 'SOLID_PROFILE_SET_DEFAULT', profileId: '1' }); + send.mockResolvedValue({ ok: true, profiles: [], defaultProfileId: null }); + await controller.remove('1'); + expect(send).toHaveBeenLastCalledWith({ type: 'SOLID_PROFILE_REMOVE', profileId: '1' }); + expect(controller.state).toMatchObject({ status: 'Profile removed.', defaultProfileId: null }); + }); + + it('keeps the current list on cancellation/failure and permits retry', async () => { + const send = vi.fn().mockResolvedValue(snapshot); + const controller = new ProfileController(vi.fn(), send); + await controller.refresh(); + send.mockResolvedValue({ error: 'Sign-in cancelled.' }); + expect(await controller.add(profile.webId)).toBe(false); + expect(controller.state).toMatchObject({ + profiles: [profile], + error: 'Sign-in cancelled.', + busy: false, + }); + send.mockResolvedValue(snapshot); + expect(await controller.add(profile.webId)).toBe(true); + expect(controller.state.error).toBe(''); + }); + + it('refreshes before retrying pending removals in sequence and preserves other profiles', async () => { + let profiles: Profile[] = [ + profile, + { ...profile, id: '2', sessionState: 'removing' }, + { ...profile, id: '3', sessionState: 'signed-out' }, + { ...profile, id: '4', sessionState: 'removing' }, + ]; + const firstRemoval = deferred(); + const send = vi.fn(async (message) => { + if (message.type === 'SOLID_PROFILE_REMOVE') { + profiles = profiles.filter((p) => p.id !== message.profileId); + if (message.profileId === '2') return firstRemoval.promise; + } + return { ok: true, profiles, defaultProfileId: '1' }; + }); + const controller = new ProfileController(vi.fn(), send); + const retry = controller.retry(); + await vi.waitFor(() => expect(controller.state.busy).toBe(true)); + await controller.retry(); + expect(send.mock.calls.map(([message]) => message)).toEqual([ + { type: 'SOLID_PROFILES_LIST' }, + { type: 'SOLID_PROFILE_REMOVE', profileId: '2' }, + ]); + firstRemoval.resolve({ ok: true, profiles, defaultProfileId: '1' }); + await retry; + expect(send).toHaveBeenLastCalledWith({ type: 'SOLID_PROFILE_REMOVE', profileId: '4' }); + expect(controller.state.profiles.map((p) => p.id)).toEqual(['1', '3']); + expect(controller.state).toMatchObject({ busy: false, error: '', defaultProfileId: '1' }); + }); + + it('keeps a repeated purge failure visible and permits another removal retry', async () => { + const pending = { ...profile, sessionState: 'removing' as const }; + const send = vi + .fn() + .mockResolvedValueOnce({ ...snapshot, profiles: [pending] }) + .mockResolvedValueOnce({ error: 'Cache unavailable.' }) + .mockResolvedValueOnce({ ...snapshot, profiles: [pending] }); + const controller = new ProfileController(vi.fn(), send); + await controller.retry(); + expect(controller.state).toMatchObject({ + profiles: [pending], + busy: false, + error: 'Cache unavailable.', + }); + send + .mockResolvedValueOnce({ ...snapshot, profiles: [pending] }) + .mockResolvedValueOnce({ ok: true, profiles: [], defaultProfileId: null }); + await controller.retry(); + expect(controller.state).toMatchObject({ profiles: [], defaultProfileId: null, error: '' }); + }); + + it.each([ + 'reply', + 'transport', + ])('reloads a partially removed profile after a %s error without losing the error', async (failure) => { + const pending = { ...profile, sessionState: 'removing' as const }; + const reload = deferred(); + const send = vi.fn().mockResolvedValueOnce(snapshot); + const controller = new ProfileController(vi.fn(), send); + await controller.refresh(); + if (failure === 'reply') send.mockResolvedValueOnce({ error: 'Cache unavailable.' }); + else send.mockRejectedValueOnce(new Error('Cache unavailable.')); + send.mockReturnValueOnce(reload.promise); + const removing = controller.remove('1'); + await vi.waitFor(() => expect(send).toHaveBeenLastCalledWith({ type: 'SOLID_PROFILES_LIST' })); + expect(controller.state.busy).toBe(true); + // A broadcast arriving during reconciliation must not clear the removal error. + await controller.refresh(); + send.mockResolvedValue({ ...snapshot, profiles: [pending] }); + reload.resolve({ ...snapshot, profiles: [pending] }); + expect(await removing).toBe(false); + expect(controller.state).toMatchObject({ + profiles: [pending], + defaultProfileId: '1', + busy: false, + error: 'Cache unavailable.', + }); + }); + + it('does not retry removals after a failed refresh or teardown', async () => { + const send = vi + .fn() + .mockResolvedValue({ ...snapshot, profiles: [{ ...profile, sessionState: 'removing' }] }); + const controller = new ProfileController(vi.fn(), send); + await controller.refresh(); + send.mockResolvedValueOnce({ error: 'Storage unavailable.' }); + await controller.retry(); + expect(send).toHaveBeenCalledTimes(2); + expect(controller.state.error).toBe('Storage unavailable.'); + const pending = deferred(); + send.mockReturnValueOnce(pending.promise); + const retry = controller.retry(); + controller.dispose(); + pending.resolve(snapshot); + await retry; + await controller.retry(); + expect(send).toHaveBeenCalledTimes(3); + }); + + it.each([ + undefined, + { ok: false }, + { ok: true, profiles: [] }, + { error: 'Storage unavailable.' }, + ])('handles missing or failed replies: %j', async (reply) => { + const controller = new ProfileController(vi.fn(), vi.fn().mockResolvedValue(reply)); + await controller.refresh(); + expect(controller.state.ready).toBe(false); + expect(controller.state.error).not.toBe(''); + expect(await controller.remove('1')).toBe(false); + expect(controller.state.busy).toBe(false); + }); + + it('ignores stale lists and duplicate actions, then reconciles queued broadcasts', async () => { + const stale = deferred(); + const mutation = deferred(); + const send = vi + .fn() + .mockReturnValueOnce(stale.promise) + .mockReturnValueOnce(mutation.promise) + .mockResolvedValue(snapshot); + const controller = new ProfileController(vi.fn(), send); + const refresh = controller.refresh(); + const adding = controller.add(profile.webId); + expect(await controller.remove('1')).toBe(false); + await controller.refresh(); + expect(send).toHaveBeenCalledTimes(2); + mutation.resolve(snapshot); + await adding; + expect(send).toHaveBeenCalledTimes(3); + stale.resolve({ ok: true, profiles: [], defaultProfileId: null }); + await refresh; + expect(controller.state.profiles).toEqual([profile]); + }); + + it('lets the latest refresh win, including errors', async () => { + const old = deferred(); + const send = vi.fn().mockReturnValueOnce(old.promise).mockResolvedValue(snapshot); + const controller = new ProfileController(vi.fn(), send); + const first = controller.refresh(); + await controller.refresh(); + old.resolve({ error: 'Stale failure' }); + await first; + expect(controller.state.error).toBe(''); + expect(controller.state.profiles).toEqual([profile]); + }); + + it('fences in-flight operations and stops rendering after teardown', async () => { + const pending = deferred(); + const render = vi.fn(); + const send = vi.fn().mockReturnValue(pending.promise); + const controller = new ProfileController(render, send); + const action = controller.add(profile.webId); + controller.dispose(); + render.mockClear(); + pending.resolve(snapshot); + expect(await action).toBe(false); + await controller.refresh(); + expect(await controller.remove('1')).toBe(false); + expect(render).not.toHaveBeenCalled(); + expect(send).toHaveBeenCalledTimes(1); + }); + + it('reports Chrome transport errors and recovers through the real message adapter', async () => { + const runtime = { + lastError: { message: 'Worker unavailable' } as { message: string } | undefined, + sendMessage: vi.fn((_request, callback) => callback(snapshot)), + }; + vi.stubGlobal('chrome', { runtime }); + const controller = new ProfileController(vi.fn()); + await controller.refresh(); + expect(controller.state.error).toBe('Worker unavailable'); + runtime.lastError = undefined; + await controller.refresh(); + expect(controller.state.ready).toBe(true); + expect(controller.state.error).toBe(''); + }); +}); diff --git a/apps/extension/test/profile-store.test.ts b/apps/extension/test/profile-store.test.ts index 310ab0c46..fa1f385d6 100644 --- a/apps/extension/test/profile-store.test.ts +++ b/apps/extension/test/profile-store.test.ts @@ -301,6 +301,32 @@ describe('ProfileStore lifecycle and adversarial isolation', () => { expect((await store.list()).profiles).toEqual([]); }); + it.each([ + 'retry', + 'restart', + ])('preserves pending removal through logout and finishes on %s', async (recovery) => { + const a = await login(); + const b = await login('bob'); + db.state.migrated = true; + purge.mockRejectedValue(new Error('cache unavailable')); + await expect(store.remove(a.profile.id)).rejects.toThrow('cache unavailable'); + await expect(store.signOut(a.profile.id)).rejects.toThrow('cache unavailable'); + expect((await store.get(a.profile.id)).profile.sessionState).toBe('removing'); + expect(await store.session(a.profile.id)).toBeNull(); + + purge.mockResolvedValue(undefined); + await store.signOut(a.profile.id); + expect((await store.get(a.profile.id)).profile.sessionState).toBe('removing'); + await expect(store.login(a.session, a.keyPair, a.display)).rejects.toThrow('removal pending'); + if (recovery === 'retry') await store.remove(a.profile.id); + else await new ProfileStore(db, purge).initialise(noLegacy, async () => {}); + expect(await store.list()).toMatchObject({ + profiles: [b.profile], + defaultProfileId: b.profile.id, + }); + expect((await store.session(b.profile.id))?.session).toEqual(b.session); + }); + it('signs out one profile and purges before re-login while retaining its display entry', async () => { const a = await login(); const b = await login('bob'); diff --git a/apps/extension/test/profile-switcher.test.ts b/apps/extension/test/profile-switcher.test.ts new file mode 100644 index 000000000..12b6fa3a4 --- /dev/null +++ b/apps/extension/test/profile-switcher.test.ts @@ -0,0 +1,350 @@ +// @vitest-environment jsdom +// AUTHORED-BY GPT-6.1-Sol (Codex) +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { mountProfileSwitcher } from '../src/popup/profile-switcher'; +import type { Profile, ProfileRequest } from '../src/shared/messages'; + +let mounted: ReturnType; +let profiles: Profile[]; +let defaultProfileId: string | null; +let requests: ProfileRequest[]; +let error: string | undefined; +let removalError: string | undefined; +// When set, the next worker reply is held until `release()` so a test can observe the busy UI. +let held: (() => void) | null = null; +let holdNext = false; +const release = () => { + const run = held; + held = null; + run?.(); +}; +// jsdom has no focus fixup: emulate the browser dropping focus from a disabled/hidden control. +const focusFixup = () => { + const active = document.activeElement as HTMLButtonElement | null; + if (!active || !(active.disabled || active.closest('[hidden]'))) return; + // jsdom ignores blur() on a disabled control; focusing then removing a probe resets to . + const probe = document.createElement('button'); + document.body.append(probe); + probe.focus(); + probe.remove(); +}; +const get = (id: string) => document.getElementById(id) as T; +const flush = async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +}; +const action = (label: string) => + Array.from(document.querySelectorAll('button')).find( + (button) => button.getAttribute('aria-label') === label, + )!; +beforeEach(async () => { + profiles = ['Personal', 'Work'].map((label, index) => ({ + id: String(index + 1), + label, + webId: `https://fixture.invalid/${index}`, + issuer: 'https://fixture.invalid/', + sessionState: 'active', + dpopKeyRef: String(index + 1), + })); + defaultProfileId = '1'; + requests = []; + held = null; + holdNext = false; + error = undefined; + removalError = undefined; + document.body.replaceChildren(); + vi.stubGlobal('chrome', { + runtime: { + sendMessage: (request: ProfileRequest, reply: (reply: unknown) => void) => { + if (holdNext) { + holdNext = false; + held = () => respond(request, reply); + return; + } + respond(request, reply); + }, + }, + }); + function respond(request: ProfileRequest, callback: (reply: unknown) => void) { + requests.push(request); + if (request.type === 'SOLID_PROFILE_REMOVE') { + profiles = profiles.map((p) => + p.id === request.profileId ? { ...p, sessionState: 'removing' } : p, + ); + if (removalError) return callback({ error: removalError }); + } + if (error) return callback({ error }); + if (request.type === 'SOLID_PROFILE_SET_DEFAULT') defaultProfileId = request.profileId; + if (request.type === 'SOLID_PROFILE_REMOVE') + profiles = profiles.filter((p) => p.id !== request.profileId); + if (request.type === 'SOLID_PROFILE_RENAME') + profiles = profiles.map((p) => + p.id === request.profileId ? { ...p, label: request.label } : p, + ); + callback({ ok: true, profiles, defaultProfileId }); + } + mounted = mountProfileSwitcher(document.body); + await flush(); +}); +afterEach(() => { + mounted.cleanup(); + vi.unstubAllGlobals(); +}); + +it('renders labelled profiles and native default controls with an avatar per profile', async () => { + expect(document.querySelectorAll('#profile-list li')).toHaveLength(2); + expect(document.querySelectorAll('.profile-avatar')).toHaveLength(2); + expect(action('Set Personal as default').getAttribute('aria-pressed')).toBe('true'); + action('Set Work as default').click(); + await flush(); + expect(action('Set Work as default').getAttribute('aria-pressed')).toBe('true'); + expect(get('profile-status').textContent).toBe('Default profile changed.'); +}); + +it('supports rename, Escape cancel and focus return without sending a mutation', async () => { + action('Rename Work').click(); + expect(document.activeElement).toBe(get('profile-input')); + expect(get('profile-input').value).toBe('Work'); + get('profile-input').dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }), + ); + expect(get('profile-editor').hidden).toBe(true); + expect(document.activeElement).toBe(action('Rename Work')); + expect(requests).toHaveLength(1); + action('Rename Work').click(); + get('profile-input').value = 'Office'; + get('profile-editor').dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + await flush(); + expect(action('Rename Office')).toBeDefined(); + expect(document.activeElement).toBe(action('Rename Office')); +}); + +it('requires explicit removal confirmation and explains its effect', async () => { + action('Remove Work').click(); + expect(requests).toHaveLength(1); + expect(document.activeElement).toBe(get('profile-cancel')); + expect(get('profile-editor-help').textContent).toContain('revokes its app access'); + get('profile-editor').dispatchEvent(new Event('submit', { cancelable: true })); + await flush(); + expect(requests.at(-1)).toEqual({ type: 'SOLID_PROFILE_REMOVE', profileId: '2' }); + expect(document.querySelectorAll('#profile-list li')).toHaveLength(1); + expect(document.activeElement).toBe(get('profile-add')); +}); + +it('keeps failed input for retry and announces the error', async () => { + get('profile-add').click(); + get('profile-input').value = 'https://fixture.invalid/new'; + error = 'Sign-in cancelled.'; + get('profile-editor').dispatchEvent(new Event('submit', { cancelable: true })); + await flush(); + expect(get('profile-error').textContent).toBe(error); + expect(get('profile-error').getAttribute('role')).toBe('alert'); + expect(get('profile-editor').hidden).toBe(false); + expect(get('profile-input').value).toBe('https://fixture.invalid/new'); +}); + +it.each([ + 'Retry removal', + 'Retry profiles', +])('recovers a failed removal after cancelling the editor with %s', async (retry) => { + removalError = 'Cache unavailable.'; + action('Remove Work').click(); + get('profile-editor').dispatchEvent(new Event('submit', { cancelable: true })); + await flush(); + expect(get('profile-error').textContent).toBe(removalError); + expect(document.querySelector('[data-profile-id="2"]')?.textContent).toContain('Removal pending'); + get('profile-cancel').click(); + expect(get('profile-editor').hidden).toBe(true); + + // A repeated purge failure stays retryable. + get('profile-retry').click(); + await flush(); + expect(requests.filter((request) => request.type === 'SOLID_PROFILE_REMOVE')).toHaveLength(2); + expect(get('profile-error').textContent).toBe(removalError); + expect(document.querySelector('[data-profile-id="2"]')?.textContent).toContain('Removal pending'); + expect(action('Set Work as default').disabled).toBe(true); + expect(action('Rename Work').disabled).toBe(true); + const removal = action('Retry removal of Work'); + expect(removal.textContent).toBe('Retry removal'); + expect(removal.disabled).toBe(false); + expect(removal.getAttribute('aria-disabled')).toBe('false'); + expect(action('Sign in to Work')).toBeUndefined(); + expect(get('profile-retry').hidden).toBe(false); + action('Set Work as default').click(); + action('Rename Work').click(); + expect(get('profile-editor').hidden).toBe(true); + + removalError = undefined; + (retry === 'Retry removal' ? removal : get('profile-retry')).click(); + await flush(); + expect(document.querySelectorAll('#profile-list li')).toHaveLength(1); + expect(requests.filter((request) => request.type === 'SOLID_PROFILE_REMOVE')).toHaveLength(3); + expect(defaultProfileId).toBe('1'); + expect(get('profile-error').textContent).toBe(''); +}); + +it('restores focus to Sign in and add after a failed keyboard submission', async () => { + get('profile-add').click(); + get('profile-input').value = 'https://fixture.invalid/new'; + const save = get('profile-save'); + save.focus(); + error = 'Sign-in cancelled.'; + holdNext = true; + get('profile-editor').dispatchEvent(new Event('submit', { cancelable: true })); + await flush(); + expect(save.disabled).toBe(true); + focusFixup(); + expect(document.activeElement).toBe(document.body); + release(); + await flush(); + expect(get('profile-error').textContent).toBe(error); + expect(get('profile-editor').hidden).toBe(false); + expect(document.activeElement).toBe(save); +}); + +it('moves focus to a visible control when Retry profiles hides itself', async () => { + removalError = 'Cache unavailable.'; + action('Remove Work').click(); + get('profile-editor').dispatchEvent(new Event('submit', { cancelable: true })); + await flush(); + get('profile-cancel').click(); + + removalError = undefined; + const retry = get('profile-retry'); + retry.focus(); + holdNext = true; + retry.click(); + await flush(); + focusFixup(); + release(); + await flush(); + expect(retry.hidden).toBe(true); + expect(document.querySelectorAll('#profile-list li')).toHaveLength(1); + expect(document.activeElement).toBe(get('profile-add')); +}); + +it('moves focus to Add when a successful profile refresh hides Retry profiles', async () => { + error = 'Storage unavailable.'; + await mounted.controller.refresh(); + const retry = get('profile-retry'); + expect(retry.hidden).toBe(false); + retry.focus(); + error = undefined; + retry.click(); + await flush(); + expect(retry.hidden).toBe(true); + expect(document.activeElement).toBe(get('profile-add')); +}); + +it('never pulls focus back from a control outside Profiles when an operation fails', async () => { + const outside = document.createElement('button'); + outside.textContent = 'Theme'; + document.body.append(outside); + get('profile-add').click(); + get('profile-input').value = 'https://fixture.invalid/new'; + get('profile-save').focus(); + error = 'Sign-in cancelled.'; + holdNext = true; + get('profile-editor').dispatchEvent(new Event('submit', { cancelable: true })); + await flush(); + outside.focus(); + release(); + await flush(); + expect(get('profile-error').textContent).toBe(error); + expect(document.activeElement).toBe(outside); +}); + +it('leaves focus on an outside control when a rename completes successfully', async () => { + const outside = document.createElement('button'); + outside.textContent = 'Theme'; + document.body.append(outside); + action('Rename Work').click(); + get('profile-input').value = 'Office'; + holdNext = true; + get('profile-editor').dispatchEvent(new Event('submit', { cancelable: true })); + await flush(); + outside.focus(); + release(); + await flush(); + expect(get('profile-editor').hidden).toBe(true); + expect(document.activeElement).toBe(outside); +}); + +it('offers removal recovery when an existing pending profile loads without an error', async () => { + profiles = profiles.map((p) => (p.id === '2' ? { ...p, sessionState: 'removing' } : p)); + await mounted.controller.refresh(); + expect(get('profile-error').textContent).toBe(''); + expect(get('profile-retry').hidden).toBe(true); + action('Retry removal of Work').click(); + await flush(); + expect(requests.at(-1)).toEqual({ type: 'SOLID_PROFILE_REMOVE', profileId: '2' }); + expect(document.querySelectorAll('#profile-list li')).toHaveLength(1); +}); + +it('shows signed-out profiles and permits re-login even at capacity', async () => { + profiles = Array.from({ length: 8 }, (_, i) => ({ + ...profiles[0], + id: String(i), + label: `Profile ${i}`, + sessionState: 'signed-out', + })); + await mounted.controller.refresh(); + expect(get('profile-add').disabled).toBe(true); + action('Sign in to Profile 0').click(); + await flush(); + expect(requests.at(-1)).toEqual({ type: 'SOLID_PROFILE_ADD', webId: profiles[0].webId }); +}); + +it('focuses Add after retrying a pending removal frees the eighth slot', async () => { + profiles = Array.from({ length: 8 }, (_, i) => ({ + ...profiles[0], + id: String(i), + label: `Profile ${i}`, + sessionState: i === 7 ? 'removing' : 'active', + })); + await mounted.controller.refresh(); + expect(get('profile-add').disabled).toBe(true); + const retry = action('Retry removal of Profile 7'); + retry.focus(); + retry.click(); + await flush(); + expect(document.querySelectorAll('#profile-list li')).toHaveLength(7); + expect(get('profile-add').disabled).toBe(false); + expect(document.activeElement).toBe(get('profile-add')); +}); + +it('focuses an enabled surviving control after adding the eighth profile', async () => { + profiles = Array.from({ length: 7 }, (_, i) => ({ + ...profiles[0], + id: String(i), + label: `Profile ${i}`, + })); + await mounted.controller.refresh(); + get('profile-add').click(); + get('profile-input').value = 'https://fixture.invalid/new'; + // The worker returns the newly authenticated profile when the add completes. + profiles = [...profiles, { ...profiles[0], id: '7', label: 'New' }]; + get('profile-editor').dispatchEvent(new Event('submit', { cancelable: true })); + await flush(); + expect(get('profile-editor').hidden).toBe(true); + expect(get('profile-add').disabled).toBe(true); + expect(document.activeElement).toBe(action('Set Profile 0 as default')); + expect((document.activeElement as HTMLButtonElement).disabled).toBe(false); +}); + +it('renders untrusted labels as text and falls back on missing or failed avatars', async () => { + profiles = [ + { ...profiles[0], label: '', avatar: 'javascript:alert(1)' }, + ]; + await mounted.controller.refresh(); + expect(document.querySelector('.profile-text strong')?.textContent).toBe(profiles[0].label); + expect(document.querySelectorAll('img')).toHaveLength(0); + profiles = [{ ...profiles[0], avatar: 'https://fixture.invalid/avatar.png' }]; + await mounted.controller.refresh(); + const img = document.querySelector('img')!; + expect(img.referrerPolicy).toBe('no-referrer'); + img.dispatchEvent(new Event('error')); + expect(document.querySelectorAll('img')).toHaveLength(0); + expect(document.querySelector('.profile-avatar')?.textContent).not.toBe(''); +}); diff --git a/apps/extension/test/service-worker-gate.test.ts b/apps/extension/test/service-worker-gate.test.ts index be162f3ce..77e67d6b8 100644 --- a/apps/extension/test/service-worker-gate.test.ts +++ b/apps/extension/test/service-worker-gate.test.ts @@ -1618,8 +1618,10 @@ describe('ProfileStore runtime API and adversarial isolation', () => { }); vi.spyOn(caches, 'open').mockResolvedValue(cache); + const broadcast = vi.spyOn(chrome.runtime, 'sendMessage'); const result = await profileMessage('SOLID_PROFILE_REMOVE', { profileId: '1' }); expect(result.error).toBe('fixture cache deletion failed'); + expect(broadcast).toHaveBeenLastCalledWith({ type: 'SOLID_STATE_CHANGED', webId: null }); expect(profileDb.state.defaultProfileId).toBe('1'); expect(profileDb.state.records[0]).toMatchObject({ profile: { sessionState: 'removing' }, diff --git a/apps/extension/webpack.config.cjs b/apps/extension/webpack.config.cjs index 43fbb0810..1d1f64a42 100644 --- a/apps/extension/webpack.config.cjs +++ b/apps/extension/webpack.config.cjs @@ -41,6 +41,7 @@ module.exports = { { from: 'src/manifest.json', to: 'manifest.json' }, { from: 'src/popup/popup.html', to: 'popup/popup.html' }, { from: 'src/popup/popup.css', to: 'popup/popup.css' }, + { from: 'src/popup/profiles.css', to: 'popup/profiles.css' }, { from: 'src/sidepanel/sidepanel.html', to: 'sidepanel/sidepanel.html' }, { from: 'src/sidepanel/sidepanel.css', to: 'sidepanel/sidepanel.css' }, { from: 'src/consent/consent.html', to: 'consent/consent.html' }, From 0baf40b5328c9a1fa4c1c4db853612c867f2cf47 Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:07:13 +0000 Subject: [PATCH 39/50] feat: explicit per-origin grants bound to a profile (EXT-05) (#12) * feat: add profile UI controller Co-Authored-By: GPT-6.1-Sol (Codex) * feat: share accessible profile controls across surfaces Co-Authored-By: GPT-6.1-Sol (Codex) * test: cover profile switching in both account surfaces Co-Authored-By: GPT-6.1-Sol (Codex) * docs: record profile switcher lane results Co-Authored-By: GPT-6.1-Sol (Codex) * feat: model explicit profile-bound origin grants Co-Authored-By: GPT-6.1-Sol (Codex) * feat: enforce profile-bound origin authorization Recheck explicit parent grants before cache access, egress and reply. Nest class scopes, reconcile exact-origin registrations, expire grants with alarms, and expose trusted management operations. Co-Authored-By: GPT-6.1-Sol (Codex) * feat: gate page capabilities behind profile consent Keep ungranted pages stub-only and preserve native fetch. Add the consent profile picker, restore scoped capabilities on approval, and clear them on revoke. Co-Authored-By: GPT-6.1-Sol (Codex) * test: cover origin consent and revocation in Chromium Seed a local type index, request consent before private fetches, and verify ungranted stubs and captured-function denial after revoke. Update browser selectors and wait for completed login before consent. Co-Authored-By: GPT-6.1-Sol (Codex) * docs: record origin grant behavior and lane validation Document permission scope, trusted management operations, conservative migration choices, final gate results and local-CSS browser verification. Co-Authored-By: GPT-6.1-Sol (Codex) * fix: keep pending profile removals retryable Co-Authored-By: GPT-6.1-Sol (Codex) * test: cover failed profile removal recovery Co-Authored-By: GPT-6.1-Sol (Codex) * docs: record removal recovery validation Co-Authored-By: GPT-6.1-Sol (Codex) * chore: run extension E2E in headless Chromium * fix: reach nested popup controls in E2E * fix: grant the E2E app access after popup login * docs: record EXT-CI reproduction and passing gates * chore: drop the EXT-CI lane report Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01AAzZz6QwQyvsDB1fCCYrCv * fix(e2e): fail fast on a busy CSS port and stop the server itself A Community Solid Server left on port 3000 answered the readiness probe while the new one exited with EADDRINUSE, so later runs silently used stale state. Spawning through npx also meant SIGTERM never reached the server. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01AAzZz6QwQyvsDB1fCCYrCv * docs: E2E runs in headless Chromium Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01AAzZz6QwQyvsDB1fCCYrCv * chore: drop the EXT-05 lane report files Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01AAzZz6QwQyvsDB1fCCYrCv * fix(profiles): preserve pending removal recovery Co-Authored-By: Codex GPT-6.1-Sol * test(profiles): cover partial removal and logout recovery Co-Authored-By: Codex GPT-6.1-Sol * fix(popup): restore profile focus after updating controls Co-Authored-By: Codex GPT-6.1-Sol * test(popup): cover removal state and focus at capacity Co-Authored-By: Codex GPT-6.1-Sol * chore: remove profile switcher lane reports Co-Authored-By: Codex GPT-6.1-Sol * fix(grants): merge consent classes inside the serialized update Co-Authored-By: Codex GPT-6.1-Sol * fix(grants): cancel only the revoking origin after class removal Co-Authored-By: Codex GPT-6.1-Sol * fix(discovery): cancel and recheck grants throughout class resolution Co-Authored-By: Codex GPT-6.1-Sol * fix(discovery): enforce the parent grant pod scope Co-Authored-By: Codex GPT-6.1-Sol * fix(identity): bind tab broadcasts to their intended origin Co-Authored-By: Codex GPT-6.1-Sol * fix(injection): specify exact ports in origin registrations Co-Authored-By: Codex GPT-6.1-Sol * fix(popup): keep focus visible when Retry profiles hides itself Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01AAzZz6QwQyvsDB1fCCYrCv * test(e2e): attach profile-switcher screenshots instead of pixel baselines The committed baselines came from a headed Xvfb run and fail under headless Chromium and on CI fonts. Screenshots are kept as review artifacts; the axe, layout and target-size assertions stay. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01AAzZz6QwQyvsDB1fCCYrCv * fix(consent): preserve retained class expiry Co-Authored-By: Codex GPT-6.1-Sol * fix(popup): restore focus lost while a profile operation is busy Disabling or hiding the focused control during an operation drops focus to (lazily, via focus fixup). Remember the control focused when the operation started and, once idle, refocus it if usable or fall back to Add / the first enabled control. Replaces the Retry-profiles special case with one guard covering every control. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01AAzZz6QwQyvsDB1fCCYrCv * fix(fetch): reject unchecked resource redirects Co-Authored-By: Codex GPT-6.1-Sol * fix(consent): cancel discovery on authority changes Co-Authored-By: Codex GPT-6.1-Sol * fix(grants): revalidate authority before listing targets Co-Authored-By: Codex GPT-6.1-Sol * fix(discovery): fetch WebID documents without credentials Co-Authored-By: Codex GPT-6.1-Sol * fix(state): revalidate identity after tab lookup Co-Authored-By: Codex GPT-6.1-Sol * fix(state): discard superseded broadcast runtime copies Co-Authored-By: Codex GPT-6.1-Sol * fix(popup): recover focus lost by any render, never steal it Track the focused control before each render: if that render disables, hides or replaces it (including a successful refresh hiding Retry profiles, which is not a busy operation), recover focus once idle. Focus the user moved to a usable control outside the switcher is left alone. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01AAzZz6QwQyvsDB1fCCYrCv * fix(popup): closing the editor returns focus only if the editor owned it Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01AAzZz6QwQyvsDB1fCCYrCv * fix(grants): recheck expiry after pruning Decide access from the live parent and child deadlines at one fresh instant after storage and pruning complete. Co-Authored-By: Codex GPT-6.1-Sol * fix(discovery): separate public indexes from pod scope Read WebID and public type indexes without credentials on any HTTP(S) origin. Confine private index authentication and returned targets to the granted pods. Co-Authored-By: Codex GPT-6.1-Sol * test(e2e): make the fixture public type index publicly readable Discovery now reads solid:publicTypeIndex without credentials, but the fixture index inherited the owner-only root ACL, so every consent resolved to no resources. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01AAzZz6QwQyvsDB1fCCYrCv * docs: drop a reference to a lane file that was never committed Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01AAzZz6QwQyvsDB1fCCYrCv --------- Co-authored-by: GPT-6.1-Sol (Codex) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/extension/README.md | 61 +- apps/extension/docs/manifest-permissions.md | 132 ++- apps/extension/e2e/README.md | 9 +- .../templates/pod/base/profile/card$.ttl.hbs | 1 + .../pod/base/profile/type-index$.ttl.hbs | 4 + .../pod/wac/profile/type-index.acl.hbs | 19 + .../e2e/tests/authenticated-fetch.spec.ts | 77 +- .../e2e/tests/client-registration.spec.ts | 16 +- apps/extension/e2e/tests/helpers.ts | 76 +- .../e2e/tests/session-restore.spec.ts | 4 +- .../background/core/authenticated-fetch.ts | 10 +- .../src/background/core/class-resolve.ts | 55 +- .../src/background/core/origin-grant.ts | 92 ++ .../src/background/global-fetch-register.ts | 99 +- .../src/background/service-worker.ts | 854 +++++++++----- apps/extension/src/consent/consent.css | 12 + apps/extension/src/consent/consent.html | 3 + apps/extension/src/consent/consent.ts | 39 +- apps/extension/src/content/content-script.ts | 19 +- apps/extension/src/inject/fetch-patch.ts | 5 + apps/extension/src/inject/inject.ts | 58 +- apps/extension/src/manifest.json | 2 +- apps/extension/src/shared/messages.ts | 24 + apps/extension/test-site/with-client-id.js | 3 +- .../test/authenticated-fetch.test.ts | 25 + apps/extension/test/class-resolve.test.ts | 62 +- apps/extension/test/consent-ui.test.ts | 72 ++ apps/extension/test/content-script.test.ts | 86 ++ apps/extension/test/inject.test.ts | 38 +- apps/extension/test/jsdom.d.ts | 1 + apps/extension/test/origin-grant.test.ts | 100 ++ .../test/origin-registration.test.ts | 140 +++ apps/extension/test/profile-fixtures.ts | 41 + .../test/service-worker-class-gate.test.ts | 1041 ++++++++++++++++- .../test/service-worker-gate.test.ts | 266 ++++- .../test/service-worker-replica.test.ts | 2 + apps/extension/vitest.config.ts | 2 +- apps/extension/webpack.config.cjs | 1 + 38 files changed, 2955 insertions(+), 596 deletions(-) create mode 100644 apps/extension/e2e/setup/templates/pod/base/profile/type-index$.ttl.hbs create mode 100644 apps/extension/e2e/setup/templates/pod/wac/profile/type-index.acl.hbs create mode 100644 apps/extension/src/background/core/origin-grant.ts create mode 100644 apps/extension/src/inject/fetch-patch.ts create mode 100644 apps/extension/test/consent-ui.test.ts create mode 100644 apps/extension/test/content-script.test.ts create mode 100644 apps/extension/test/origin-grant.test.ts create mode 100644 apps/extension/test/origin-registration.test.ts diff --git a/apps/extension/README.md b/apps/extension/README.md index 317261430..b3a83b810 100644 --- a/apps/extension/README.md +++ b/apps/extension/README.md @@ -1,9 +1,9 @@ # Solid Browser Extension A Manifest V3 browser extension that brings **Solid sign-in to the browser itself**. It -injects a `window.solid` API into every page (a DPoP-authenticated `fetch`, the user's -`webId`, `login`/`logout`/`setClientId`, and per-data-class access grants) and provides a -toolbar account menu whose icon is the signed-in WebID's avatar. The UI is built with +injects a `window.solid` consent stub into every page (`login` and `requestAccess`). +Explicit origin consent adds DPoP-authenticated `fetch`, the bound profile's `webId`, +and class-management APIs. The toolbar account menu shows the signed-in WebID's avatar. The UI is built with [`@jeswr/solid-elements`](https://github.com/jeswr/solid-elements) so it is visually consistent with [Pod Manager](https://github.com/jeswr/solid-pod-manager) and the rest of the [@jeswr Solid app suite](https://github.com/jeswr). @@ -40,11 +40,11 @@ Four contexts, with the access token held in exactly one of them (the service wo - **Every proxied pod read/write is gated before cache access or egress.** The worker cross-checks the content script's origin stamp against Chrome's sender, then checks origin grants and, for class-only origins, the pinned targets, modes, and expiry. - Ungranted pages receive `webId: null`. + Ungranted pages have no `webId` or `fetch` property. ### The credential boundary (security heart) -Because `solid.fetch` is callable from **any** page, the worker must never hand a foreign +Because any page can send proxy messages, the worker must never hand a foreign origin the user's token. `core/origin-policy.ts` + `core/authenticated-fetch.ts` enforce, fail-closed: @@ -66,6 +66,8 @@ WebID/origin-mismatch test that genuinely fails without the guard. ## `window.solid` ```ts +// Ungranted origins receive only login() and requestAccess(). +// These additional properties become available after explicit origin consent. interface SolidExtension { readonly webId: string | null; fetch(input: RequestInfo | URL, init?: RequestInit): Promise; @@ -99,9 +101,10 @@ interface SolidGrant { ``` Presence can also be detected using the `solid-extension:ready` event or the sticky -`` marker. These carry no identity. The best-effort global -`fetch` patch routes cross-origin HTTP(S) requests through the worker; requests the worker -does not authorize fall back to the page's captured native fetch with normal CORS behavior. +`` marker. These carry no identity. The best-effort +`fetch` patch is registered only on granted origins; other pages retain native fetch. +Requests the worker does not authorize fall back to the page's captured native fetch +with normal CORS behavior. Explicit `solid.fetch` calls retain the worker's denial response. ## Auth + session @@ -119,14 +122,15 @@ Explicit `solid.fetch` calls retain the worker's denial response. The public key belongs in `manifest.key`; the extension signing private key stays with Jesse. A page may still declare its own Client ID Document via `solid.setClientId(...)`. - **Profiles:** `ProfileStore` supports up to eight distinct WebIDs. Each profile has its own - access/refresh token set, non-extractable DPoP keypair, pod settings and grants, persisted - atomically in IndexedDB under the profile id. The current single session migrates to profile + access/refresh token set, non-extractable DPoP keypair and pod settings, persisted + atomically in IndexedDB under the profile id. Explicit origin grants bind those profile IDs + in worker-owned `chrome.storage.local`. The current single session migrates to profile `1`, keeping its matching key. A missing/mismatched key retains the display entry and requires re-login. Legacy token/key storage is cleared only after migration commits. -- **Default and restore:** the default profile supplies the existing account surface and page - APIs. Requests capture that profile's id; a default switch cannot substitute another - profile's credentials mid-request. Refresh is single-flight per profile, reuses its persisted - key, and commits rotated tokens before use. Reopening the worker restores from IndexedDB. +- **Default and restore:** the default profile supplies the account surface. Each origin + grant binds one profile independently of that default. Requests capture the bound profile's + id; a default switch cannot substitute another profile's credentials mid-request. Refresh + is single-flight per profile, reuses its persisted key, and commits rotated tokens before use. Reopening the worker restores from IndexedDB. - **Removal and logout:** removing a profile revokes its grants, cancels pending replica fetches, finishes local replica commits, purges its WebID's replica slices and deletes its credentials. Interrupted removal resumes on worker wake. Logout signs out only the default profile, retaining its display entry; other @@ -152,9 +156,13 @@ The worker API accepts the following messages **only from browser-attested exten A successful legacy `SOLID_LOGIN` selects the logged-in profile as default. Re-login updates only the same WebID. Removing the default selects the oldest survivor; an empty store has no -default. Per-origin -profile bindings and the consent profile picker belong to EXT-05; this lane scopes the existing -grants to each profile without adding that origin-grant model. +default. Per-origin bindings and the consent profile picker are implemented. `requestAccess` +requires a click, opens extension-owned consent, and shows the selected profile's pinned class targets. +Approved class grants nest under `{origin, profileId, scope: {pods, classes}, createdAt, expiresAt}`. +Every request rechecks the parent and its class constraints. Revocation/expiry unregisters +the origin's patch, clears page capabilities and denies captured functions on their next call. +Legacy implicit grants require fresh consent. The trusted grants-manager worker seam is +documented in [manifest permissions](docs/manifest-permissions.md); its side-panel UI is EXT-06. The trusted `clientIdMode` setting is stored as `solid:client-id-mode` in `chrome.storage.local`. It accepts `document` or `dcr`, defaulting to `dcr` while Pages @@ -226,23 +234,23 @@ document.querySelector('#share-contacts').addEventListener('click', async () => ``` - **`requestAccess`** opens a consent window in extension chrome showing the verified app - origin, classes, modes, purpose, expiry, and resolved targets. It requires transient user + origin, profile picker, classes, modes, purpose, expiry, and resolved targets. It requires transient user activation and is limited to three admitted requests per origin per minute (best-effort in-memory throttle, reset on worker restart). At least one valid HTTP(S) class IRI and recognized mode are required. `duration` is optional in the TypeScript shape but mandatory at runtime: a positive ISO-8601 duration, capped at 365 days. Years/months use 365/30 days. Approval returns one grant per approved class; denying all returns `{ ok: false, grants: [] }`. Invalid requests, missing activation/session, or throttling reject the promise. -- **`listGrants`** returns only this origin's non-expired class grants for the active WebID; - no session or an unverifiable origin yields `[]`. Summaries contain no WebID or credential. +- **`listGrants`** returns only this origin's non-expired class grants for the bound profile's WebID; + no live parent/session or an unverifiable origin yields `[]`. Summaries contain no WebID or credential. - **`revokeGrant`** removes this origin's class grant and returns whether it was removed. Removal precedes replica-slice purging, so even a purge failure cannot keep access live. Cached bytes still covered by another live read grant of the same origin are retained. - This cannot withdraw a separate legacy whole-pod origin grant. + The parent origin binding is managed separately from its nested class records. - **`resolveClass`** walks the WebID's public/private Solid Type Indexes for `solid:forClass` and returns `solid:instance` / `solid:instanceContainer` targets on allowed pod origins. It requires a verified, granted origin and an active session. Class-only origins can - resolve only classes they currently hold; legacy full-grant origins can resolve any class. + resolve only classes they currently hold beneath their parent origin grant. It returns `shape: null` today: SAI discovery and SHACL validation are not implemented. An unknown/unreachable class resolves to an empty target set. @@ -251,11 +259,10 @@ expiry, including on cache hits. Instances cover an exact resource path; contain their path and descendants. GET/HEAD require `read`, POST requires `append`, and PUT/PATCH/DELETE require `write`; `write` also satisfies `append`, but does not imply `read`. Empty target sets authorize nothing. Re-resolution does not widen an existing grant. -`purpose` is an audit/display value, not an enforced usage restriction. Legacy whole-pod -origin grants remain broader and take precedence over class grants: successful page login -and a verified `setClientId` call create those grants, and credential origins are granted -automatically. To request only class-scoped access, sign in through the extension UI and -call `requestAccess` without first opting the app into whole-pod access. +`purpose` is an audit/display value, not an enforced usage restriction. Login, +`setClientId` and credential origins confer no implicit access. The origin parent and its +nested class scopes are both required. Changing the default profile does not rebind apps; +changing an origin's profile clears its old pod/class scope before fresh consent. ## Build & load the unpacked extension diff --git a/apps/extension/docs/manifest-permissions.md b/apps/extension/docs/manifest-permissions.md index b2328dbae..e3f0ce08f 100644 --- a/apps/extension/docs/manifest-permissions.md +++ b/apps/extension/docs/manifest-permissions.md @@ -1,57 +1,89 @@ -# Manifest permissions — rationale (Phase 0) +# Manifest permissions — rationale (EXT-05) -> AUTHORED-BY Claude Opus 4.8 +The host permission enables worker egress to user-chosen Solid servers. Explicit origin +consent, independently checked by the worker, controls which pages can use that egress. -`manifest.json` cannot carry comments, so this file is the durable record of WHY each -permission and host-permission is requested, and what the Phase-0 hardening reviewed. -Reviewed against the shared-replica design `docs/design/shared-replica-architecture.md` -§4.3 ("PII broadcast", "Foreign-origin fetch") and §5.7. +## Permissions -## `permissions` +| Permission | Purpose | +|---|---| +| `storage` | Settings, recent-account display metadata, client IDs, transient PKCE parameters, and `solid:origin-grants`. The origin table contains `{origin, profileId, scope: {pods, classes?}, createdAt, expiresAt?}`; class grants nest under their parent. Tokens and non-extractable DPoP keys remain in worker-owned profile IndexedDB. Legacy implicit grants do not authorize access. | +| `identity` | Solid-OIDC authorization-code + PKCE through `chrome.identity.launchWebAuthFlow`. | +| `sidePanel` | The persistent account surface; EXT-06 owns its grants-manager UI. | +| `contextMenus` | The toolbar's “Open Solid side panel” menu entry. | +| `scripting` | Register the MAIN-world fetch-patch entry only for explicitly granted origins, with one exact `scheme://host:port/*` match per origin (including explicit default ports), no wildcard hosts or schemes, top frame only. Reconcile on worker wake, install/update, grant changes, and expiry. Remove the legacy `solid-main-world-inject` all-origins registration. | +| `alarms` | Wake the worker at the earliest origin-grant expiry to unregister scripts and clear page capabilities. Alarms are a cleanup mechanism: every request independently checks expiry, even if the alarm is late. | -| Permission | Why it is required | Could it be narrowed? | -|---|---|---| -| `storage` | Stores recent-account display metadata, per-origin client ids, transient PKCE parameters and onboarding settings in `chrome.storage.local`. Legacy session/grant/pod settings are read once for migration. Per-profile tokens, grants, pod settings and non-extractable DPoP `CryptoKey`s now live atomically in worker-owned IndexedDB (`profile-db.ts`, an unpermissioned Web Platform API); legacy credentials and the singleton key record are cleared after that transaction commits. | No — still needed for settings and the auth redirect. | -| `identity` | `chrome.identity.launchWebAuthFlow` drives the Solid-OIDC auth-code + PKCE redirect (a service worker has no `window`). | No. | -| `sidePanel` | The persistent side-panel account surface. | No. | -| `contextMenus` | The right-click "Open Solid side panel" entry. | No. | - -No `tabs` permission is requested: `broadcastStateChange` reads tab URLs via the existing -`` host permission (host access implies tab-URL visibility), and the WebID it -sends is scoped per-tab to GRANTED origins only (a non-granted tab receives `webId: null`). +No `tabs` permission is requested. Existing host access permits tab-URL visibility for +scoped state broadcasts. Each tab receives only its bound profile's WebID; other tabs +receive null. The worker revalidates each tab's URL origin immediately before sending to +the top frame. Tab messages include the intended `origin`; the ISOLATED bridge drops a +message unless it equals its own `location.origin`, closing navigation races during delivery. +Switching the default profile does not change origin bindings. ## `host_permissions: [""]` -**Required, and genuinely broad — documented rather than removed.** The service worker is -the sole egress: it makes cross-origin `fetch`es to **arbitrary, user-chosen** Solid pod -origins, IdP issuer origins, token endpoints, and WebID profile documents (discovery, token -exchange, refresh, profile, and every authenticated resource read/write). A Solid pod can be -hosted on **any** origin the owner picks, so the set of origins the SW must reach is -unbounded at install time. Without `` host permission these credentialed -cross-origin fetches would be subject to extension CORS restrictions and could not reliably -read response bodies/headers, breaking the core auth flow against arbitrary pods. - -This breadth is NOT the security boundary. The boundary is the SW-enforced, fail-closed -gates, all of which run regardless of host permissions: - -- **Target-origin credential gate** (`origin-policy.ts`): the access token is attached ONLY - to the WebID / issuer / configured-pod origins — never a foreign origin, never `/token`, - never over cleartext (loopback dev excepted). A fetch to any other origin is a plain, - token-free fetch. -- **Per-requesting-origin default-deny gate** (`requesting-origin.ts`, Phase 0): the calling - app's origin must be GRANTED (an explicit per-origin grant, or a session credential/pod - origin) before any pod egress or cache read. The requesting origin is the **browser- - attested** `sender` cross-checked against the page stamp; opaque/`null`/mismatched → DENY. -- **Same-origin-or-deny foreign-fetch policy** (Phase 0): a request to a non-credential - ("foreign") origin is allowed only when it targets the requesting app's OWN origin, so the - extension cannot be used as an ambient-cookie proxy to arbitrary third parties. - -## `content_scripts` matches: `[""]` - -**Required.** `window.solid` (the cooperation surface) must be available on any page that -might be a Solid app — the owner chooses which apps to use, so the match set is unbounded. -Presence of `window.solid` is a UX/cooperation signal, NOT a security boundary (design -§5.7): the credential never leaves the SW, and the per-requesting-origin gate denies any -non-granted origin even though the script is injected everywhere. Identity (the WebID) is -NOT exposed to a page merely because the script is injected — `SOLID_GET_STATE` returns the -WebID only to a verified, granted origin. +The worker discovers arbitrary user-chosen WebID, pod and issuer origins and performs +cross-origin discovery, token exchange/refresh and authenticated resource access. These +hosts cannot be enumerated at installation. The broad host permission does not grant +web pages access to any profile or pod. + +The worker enforces these boundaries before cache access or egress: + +- The ISOLATED bridge overwrites any page-supplied origin with `window.location.origin`. + It also reads transient user activation itself. The worker requires agreement with + Chrome's sender origin/URL; mismatched, opaque and missing anchors fail closed. +- A live explicit origin grant selects exactly one profile. Login, a client-ID declaration, + a pod/issuer origin, a default-profile switch, and standalone legacy class records do not + grant a page access. Removed/signed-out profiles cannot supply credentials. +- Credential targets must be in both the profile's allowed-origin set and the parent + grant's pod scope. Nested class grants additionally enforce pinned targets, mode, WebID + and expiry. A child cannot bypass an absent, revoked or expired parent. +- Page-supplied `Authorization`/`DPoP` headers are stripped. Tokens attach only to approved + profile credential origins, never the token endpoint, never remote cleartext HTTP. +- Foreign targets are permitted only on the calling app's own origin without Solid + credentials. A declined automatic diversion returns a sentinel, with no worker network; + the page performs its own native fetch with normal CORS semantics. + +Revocation unregisters future injections, cancels pending profile operations and purges +that origin's replica slice. Existing pages also lose their privileged API/patch on the +state broadcast. A captured function remains subject to the worker gate on its next call. +Grant expiry is checked before serving cached bytes, before egress and before returning +results. Registration failure does not confer authority. + +## Manifest content scripts: `[""]` + +The ISOLATED bridge and MAIN-world **stub** remain available on arbitrary pages so a new +app can request consent. Without an origin grant `window.solid` has only `login` and +`requestAccess`, plus the credential-free DOM presence marker/event. There is no `webId`, +`fetch`, client-ID setter, logout or class-management API, and native `window.fetch` is +preserved. The stub enables these capabilities only after a grant-backed state response. +The dynamic registration is scoped to granted origins, never ``. + +Presence and page-side capability state are cooperation signals, not authentication: +a page can spoof messages to its own MAIN-world scripts, but cannot mint an origin grant, +select a different profile's credentials or bypass worker enforcement. + +## Trusted management seam + +Only browser-attested extension contexts may list origin grants (`SOLID_ORIGIN_GRANTS_LIST`), +revoke (`SOLID_ORIGIN_GRANT_REVOKE`), shorten expiry (`SOLID_ORIGIN_GRANT_EXPIRY`), or change +profile (`SOLID_ORIGIN_GRANT_PROFILE`). Mutations name the exact `origin`; expiry uses an +epoch-millisecond `expiresAt`, profile changes use `profileId`. Profile rebinding clears the +old pod/class scope and cached slice; fresh class consent is required for data access. +The consent picker resolves and displays each profile's own targets, never transfers the +previous profile's targets. + +Chrome's [content-script registration](https://developer.chrome.com/docs/extensions/reference/api/scripting#method-registerContentScripts) +and [match-pattern documentation](https://developer.chrome.com/docs/extensions/develop/concepts/match-patterns) +describe the browser API; the worker's canonical-origin comparison remains the authority. + +Dynamic registrations always specify a port: HTTPS uses `:443` and HTTP uses `:80` when +its canonical origin omits the default port. Chrome treats an omitted pattern port as a +wildcard, so `https://app.example/*` would also inject into `https://app.example:8443`. +An explicit default port also matches port-less URLs; no wildcard-port fallback is needed. +This follows Chrome's [port matching documentation](https://developer.chrome.com/docs/extensions/develop/concepts/match-patterns) +and Chromium's [`ExtensionURLPatternTest.Match17`](https://chromium.googlesource.com/chromium/src/+/main/extensions/common/url_pattern_unittest.cc), +which checks default-port matching and rejection of alternate ports. Unit regressions model +these port semantics for HTTPS, HTTP, custom ports and IPv6, including replacement of old +wildcard-port registrations on wake. diff --git a/apps/extension/e2e/README.md b/apps/extension/e2e/README.md index 9ec1f11a6..8d61850dc 100644 --- a/apps/extension/e2e/README.md +++ b/apps/extension/e2e/README.md @@ -19,11 +19,10 @@ Chromium (`channel: 'chromium'`, which supports extensions). No display server i needed. The suite has zero retries. Popup sign-in does not grant unrelated web origins access. The fetch/navigation -tests explicitly opt the test site in using `SOLID_SET_CLIENT_ID` from its -browser-attested isolated world, await the worker's acknowledgement, and reload to -read the granted state. The fetch test also checks that the ungranted site receives -no WebID and its private fetch is denied. The app-login scenario separately drives -the public `window.solid.setClientId` API. +tests first check that the ungranted test site gets only the `window.solid` stub, then +grant it through the real flow: a user click calls `window.solid.requestAccess`, and the +test approves the consent window (profile picker included). The revocation test checks +that a captured `fetch` stops working on the next request. On runners with a read-only home directory, Chromium can fail before any test with `chrome_crashpad_handler: --database is required`. The launches disable crash diff --git a/apps/extension/e2e/setup/templates/pod/base/profile/card$.ttl.hbs b/apps/extension/e2e/setup/templates/pod/base/profile/card$.ttl.hbs index afa18f651..7e59cd4eb 100644 --- a/apps/extension/e2e/setup/templates/pod/base/profile/card$.ttl.hbs +++ b/apps/extension/e2e/setup/templates/pod/base/profile/card$.ttl.hbs @@ -12,5 +12,6 @@ {{#if name}}foaf:name "{{name}}";{{/if}} vcard:hasPhoto "{{base.path}}profile/photo.jpg"; {{#if oidcIssuer}}solid:oidcIssuer <{{oidcIssuer}}>;{{/if}} + solid:publicTypeIndex <{{base.path}}profile/type-index>; pim:storage <{{base.path}}>; a foaf:Person. diff --git a/apps/extension/e2e/setup/templates/pod/base/profile/type-index$.ttl.hbs b/apps/extension/e2e/setup/templates/pod/base/profile/type-index$.ttl.hbs new file mode 100644 index 000000000..e9c2cda03 --- /dev/null +++ b/apps/extension/e2e/setup/templates/pod/base/profile/type-index$.ttl.hbs @@ -0,0 +1,4 @@ +@prefix solid: . +<#notes> a solid:TypeRegistration; + solid:forClass ; + solid:instance <{{base.path}}private/notes>. diff --git a/apps/extension/e2e/setup/templates/pod/wac/profile/type-index.acl.hbs b/apps/extension/e2e/setup/templates/pod/wac/profile/type-index.acl.hbs new file mode 100644 index 000000000..cacef2149 --- /dev/null +++ b/apps/extension/e2e/setup/templates/pod/wac/profile/type-index.acl.hbs @@ -0,0 +1,19 @@ +# AUTHORED-BY Claude Opus 5.5 +# ACL resource for the public type index +@prefix acl: . +@prefix foaf: . + +# A solid:publicTypeIndex is public by definition: the extension reads it +# without credentials during class discovery. +<#public> + a acl:Authorization; + acl:agentClass foaf:Agent; + acl:accessTo <./type-index>; + acl:mode acl:Read. + +# The owner has full access to the type index +<#owner> + a acl:Authorization; + acl:agent <{{webId}}>; + acl:accessTo <./type-index>; + acl:mode acl:Read, acl:Write, acl:Control. diff --git a/apps/extension/e2e/tests/authenticated-fetch.spec.ts b/apps/extension/e2e/tests/authenticated-fetch.spec.ts index 8a2426f47..d360f6610 100644 --- a/apps/extension/e2e/tests/authenticated-fetch.spec.ts +++ b/apps/extension/e2e/tests/authenticated-fetch.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from '../fixtures'; -import { grantTestSite, performLogin, TEST_WEBID } from './helpers'; +import { grantOrigin, performLogin } from './helpers'; const TEST_SITE = 'http://localhost:8080'; @@ -11,26 +11,67 @@ test('authenticated fetch retrieves a private resource via window.solid', async const page = await context.newPage(); await page.goto(TEST_SITE); - - // Popup sign-in cannot disclose identity or pod bytes to an ungranted app. - const denied = await page.evaluate(async (privateUrl) => { - const solid = ( - window as unknown as { - solid: { webId: string | null; fetch(url: string): Promise }; - } - ).solid; - try { - await solid.fetch(privateUrl); - return { webId: solid.webId, error: null }; - } catch (error) { - return { webId: solid.webId, error: (error as Error).message }; - } - }, new URL('../private/notes', TEST_WEBID).href); - expect(denied).toEqual({ webId: null, error: 'Origin not granted access' }); - await grantTestSite(context, page); + await grantOrigin(context, page); // The test site reads window.solid.webId + window.solid.fetch(private resource). await expect(page.locator('#webid')).toContainText('test-pod', { timeout: 15_000 }); await expect(page.locator('#fetch-result')).toContainText('Private Note', { timeout: 15_000 }); await expect(page.locator('#status')).toContainText('Private resource fetched successfully'); }); + +test('an ungranted origin stays stub-only and revocation stops a captured fetch', async ({ + context, + extensionId, +}) => { + await performLogin(context, extensionId); + const page = await context.newPage(); + await page.goto(TEST_SITE); + await expect + .poll(() => + page.evaluate(() => Object.keys((window as unknown as { solid: object }).solid).sort()), + ) + .toEqual(['login', 'requestAccess']); + await grantOrigin(context, page); + await expect(page.locator('#fetch-result')).toContainText('Private Note'); + await page.evaluate(() => { + (window as unknown as { capturedFetch: unknown }).capturedFetch = ( + window as unknown as { solid: { fetch: unknown } } + ).solid.fetch; + }); + const other = await context.newPage(); + await other.goto('http://localhost:8081'); + await expect + .poll(() => + other.evaluate(() => Object.keys((window as unknown as { solid: object }).solid).sort()), + ) + .toEqual(['login', 'requestAccess']); + const owner = await context.newPage(); + await owner.goto(`chrome-extension://${extensionId}/popup/popup.html`); + expect( + ( + await owner.evaluate(() => + chrome.runtime.sendMessage({ + type: 'SOLID_ORIGIN_GRANT_REVOKE', + origin: 'http://localhost:8080', + }), + ) + ).ok, + ).toBe(true); + await expect + .poll(() => + page.evaluate(() => Object.keys((window as unknown as { solid: object }).solid).sort()), + ) + .toEqual(['login', 'requestAccess']); + expect( + await page.evaluate(async () => { + try { + await ( + window as unknown as { capturedFetch: (url: string) => Promise } + ).capturedFetch('http://localhost:3000/test-pod/private/notes'); + return 'unexpected success'; + } catch (error) { + return (error as Error).message; + } + }), + ).toMatch(/not granted/); +}); diff --git a/apps/extension/e2e/tests/client-registration.spec.ts b/apps/extension/e2e/tests/client-registration.spec.ts index 27c0d210d..9300a698b 100644 --- a/apps/extension/e2e/tests/client-registration.spec.ts +++ b/apps/extension/e2e/tests/client-registration.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from '../fixtures'; -import { completeOidcLogin, grantTestSite, TEST_WEBID } from './helpers'; +import { completeOidcLogin, grantOrigin, TEST_WEBID } from './helpers'; const TEST_SITE = 'http://localhost:8080'; const APP_SITE = 'http://localhost:8081'; @@ -11,10 +11,10 @@ test('default popup login (dynamic registration) authenticates a private fetch', const popupPage = await context.newPage(); await popupPage.goto(`chrome-extension://${extensionId}/popup/popup.html`); // The signed-out surface is ; target its shadow parts. - await popupPage.fill('#login-panel [part="webid-input"]', TEST_WEBID); + await popupPage.getByLabel('Your WebID').fill(TEST_WEBID); const loginPagePromise = context.waitForEvent('page'); - await popupPage.click('#login-panel [part="login-button"]'); + await popupPage.getByRole('button', { name: 'Sign in', exact: true }).click(); await completeOidcLogin(await loginPagePromise); await popupPage.goto(`chrome-extension://${extensionId}/popup/popup.html`); @@ -23,7 +23,7 @@ test('default popup login (dynamic registration) authenticates a private fetch', const page = await context.newPage(); await page.goto(TEST_SITE); - await grantTestSite(context, page); + await grantOrigin(context, page); await expect(page.locator('#fetch-result')).toContainText('Private Note', { timeout: 15_000 }); }); @@ -42,9 +42,15 @@ test('a website declares its own client identifier via window.solid.setClientId' // The app reads its own Client ID Document URL and calls solid.setClientId(), then logs in. const loginPagePromise = context.waitForEvent('page'); await page.evaluate((webId) => { - (window as unknown as { solid: { login(id: string): Promise } }).solid.login(webId); + (window as unknown as { solid: { login(id: string): Promise } }).solid + .login(webId) + .then(() => { + document.body.dataset.loginComplete = 'true'; + }); }, TEST_WEBID); await completeOidcLogin(await loginPagePromise); + await expect(page.locator('body')).toHaveAttribute('data-login-complete', 'true'); + await grantOrigin(context, page); await expect(page.locator('#fetch-result')).toContainText('Private Note', { timeout: 15_000 }); }); diff --git a/apps/extension/e2e/tests/helpers.ts b/apps/extension/e2e/tests/helpers.ts index f55a73be4..e44345c72 100644 --- a/apps/extension/e2e/tests/helpers.ts +++ b/apps/extension/e2e/tests/helpers.ts @@ -1,36 +1,8 @@ -import type { BrowserContext, Page } from '@playwright/test'; -import { expect } from '../fixtures'; +import { type BrowserContext, expect, type Page } from '@playwright/test'; /** The seeded CSS test WebID (see e2e/setup/seed.json). */ export const TEST_WEBID = 'http://localhost:3000/test-pod/profile/card#me'; -/** - * Opt a test app into the popup's session through the real, origin-verified content - * script path. Popup login alone deliberately grants no unrelated website access. - * Await the worker's acknowledgement before loading the app again for its state read. - */ -export async function grantTestSite(context: BrowserContext, page: Page): Promise { - const origin = new URL(page.url()).origin; - const result = await context.serviceWorkers()[0].evaluate(async (appOrigin) => { - const tabs = await chrome.tabs.query({ url: `${appOrigin}/*` }); - const tabId = tabs[0]?.id; - if (tabId === undefined) throw new Error('Test app tab not found'); - const [reply] = await chrome.scripting.executeScript({ - target: { tabId }, - world: 'ISOLATED', - func: () => - chrome.runtime.sendMessage({ - type: 'SOLID_SET_CLIENT_ID', - origin: location.origin, - clientId: `${location.origin}/client-id`, - }), - }); - return reply.result; - }, origin); - expect(result).toEqual({ ok: true }); - await page.reload(); -} - /** * Complete the CSS OIDC login + consent flow on the window * `chrome.identity.launchWebAuthFlow` opened. CSS auto-redirects to the password page, @@ -65,15 +37,53 @@ export async function performLogin( const popupPage = await context.newPage(); await popupPage.goto(`chrome-extension://${extensionId}/popup/popup.html`); // The signed-out surface is now ; drive its shadow parts. - // CSS descendants pierce the open shadow root and reach controls nested in the form. - // `>>>` is parsed as `>>` then `>` and incorrectly requires a direct child. - await popupPage.fill('#login-panel [part="webid-input"]', webId); + // Playwright's CSS engine pierces the (open) shadow root, so `part=` selectors work. + await popupPage.getByLabel('Your WebID').fill(webId); const loginPagePromise = context.waitForEvent('page'); - await popupPage.click('#login-panel [part="login-button"]'); + await popupPage.getByRole('button', { name: 'Sign in', exact: true }).click(); await completeOidcLogin(await loginPagePromise); await popupPage.goto(`chrome-extension://${extensionId}/popup/popup.html`); await popupPage.locator('#signed-in').waitFor({ state: 'visible', timeout: 15_000 }); await popupPage.close(); } + +/** Drive the actual gesture-gated origin/class consent against the local CSS fixture. */ +export async function grantOrigin(context: BrowserContext, page: Page): Promise { + await page.waitForFunction( + () => + typeof (window as unknown as { solid?: { requestAccess?: unknown } }).solid?.requestAccess === + 'function', + ); + await page.evaluate(() => { + const button = document.createElement('button'); + button.id = 'grant-origin-fixture'; + button.textContent = 'Grant origin access'; + button.addEventListener('click', () => { + const solid = ( + window as unknown as { + solid: { requestAccess(request: unknown): Promise<{ ok: boolean }> }; + } + ).solid; + void solid + .requestAccess({ + classes: ['https://schema.org/TextDigitalDocument'], + modes: ['read'], + duration: 'P1D', + }) + .then((result) => { + button.dataset.approved = String(result.ok); + }); + }); + document.body.appendChild(button); + }); + const consentPromise = context.waitForEvent('page'); + await page.getByRole('button', { name: 'Grant origin access' }).click(); + const consent = await consentPromise; + await consent.waitForURL('**/consent/consent.html?*'); + await expect(consent.getByLabel('Share from profile')).toBeVisible(); + await expect(consent.locator('.targets')).toContainText('/private/notes'); + await consent.getByRole('button', { name: 'Approve selected' }).click(); + await expect(page.locator('#grant-origin-fixture')).toHaveAttribute('data-approved', 'true'); +} diff --git a/apps/extension/e2e/tests/session-restore.spec.ts b/apps/extension/e2e/tests/session-restore.spec.ts index e061633f0..d45711e11 100644 --- a/apps/extension/e2e/tests/session-restore.spec.ts +++ b/apps/extension/e2e/tests/session-restore.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from '../fixtures'; -import { grantTestSite, performLogin } from './helpers'; +import { grantOrigin, performLogin } from './helpers'; const TEST_SITE = 'http://localhost:8080'; @@ -11,7 +11,7 @@ test('session persists across navigation (the worker holds the session)', async const page = await context.newPage(); await page.goto(TEST_SITE); - await grantTestSite(context, page); + await grantOrigin(context, page); await expect(page.locator('#webid')).toContainText('test-pod', { timeout: 15_000 }); await page.goto('about:blank'); diff --git a/apps/extension/src/background/core/authenticated-fetch.ts b/apps/extension/src/background/core/authenticated-fetch.ts index b6b078d53..9bd2203a9 100644 --- a/apps/extension/src/background/core/authenticated-fetch.ts +++ b/apps/extension/src/background/core/authenticated-fetch.ts @@ -14,6 +14,8 @@ * 3. NONCE RETRY (RFC 9449 §8). On a pure `use_dpop_nonce` 401, the proof is re-minted * with the server `DPoP-Nonce` and the request retried ONCE. * 4. SINGLE RETRY. Exactly one retry, so a misbehaving server cannot loop us. + * 5. NO REDIRECTS. Never forward DPoP or return bytes from an unchecked redirect target, + * including on the credential-free path and the nonce retry. * * The re-entrancy guard (a fetch the SW itself issues for discovery/token/refresh must * never recurse into THIS path) is the caller's responsibility: discovery/token use the @@ -89,7 +91,12 @@ export async function authenticatedFetch( if (!mayAttach) { // Foreign / public / token-endpoint: a plain, credential-free fetch. - const response = await fetchImpl(url, { method, headers: safeHeaders, body }); + const response = await fetchImpl(url, { + method, + headers: safeHeaders, + body, + redirect: 'error', + }); return { response, authenticated: false }; } @@ -109,6 +116,7 @@ export async function authenticatedFetch( DPoP: proof, }, body, + redirect: 'error', }); }; diff --git a/apps/extension/src/background/core/class-resolve.ts b/apps/extension/src/background/core/class-resolve.ts index ad08b8b3f..bda193a43 100644 --- a/apps/extension/src/background/core/class-resolve.ts +++ b/apps/extension/src/background/core/class-resolve.ts @@ -11,8 +11,8 @@ * are documented follow-ups; the resolver is structured so they slot in as additional passes. * * RDF is parsed with N3.js (the house rule — never regex over Turtle). The pure parse helpers - * are unit-testable against fixtures; `resolveClassTargets` takes an INJECTED authed fetch so - * the network walk is testable without a live pod and reuses the SW's DPoP egress in prod. + * are unit-testable against fixtures; `resolveClassTargets` takes an injected fetch that + * distinguishes public discovery from authenticated private-index reads. * * FAIL-CLOSED / defence-in-depth: * - only `NamedNode`, http(s), same-*credential*-origin targets are kept (a type index that @@ -33,14 +33,19 @@ const FOR_CLASS = `${SOLID}forClass`; const INSTANCE = `${SOLID}instance`; const INSTANCE_CONTAINER = `${SOLID}instanceContainer`; -/** An injected fetch returning the body text + ok flag. In prod: the SW's authed DPoP egress. */ -export type ResolveFetch = (url: string) => Promise<{ ok: boolean; text: string }>; +/** Public documents must never carry credentials, even on an authenticated pod origin. */ +export type ResolveFetch = ( + url: string, + authenticated: boolean, +) => Promise<{ ok: boolean; text: string }>; export interface ResolveOptions { /** The active session's WebID (the profile document to read the type-index refs from). */ readonly webId: string; - /** The injected authed fetch. */ + /** The injected fetch, with credential use explicit for each document. */ readonly fetch: ResolveFetch; + /** Origins permitted for private-index reads. Omit to discover public documents only. */ + readonly authenticatedOrigins?: ReadonlySet; /** * The pod/credential origins a target is allowed to live on (defence in depth). When given, * a resolved target on any other origin is DROPPED. Omit to keep all http(s) targets. @@ -69,11 +74,15 @@ function normaliseTargetUrl(value: string): { url: string; origin: string } | nu /** * The type-index document URLs advertised on the WebID subject (`solid:publicTypeIndex` + - * `solid:privateTypeIndex`), NamedNode + http(s) only, deduped in document order. PURE. + * `solid:privateTypeIndex`), NamedNode + http(s) only. Public refs take precedence when a + * document is advertised both ways, so it is fetched once without credentials. PURE. */ -export function parseTypeIndexRefs(webId: string, profileTurtle: string): string[] { +export function parseTypeIndexRefs( + webId: string, + profileTurtle: string, +): Array<{ url: string; authenticated: boolean }> { const store = parse(webId, profileTurtle); - const out: string[] = []; + const out: Array<{ url: string; authenticated: boolean }> = []; for (const pred of [PUBLIC_TYPE_INDEX, PRIVATE_TYPE_INDEX]) { for (const q of store.getQuads( DataFactory.namedNode(webId), @@ -83,7 +92,8 @@ export function parseTypeIndexRefs(webId: string, profileTurtle: string): string )) { if (q.object.termType !== 'NamedNode') continue; const n = normaliseTargetUrl(q.object.value); - if (n && !out.includes(q.object.value)) out.push(q.object.value); + if (n && !out.some((ref) => ref.url === q.object.value)) + out.push({ url: q.object.value, authenticated: pred === PRIVATE_TYPE_INDEX }); } } return out; @@ -151,10 +161,10 @@ export async function resolveClassTargets( classIRI: string, opts: ResolveOptions, ): Promise { - const profile = await safeFetch(opts.fetch, opts.webId); + const profile = await safeFetch(opts.fetch, opts.webId, false); if (profile === null) return []; - let refs: string[]; + let refs: ReturnType; try { refs = parseTypeIndexRefs(opts.webId, profile); } catch { @@ -164,15 +174,18 @@ export async function resolveClassTargets( const seen = new Set(); const out: PinnedTarget[] = []; for (const ref of refs) { - // Defence in depth: when confined to pod origins, NEVER fetch a type-index document on a - // foreign origin — a compromised/hostile profile could point `publicTypeIndex` at an - // attacker URL and turn resolution into an SW-side SSRF. Skip any off-pod ref. - if (opts.allowedOrigins && !isAllowedOrigin(ref, opts.allowedOrigins)) continue; - const body = await safeFetch(opts.fetch, ref); + // Public indexes may live beside the WebID or on any other http(s) host. Only + // private-index egress is confined to authenticated origins; targets are filtered below. + if ( + ref.authenticated && + (!opts.authenticatedOrigins || !isAllowedOrigin(ref.url, opts.authenticatedOrigins)) + ) + continue; + const body = await safeFetch(opts.fetch, ref.url, ref.authenticated); if (body === null) continue; let targets: PinnedTarget[]; try { - targets = parseTargetsForClass(ref, body, classIRI, opts.allowedOrigins); + targets = parseTargetsForClass(ref.url, body, classIRI, opts.allowedOrigins); } catch { continue; } @@ -195,9 +208,13 @@ function isAllowedOrigin(url: string, allowed: ReadonlySet): boolean { } } -async function safeFetch(fetchFn: ResolveFetch, url: string): Promise { +async function safeFetch( + fetchFn: ResolveFetch, + url: string, + authenticated: boolean, +): Promise { try { - const { ok, text } = await fetchFn(url); + const { ok, text } = await fetchFn(url, authenticated); return ok ? text : null; } catch { return null; diff --git a/apps/extension/src/background/core/origin-grant.ts b/apps/extension/src/background/core/origin-grant.ts new file mode 100644 index 000000000..b832abafd --- /dev/null +++ b/apps/extension/src/background/core/origin-grant.ts @@ -0,0 +1,92 @@ +// AUTHORED-BY GPT-6.1-Sol (Codex) +/** Explicit origin → profile authority. Class grants only have meaning beneath this record. */ +import { type ClassGrant, sanitiseGrants } from './class-grant'; + +export interface OriginGrant { + readonly origin: string; + readonly profileId: string; + readonly scope: { readonly pods: readonly string[]; readonly classes?: readonly ClassGrant[] }; + readonly createdAt: number; + readonly expiresAt?: number; +} + +export function canonicalOrigin(value: unknown): string | null { + if (typeof value !== 'string') return null; + try { + const u = new URL(value); + return (u.protocol === 'https:' || u.protocol === 'http:') && u.origin === value + ? u.origin + : null; + } catch { + return null; + } +} + +/** Corrupt/ambiguous records never confer authority; duplicate origin bindings deny both. */ +export function sanitiseOriginGrants(raw: unknown): OriginGrant[] { + if (!Array.isArray(raw)) return []; + const valid: OriginGrant[] = []; + const counts = new Map(); + for (const value of raw) { + if (!value || typeof value !== 'object') continue; + const g = value as OriginGrant; + if ( + !canonicalOrigin(g.origin) || + typeof g.profileId !== 'string' || + !g.profileId || + !Number.isFinite(g.createdAt) || + (g.expiresAt !== undefined && !Number.isFinite(g.expiresAt)) || + !g.scope || + !Array.isArray(g.scope.pods) || + !g.scope.pods.every((p) => canonicalOrigin(p) !== null) || + (g.scope.classes !== undefined && !Array.isArray(g.scope.classes)) + ) + continue; + counts.set(g.origin, (counts.get(g.origin) ?? 0) + 1); + valid.push({ + origin: g.origin, + profileId: g.profileId, + createdAt: g.createdAt, + ...(g.expiresAt === undefined ? {} : { expiresAt: g.expiresAt }), + scope: { + pods: [...g.scope.pods], + ...(g.scope.classes === undefined + ? {} + : { + classes: sanitiseGrants(g.scope.classes).filter((c) => c.origin === g.origin), + }), + }, + }); + } + return valid.filter((g) => counts.get(g.origin) === 1); +} + +export function liveOriginGrant(grants: readonly OriginGrant[], origin: string, now: number) { + return ( + grants.find((g) => g.origin === origin && (g.expiresAt === undefined || g.expiresAt > now)) ?? + null + ); +} + +export interface OriginGrantPersistence { + read(): Promise; + write(grants: readonly OriginGrant[]): Promise; +} + +export class OriginGrantStore { + private tail: Promise = Promise.resolve(); + constructor(private readonly persistence: OriginGrantPersistence) {} + async list(): Promise { + await this.tail; + return sanitiseOriginGrants(await this.persistence.read()); + } + /** One writer makes replacing a profile binding atomic, including concurrent consent. */ + update(change: (grants: OriginGrant[]) => readonly OriginGrant[]): Promise { + const run = this.tail.then(async () => { + const current = sanitiseOriginGrants(await this.persistence.read()); + await this.persistence.write(sanitiseOriginGrants(change(current))); + }); + this.tail = run.catch(() => {}); + return run; + } +} diff --git a/apps/extension/src/background/global-fetch-register.ts b/apps/extension/src/background/global-fetch-register.ts index aeedfc21f..9a8f65b58 100644 --- a/apps/extension/src/background/global-fetch-register.ts +++ b/apps/extension/src/background/global-fetch-register.ts @@ -1,72 +1,53 @@ -// AUTHORED-BY Claude Opus 4.8 -/** - * Programmatic registration of the MAIN-world injection (design §5.1). - * - * A SECONDARY, runtime injection path for `inject.js` into the MAIN world via - * `chrome.scripting.registerContentScripts({ world: 'MAIN', runAt: 'document_start', - * persistAcrossSessions: true })` (the MetaMask `window.ethereum` pattern). The PRIMARY path - * is now the manifest-declared MAIN-world content script (see manifest.json) — that runs at - * document_start on EVERY navigation with NO dependency on the service worker being awake, - * whereas this dynamic registration only ever takes effect once the SW has run to register it - * (a dormant MV3 SW on a cold page load leaves `window.solid` undefined until something wakes - * it — the reason the manifest declaration is the reliable path). Both paths are kept: the - * manifest declaration for reliability, this one for runtime re-registration / evolution + - * `persistAcrossSessions`. Declaring it BOTH ways was ORIGINALLY the Medium #2 double-run throw - * on `Object.defineProperty(window,'solid',{configurable:false})`; that is now SAFE because - * inject.ts carries a `window.__solidInjected` / `'solid' in window` guard that makes the - * second run an inert no-op (covered by inject.test.ts). This function is idempotent (it removes - * a stale prior registration first) and survives across browser sessions. - * - * SECURITY NOTE: the injected script is BEST-EFFORT TRANSPARENCY with ZERO security weight - * (design §5.1) — it is racy + bypassable, and the SW gate remains the sole boundary on every - * routed request. Registration failing (older Chrome, missing `scripting` permission) is - * therefore NON-FATAL: with NO injection at all the security posture is unchanged (a plain - * fetch hits the pod and gets a 401 / WAC decision; the explicit `window.solid` API is simply - * unavailable on that page). So this function swallows errors and never throws into SW boot. - */ - -const SCRIPT_ID = 'solid-main-world-inject'; -const INJECT_JS = 'inject/inject.js'; - -/** The chrome.scripting surface we touch (typed minimally so a stub can satisfy it in tests). */ +// AUTHORED-BY GPT-6.1-Sol (Codex) +/** Reconcile persisted MAIN registrations with the explicit origin grants on every wake/change. */ interface ScriptingApi { - getRegisteredContentScripts(filter?: { ids?: string[] }): Promise>; + getRegisteredContentScripts(): Promise>; registerContentScripts(scripts: chrome.scripting.RegisteredContentScript[]): Promise; - unregisterContentScripts(filter?: { ids?: string[] }): Promise; + unregisterContentScripts(filter: { ids: string[] }): Promise; } -/** - * Register (idempotently) the MAIN-world global-fetch-patch injection. Removes any stale prior - * registration of the same id first so a reload/upgrade does not error on a duplicate id. - * Best-effort: any failure is swallowed (the manifest content script + the SW gate stand). - */ -export async function registerGlobalFetchInjection( - scripting: ScriptingApi | undefined = (globalThis as { chrome?: { scripting?: ScriptingApi } }) - .chrome?.scripting, +export async function syncOriginFetchInjection( + origins: readonly string[], + scripting: ScriptingApi | undefined = globalThis.chrome?.scripting, ): Promise { - if (!scripting?.registerContentScripts) return false; + if (!scripting) return false; try { - const existing = await scripting - .getRegisteredContentScripts({ ids: [SCRIPT_ID] }) - .catch(() => [] as Array<{ id: string }>); - if (existing.some((s) => s.id === SCRIPT_ID)) { - await scripting.unregisterContentScripts({ ids: [SCRIPT_ID] }).catch(() => {}); - } - await scripting.registerContentScripts([ - { - id: SCRIPT_ID, - js: [INJECT_JS], - matches: [''], - runAt: 'document_start', - world: 'MAIN', + const wanted = new Map( + origins.map((origin) => { + const url = new URL(origin); + // Chrome treats an omitted port as '*', including on canonical default-port origins. + // Explicit :443/:80 also match port-less URLs (Chromium URLPattern Match17). + const port = url.port || (url.protocol === 'https:' ? '443' : '80'); + return [ + `solid-origin-${encodeURIComponent(origin)}`, + `${url.protocol}//${url.hostname}:${port}/*`, + ]; + }), + ); + const existing = await scripting.getRegisteredContentScripts(); + const stale = existing.filter( + (s) => + s.id === 'solid-main-world-inject' || + (s.id.startsWith('solid-origin-') && + (s.matches?.length !== 1 || s.matches[0] !== wanted.get(s.id))), + ); + if (stale.length) await scripting.unregisterContentScripts({ ids: stale.map((s) => s.id) }); + const kept = new Set(existing.filter((s) => !stale.includes(s)).map((s) => s.id)); + const scripts = [...wanted] + .filter(([id]) => !kept.has(id)) + .map(([id, match]) => ({ + id, + matches: [match], + js: ['inject/fetch-patch.js'], + world: 'MAIN' as const, + runAt: 'document_start' as const, persistAcrossSessions: true, allFrames: false, - }, - ]); + })); + if (scripts.length) await scripting.registerContentScripts(scripts); return true; } catch { - // Non-fatal — the manifest-declared MAIN-world script still injects, and the SW gate is - // the sole security boundary regardless of whether the patch ran (design §5.1). + // A registration never grants authority; requests remain independently gated in the SW. return false; } } diff --git a/apps/extension/src/background/service-worker.ts b/apps/extension/src/background/service-worker.ts index 8271ed723..11b4c518a 100644 --- a/apps/extension/src/background/service-worker.ts +++ b/apps/extension/src/background/service-worker.ts @@ -29,6 +29,7 @@ import type { GrantSummary, ListGrantsRequest, LoginRequest, + OriginGrantRequest, PendingConsentView, ProfileRequest, ProfilesResponse, @@ -46,15 +47,19 @@ import { type ClassGrant, decideClassAccess, grantsForSession, - liveGrantOrigins, modeSatisfies, type PinnedTarget, pruneExpiredGrants, - sanitiseGrants, } from './core/class-grant'; import { normaliseAccessRequest } from './core/class-request'; -import { resolveClassTargets } from './core/class-resolve'; +import { type ResolveFetch, resolveClassTargets } from './core/class-resolve'; import { buildPendingConsent, consentDecisionToGrants, type PendingConsent } from './core/consent'; +import { + canonicalOrigin, + liveOriginGrant, + type OriginGrant, + OriginGrantStore, +} from './core/origin-grant'; import { computeAllowedOrigins, isValidClientIdUrl } from './core/origin-policy'; import { type EgressPort, @@ -69,17 +74,11 @@ import { type RequestAccessDenyReason, type ThrottleState, } from './core/request-access-throttle'; -import { - computeGrantedOrigins, - decideRequestingOrigin, - isRequestingOriginGranted, - type MessageSender, - resolveRequestingOrigin, -} from './core/requesting-origin'; +import { type MessageSender, resolveRequestingOrigin } from './core/requesting-origin'; import { filterResponseHeaders } from './core/response-headers'; import { SingleFlight } from './core/single-flight'; import { parseWebIdProfile } from './core/webid'; -import { registerGlobalFetchInjection } from './global-fetch-register'; +import { syncOriginFetchInjection } from './global-fetch-register'; import { profileAuthority } from './profile-authority'; import { loadClientIds, @@ -157,20 +156,81 @@ async function purgeGrantSlices(gone: readonly ClassGrant[], retainPool: readonl * best-effort. The surviving `live` grants are the retain-pool so an expiring grant that overlaps a * still-live one does not evict the survivor's data. */ +const originGrants = new OriginGrantStore({ + read: async () => (await chrome.storage.local.get('solid:origin-grants'))['solid:origin-grants'], + write: async (grants) => chrome.storage.local.set({ 'solid:origin-grants': grants }), +}); + +async function originGrantFor(origin: string): Promise { + return liveOriginGrant(await originGrants.list(), origin, Date.now()); +} + async function pruneAndPurgeExpiredGrants(profileId: string, now: number): Promise { - const store = await getProfiles(); - let live: ClassGrant[] = []; - let expired: ClassGrant[] = []; - await store.capabilities(profileId, (record) => { - const all = sanitiseGrants(record.classGrants); - live = pruneExpiredGrants(all, now); - expired = all.filter((g) => !live.includes(g)); - record.classGrants = live; - }); + const expired: ClassGrant[] = []; + const live: ClassGrant[] = []; + await originGrants.update((grants) => + grants.map((g) => { + if (g.profileId !== profileId || g.scope.classes === undefined) return g; + const keep = pruneExpiredGrants(g.scope.classes, now); + expired.push(...g.scope.classes.filter((c) => !keep.includes(c))); + live.push(...keep); + return { ...g, scope: { ...g.scope, classes: keep } }; + }), + ); await purgeGrantSlices(expired, live); return live; } +/** Expiry alarms are advisory; every request independently checks durable grant expiry. */ +let originSyncTail: Promise = Promise.resolve(); +function syncOriginGrants(): Promise { + const run = originSyncTail.then(reconcileOriginGrants); + originSyncTail = run.catch(() => {}); + return run; +} +async function reconcileOriginGrants(): Promise { + const store = await getProfiles(); + const { profiles } = await store.list(); + const active = new Set(profiles.filter((p) => p.sessionState === 'active').map((p) => p.id)); + const gone: OriginGrant[] = []; + await originGrants.update((grants) => + grants.filter((g) => { + const keep = + active.has(g.profileId) && (g.expiresAt === undefined || g.expiresAt > Date.now()); + if (!keep) gone.push(g); + return keep; + }), + ); + for (const g of gone) { + cancelProfileOperations(g.profileId); + const profile = profiles.find((p) => p.id === g.profileId); + // Signed-out/removing profiles retain metadata until their mandatory lifecycle purge succeeds. + if (profile?.sessionState === 'active') await purgeOriginSlice(g, profile.webId); + } + const grants = await originGrants.list(); + await syncOriginFetchInjection(grants.map((g) => g.origin)); + const expiries = grants.flatMap((g) => (g.expiresAt === undefined ? [] : [g.expiresAt])); + if (chrome.alarms) { + await chrome.alarms.clear('solid-origin-expiry'); + if (expiries.length) + await chrome.alarms.create('solid-origin-expiry', { when: Math.min(...expiries) }); + } +} + +async function purgeOriginSlice(grant: OriginGrant, webId: string): Promise { + try { + const [cache, db] = await Promise.all([getReplicaCache(), getReplicaDb()]); + await purgeReplicaSlice(cache, db, { + webId, + grantScope: grant.origin, + targets: grant.scope.pods.map((pod) => ({ url: `${pod}/`, kind: 'container' as const })), + retainTargets: [], + }); + } catch { + // Best effort, as with class-slice purges: removed authority is the serving boundary. + } +} + const getProfiles = profileAuthority(async (webId) => { const [cache, db] = await Promise.all([getReplicaCache(), getReplicaDb()]); await purgeReplica(cache, db, webId); @@ -189,7 +249,7 @@ function withProfileTask(id: string, task: () => Promise): Promise { } /** Concurrent requests retain their own cancellation scope; lifecycle changes cancel all of them. */ -const profileOperations = new Map>(); +const profileOperations = new Map>(); async function cancellable(signal: AbortSignal, task: () => Promise): Promise { signal.throwIfAborted(); @@ -208,10 +268,11 @@ async function cancellable(signal: AbortSignal, task: () => Promise): Prom async function withProfileOperation( id: string, task: (signal: AbortSignal) => Promise, + origin?: string, ): Promise { const controller = new AbortController(); - const operations = profileOperations.get(id) ?? new Set(); - operations.add(controller); + const operations = profileOperations.get(id) ?? new Map(); + operations.set(controller, origin); profileOperations.set(id, operations); try { await profileTasks.get(id); @@ -225,8 +286,10 @@ async function withProfileOperation( } } -function cancelProfileOperations(id: string): void { - for (const operation of profileOperations.get(id) ?? []) operation.abort(); +function cancelProfileOperations(id: string, origin?: string): void { + for (const [operation, requestingOrigin] of profileOperations.get(id) ?? []) { + if (origin === undefined || origin === requestingOrigin) operation.abort(); + } } function withProfileLifecycle(id: string, task: () => Promise): Promise { @@ -266,7 +329,11 @@ function refreshOnce(id: string): Promise { ) { cancelProfileOperations(id); try { - await store.signOut(id); + try { + await store.signOut(id); + } finally { + await syncOriginGrants(); + } } finally { // Revocation commits before replica cleanup, which may fail and need a retry. // A toolbar update may await this refresh; schedule its guarded repaint. @@ -283,6 +350,32 @@ function refreshOnce(id: string): Promise { type SessionContext = { profileId: string; session: StoredSession; keyPair: CryptoKeyPair }; +/** Recheck captured credentials after asynchronous work, including key replacement on re-login. */ +async function checkProfileSession(ctx: SessionContext, signal: AbortSignal): Promise { + signal.throwIfAborted(); + const current = await (await getProfiles()).session(ctx.profileId); + signal.throwIfAborted(); + if ( + current?.session.dpopJkt !== ctx.session.dpopJkt || + current?.session.webId !== ctx.session.webId + ) + throw new Error('Profile session changed.'); +} + +/** Read the live parent last, so expiry during a session lookup cannot disclose its contents. */ +async function checkOriginAuthority( + parent: OriginGrant, + ctx: SessionContext, + signal: AbortSignal, +): Promise { + await checkProfileSession(ctx, signal); + const current = await originGrantFor(parent.origin); + signal.throwIfAborted(); + if (current?.profileId !== ctx.profileId || current?.createdAt !== parent.createdAt) + throw new Error('Origin grant changed.'); + return current; +} + /** Resolve the default once per request. Never re-select it after an await. */ async function ensureSession(profileId?: string): Promise { const store = await getProfiles(); @@ -297,10 +390,24 @@ async function ensureSession(profileId?: string): Promise return ctx ? { ...ctx, profileId: id } : null; } -async function teardownSession(): Promise { +async function teardownSession(sender?: MessageSender, stamp?: string): Promise { const store = await getProfiles(); - const record = await store.getDefault(); - if (record) await withProfileLifecycle(record.profile.id, () => store.signOut(record.profile.id)); + const origin = resolveRequestingOrigin(sender, stamp); + const grant = origin ? await originGrantFor(origin) : null; + if (!isExtensionContext(sender) && !grant) throw new Error('Origin not granted access'); + const record = isExtensionContext(sender) + ? await store.getDefault() + : grant + ? await store.get(grant.profileId) + : null; + if (record) + await withProfileLifecycle(record.profile.id, async () => { + try { + await store.signOut(record.profile.id); + } finally { + await syncOriginGrants(); + } + }); else if ((await store.list()).profiles.length === 0) { const [cache, db] = await Promise.all([getReplicaCache(), getReplicaDb()]); await purgeReplica(cache, db, null); @@ -354,31 +461,7 @@ const CLASS_DENY_MESSAGE = { 'no-class-grant': 'No access grant for this data', } as const; -/** Whether a request URL's origin is one of the session's credential (pod) origins. */ -function originInSet(allowed: ReadonlySet, url: string): boolean { - const o = originOf(url); - return o !== null && allowed.has(o); -} - -/** - * The FULL access gate — re-run BEFORE any pod egress or cache read (incl. on a cache hit, via - * the replica's injected gate). Two layers, both fail-closed, both re-reading durable storage on - * EVERY request (no load-bearing in-memory gate state → a cold SW wake is fail-CLOSED): - * - * 1. The Phase-0/1 PER-ORIGIN gate (`decideRequestingOrigin`): dual-origin agreement, opaque - * → deny, default-deny per-origin grant, same-origin-or-deny foreign fetch. The granted set - * is the login/`setClientId` grants ∪ the session credential origins ∪ the origins holding a - * live class grant for the ACTIVE WebID (so a class-only origin passes this layer and is then - * confined by layer 2). A DENY here is unchanged from Phase-0. - * 2. The Phase-2 PER-DATA-CLASS gate (`decideClassAccess`): runs ONLY for a pod (credential) - * target AND ONLY for an origin that is present solely because it holds class grants (a - * FULL-grant origin — a login/credential origin — BYPASSES it, preserving the legacy - * whole-pod behaviour → backwards compatibility, design item 5). The request URL + method - * must fall within a non-expired grant's PINNED resolved target set with a satisfying mode, - * else DENY with a distinguishable class reason. - * - * `webId` is the active session WebID (the grant session boundary). Fail-closed on DENY. - */ +/** Re-read the explicit parent, then enforce its pod scope and nested pinned classes. */ async function gateAccess( message: FetchRequest, requestingOrigin: string, @@ -387,44 +470,36 @@ async function gateAccess( webId: string, profileId: string, ): Promise<{ grantScope: string } | { deny: Response }> { - const now = Date.now(); - const { grantedOrigins: explicitGrants } = await (await getProfiles()).get(profileId); - // Opportunistically prune expired grants on the fetch path (design item 3: expired → pruned) AND - // purge each expired grant's replica slice (residual b — no bytes linger past expiry). - const liveGrants = await pruneAndPurgeExpiredGrants(profileId, now); - const classOrigins = [...liveGrantOrigins(liveGrants, now, webId)]; - - const decision = decideRequestingOrigin(sender, message.stampedOrigin, message.url, { - explicitGrants: [...explicitGrants, ...classOrigins], - credentialOrigins: allowedTargetOrigins, - }); - // Defence-in-depth: the re-resolved origin must match the one resolved earlier this request. - if (decision.deny !== null || decision.requestingOrigin !== requestingOrigin) { - return { - deny: denyResponse( - decision.deny ? DENY_MESSAGE[decision.deny] : DENY_MESSAGE['forbidden-origin'], - ), - }; + const grant = await originGrantFor(requestingOrigin); + if (resolveRequestingOrigin(sender, message.stampedOrigin) !== requestingOrigin) + return { deny: denyResponse(DENY_MESSAGE['forbidden-origin']) }; + if (!grant || grant.profileId !== profileId) + return { deny: denyResponse(DENY_MESSAGE['origin-not-granted']) }; + const target = originOf(message.url); + if (!target || (!allowedTargetOrigins.has(target) && target !== requestingOrigin)) + return { deny: denyResponse(DENY_MESSAGE['cross-origin-foreign']) }; + if (allowedTargetOrigins.has(target)) { + if (!grant.scope.pods.includes(target)) return { deny: denyResponse('Out of grant scope') }; + if (grant.scope.classes !== undefined) await pruneAndPurgeExpiredGrants(profileId, Date.now()); } - // Layer 2 — the per-class gate. Only pod (credential) targets are class-scoped; a same-origin - // foreign target carries no pod token and is governed by the foreign-fetch policy above. - if (originInSet(allowedTargetOrigins, message.url)) { - // A FULL-grant origin (explicit login/setClientId grant OR a credential/pod origin) keeps - // whole-pod access — the legacy behaviour. Only a CLASS-ONLY origin is confined to its pins. - // NB the full-grant set is built via computeGrantedOrigins so the login-grant strings are - // NORMALISED (host-lowercased) to match the browser-attested `requestingOrigin` — and it - // DELIBERATELY excludes the class-grant origins (they are the ones we mean to confine). - const fullGrant = computeGrantedOrigins({ - explicitGrants, - credentialOrigins: allowedTargetOrigins, - }); - if (!fullGrant.has(requestingOrigin)) { - const forSession = grantsForSession(liveGrants, requestingOrigin, webId); - const reason = decideClassAccess(forSession, message.url, message.method, now); - if (reason !== null) { - return { deny: denyResponse(CLASS_DENY_MESSAGE[reason]) }; - } + // Pruning, purging and even the storage read can span a deadline or scope change. + // Decide from the live parent and its children at ONE fresh instant, with no more awaits. + const parents = await originGrants.list(); + const now = Date.now(); + const current = liveOriginGrant(parents, requestingOrigin, now); + if (!current || current.profileId !== profileId) + return { deny: denyResponse(DENY_MESSAGE['origin-not-granted']) }; + if (allowedTargetOrigins.has(target)) { + if (!current.scope.pods.includes(target)) return { deny: denyResponse('Out of grant scope') }; + if (current.scope.classes !== undefined) { + const reason = decideClassAccess( + grantsForSession(current.scope.classes, requestingOrigin, webId), + message.url, + message.method, + now, + ); + if (reason) return { deny: denyResponse(CLASS_DENY_MESSAGE[reason]) }; } } @@ -456,16 +531,27 @@ async function handleFetch( }; } - const selected = await (await getProfiles()).getDefault(); + const grant = await originGrantFor(requestingOrigin); + if (!grant) { + void syncOriginGrants().catch(() => {}); + return message.autoDivert + ? passthroughSentinel(message) + : { requestId: message.requestId, status: 403, error: DENY_MESSAGE['origin-not-granted'] }; + } + const selected = await (await getProfiles()).get(grant.profileId); if (selected) { let egressStarted = false; - const result = await withProfileOperation(selected.profile.id, async (signal) => { - const ctx = await ensureSession(selected.profile.id); - signal.throwIfAborted(); - return handleProfileFetch(message, sender, requestingOrigin, ctx, signal, () => { - egressStarted = true; - }); - }); + const result = await withProfileOperation( + selected.profile.id, + async (signal) => { + const ctx = await ensureSession(selected.profile.id); + signal.throwIfAborted(); + return handleProfileFetch(message, sender, requestingOrigin, ctx, signal, () => { + egressStarted = true; + }); + }, + requestingOrigin, + ); if (result) return result; // Native fallback would replay a request the pod may already have processed. if (egressStarted) return { requestId: message.requestId, error: 'Fetch cancelled' }; @@ -533,6 +619,8 @@ async function handleProfileFetch( // conditional header; it never re-implements the credential path. const egress: EgressPort = { fetch: async (url, method, headers, body, n) => { + const decision = await gate(); + if ('deny' in decision) throw new Error(await decision.deny.text()); const result = await authenticatedFetch(fetchSession, { url, method, @@ -540,7 +628,9 @@ async function handleProfileFetch( body, nonce: n, fetchImpl: (input, init) => - cancellable(signal, () => { + cancellable(signal, async () => { + const latest = await gate(); + if ('deny' in latest) throw new Error(await latest.deny.text()); onEgress(); return fetch(input, { ...init, signal }); }), @@ -563,6 +653,8 @@ async function handleProfileFetch( const persist = (task: () => Promise): Promise => withProfileTask(ctx.profileId, async () => { signal.throwIfAborted(); + const decision = await gate(); + if ('deny' in decision) throw new Error(await decision.deny.text()); return task(); }); const replica = new SharedReplica({ @@ -612,6 +704,9 @@ async function handleProfileFetch( // Defense-in-depth: relay only the allowlisted, app-relevant response headers back to // the page rather than every header the server emitted (see response-headers.ts). + const finalDecision = await gate(); + if ('deny' in finalDecision) + return { requestId: message.requestId, status: 403, error: await finalDecision.deny.text() }; const headers = filterResponseHeaders(result.response.headers); // A HEAD response carries NO body (Low #5) — the replica already strips it; relay no body. const isHead = message.method.toUpperCase() === 'HEAD'; @@ -680,14 +775,6 @@ let stateChangeVersion = 0; async function broadcastStateChange(ctx: SessionContext | null, version: number): Promise { const webId = ctx?.session.webId ?? null; - let granted: ReadonlySet = new Set(); - if (ctx) { - const { grantedOrigins: explicitGrants } = await (await getProfiles()).get(ctx.profileId); - granted = computeGrantedOrigins({ - explicitGrants, - credentialOrigins: await sessionCredentialOrigins(ctx.session, ctx.keyPair, ctx.profileId), - }); - } const tabs = await chrome.tabs.query({}); // Tab discovery and grant reads can overlap a default switch. Discard the whole stale @@ -704,13 +791,39 @@ async function broadcastStateChange(ctx: SessionContext | null, version: number) } else if (current?.profile.sessionState === 'active') return; for (const tab of tabs) { if (tab.id === undefined) continue; + const tabId = tab.id; const tabOrigin = tab.url ? originOf(tab.url) : null; // A signed-in WebID is only revealed to a granted tab; everyone else (and every tab on // logout) gets null. Fail-closed: an unparseable tab URL never receives the WebID. - const tabWebId = webId !== null && tabOrigin !== null && granted.has(tabOrigin) ? webId : null; - chrome.tabs - .sendMessage(tab.id, { type: 'SOLID_STATE_CHANGED', webId: tabWebId }) - .catch(() => {}); + const grant = tabOrigin ? await originGrantFor(tabOrigin) : null; + const deliver = async (signal: AbortSignal) => { + const bound = grant ? await (await getProfiles()).session(grant.profileId) : null; + signal.throwIfAborted(); + // The bridge checks origin as well, closing navigation after this URL lookup. + const currentTab = await chrome.tabs.get(tabId).catch(() => null); + signal.throwIfAborted(); + if (!tabOrigin || !currentTab?.url || originOf(currentTab.url) !== tabOrigin) return; + let tabWebId: string | null = null; + if (grant && bound) { + try { + await checkOriginAuthority(grant, { ...bound, profileId: grant.profileId }, signal); + tabWebId = bound.session.webId; + } catch { + signal.throwIfAborted(); + } + } + if (version !== stateChangeVersion) return; + chrome.tabs + .sendMessage( + tabId, + { type: 'SOLID_STATE_CHANGED', origin: tabOrigin, webId: tabWebId }, + { frameId: 0 }, + ) + .catch(() => {}); + }; + if (grant) await withProfileOperation(grant.profileId, deliver, tabOrigin ?? undefined); + else await deliver(new AbortController().signal); + if (version !== stateChangeVersion) return; } // The popup/side panel are trusted extension contexts — give them the real value. chrome.runtime.sendMessage({ type: 'SOLID_STATE_CHANGED', webId }).catch(() => {}); @@ -746,11 +859,6 @@ function handleLogin( name, photoUrl, }); - if (requestingOrigin !== null) - await store.capabilities(profile.id, (record) => { - if (!record.grantedOrigins.includes(requestingOrigin)) - record.grantedOrigins.push(requestingOrigin); - }); if (makeDefault) await store.setDefault(profile.id); await rememberAccount({ webId: session.webId, name, photoUrl }); void refreshProfile(profile.id, session.webId); @@ -784,6 +892,7 @@ async function updateDefaultIconAndBroadcast(): Promise { isCurrent, ); else await setSignedOutIcon(isCurrent); + await syncOriginGrants(); await broadcastStateChange(ctx, version); } @@ -809,7 +918,13 @@ async function handleProfiles( await store.rename(message.profileId, message.label); break; case 'SOLID_PROFILE_REMOVE': - await withProfileLifecycle(message.profileId, () => store.remove(message.profileId)); + await withProfileLifecycle(message.profileId, async () => { + try { + await store.remove(message.profileId); + } finally { + await syncOriginGrants(); + } + }); break; case 'SOLID_PROFILE_SET_DEFAULT': await store.setDefault(message.profileId); @@ -856,7 +971,13 @@ async function handleGetState( message: GetStateRequest, sender: MessageSender | undefined, ): Promise { - const ctx = await ensureSession(); + const requestingOrigin = resolveRequestingOrigin(sender, message.stampedOrigin); + const grant = requestingOrigin ? await originGrantFor(requestingOrigin) : null; + const ctx = isExtensionContext(sender) + ? await ensureSession() + : grant + ? await ensureSession(grant.profileId) + : null; const profile = ctx ? (await (await getProfiles()).get(ctx.profileId)).profile : null; if (isExtensionContext(sender)) { @@ -870,17 +991,8 @@ async function handleGetState( }; } - // Web page: scope identity exposure to opt-in/granted origins only. - const requestingOrigin = resolveRequestingOrigin(sender, message.stampedOrigin); - let mayExpose = false; - if (requestingOrigin !== null && ctx) { - const { grantedOrigins: explicitGrants } = await (await getProfiles()).get(ctx.profileId); - const granted = computeGrantedOrigins({ - explicitGrants, - credentialOrigins: await sessionCredentialOrigins(ctx.session, ctx.keyPair, ctx.profileId), - }); - mayExpose = isRequestingOriginGranted(granted, requestingOrigin); - } + const latest = requestingOrigin ? await originGrantFor(requestingOrigin) : null; + const mayExpose = !!ctx && latest?.profileId === ctx.profileId; return { webId: mayExpose ? (ctx?.session.webId ?? null) : null, @@ -892,16 +1004,6 @@ async function handleGetState( }; } -/** The session's credential (pod/WebID/issuer) origins — the auto-granted requester set. */ -async function sessionCredentialOrigins( - session: StoredSession, - keyPair: CryptoKeyPair, - profileId: string, -): Promise> { - const fetchSession = await buildFetchSession(session, keyPair, profileId); - return fetchSession.allowedOrigins; -} - async function handleSetClientId( message: SetClientIdRequest, sender: MessageSender | undefined, @@ -914,20 +1016,13 @@ async function handleSetClientId( throw new Error('Client identifier must be an https: URL (http: allowed for loopback only).'); } // Cross-check the page-supplied origin against the browser-attested sender; store the - // client-id (and grant the app) only under the VERIFIED origin, so a renderer cannot + // client-id only under the VERIFIED origin, so a renderer cannot // map another origin's client-id or self-grant a foreign origin. const requestingOrigin = resolveRequestingOrigin(sender, message.origin); if (requestingOrigin === null) { throw new Error('Could not verify the requesting origin.'); } await saveClientId(requestingOrigin, message.clientId); - // Declaring a client-id is a deliberate owner opt-in from this app → grant it access. - const store = await getProfiles(); - const record = await store.getDefault(); - if (record?.profile.sessionState === 'active') - await store.capabilities(record.profile.id, (r) => { - if (!r.grantedOrigins.includes(requestingOrigin)) r.grantedOrigins.push(requestingOrigin); - }); return { ok: true }; } catch (err) { return { error: err instanceof Error ? err.message : 'Invalid client identifier' }; @@ -944,9 +1039,11 @@ async function handleSetClientId( // `requestAccess` promise times out — no grant is minted without a live approval. /** Pending consent requests awaiting a decision (consentId → record). */ -const pendingConsents = new Map(); +type ProfileConsent = { pending: PendingConsent; ctx: SessionContext; label: string }; +const pendingConsents = new Map>(); /** Resolvers for the awaiting `requestAccess` calls (consentId → resolve(approvedClasses)). */ -const consentResolvers = new Map void>(); +type ConsentChoice = { approved: string[]; profileId?: string }; +const consentResolvers = new Map void>(); const CONSENT_TIMEOUT_MS = 5 * 60_000; /** @@ -987,7 +1084,7 @@ function isHttpIri(value: unknown): value is string { /** * The POD-ONLY origin set for class resolution — the WebID origin ∪ configured pod origins. It - * confines both the type-index REF fetches and the resolved TARGETS: the full credential set + * confines authenticated private-index fetches and the resolved TARGETS: the full credential set * (`buildFetchSession().allowedOrigins`) ALSO includes the ISSUER origin, and a hostile WebID * profile pointing a type-index ref at the issuer would otherwise trigger an authenticated SW * fetch to an issuer *path* (token-to-issuer-path leak, roborev round-2 Medium). @@ -1016,20 +1113,39 @@ async function podOriginsFor( }); } -/** The class resolver's authed fetch — the SW's DPoP egress, surfaced as `{ ok, text }`. */ +/** Public discovery never carries credentials; private indexes use scoped DPoP reads. */ function makeResolveFetch( session: FetchSession, -): (url: string) => Promise<{ ok: boolean; text: string }> { - return async (url: string) => { + webId: string, + signal = new AbortController().signal, + beforeRequest: (url: string, authenticated: boolean) => Promise = async () => {}, +): ResolveFetch { + return async (url, useCredentials) => { try { - const { response } = await authenticatedFetch(session, { - url, - method: 'GET', - headers: { Accept: 'text/turtle' }, - body: null, - nonce: undefined, - }); - return { ok: response.ok, text: response.ok ? await response.text() : '' }; + const authenticated = useCredentials && url !== webId; + const fetchImpl: typeof fetch = (input, init) => + cancellable(signal, async () => { + await beforeRequest(input.toString(), authenticated); + signal.throwIfAborted(); + return fetch(input, { ...init, credentials: 'omit', redirect: 'error', signal }); + }); + const response = authenticated + ? ( + await authenticatedFetch(session, { + url, + method: 'GET', + headers: { Accept: 'text/turtle' }, + body: null, + nonce: undefined, + fetchImpl, + }) + ).response + : await fetchImpl(url, { headers: { Accept: 'text/turtle' } }); + await beforeRequest(url, authenticated); + const text = response.ok ? await cancellable(signal, () => response.text()) : ''; + await beforeRequest(url, authenticated); + signal.throwIfAborted(); + return { ok: response.ok, text }; } catch { return { ok: false, text: '' }; } @@ -1037,27 +1153,27 @@ function makeResolveFetch( } /** Open the consent window and resolve with the approved class IRIs (or [] on deny/timeout). */ -function openConsentAndAwait(consentId: string): Promise { - return new Promise((resolve) => { +function openConsentAndAwait(consentId: string): Promise { + return new Promise((resolve) => { let settled = false; let timer: ReturnType | undefined; - const finish = (approved: string[]): void => { + const finish = (choice: ConsentChoice): void => { if (settled) return; settled = true; if (timer) clearTimeout(timer); - resolve(approved); + resolve(choice); }; consentResolvers.set(consentId, finish); - timer = setTimeout(() => finish([]), CONSENT_TIMEOUT_MS); + timer = setTimeout(() => finish({ approved: [] }), CONSENT_TIMEOUT_MS); const url = chrome.runtime.getURL( `consent/consent.html?consentId=${encodeURIComponent(consentId)}`, ); try { chrome.windows.create({ url, type: 'popup', width: 460, height: 640 }, () => { - if (chrome.runtime.lastError) finish([]); + if (chrome.runtime.lastError) finish({ approved: [] }); }); } catch { - finish([]); + finish({ approved: [] }); } }); } @@ -1088,66 +1204,134 @@ async function handleRequestAccess( return { requestId, error: REQUEST_ACCESS_DENY_MESSAGE[reason] }; } - const ctx = await ensureSession(); - if (!ctx) return { requestId, error: 'Not authenticated' }; - const now = Date.now(); - const spec = normaliseAccessRequest( - { - classes: message.classes, - modes: message.modes, - purpose: message.purpose, - duration: message.duration, - }, - now, - ); + const spec = normaliseAccessRequest(message, now); if ('error' in spec) return { requestId, error: spec.error }; - - // Resolve each requested class to its concrete, pinned targets (confined to POD origins — - // excludes the issuer, so a hostile profile can't steer resolution at the issuer). - const fetchSession = await buildFetchSession(ctx.session, ctx.keyPair, ctx.profileId); - const resolveFetch = makeResolveFetch(fetchSession); - const podOrigins = await podOriginsFor(ctx.session, ctx.profileId); - const resolved = new Map(); - for (const cls of spec.classes) { - const targets = await resolveClassTargets(cls, { - webId: ctx.session.webId, - fetch: resolveFetch, - allowedOrigins: podOrigins, - }); - resolved.set(cls, { targets, shape: null }); - } - + const store = await getProfiles(); + const { profiles, defaultProfileId } = await store.list(); + const parent = await originGrantFor(requestingOrigin); const consentId = crypto.randomUUID(); - const pending = buildPendingConsent( - consentId, - requestingOrigin, - ctx.session.webId, - spec, - resolved, - now, + const choices = new Map(); + // Each profile's targets are independently resolved and shown before approval. + const ordered = [...profiles].sort( + (a, b) => Number(b.id === defaultProfileId) - Number(a.id === defaultProfileId), ); - pendingConsents.set(consentId, pending); - - let approvedClasses: string[]; + for (const profile of ordered) { + try { + const choice = await withProfileOperation( + profile.id, + async (signal) => { + const ctx = await ensureSession(profile.id); + signal.throwIfAborted(); + if (!ctx) return null; + const checkAuthority = async () => { + await checkProfileSession(ctx, signal); + const latest = await originGrantFor(requestingOrigin); + signal.throwIfAborted(); + // Initial consent needs no parent. An existing binding must remain live and unchanged. + if (latest?.profileId !== parent?.profileId || latest?.createdAt !== parent?.createdAt) + throw new Error('Origin grant changed.'); + }; + const session = await buildFetchSession(ctx.session, ctx.keyPair, ctx.profileId); + const allowedOrigins = await podOriginsFor(ctx.session, ctx.profileId); + const resolved = new Map(); + for (const cls of spec.classes) { + const targets = await resolveClassTargets(cls, { + webId: ctx.session.webId, + fetch: makeResolveFetch( + { ...session, allowedOrigins }, + ctx.session.webId, + signal, + checkAuthority, + ), + authenticatedOrigins: allowedOrigins, + allowedOrigins, + }); + await checkAuthority(); + resolved.set(cls, { targets, shape: null }); + } + return { + ctx, + label: profile.label, + pending: buildPendingConsent( + consentId, + requestingOrigin, + ctx.session.webId, + spec, + resolved, + now, + ), + }; + }, + requestingOrigin, + ); + if (choice) choices.set(profile.id, choice); + } catch { + // A signed-out, replaced or revoked session cannot contribute a consent choice. + } + } + if (!choices.size) return { requestId, error: 'Not authenticated' }; + pendingConsents.set(consentId, choices); + let decision: ConsentChoice; try { - approvedClasses = await openConsentAndAwait(consentId); + decision = await openConsentAndAwait(consentId); } finally { pendingConsents.delete(consentId); consentResolvers.delete(consentId); } - - const grants = consentDecisionToGrants(pending, approvedClasses, () => crypto.randomUUID()); + const selected = decision.profileId ? choices.get(decision.profileId) : undefined; + if (!selected || decision.approved.length === 0) return { requestId, ok: false, grants: [] }; + const { ctx, pending } = selected; + const grants = consentDecisionToGrants(pending, decision.approved, () => crypto.randomUUID()); + if (!grants.length || spec.expiresAt <= Date.now()) return { requestId, ok: false, grants: [] }; try { - await (await getProfiles()).capabilities(ctx.profileId, (record) => { - if (record.session?.dpopJkt !== ctx.session.dpopJkt) + await withProfileLifecycle(ctx.profileId, async () => { + const current = await store.session(ctx.profileId); + if (current?.session.dpopJkt !== ctx.session.dpopJkt) throw new Error('Profile session changed.'); - record.classGrants.push(...grants); + const previous = await originGrantFor(requestingOrigin); + if (previous) cancelProfileOperations(previous.profileId); + await originGrants.update((all) => { + const current = liveOriginGrant(all, requestingOrigin, Date.now()); + const nested = + current?.profileId === ctx.profileId + ? [ + ...(current.scope.classes ?? []).map((child) => ({ + ...child, + // Fresh consent cannot extend an unrelated child's effective deadline. + expiresAt: Math.min(child.expiresAt, current.expiresAt ?? Infinity), + })), + ...grants, + ] + : grants; + return [ + ...all.filter((g) => g.origin !== requestingOrigin), + { + origin: requestingOrigin, + profileId: ctx.profileId, + createdAt: now, + expiresAt: spec.expiresAt, + scope: { + pods: [ + ...new Set(nested.flatMap((g) => g.targets.map((t) => new URL(t.url).origin))), + ], + classes: nested, + }, + }, + ]; + }); + if (previous) { + const old = await store.get(previous.profileId).catch(() => null); + if (old) await purgeOriginSlice(previous, old.profile.webId); + } }); + await updateDefaultIconAndBroadcast(); } catch { return { requestId, error: 'Profile session changed.' }; } - return { requestId, ok: grants.length > 0, grants: grants.map(grantSummary) }; + const latest = await originGrantFor(requestingOrigin); + if (latest?.profileId !== ctx.profileId) return { requestId, ok: false, grants: [] }; + return { requestId, ok: true, webId: ctx.session.webId, grants: grants.map(grantSummary) }; } /** The consent window (extension chrome only) asks for the pending record to render. */ @@ -1156,12 +1340,22 @@ function handleGetPendingConsent( sender: MessageSender | undefined, ): PendingConsentView | null { if (!isExtensionContext(sender)) return null; // only the extension's own window may read it. - const pending = pendingConsents.get(message.consentId); - if (!pending) return null; + const choices = pendingConsents.get(message.consentId); + const profileId = message.profileId ?? choices?.keys().next().value; + const selected = profileId ? choices?.get(profileId) : undefined; + if (!selected) return null; + const pending = selected.pending; + return { consentId: pending.consentId, origin: pending.origin, webId: pending.webId, + profileId: selected.ctx.profileId, + profiles: [...(choices ?? [])].map(([id, value]) => ({ + id, + label: value.label, + webId: value.ctx.session.webId, + })), classes: pending.classes.map((c) => ({ dataClass: c.dataClass, modes: c.modes, @@ -1182,11 +1376,13 @@ function handleConsentDecision( const resolver = consentResolvers.get(message.consentId); if (!resolver) return; consentResolvers.delete(message.consentId); - const pending = pendingConsents.get(message.consentId); - // Defence in depth: only classes actually shown in this pending request can be approved. - const requested = new Set(pending?.classes.map((c) => c.dataClass) ?? []); - const approved = message.approvedClasses.filter((c) => requested.has(c)); - resolver(approved); + const choices = pendingConsents.get(message.consentId); + const selected = message.profileId ? choices?.get(message.profileId) : undefined; + const requested = new Set(selected?.pending.classes.map((c) => c.dataClass) ?? []); + const approved = Array.isArray(message.approvedClasses) + ? message.approvedClasses.filter((c) => requested.has(c)) + : []; + resolver({ approved, profileId: selected?.ctx.profileId }); } async function handleListGrants( @@ -1196,12 +1392,30 @@ async function handleListGrants( const requestId = message.requestId; const requestingOrigin = resolveRequestingOrigin(sender, message.stampedOrigin); if (requestingOrigin === null) return { requestId, grants: [] }; - const ctx = await ensureSession(); - if (!ctx) return { requestId, grants: [] }; - const now = Date.now(); - const live = await pruneAndPurgeExpiredGrants(ctx.profileId, now); - const mine = grantsForSession(live, requestingOrigin, ctx.session.webId); - return { requestId, grants: mine.map(grantSummary) }; + const parent = await originGrantFor(requestingOrigin); + if (!parent) return { requestId, grants: [] }; + try { + const result = await withProfileOperation( + parent.profileId, + async (signal) => { + const ctx = await ensureSession(parent.profileId); + signal.throwIfAborted(); + if (!ctx) return null; + await pruneAndPurgeExpiredGrants(ctx.profileId, Date.now()); + const latest = await checkOriginAuthority(parent, ctx, signal); + const mine = grantsForSession( + pruneExpiredGrants(latest.scope.classes ?? [], Date.now()), + requestingOrigin, + ctx.session.webId, + ); + return { requestId, grants: mine.map(grantSummary) }; + }, + requestingOrigin, + ); + return result ?? { requestId, grants: [] }; + } catch { + return { requestId, grants: [] }; + } } async function handleRevokeGrant( @@ -1213,19 +1427,24 @@ async function handleRevokeGrant( if (requestingOrigin === null) { return { requestId, ok: false, error: 'Could not verify the requesting origin.' }; } - const ctx = await ensureSession(); + const parent = await originGrantFor(requestingOrigin); + const ctx = parent ? await ensureSession(parent.profileId) : null; if (!ctx) return { requestId, ok: false }; const removed: ClassGrant[] = []; let survivors: ClassGrant[] = []; - await (await getProfiles()).capabilities(ctx.profileId, (record) => { - removed.push( - ...record.classGrants.filter( - (g) => g.grantId === message.grantId && g.origin === requestingOrigin, - ), - ); - record.classGrants = record.classGrants.filter((g) => !removed.includes(g)); - survivors = pruneExpiredGrants(record.classGrants, Date.now()); - }); + await originGrants.update((all) => + all.map((g) => { + if (g.origin !== requestingOrigin || g.profileId !== ctx.profileId || !g.scope.classes) + return g; + removed.push(...g.scope.classes.filter((c) => c.grantId === message.grantId)); + survivors = pruneExpiredGrants( + g.scope.classes.filter((c) => c.grantId !== message.grantId), + Date.now(), + ); + return { ...g, scope: { ...g.scope, classes: survivors } }; + }), + ); + if (removed.length > 0) cancelProfileOperations(ctx.profileId, requestingOrigin); await purgeGrantSlices(removed, survivors); return { requestId, ok: removed.length > 0 }; } @@ -1242,44 +1461,121 @@ async function handleResolveClass( if (!isHttpIri(message.classIRI)) { return { requestId, error: 'resolveClass: an https: class IRI is required.' }; } - const ctx = await ensureSession(); - if (!ctx) return { requestId, error: 'Not authenticated' }; + const parent = await originGrantFor(requestingOrigin); + if (!parent) return { requestId, error: 'Not authenticated' }; - const fetchSession = await buildFetchSession(ctx.session, ctx.keyPair, ctx.profileId); - // Only a GRANTED origin (full OR class) may introspect — never leak the pod's type-index - // layout to an arbitrary, un-opted-in page. - const now = Date.now(); - const { grantedOrigins: explicitGrants } = await (await getProfiles()).get(ctx.profileId); - const live = await pruneAndPurgeExpiredGrants(ctx.profileId, now); - const classOrigins = [...liveGrantOrigins(live, now, ctx.session.webId)]; - const granted = computeGrantedOrigins({ - explicitGrants: [...explicitGrants, ...classOrigins], - credentialOrigins: fetchSession.allowedOrigins, - }); - if (!isRequestingOriginGranted(granted, requestingOrigin)) { - return { requestId, error: 'Origin not granted access' }; + try { + const result = await withProfileOperation( + parent.profileId, + async (signal) => { + const ctx = await ensureSession(parent.profileId); + signal.throwIfAborted(); + if (!ctx) return { requestId, error: 'Not authenticated' }; + const checkGrant = async () => { + const current = await checkOriginAuthority(parent, ctx, signal); + if ( + current.scope.classes !== undefined && + !grantsForSession( + pruneExpiredGrants(current.scope.classes, Date.now()), + requestingOrigin, + ctx.session.webId, + ).some((g) => g.dataClass === message.classIRI) + ) + throw new Error('No access grant for this data class'); + return current; + }; + const current = await checkGrant(); + const fetchSession = await buildFetchSession(ctx.session, ctx.keyPair, ctx.profileId); + const profilePods = await podOriginsFor(ctx.session, ctx.profileId); + const allowedOrigins = new Set( + [...profilePods].filter((pod) => current.scope.pods.includes(pod)), + ); + const targets = await resolveClassTargets(message.classIRI, { + webId: ctx.session.webId, + fetch: makeResolveFetch( + { ...fetchSession, allowedOrigins }, + ctx.session.webId, + signal, + async (url, authenticated) => { + const latest = await checkGrant(); + if (!authenticated) return; + const targetOrigin = originOf(url); + if ( + !targetOrigin || + !allowedOrigins.has(targetOrigin) || + !latest.scope.pods.includes(targetOrigin) + ) + throw new Error('Out of grant scope'); + }, + ), + authenticatedOrigins: allowedOrigins, + allowedOrigins, + }); + const latest = await checkGrant(); + return { + requestId, + shape: null, + targets: targets + .filter((t) => latest.scope.pods.includes(new URL(t.url).origin)) + .map((t) => ({ url: t.url, kind: t.kind })), + }; + }, + requestingOrigin, + ); + return result ?? { requestId, error: 'Discovery cancelled' }; + } catch (error) { + return { requestId, error: error instanceof Error ? error.message : 'Discovery failed' }; } - // CLASS-SCOPED introspection: a FULL-grant origin (login/credential) may resolve any class, but - // a CLASS-ONLY origin may resolve ONLY a class it actually holds a live grant for — otherwise a - // narrow "finance" grant could enumerate the pod's "health" target URLs (private-structure leak, - // roborev Medium). Fail-closed. - const fullGrant = computeGrantedOrigins({ - explicitGrants, - credentialOrigins: fetchSession.allowedOrigins, - }); - if (!fullGrant.has(requestingOrigin)) { - const mine = grantsForSession(live, requestingOrigin, ctx.session.webId); - if (!mine.some((g) => g.dataClass === message.classIRI)) { - return { requestId, error: 'No access grant for this data class' }; +} + +async function handleOriginGrants(message: OriginGrantRequest, sender: MessageSender | undefined) { + if (!isExtensionContext(sender)) + return { error: 'Origin grant management requires extension context.' }; + await syncOriginGrants(); + if (message.type !== 'SOLID_ORIGIN_GRANTS_LIST') { + if (!canonicalOrigin(message.origin)) return { error: 'Invalid origin.' }; + const grant = await originGrantFor(message.origin); + if (!grant) return { ok: false }; + if ( + message.type === 'SOLID_ORIGIN_GRANT_EXPIRY' && + (!Number.isFinite(message.expiresAt) || + message.expiresAt <= Date.now() || + (grant.expiresAt !== undefined && message.expiresAt > grant.expiresAt)) + ) + return { error: 'Expiry may only be shortened; request fresh consent to extend it.' }; + if (message.type === 'SOLID_ORIGIN_GRANT_PROFILE') { + const target = await (await getProfiles()).get(message.profileId); + if (target.profile.sessionState !== 'active') return { error: 'Profile is not active.' }; + cancelProfileOperations(message.profileId); } + await withProfileLifecycle(grant.profileId, async () => { + await originGrants.update((all) => + message.type === 'SOLID_ORIGIN_GRANT_REVOKE' + ? all.filter((g) => g.origin !== message.origin) + : all.map((g) => + g.origin !== message.origin + ? g + : message.type === 'SOLID_ORIGIN_GRANT_PROFILE' + ? { + ...g, + profileId: message.profileId, + createdAt: Date.now(), + scope: { pods: [], classes: [] }, + } + : { ...g, expiresAt: message.expiresAt }, + ), + ); + if ( + message.type === 'SOLID_ORIGIN_GRANT_REVOKE' || + message.type === 'SOLID_ORIGIN_GRANT_PROFILE' + ) { + const record = await (await getProfiles()).get(grant.profileId); + await purgeOriginSlice(grant, record.profile.webId); + } + }); + await updateDefaultIconAndBroadcast(); } - - const targets = await resolveClassTargets(message.classIRI, { - webId: ctx.session.webId, - fetch: makeResolveFetch(fetchSession), - allowedOrigins: await podOriginsFor(ctx.session, ctx.profileId), - }); - return { requestId, shape: null, targets: targets.map((t) => ({ url: t.url, kind: t.kind })) }; + return { ok: true, grants: await originGrants.list() }; } chrome.runtime.onMessage.addListener( @@ -1293,6 +1589,12 @@ chrome.runtime.onMessage.addListener( ); }; switch (message.type) { + case 'SOLID_ORIGIN_GRANTS_LIST': + case 'SOLID_ORIGIN_GRANT_REVOKE': + case 'SOLID_ORIGIN_GRANT_PROFILE': + case 'SOLID_ORIGIN_GRANT_EXPIRY': + respond(handleOriginGrants(message, sender)); + return true; case 'SOLID_PROFILES_LIST': case 'SOLID_PROFILE_ADD': case 'SOLID_PROFILE_RENAME': @@ -1309,7 +1611,7 @@ chrome.runtime.onMessage.addListener( respond(handleLogin(message, sender)); return true; case 'SOLID_LOGOUT': - teardownSession() + teardownSession(sender, message.stampedOrigin) .then(() => updateDefaultIconAndBroadcast()) .then(() => sendResponse({ ok: true })) .catch((err) => sendResponse({ error: String(err) })); @@ -1350,10 +1652,8 @@ chrome.runtime.onInstalled.addListener((details) => { if (details.reason === 'install') { chrome.storage.local.set({ 'solid:show-pin-nudge': true }).catch(() => {}); } - // (Re-)register the MAIN-world global-fetch-patch injection on install/update — the most - // reliable MAIN injection (design §5.1). Belt-and-braces with the manifest content script; - // non-fatal if unavailable (the SW gate is the sole security boundary regardless). - void registerGlobalFetchInjection(); + // Remove the legacy all-origins registration and restore only current explicit grants. + void syncOriginGrants().catch(() => {}); }); // Wire the persistent side panel: a right-click "Open Solid side panel" action-menu entry @@ -1362,3 +1662,7 @@ registerSidePanel(); // Re-hydrate the session + icon on every SW wake. void updateDefaultIconAndBroadcast().catch(() => {}); + +chrome.alarms?.onAlarm.addListener((alarm) => { + if (alarm.name === 'solid-origin-expiry') void updateDefaultIconAndBroadcast().catch(() => {}); +}); diff --git a/apps/extension/src/consent/consent.css b/apps/extension/src/consent/consent.css index 64388f411..c931ca553 100644 --- a/apps/extension/src/consent/consent.css +++ b/apps/extension/src/consent/consent.css @@ -69,3 +69,15 @@ body { .btn-primary { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); } .btn:disabled { opacity: 0.5; cursor: not-allowed; } .error { color: var(--danger); margin-top: 12px; } + +#profile { + display: block; + width: 100%; + margin-block: 0.5rem 1rem; + padding: 0.65rem; + color: inherit; + background: var(--bg); + border: 1px solid currentColor; + border-radius: 0.5rem; + font: inherit; +} diff --git a/apps/extension/src/consent/consent.html b/apps/extension/src/consent/consent.html index c78701401..85d97e6e8 100644 --- a/apps/extension/src/consent/consent.html +++ b/apps/extension/src/consent/consent.html @@ -14,6 +14,9 @@

    Access request

    .

    + + +
    - +

    Solid

    @@ -26,6 +28,10 @@

    Solid

    @@ -37,21 +43,27 @@

    Solid

    `.autoRestore = false` PROPERTY in account-surface.ts (NOT a markup attribute — the surface owns the restore flow; see the note there). --> -