@immediately-run/preauth-core 0.1.13 → 0.1.17

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
@@ -22,6 +22,20 @@ Spec: `UI_AS_APPS_SPEC.md` §8.9 (target check), §8.15 (M1 pre-authorization),
22
22
  | `bootConsent` | `mintConsentedGrants` — the ONE mint path. Environment-neutral: a caller passes `onError` instead of the core logging with a host-specific prefix. |
23
23
  | `m1PreAuth` | `planPreAuthCapabilities` / `isPreAuthClean` (the pure §8.9 target check) + `applyPreAuth` (validate-then-mint, all-or-nothing). |
24
24
 
25
+ ## Published versions
26
+
27
+ `ci.yml` publishes the **head-of-main** version on each push to `main` — one push, one
28
+ `npm publish` of whatever `package.json` reads at that commit. A branch that bumps the
29
+ version more than once publishes only its **last** bump; the intermediate numbers are
30
+ commits, not releases.
31
+
32
+ **0.1.15 and 0.1.16 were never published.** They are intermediate states of the
33
+ `roadmap-items-r3-407-426` branch — 0.1.15 (`auth:identity` becomes app-scoped) and
34
+ 0.1.16 (`device:geolocation`) — and npm goes straight from 0.1.14 to **0.1.17**, which
35
+ carries all of that work. Cite 0.1.17 as the release for anything on that branch:
36
+ pinning 0.1.15 or 0.1.16 fails `npm ci` with `ETARGET`, and site-main pins this package
37
+ EXACTLY.
38
+
25
39
  ## Consuming it
26
40
 
27
41
  Via the **`file:` sibling pattern** site-main already uses for the sandpack fork:
@@ -1,6 +1,6 @@
1
1
  export type CapabilityKind = 'read' | 'action';
2
2
  export type CapabilityTier = 'baseline' | 'elevated' | 'first-party-only';
3
- export type Capability = 'theme:read' | 'theme:set' | 'auth:status' | 'auth:identity' | 'route:read' | 'formFactor:read' | 'chrome:read' | 'mounts:read' | 'mounts:registry' | 'spaces:app' | 'spaces:user' | 'spaces:admin' | 'settings:app' | 'settings:fork' | 'settings:all' | 'contribute:self' | 'contribute:any' | 'contribute:direct' | 'editor:read' | 'editor:open' | 'editor:write' | 'editor:document' | 'editor:requestEdit' | 'vcs:read' | 'vcs:reset' | 'dnd:source' | 'catalog:read' | 'commands:read' | 'commands:run' | 'ipc' | 'task:invoke' | 'net:fetch' | 'feed:fetch' | 'secrets:add' | 'secrets:list' | 'secrets:revoke' | 'agent:session' | 'diagnostics:read' | 'llm:chat' | 'authoring:run' | 'analytics:emit';
3
+ export type Capability = 'theme:read' | 'theme:set' | 'auth:status' | 'auth:identity' | 'route:read' | 'formFactor:read' | 'chrome:read' | 'mounts:read' | 'mounts:registry' | 'spaces:app' | 'spaces:user' | 'spaces:admin' | 'settings:app' | 'settings:fork' | 'settings:all' | 'contribute:self' | 'contribute:any' | 'contribute:direct' | 'editor:read' | 'editor:open' | 'editor:reveal' | 'editor:write' | 'editor:document' | 'editor:requestEdit' | 'vcs:read' | 'vcs:reset' | 'dnd:source' | 'catalog:read' | 'commands:read' | 'commands:run' | 'ipc' | 'task:invoke' | 'net:fetch' | 'feed:fetch' | 'secrets:add' | 'secrets:list' | 'secrets:revoke' | 'agent:session' | 'diagnostics:read' | 'llm:chat' | 'authoring:run' | 'analytics:emit' | 'device:geolocation' | 'device:camera' | 'device:microphone';
4
4
  export interface CapabilityDef {
5
5
  kind: CapabilityKind;
6
6
  tier: CapabilityTier;
@@ -16,10 +16,26 @@ export interface CapabilityDef {
16
16
  * (region binding only). In core_concepts §5 terms the consent-path is the
17
17
  * "above-the-floor, up-to-the-ceiling → first-use consent" band: an app-scoped
18
18
  * elevated cap sits in that band for the stage principal, a non-app-scoped one
19
- * is above the stage ceiling (granted only by a slot's elevated principal). The app-scoped set is `net:fetch`, `task:invoke`,
20
- * `contribute:self` (decision #1 its baseline→elevated reclassification landed
21
- * in R3-33d), and `diagnostics:read` (R3-74 / P3-72, D4); the durable grant
22
- * participates in the §8.15 90-day expiry like any app-scoped grant. */
19
+ * is above the stage ceiling (granted only by a slot's elevated principal). The
20
+ * durable grant participates in the §8.15 90-day expiry like any app-scoped grant.
21
+ *
22
+ * **The app-scoped set, in full** this is a security boundary, so it is stated
23
+ * completely rather than by example. The authoritative form is DERIVED, never
24
+ * hand-maintained: `APP_SCOPED_CAPABILITIES` below filters this table on the flag,
25
+ * and `isAppScoped` is what every consumer branches on. As of registry 1.12.0 the
26
+ * eleven rows carrying it are:
27
+ *
28
+ * `auth:identity` (R3-407) · `contribute:self` (decision #1 — its
29
+ * baseline→elevated reclassification landed in R3-33d) · `task:invoke` ·
30
+ * `net:fetch` · `feed:fetch` (R3-227) · `diagnostics:read` (R3-74 / P3-72, D4) ·
31
+ * `llm:chat` (D5) · `analytics:emit` (R3-350) · `device:geolocation` (R3-424) ·
32
+ * `device:camera` and `device:microphone` (R3-425).
33
+ *
34
+ * Two of those — `net:fetch` and `feed:fetch` — are additionally HOST-PARAMETERIZED
35
+ * (see `HOST_PARAMETERIZED_CAPABILITIES`), so they are never earned as a bare on/off
36
+ * capability: the durable authority IS their parameter set. The remaining nine are
37
+ * plain on/off grants. That distinction bounds what a grant CONVEYS; it does not
38
+ * narrow who may earn one, which is what `appScoped` decides. */
23
39
  appScoped?: boolean;
24
40
  /** Render this capability's consent line with the platform's **maximally-
25
41
  * explicit** (scariest) styling, never bundled into a combined prompt
@@ -31,7 +47,39 @@ export interface CapabilityDef {
31
47
  maximallyExplicit?: boolean;
32
48
  }
33
49
  export declare const CAPABILITIES: Record<Capability, CapabilityDef>;
34
- /** The current registry/vocabulary version (§5.11). Bumped to 1.8.0 with the elevated,
50
+ /** The current registry/vocabulary version (§5.11). Bumped to 1.12.0 for a change that
51
+ * adds no NAME: `auth:identity`'s reclassification to `appScoped` (R3-407). A version
52
+ * is spent here for the same reason it is spent on a new row — the gate can only
53
+ * compare versions, so a change of MEANING that does not move `since` is a change the
54
+ * gate cannot see, and the resulting mismatch fails silently rather than loudly. The
55
+ * full reasoning is on the `auth:identity` row.
56
+ *
57
+ * Note that R3-407 originally landed the reclassification INSIDE 1.9.0, which was
58
+ * already published (0.1.14) with the pre-reclassification meaning — so 1.9.0 briefly
59
+ * named two different vocabularies, the exact thing the `feed:fetch` and
60
+ * `device:geolocation` notes below each refused to allow. 1.12.0 is that mistake
61
+ * undone, not a second one: 1.10.0 and 1.11.0 keep the vocabularies the docs already
62
+ * record for them.
63
+ *
64
+ * Prior notes — bumped to 1.11.0 with the elevated,
65
+ * app-scoped `device:camera` and `device:microphone` — the two CAPTURE devices
66
+ * (`BROWSER_CAPABILITIES_SPEC` §2/§3, R3-425). They share one version because they
67
+ * ship together and a host either has the capture broker + the host-chrome indicator
68
+ * or it has neither; a host on 1.10.0 refuses a binding that requests either (T26)
69
+ * rather than mounting with a camera that can never open. `device:clipboard` is NOT
70
+ * in this version — see the note above the table.
71
+ *
72
+ * Prior notes — bumped to 1.10.0 with the elevated,
73
+ * app-scoped `device:geolocation` — the first host-brokered `device:*` row
74
+ * (`BROWSER_CAPABILITIES_SPEC` §2–§4, R3-424). It takes its own version for the same
75
+ * reason `feed:fetch` did: 1.9.0 is already published (**0.1.14**, with
76
+ * `editor:reveal` — commit `8f7ac42`, which is the release that bumped the package to
77
+ * 0.1.14; 0.1.15 is this branch's own unpublished commit, NOT a release), and a
78
+ * registry version that does not identify a vocabulary is not much of a version
79
+ * gate. A host older than 1.10.0 refuses a binding that requests `device:geolocation`
80
+ * (T26) rather than mounting with the sensor silently inert.
81
+ *
82
+ * Prior notes — bumped to 1.8.0 with the elevated,
35
83
  * app-scoped, host-parameterized `feed:fetch` (`CONNECTOR_EGRESS_FIXING_SPEC` §2 —
36
84
  * R3-227), mirroring capabilities.json. (1.7.0 added the baseline state read
37
85
  * `chrome:read`; 1.6.0 added `analytics:emit`; 1.5.0 added the first-party-only
@@ -45,7 +93,7 @@ export declare const CAPABILITIES: Record<Capability, CapabilityDef>;
45
93
  * A host older than 1.8.0 therefore refuses a binding that requests `feed:fetch` (T26)
46
94
  * rather than mounting half-working, which is the right outcome: a host that cannot
47
95
  * enforce target-fixing must not run a connector that assumes it. */
48
- export declare const REGISTRY_VERSION = "1.8.0";
96
+ export declare const REGISTRY_VERSION = "1.12.0";
49
97
  /** Is `cap` a known host-core capability? (Closed vocabulary — §5.12.) */
50
98
  export declare function isKnownCapability(cap: string): cap is Capability;
51
99
  export declare function tierOf(cap: Capability): CapabilityTier;
@@ -22,7 +22,50 @@ exports.CAPABILITIES = {
22
22
  'theme:read': { kind: 'read', tier: 'baseline', since: '1.0.0' },
23
23
  'theme:set': { kind: 'action', tier: 'elevated', since: '1.0.0' },
24
24
  'auth:status': { kind: 'read', tier: 'baseline', since: '1.0.0' },
25
- 'auth:identity': { kind: 'read', tier: 'elevated', since: '1.0.0' },
25
+ // R3-407: elevated read, app-scoped a stage app EARNS the user's login/avatar
26
+ // through the ordinary declared-capability consent path ('See your account'),
27
+ // recorded per (app, principal) like every durable grant. Never baseline:
28
+ // identity is asked for, not taken (an identity-by-default stage would be a
29
+ // tracking/attribution leak to arbitrary third-party code).
30
+ //
31
+ // **`since` is 1.12.0, not 1.0.0 — a RECLASSIFICATION takes a registry version,
32
+ // exactly as a new name does.** The row is old: an elevated, region-binding-only
33
+ // identity read has existed since 1.0.0. What R3-407 changed is `appScoped`, and
34
+ // that flag is not decoration — it IS the admission rule. `planPreAuthCapabilities`
35
+ // below and site-main's `resolveGrantedFrameCaps` both branch on `isAppScoped`, so
36
+ // one capability NAME means "mintable for a URL-loaded appKey" on one side of this
37
+ // change and "region-binding-only, drop it" on the other.
38
+ //
39
+ // Left at 1.0.0 the change was invisible to the §5.11/T26 version gate, and what
40
+ // that produced was a SILENT failure — the R3-233 validate-then-drop class, across
41
+ // a version boundary instead of a code boundary. A minter holding the new registry
42
+ // classifies `auth:identity` as app-scoped, mints a durable grant and answers
43
+ // `ok: true`; a consumer holding the old registry reads that grant back, finds
44
+ // `appScoped` falsy in ITS copy of this table, and drops the capability from the
45
+ // frame — while `planMissingPlainCaps` skips it on the same predicate, so it is not
46
+ // offered on the consent screen either. The user cannot grant it and nothing
47
+ // anywhere reports a problem.
48
+ //
49
+ // The `device:*` rows do NOT have this problem, and the difference is the whole
50
+ // point: they are new NAMES. An old host does not know the name, `isKnownCapability`
51
+ // is false, and every path refuses by name — loudly (T26, "update immediately.run").
52
+ // A known name whose FLAGS moved is refused nowhere, because no path compares flags
53
+ // across versions; only `since` is comparable across versions at all. Moving it is
54
+ // what puts the change back where the gate can see it.
55
+ //
56
+ // This takes nothing away from an older host: `since` is read out of the reader's
57
+ // OWN table, so a consumer pinned below 1.12.0 keeps exactly the row it always had,
58
+ // and no binding it can serve today starts being refused. What changes is that from
59
+ // 1.12.0 on the version NUMBER identifies which of the two meanings is in play —
60
+ // the same rule `feed:fetch` and `device:geolocation` each wrote down for a new
61
+ // name, applied to a change of meaning.
62
+ //
63
+ // `since` alone is necessary but not sufficient, because two of the three consumers
64
+ // of this flag never read `since`: `resolveGrantedFrameCaps` (site-main) and, until
65
+ // R3-407's follow-up, `planPreAuthCapabilities`. The M1 gate is now version-aware
66
+ // (see `m1PreAuth.ts`) so the mint path — the one that produces the orphan grant —
67
+ // refuses rather than mints. The frame read-back stays a defence-in-depth filter.
68
+ 'auth:identity': { kind: 'read', tier: 'elevated', since: '1.12.0', appScoped: true },
26
69
  'route:read': { kind: 'read', tier: 'baseline', since: '1.0.0' },
27
70
  'formFactor:read': { kind: 'read', tier: 'baseline', since: '1.0.0' },
28
71
  'mounts:read': { kind: 'read', tier: 'baseline', since: '1.0.0' },
@@ -74,6 +117,23 @@ exports.CAPABILITIES = {
74
117
  // validates the path and drives Sandpack. Elevated — it moves the host's focus,
75
118
  // so a previewed app must not hold it; only a consented/build-default binding.
76
119
  'editor:open': { kind: 'action', tier: 'elevated', since: '1.0.0' },
120
+ // Bring the user TO the editor across an activity boundary (R3-389,
121
+ // TOOLS_ACTIVITY_SPEC §5.2). Strictly more than `editor:open`, which opens a file
122
+ // in whatever column the editor already occupies: an activity that owns the main
123
+ // pane UNMOUNTS the editor, so a surface there must be able to switch the active
124
+ // activity for its click to be visible at all.
125
+ //
126
+ // That is an attention move, so it is the parameterized ESCALATION of `open`
127
+ // (§8.4): `open({ reveal: true })` requires THIS instead of `editor:open`, and a
128
+ // frame holding only `editor:open` is refused rather than silently downgraded to a
129
+ // no-op reveal (T11).
130
+ //
131
+ // Elevated, and it buys no new REACH — the caller names a path, never an activity;
132
+ // the host resolves which activity owns the editor from its own state, reads its
133
+ // OWN `navigator.userActivation` (an app cannot forge it, and the gate fails closed
134
+ // without it), and rate-limits. The authority is "may ask", not "may decide where
135
+ // the user goes".
136
+ 'editor:reveal': { kind: 'action', tier: 'elevated', since: '1.9.0' },
77
137
  // Mutate the editor session's working tree — create/delete/rename/upload a file
78
138
  // (migrate-sidebars Phase 04, EDITOR_AS_APP_SPEC §5.2). A NARROW, per-path gated
79
139
  // action: the file explorer NAMES a path and the HOST performs the COW write
@@ -260,8 +320,142 @@ exports.CAPABILITIES = {
260
320
  // platform behavior may depend on an app consuming this channel, so an app that
261
321
  // never reads it is indistinguishable from one that does.
262
322
  'chrome:read': { kind: 'read', tier: 'baseline', since: '1.7.0' },
323
+ // BROWSER_CAPABILITIES_SPEC §2–§4 (R3-424) — the first `device:*` row: the HOST
324
+ // calls `navigator.geolocation` at ITS OWN origin and hands the app coordinates.
325
+ // It exists because the blocker is the ORIGIN, not policy: an app frame is
326
+ // opaque-origin, browsers key permission grants on an origin, and
327
+ // `getCurrentPosition` inside the frame never prompts — it just times out
328
+ // (`code=3`). Widening the sandbox with `allow-same-origin` is off the table
329
+ // (UI_AS_APPS G1/T1), so this follows the `net:fetch` shape: the app never gets
330
+ // the browser handle, the host performs the privileged call and returns a
331
+ // serialized result.
332
+ //
333
+ // **kind: 'action', not 'read'** — deliberately, and the same call the
334
+ // `diagnostics:read` row made. `kind` names the ENFORCEMENT POINT, not the
335
+ // English verb (see this file's header): a `read` is gated by a `view()`
336
+ // projection on a channel the host is already maintaining (§8.3), an `action` is
337
+ // gated before a handler runs (§8.4). There is no standing "position" state to
338
+ // project — the value does not exist until the app asks the host to acquire it,
339
+ // which turns on a sensor and (on first use per device) raises the browser's own
340
+ // prompt. That is a host operation invoked on request, so §8.4 is the chokepoint
341
+ // and `action` is what routes it there.
342
+ //
343
+ // **Elevated + appScoped**: above the stage floor and within the stage ceiling —
344
+ // first use shows the powerbox consent naming the app and the device, the grant
345
+ // persists on `(app, principal)` and is revocable from the same surfaces as a
346
+ // space grant. Above the M3 ceiling: the M3 stance delegates nothing, so a
347
+ // stranger's app is refused with no prompt at all (G-DEV-2). NOT parameterized:
348
+ // the grant is a plain on/off, so it mints through the plain-cap path. A
349
+ // coarse/precise split would make it parameterized; that stays an open question
350
+ // in the spec rather than a shape guessed at here.
351
+ 'device:geolocation': { kind: 'action', tier: 'elevated', since: '1.10.0', appScoped: true },
352
+ // BROWSER_CAPABILITIES_SPEC §2/§3 (R3-425) — the two CAPTURE devices. They take
353
+ // `device:geolocation`'s shape exactly (`action` / `elevated` / `appScoped`, plain
354
+ // on/off), for the same reasons written out on that row, so only what is DIFFERENT
355
+ // about them is recorded here.
356
+ //
357
+ // The origin blocker is harsher than geolocation's, not softer: inside the app
358
+ // frame `getUserMedia({video})`/`({audio})` does not merely fail to prompt, it
359
+ // throws `SecurityError: Invalid security origin` outright (§1, measured). And the
360
+ // richer result cannot be handed over either — a `MediaStreamTrack` is not
361
+ // transferable between windows (`DataCloneError`), while `ImageBitmap`,
362
+ // `ArrayBuffer`, `MessagePort` and `ReadableStream` all are. So the host opens the
363
+ // device at its own origin and hands the app BYTES or FRAMES, never a handle.
364
+ //
365
+ // TWO THINGS FOLLOW FROM "capture", and they are the whole reason these are not
366
+ // just more geolocation rows:
367
+ //
368
+ // 1. A capture has a LIVE SESSION with a duration a bystander can be caught in.
369
+ // While one is open the host shows a persistent indicator in its own chrome
370
+ // that the stage app cannot cover or remove (G-DEV-5) — the same rule that
371
+ // keeps sign-in in host chrome. A position read has no such session and needs
372
+ // no such indicator.
373
+ // 2. The DEFAULT delivery is a one-shot capture task drawn by the HOST
374
+ // (`capture-photo@1` / `capture-audio@1`): the user frames the shot, taps Done,
375
+ // and the app receives bytes. The app is never in the loop while the device is
376
+ // live, so "nothing is recorded when the user cancels" is a property of the
377
+ // mechanism rather than a promise the app keeps.
378
+ //
379
+ // ONE capability per device, NOT one per delivery grade. The grade (one-shot bytes
380
+ // vs. a live frame stream) is a property of the durable grant and the consent line
381
+ // the user reads — never a per-call knob the app picks — which is the same rule
382
+ // R3-424 wrote down for accuracy. Splitting the vocabulary by grade would mint a
383
+ // name per grade and force a re-consent to add one; keeping the grade off the row
384
+ // leaves the frame-stream grade additive.
385
+ //
386
+ // `device:clipboard` is deliberately NOT here — see the note under the table.
387
+ 'device:camera': { kind: 'action', tier: 'elevated', since: '1.11.0', appScoped: true },
388
+ 'device:microphone': { kind: 'action', tier: 'elevated', since: '1.11.0', appScoped: true },
263
389
  };
264
- /** The current registry/vocabulary version (§5.11). Bumped to 1.8.0 with the elevated,
390
+ // `device:clipboard` proposed in BROWSER_CAPABILITIES_SPEC §2, DELIBERATELY LEFT
391
+ // OUT of the vocabulary by R3-425.
392
+ //
393
+ // Not an oversight and not "no time": the spec's own open question is *"writes may be
394
+ // safe at the stage floor (the sandbox already allows copy via `execCommand`); reads
395
+ // should stay consented."* That question is about the row's TIER, and a single
396
+ // `device:clipboard` row cannot hold two tiers. Shipping one now would answer the
397
+ // question by accident — the exact failure R3-424 avoided by keeping the
398
+ // coarse/precise location split out of the call params.
399
+ //
400
+ // The vocabulary is CLOSED and VERSIONED, which makes a name expensive: if the
401
+ // answer turns out to be "write is baseline, read is elevated", the shape is two
402
+ // rows (`device:clipboard-read` / `device:clipboard-write`) and the single name
403
+ // shipped today would have to be deprecated — a published name that no longer means
404
+ // anything, in a registry whose whole value is that a version identifies a
405
+ // vocabulary. Nothing is lost by waiting: no capability is required to decide, and
406
+ // adding a row is additive.
407
+ //
408
+ // It is also outside this item. R3-425 is camera and microphone; clipboard has no
409
+ // capture session, no host-drawn capture surface and nothing for the G-DEV-5
410
+ // indicator to indicate, so it would arrive with none of the machinery that makes
411
+ // the two rows above enforceable.
412
+ // WHICH PACKAGE VERSIONS ACTUALLY EXIST. `ci.yml` publishes the HEAD-OF-MAIN version
413
+ // only — one push to main, one `npm publish` of whatever `package.json` says at that
414
+ // commit. A branch that bumps the version several times therefore publishes exactly its
415
+ // LAST bump, and the intermediate numbers never reach npm.
416
+ //
417
+ // On this branch that makes **0.1.15 and 0.1.16 intermediate branch states that are
418
+ // never published**: 0.1.15 (`72c4217`, `auth:identity` app-scoped) and 0.1.16
419
+ // (`edd6c16`, `device:geolocation`) exist only as commits. npm goes 0.1.14 -> 0.1.17,
420
+ // and **0.1.17 is the release that carries all three changes**. Cite 0.1.17 as the
421
+ // version anything on this branch ships in — a consumer that pins 0.1.15 or 0.1.16
422
+ // fails `npm ci` with `ETARGET`, and this repo's own consumer (site-main) pins
423
+ // EXACTLY, so it would be the one to hit it. The bumps are left in history rather than
424
+ // collapsed; this note is what stops the next reader hunting for a release that is not
425
+ // there.
426
+ /** The current registry/vocabulary version (§5.11). Bumped to 1.12.0 for a change that
427
+ * adds no NAME: `auth:identity`'s reclassification to `appScoped` (R3-407). A version
428
+ * is spent here for the same reason it is spent on a new row — the gate can only
429
+ * compare versions, so a change of MEANING that does not move `since` is a change the
430
+ * gate cannot see, and the resulting mismatch fails silently rather than loudly. The
431
+ * full reasoning is on the `auth:identity` row.
432
+ *
433
+ * Note that R3-407 originally landed the reclassification INSIDE 1.9.0, which was
434
+ * already published (0.1.14) with the pre-reclassification meaning — so 1.9.0 briefly
435
+ * named two different vocabularies, the exact thing the `feed:fetch` and
436
+ * `device:geolocation` notes below each refused to allow. 1.12.0 is that mistake
437
+ * undone, not a second one: 1.10.0 and 1.11.0 keep the vocabularies the docs already
438
+ * record for them.
439
+ *
440
+ * Prior notes — bumped to 1.11.0 with the elevated,
441
+ * app-scoped `device:camera` and `device:microphone` — the two CAPTURE devices
442
+ * (`BROWSER_CAPABILITIES_SPEC` §2/§3, R3-425). They share one version because they
443
+ * ship together and a host either has the capture broker + the host-chrome indicator
444
+ * or it has neither; a host on 1.10.0 refuses a binding that requests either (T26)
445
+ * rather than mounting with a camera that can never open. `device:clipboard` is NOT
446
+ * in this version — see the note above the table.
447
+ *
448
+ * Prior notes — bumped to 1.10.0 with the elevated,
449
+ * app-scoped `device:geolocation` — the first host-brokered `device:*` row
450
+ * (`BROWSER_CAPABILITIES_SPEC` §2–§4, R3-424). It takes its own version for the same
451
+ * reason `feed:fetch` did: 1.9.0 is already published (**0.1.14**, with
452
+ * `editor:reveal` — commit `8f7ac42`, which is the release that bumped the package to
453
+ * 0.1.14; 0.1.15 is this branch's own unpublished commit, NOT a release), and a
454
+ * registry version that does not identify a vocabulary is not much of a version
455
+ * gate. A host older than 1.10.0 refuses a binding that requests `device:geolocation`
456
+ * (T26) rather than mounting with the sensor silently inert.
457
+ *
458
+ * Prior notes — bumped to 1.8.0 with the elevated,
265
459
  * app-scoped, host-parameterized `feed:fetch` (`CONNECTOR_EGRESS_FIXING_SPEC` §2 —
266
460
  * R3-227), mirroring capabilities.json. (1.7.0 added the baseline state read
267
461
  * `chrome:read`; 1.6.0 added `analytics:emit`; 1.5.0 added the first-party-only
@@ -275,7 +469,7 @@ exports.CAPABILITIES = {
275
469
  * A host older than 1.8.0 therefore refuses a binding that requests `feed:fetch` (T26)
276
470
  * rather than mounting half-working, which is the right outcome: a host that cannot
277
471
  * enforce target-fixing must not run a connector that assumes it. */
278
- exports.REGISTRY_VERSION = '1.8.0';
472
+ exports.REGISTRY_VERSION = '1.12.0';
279
473
  /** Is `cap` a known host-core capability? (Closed vocabulary — §5.12.) */
280
474
  function isKnownCapability(cap) {
281
475
  return Object.prototype.hasOwnProperty.call(exports.CAPABILITIES, cap);
@@ -5,7 +5,12 @@ export type PreAuthRefusalReason =
5
5
  /** A non-app-scoped elevated cap — region-binding-only authority (§8.9). */
6
6
  'broad-elevated'
7
7
  /** Not in the closed capability vocabulary (§5.12) — fail-closed. */
8
- | 'unknown';
8
+ | 'unknown'
9
+ /** Known here, but declared at a `since` NEWER than the host that will consume the
10
+ * grant, so that host cannot enforce it (§5.11 / T26). Minting it anyway is a
11
+ * validate-then-drop: the mint answers `ok` and the consumer silently discards the
12
+ * capability. Refusing is the loud alternative. */
13
+ | 'unsupported';
9
14
  export interface PreAuthRefusal {
10
15
  capability: string;
11
16
  reason: PreAuthRefusalReason;
@@ -21,8 +26,27 @@ export interface PreAuthPlan {
21
26
  /**
22
27
  * The pure §8.9 target check: partition requested capability names into
23
28
  * {grantable app-scoped, baseline no-op, refused}. Order-independent; total.
29
+ *
30
+ * `hostVersion` is the registry version of the host that will CONSUME the grant, and
31
+ * it defaults to this build's own `REGISTRY_VERSION` — so every existing caller keeps
32
+ * its exact behaviour (at that default, every known capability is supported). Pass it
33
+ * when the minter and the consumer can be on different vocabularies, which is the
34
+ * normal case: the backend floats its `preauth-core` range while site-main PINS one,
35
+ * so the minter routinely holds a newer table than the host that reads the grant back.
36
+ *
37
+ * Why the check belongs here and not only on `since`: a capability's `since` is read
38
+ * out of the reader's OWN table, so advancing it makes a reclassification VISIBLE to
39
+ * the gate but does not make the gate CONSULTED on this path — this function decided
40
+ * grantability from `isAppScoped` alone, with no version anywhere in it. That is how a
41
+ * reclassified capability (`auth:identity`, R3-407) could be minted for a host whose
42
+ * vocabulary still calls it region-binding-only, which then drops it from the frame and
43
+ * omits it from the consent screen with nothing reported: the R3-233 validate-then-drop
44
+ * failure, reached across a version boundary. The version check is placed before the
45
+ * tier branches, and so applies to baseline caps too, matching the registry merge —
46
+ * which likewise runs `unsupportedCapabilities` over a region's whole effective set
47
+ * regardless of tier, because "this host cannot enforce it" is prior to what tier it is.
24
48
  */
25
- export declare function planPreAuthCapabilities(requested: readonly string[]): PreAuthPlan;
49
+ export declare function planPreAuthCapabilities(requested: readonly string[], hostVersion?: string): PreAuthPlan;
26
50
  /** Safe to apply iff the §8.9 check refused nothing (fail-closed, all-or-nothing). */
27
51
  export declare const isPreAuthClean: (plan: PreAuthPlan) => boolean;
28
52
  /** What a policy/settings surface asks M1 to pre-authorize for `(uid, appKey)` —
@@ -59,7 +83,12 @@ export interface PreAuthResult {
59
83
  * unbounded).
60
84
  *
61
85
  * Refusal is terminal and silent of side effects: when any requested capability
62
- * is broad-elevated or unknown, the function mints NOTHING and returns the
63
- * refusals — the caller surfaces them (the policy is malformed/over-broad).
86
+ * is broad-elevated, unknown, or unsupported by the consuming host, the function
87
+ * mints NOTHING and returns the refusals — the caller surfaces them (the policy is
88
+ * malformed, over-broad, or aimed at a host too old to enforce what it asks for).
89
+ *
90
+ * `hostVersion` is forwarded to the target check: pass the registry version of the
91
+ * host that will consume these grants when it may differ from this build's. It
92
+ * defaults to this build's own, which preserves every existing caller's behaviour.
64
93
  */
65
- export declare function applyPreAuth(store: MintStore, uid: string, appKey: string, request: PreAuthRequest, onError?: MintErrorSink): Promise<PreAuthResult>;
94
+ export declare function applyPreAuth(store: MintStore, uid: string, appKey: string, request: PreAuthRequest, onError?: MintErrorSink, hostVersion?: string): Promise<PreAuthResult>;
package/dist/m1PreAuth.js CHANGED
@@ -17,13 +17,44 @@
17
17
  // `mintPath:'policy'`) and cannot drift from M3.
18
18
  //
19
19
  // THE SECURITY INVARIANT — the §8.9 target check. A pre-auth for a URL-loaded
20
- // `appKey` may only cover **app-scoped** elevated capabilities (`net:fetch`,
21
- // `task:invoke`, `contribute:self` the set an ordinary previewed/forked app can
22
- // EARN per §8.9/§8.15) plus mounts (app-scoped by construction). A **broad-elevated**
23
- // capability — any non-app-scoped elevated cap (`spaces:user`/`spaces:admin`,
24
- // `editor:write`, `contribute:direct`/`contribute:any`, `editor:open`, …) — is
25
- // REFUSED: M1 cannot mint it for a URL-loaded appKey. Unknown capabilities are
26
- // refused (fail-closed). Baseline capabilities need no grant and are dropped.
20
+ // `appKey` may only cover **app-scoped** elevated capabilities — the set an ordinary
21
+ // previewed/forked app can EARN per §8.9/§8.15 plus mounts (app-scoped by
22
+ // construction). A **broad-elevated** capability — any non-app-scoped elevated cap
23
+ // (`spaces:user`/`spaces:admin`, `editor:write`, `contribute:direct`/`contribute:any`,
24
+ // `editor:open`, …) — is REFUSED: M1 cannot mint it for a URL-loaded appKey. Unknown
25
+ // capabilities are refused (fail-closed), as are capabilities the CONSUMING host's
26
+ // registry version is too old to enforce. Baseline capabilities need no grant and are
27
+ // dropped.
28
+ //
29
+ // WHAT THAT SET ACTUALLY CONTAINS, stated in full because this is the boundary M1
30
+ // pre-grants across WITHOUT A PROMPT. The check reads `isAppScoped`, so the list is
31
+ // derived from the capability table and cannot drift from it; as of registry 1.12.0
32
+ // the eleven app-scoped rows are:
33
+ //
34
+ // `auth:identity` (R3-407) · `contribute:self` · `task:invoke` · `net:fetch` ·
35
+ // `feed:fetch` (R3-227) · `diagnostics:read` · `llm:chat` · `analytics:emit`
36
+ // (R3-350) · `device:geolocation` (R3-424) · `device:camera` and
37
+ // `device:microphone` (R3-425).
38
+ //
39
+ // So M1 can pre-grant, with no consent modal at boot, not only the original egress and
40
+ // delegation caps but the user's IDENTITY, their LOCATION, and their CAMERA and
41
+ // MICROPHONE. That is the shipped invariant and it is deliberate — a policy/settings
42
+ // write path is an operator- or user-tier decision recorded ahead of time, and the
43
+ // same §8.15 grant, expiry and revocation apply as if the modal had drawn it. It is
44
+ // stated here so the boundary is read rather than inferred from a stale example list.
45
+ //
46
+ // Not overstating it, in three directions:
47
+ // - `net:fetch` and `feed:fetch` are HOST-PARAMETERIZED, so a pre-auth conveys their
48
+ // parameter set (hosts / compiled templates), never a bare "may fetch". The other
49
+ // nine are plain on/off grants. `applyPreAuth` filters the parameterized ones out
50
+ // of the plain-cap mint below.
51
+ // - M1 clamps WHO may hold a capability, not what the capability then does. A
52
+ // pre-granted `device:camera` still opens the device through the host's own capture
53
+ // surface, under the host-chrome indicator the app cannot cover (G-DEV-5); it does
54
+ // not hand the app a device handle.
55
+ // - Pre-auth is not the M3 stance. A stranger's app under M3 is refused these with no
56
+ // prompt at all (G-DEV-2); M1 is the path where an operator or the user has already
57
+ // decided, not a way around that refusal.
27
58
  //
28
59
  // The check is **all-or-nothing**: if a policy names ANY refused capability the
29
60
  // whole pre-auth is rejected and NOTHING is minted — a partial apply would
@@ -40,8 +71,27 @@ const bootConsent_1 = require("./bootConsent");
40
71
  /**
41
72
  * The pure §8.9 target check: partition requested capability names into
42
73
  * {grantable app-scoped, baseline no-op, refused}. Order-independent; total.
74
+ *
75
+ * `hostVersion` is the registry version of the host that will CONSUME the grant, and
76
+ * it defaults to this build's own `REGISTRY_VERSION` — so every existing caller keeps
77
+ * its exact behaviour (at that default, every known capability is supported). Pass it
78
+ * when the minter and the consumer can be on different vocabularies, which is the
79
+ * normal case: the backend floats its `preauth-core` range while site-main PINS one,
80
+ * so the minter routinely holds a newer table than the host that reads the grant back.
81
+ *
82
+ * Why the check belongs here and not only on `since`: a capability's `since` is read
83
+ * out of the reader's OWN table, so advancing it makes a reclassification VISIBLE to
84
+ * the gate but does not make the gate CONSULTED on this path — this function decided
85
+ * grantability from `isAppScoped` alone, with no version anywhere in it. That is how a
86
+ * reclassified capability (`auth:identity`, R3-407) could be minted for a host whose
87
+ * vocabulary still calls it region-binding-only, which then drops it from the frame and
88
+ * omits it from the consent screen with nothing reported: the R3-233 validate-then-drop
89
+ * failure, reached across a version boundary. The version check is placed before the
90
+ * tier branches, and so applies to baseline caps too, matching the registry merge —
91
+ * which likewise runs `unsupportedCapabilities` over a region's whole effective set
92
+ * regardless of tier, because "this host cannot enforce it" is prior to what tier it is.
43
93
  */
44
- function planPreAuthCapabilities(requested) {
94
+ function planPreAuthCapabilities(requested, hostVersion = capabilities_1.REGISTRY_VERSION) {
45
95
  const grantable = [];
46
96
  const baseline = [];
47
97
  const refused = [];
@@ -50,6 +100,12 @@ function planPreAuthCapabilities(requested) {
50
100
  refused.push({ capability: cap, reason: 'unknown' });
51
101
  continue;
52
102
  }
103
+ // Known to THIS registry but not to the consuming host's (§5.11/T26). Refuse
104
+ // rather than mint a grant that host will silently discard.
105
+ if (!(0, capabilities_1.isSupportedCapability)(cap, hostVersion)) {
106
+ refused.push({ capability: cap, reason: 'unsupported' });
107
+ continue;
108
+ }
53
109
  if ((0, capabilities_1.isBaseline)(cap)) {
54
110
  baseline.push(cap);
55
111
  continue;
@@ -82,11 +138,16 @@ exports.isPreAuthClean = isPreAuthClean;
82
138
  * unbounded).
83
139
  *
84
140
  * Refusal is terminal and silent of side effects: when any requested capability
85
- * is broad-elevated or unknown, the function mints NOTHING and returns the
86
- * refusals — the caller surfaces them (the policy is malformed/over-broad).
141
+ * is broad-elevated, unknown, or unsupported by the consuming host, the function
142
+ * mints NOTHING and returns the refusals — the caller surfaces them (the policy is
143
+ * malformed, over-broad, or aimed at a host too old to enforce what it asks for).
144
+ *
145
+ * `hostVersion` is forwarded to the target check: pass the registry version of the
146
+ * host that will consume these grants when it may differ from this build's. It
147
+ * defaults to this build's own, which preserves every existing caller's behaviour.
87
148
  */
88
- async function applyPreAuth(store, uid, appKey, request, onError) {
89
- const plan = planPreAuthCapabilities(request.capabilities);
149
+ async function applyPreAuth(store, uid, appKey, request, onError, hostVersion) {
150
+ const plan = planPreAuthCapabilities(request.capabilities, hostVersion);
90
151
  if (!(0, exports.isPreAuthClean)(plan)) {
91
152
  return { ok: false, refused: plan.refused };
92
153
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@immediately-run/preauth-core",
3
- "version": "0.1.13",
3
+ "version": "0.1.17",
4
4
  "description": "The shared §8.9 pre-auth target check + the single grant-mint path (mintConsentedGrants) + the capability vocabulary + the byte-faithful grant/space/net-fetch document layout. Consumed by site-main (browser Firestore) and the backend (admin Firestore) so there is ONE gate, ONE mint path, ONE wire layout.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {