@ai-ecoverse/slicc-vault 1.8.3 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,7 +17,7 @@ The build (`npm run build`) writes a flat static tree to `dist/site/`:
17
17
  | `vault.css`, `fonts/` | Spectrum 2 from the CSS-only `@spectrum-css` packages, and Adobe Clean |
18
18
  | `config.json` | `{ version, embedders }`. `embedders` is `vault.sliccy.ai`'s fallback list, `["https://seven.sliccy.ai"]` |
19
19
 
20
- The bundles hold this repo's code and `@ai-ecoverse/slicc-scrub`. The only third-party code is for KeePass imports: `kdbxweb` (with `@xmldom/xmldom` and `fflate`) and `hash-wasm`'s argon2, exact-pinned and bundled into a separate chunk under `chunks/` that the core frame loads only when a KeePass import starts; its wasm is inlined, never fetched. The sliccy-ai Worker serves the tree from the network on every load, never from OPFS, with:
20
+ The bundles hold this repo's code, `@ai-ecoverse/slicc-scrub`, and `@ai-ecoverse/jerk-oauth` (certified 0.15.1, exact-pinned, for token refresh): its wasm is inlined into a separate chunk under `chunks/` (about 970 KB) that loads only on the first refresh or MCP `grant`, never fetched. The only third-party code is for KeePass imports: `kdbxweb` (with `@xmldom/xmldom` and `fflate`) and `hash-wasm`'s argon2, exact-pinned and bundled into a separate chunk under `chunks/` that the core frame loads only when a KeePass import starts; its wasm is inlined, never fetched. The sliccy-ai Worker serves the tree from the network on every load, never from OPFS, with:
21
21
  - `Content-Security-Policy` with `script-src 'self'`, no inline scripts or styles, and `frame-ancestors` listing the seven origins;
22
22
  - `Cross-Origin-Embedder-Policy: require-corp`, `Cross-Origin-Resource-Policy: same-site`, `Cross-Origin-Opener-Policy: same-origin`;
23
23
  - `Origin-Agent-Cluster: ?1`, `nosniff`, `Referrer-Policy: no-referrer`, `no-cache` with an ETag.
@@ -56,17 +56,17 @@ Everything lives in the vault origin's IndexedDB, database `slicc-vault`, versio
56
56
 
57
57
  ```
58
58
  { ns, kind: "secret" | "grant" | "model" | "mcp", name, placeholder, sealed: { iv, data },
59
- source, createdAt, updatedAt, usedAt?, expiresAt?, refresh?, oauth?, provider?, server?, settings?, removedAt? }
59
+ source, createdAt, updatedAt, usedAt?, expiresAt?, refresh?, oauth?, provider?, server?, settings?, url?, sealedBy?, removedAt?, removedWith? }
60
60
  ```
61
61
 
62
62
  - **`ns`** is the embedder's origin, below.
63
63
  - **`name`** matches `^[A-Za-z_][A-Za-z0-9_]*$`, at most 128 characters.
64
64
  - **A value** is at least 8 bytes of UTF-8, without CR, LF or NUL, and contains no placeholder. Anything shorter is refused with `EINVAL` "values shorter than 8 bytes can't be protected from the model", because the scrubber couldn't tell it apart from ordinary output.
65
65
  - **`placeholder`** is `fnox_proxy_` plus 24 lower-case hex characters from `crypto.getRandomValues`, unique across the whole vault and stable for the record's life. All placeholders have the same length, so none contains another.
66
- - **`provider`** (a `model` record's pi-ai provider id, required, `^[a-z0-9][a-z0-9._-]{0,63}$`), **`server`** (an `mcp` record's MCP server name, optional) and **`settings`** (non-secret `NAME = text` pairs for a `model` or `mcp` record, at most 16, each at most 256 characters, e.g. `AWS_REGION`) bind a record to what may use it. A reseal without them keeps the stored ones, and one with them rebinds the record; a new `model` record without a provider is refused with `EINVAL` "a model record needs its provider".
66
+ - **`provider`** (a `model` record's pi-ai provider id, required, `^[a-z0-9][a-z0-9._-]{0,63}$`), **`server`** (an `mcp` record's MCP server name, optional) and **`settings`** (non-secret `NAME = text` pairs for a `model` or `mcp` record, at most 16, each at most 256 characters, e.g. `AWS_REGION`) bind a record to what may use it. In `list`, a grant shows the `server` or `provider` of the live tokens of the same `source` that name it as their `refresh`, when they all agree (and none when they don't, whatever the grant stored), for display and lookup only; nothing is copied onto the grant. A reseal without them keeps the stored ones, and one with them rebinds the record; a new `model` record without a provider is refused with `EINVAL` "a model record needs its provider".
67
67
  - **`source`** is `settings` (entered in spectrum), `process`, `jerk:<provider>@<device>`, `oauth:<provider>` or `import:<kind>`.
68
68
  - **`state`** is computed, never stored: `removed` when `removedAt` is set, `active` when the accepted policy has a rule for the record (below), otherwise `inert`.
69
- - **Removing** sets `removedAt`. A removed record keeps its placeholder, can be restored with `restore`, and is deleted for good 30 days later. Sealing a removed name brings it back with a new value and the same placeholder.
69
+ - **Removing** sets `removedAt`. A removed record keeps its placeholder, can be restored with `restore`, and is deleted for good 30 days later. Sealing a removed name brings it back with a new value and the same placeholder. Removing a token removes the grant its `refresh` names (when the grant has the token's own `source`, and no other live token of that source still names it) in the same transaction, and restoring it restores that grant too, only when that grant was removed by this token's removal (the vault records which token took it as `removedWith`, `<kind>\u0000<name>` of that token, cleared on restore) and is still within the 30 days; the one `remove` or `restore` audit entry names it as `grant`. Removing an already removed record, or restoring a live one, succeeds and changes nothing (its 30 days don't restart, and nothing is audited).
70
70
  - **Times** are epoch milliseconds everywhere.
71
71
 
72
72
  ## Backup reminder
@@ -222,13 +222,13 @@ bios relays each kernel request unchanged as the `sealed` op, with the kernel's
222
222
 
223
223
  The vault has no network stack, so sign-ins run in seven's page (route (b), §3.11) and the vault keeps only the results.
224
224
  - **A sign-in's tokens:** bios runs the sign-in, does the code exchange, and hands the results to `seal`, keeping no copy:
225
- - the refresh token as `{ kind: 'grant', name, value, source: 'oauth:<provider>', oauth: { tokenUrl, clientId, scope?, resource? } }`. A grant is never injected and never exported; `tokenUrl` must be `https:`, without userinfo (`user:pw@`, `user@`, even an empty `:@`, also when hidden by whitespace or backslashes the URL parser normalises: any `@` in the authority refuses);
225
+ - the refresh token as `{ kind: 'grant', name, value, source: 'oauth:<provider>', oauth: { tokenUrl, clientId, scope?, resource? } }`. A grant is never injected and never exported; `tokenUrl` must be `https:`, without userinfo (`user:pw@`, `user@`, even an empty `:@`, also when hidden by whitespace or backslashes the URL parser normalises: any `@` in the authority refuses), and a `resource` must be an `https:` URL (or `http:` on 127.0.0.1, localhost or [::1]) without userinfo or fragment;
226
226
  - the access token as `{ name, value, source: 'oauth:<provider>', expiresAt, refresh: '<grant name>' }`. A grant name needs an `oauth:` source and `jerk-seal` a `jerk:` one (`EINVAL`), and a refresh uses the grant only when its source is the token's own (else the grant is missing).
227
227
  - With `request` (only for a `secret`; the id of a pending `oauth` request for the same provider as `oauth:<provider>`, or a `jerk-seal` request for the provider of `jerk:<provider>@…`) and `jerk` (`{ exit, user?, scopes?, expires_at? }`), `seal` also answers that request, in the same transaction that stores the record (a request that ended meanwhile refuses with `EINVAL`, and nothing is stored): its `/dev/sealed` `status` becomes `done`, with `jerk`, the record's `placeholder` and `record` state.
228
228
  - **A J1 lease:** `{ name, value, source: 'jerk:<provider>@<device>', expiresAt, refresh: 'jerk-seal' }`.
229
229
  - **When a token is due** (5 minutes before `expiresAt`): the core frame checks once a minute, and right after a page says hello, for every connected namespace and for `secret`, `model` and `mcp` records alike, and `refresh { name }` asks for it at once. Vault frames of the same storage (several tabs) take a Web Lock per refresh source, so one grant is sent to its IdP by one frame at a time; a frame that finds the token changed while it waited (refreshed or sealed again) sends nothing and answers with the stored token's `{ state: 'refreshed', expiresAt? }`.
230
230
  - A `jerk-seal` token is announced once per `expiresAt` (kept in IndexedDB, so across frames), and again every 10 minutes while it stays unsealed, in case no live page heard it, with the `refresh` event, and bios runs J1 `seal` again (`{ state: 'requested' }`).
231
- - An OAuth token is refreshed in the vault: it builds `POST tokenUrl` with `grant_type=refresh_token`, the refresh token, `client_id` and any `scope` and `resource` (form-encoded), and sends it through bios with the `egress` event. bios sends the request through its exits and posts `{ status, headers, body }` (or `{ error }`) on the event's port within 30 s. The event goes to the page port of the namespace that connected last. A port that doesn't answer in time (`ETIMEDOUT`), closed or not, keeps its ops and events but gets no more `egress`, and the next refresh goes to the page port before it. The vault reads `access_token`, `expires_in` and any new `refresh_token`, checks them as values, and rotates the records in one transaction with the `refresh` audit entry (their placeholders stay; if the token or its grant was changed, removed or restored during the round trip, nothing is written and it answers `EAGAIN`). An `expires_in` of 0 makes the token due at once, and one that isn't a non-negative finite number is `EACCES: refresh failed: the IdP returned an invalid expires_in` (likewise a `refresh_token` that is present but not a string, a `token_type` other than `Bearer`, and `access_token`, `refresh_token`, `token_type` or `expires_in` given twice: `EACCES: refresh failed: the IdP returned NAME twice`); a response carrying `error` fails even with a token; a response without `expires_in` leaves the new token without an expiry, so it isn't refreshed again until a new one is sealed. It answers `{ state: 'refreshed', expiresAt? }`, without `expiresAt` when the token has none. A failed refresh is `EACCES: refresh failed: <the IdP's error code>`, scrubbed. Without a connected page it's `ENOTCONN`.
231
+ - An OAuth token is refreshed in the vault with jerk-oauth: `refreshTokenRequestBytes` builds `POST <tokenUrl, serialized>` with `grant_type=refresh_token`, the refresh token, `client_id` and any `scope` and `resource` (form-encoded), from a byte buffer it zeroes, and the vault sends it through bios with the `egress` event. bios sends the request through its exits and posts `{ status, headers, body }` (or `{ error }`) on the event's port within 30 s. The event goes to the page port of the namespace that connected last. A port that doesn't answer in time (`ETIMEDOUT`), closed or not, keeps its ops and events but gets no more `egress`, and the next refresh goes to the page port before it. The vault checks the response with its own rules below and then with jerk-oauth's `parseRefreshResponse` (with the clock as whole seconds; a clock that isn't a safe integer is `EACCES: refresh failed: the vault's clock is invalid`), reads `access_token`, `expires_in` and any new `refresh_token`, checks them as values, and rotates the records in one transaction with the `refresh` audit entry (their placeholders stay; if the token or its grant was changed, removed or restored during the round trip, nothing is written and it answers `EAGAIN`). An `expires_in` of 0 makes the token due at once, and one that isn't a whole number from 0 to ten years is `EACCES: refresh failed: the IdP returned an invalid expires_in` (likewise a `refresh_token` that is present but not a string, a `token_type` that isn't `Bearer` in any case or is missing, and `access_token`, `refresh_token`, `token_type` or `expires_in` given twice: `EACCES: refresh failed: the IdP returned NAME twice`); a response carrying `error` fails even with a token; a response without `expires_in` leaves the new token without an expiry, so it isn't refreshed again until a new one is sealed. It answers `{ state: 'refreshed', expiresAt? }`, without `expiresAt` when the token has none. A failed refresh is `EACCES: refresh failed: <the IdP's error code>`, scrubbed; a code that isn't 1–128 characters of RFC 6749's error charset reads "the IdP sent an unreadable error", and `error_description` is never shown. Any other response jerk-oauth refuses is `EACCES: refresh failed: the IdP response is invalid`. MCP `grant` responses are parsed the same way. Without a connected page it's `ENOTCONN`.
232
232
  - `egress` carries the refresh token to the page, in transit only; the response goes back to the vault, never to the kernel.
233
233
 
234
234
  ## Import and export
@@ -349,7 +349,7 @@ The vault never injects a secret into a model or MCP endpoint, whatever a rule s
349
349
 
350
350
  **MCP OAuth (`grant`).** The worker registers the server with its `tokenUrl`, sends the code exchange through `mcp` (to exactly `tokenUrl`, with no credential), and hands the token endpoint's body to `grant`, holding it only in transit.
351
351
  - `oauth.tokenUrl` must be `https:` and the server's registered `tokenUrl` (`EACCES` otherwise). A server or record name that holds a stored value or one of the returned tokens, in any encoded form, is `EINVAL`. The body is checked as for a refresh (an object with a string `access_token`, no `error`, `token_type` Bearer if present, a valid `expires_in`), and every token must be a valid value (`EINVAL` otherwise), before anything is stored.
352
- - The access token is sealed as an `mcp` record bound to `server`, named `name` or `<SERVER>_ACCESS_TOKEN` (upper-cased, other characters as `_`, `MCP_` in front of a leading digit), with source `oauth:mcp` and `expiresAt` from `expires_in`. A `refresh_token` is sealed as a `grant` named `<name>_GRANT` with `oauth`, and becomes the token's refresh source.
352
+ - The access token is sealed as an `mcp` record bound to `server`, named `name` or `<SERVER>_ACCESS_TOKEN` (upper-cased, other characters as `_`, `MCP_` in front of a leading digit), with source `oauth:mcp` and `expiresAt` from `expires_in`. A `refresh_token` is sealed as a `grant` named `<name>_GRANT` with `oauth` and the same `server`, and becomes the token's refresh source.
353
353
  - Both records are written in one transaction, so `grant` stores all or nothing. The reply's `response` is JSON, a string or bytes like the input, with only `access_token` and `refresh_token` (as their placeholders), any string `token_type`, a numeric `expires_in` that isn't a stored value, and string `scope` scrubbed over every value; every other field, `id_token` included, is dropped. A `grant` whose access-token name is already bound to another server is `EEXIST`, checked in the same transaction.
354
354
 
355
355
  A stream is compiled against the namespace's values when it opens. If a value is added, rotated or removed while it's open, its next `push`, `idle` or `end` answers `EAGAIN` and the stream is gone: open it again and push the output from its start.
@@ -152,6 +152,7 @@ export interface SealEntry extends AuditBase {
152
152
  }
153
153
  export interface RemoveEntry extends AuditBase {
154
154
  type: 'remove' | 'restore';
155
+ grant?: string;
155
156
  }
156
157
  export interface PolicyEntry {
157
158
  type: 'policy';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-ecoverse/slicc-vault",
3
- "version": "1.8.3",
3
+ "version": "1.9.0",
4
4
  "description": "The separate-origin vault at vault.sliccy.ai: sealed secrets, OAuth tokens, and model and MCP credentials for SLICC seven, used only by its injector",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -31,13 +31,13 @@
31
31
  "dist/types"
32
32
  ],
33
33
  "scripts": {
34
- "prepare": "node scripts/endpoints.mjs && { [ ! -e .git ] || lefthook install; }",
35
- "build": "node scripts/endpoints.mjs && node build.mjs && tsc -p tsconfig.build.json",
34
+ "prepare": "node scripts/endpoints.mjs && node scripts/jerk-wasm.mjs && { [ ! -e .git ] || lefthook install; }",
35
+ "build": "node scripts/endpoints.mjs && node scripts/jerk-wasm.mjs && node build.mjs && tsc -p tsconfig.build.json",
36
36
  "prepack": "npm run build",
37
37
  "start": "npm run build && node test/integration/serve.mjs",
38
38
  "pretest": "npm run build && playwright-core install --no-shell chromium",
39
39
  "test": "node --test --test-timeout=300000 --test-global-setup=node_modules/@ai-ecoverse/slicc-shared-web/harness/global.mjs 'test/integration/*.test.mjs'",
40
- "test:unit": "node scripts/endpoints.mjs && NODE_NO_WARNINGS=1 mcr -c node_modules/@ai-ecoverse/slicc-shared-web/mcr.config.mjs node --import fake-indexeddb/auto --test --test-force-exit 'test/unit/**/*.test.mjs'",
40
+ "test:unit": "node scripts/endpoints.mjs && node scripts/jerk-wasm.mjs && NODE_NO_WARNINGS=1 mcr -c node_modules/@ai-ecoverse/slicc-shared-web/mcr.config.mjs node --import fake-indexeddb/auto --test --test-force-exit 'test/unit/**/*.test.mjs'",
41
41
  "typecheck": "tsc",
42
42
  "lint": "slicc-lint",
43
43
  "format": "biome check --write .",
@@ -46,6 +46,7 @@
46
46
  "release": "semantic-release"
47
47
  },
48
48
  "devDependencies": {
49
+ "@ai-ecoverse/jerk-oauth": "0.15.1",
49
50
  "@ai-ecoverse/slicc-scrub": "1.0.0",
50
51
  "@ai-ecoverse/slicc-shared-web": "1.13.1",
51
52
  "@biomejs/biome": "2.5.15",