@immediately-run/preauth-core 0.1.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 ADDED
@@ -0,0 +1,41 @@
1
+ # @immediately-run/preauth-core
2
+
3
+ The shared **pre-authorization core** for immediately.run. It exists to make one
4
+ sentence a structural fact:
5
+
6
+ > Every surface that pre-authorizes or mints a durable capability grant —
7
+ > site-main's M3 consent screen, its M1 settings pre-auth, the backend
8
+ > `POST /preauth` executor, and the `immediately-run preauth` CLI — drives the
9
+ > **same** §8.9 target check and the **same** mint path, writing **byte-identical**
10
+ > Firestore documents. Not a second copy of any of them.
11
+
12
+ Spec: `UI_AS_APPS_SPEC.md` §8.9 (target check), §8.15 (M1 pre-authorization),
13
+ §8.6/§8.7 (the durable grant set). Plan: `docs/plans/cli-preauth-shared-core.md`.
14
+
15
+ ## What's in here (pure TS, zero runtime deps, no React, no Firebase)
16
+
17
+ | Module | Surface |
18
+ |---|---|
19
+ | `capabilities` | The capability vocabulary — the **single source of truth** (`CAPABILITIES`, `isAppScoped` / `isBaseline` / `isKnownCapability`, the version gate). site-main re-exports it; the backend imports the same predicates. The §8.9 gate's correctness IS this classification, so there is exactly one. |
20
+ | `port` | `MintStore` — the 3-method persistence port `mintConsentedGrants` calls (`createSpace`, `grantSpaceToApp`, `grantNetFetchHosts`) + its param/domain types (`GrantMode`, `MintPath`, `NetFetchHost`, …). |
21
+ | `docLayout` | The byte-faithful Firestore **paths + field builders** (`grantKey`, `GRANT_EXPIRY_MS`, the grant/space/net-fetch document builders). Each store adapter injects only its SDK's timestamp/increment sentinels and does the raw `.set()`; drift is impossible without editing a helper both consume. |
22
+ | `bootConsent` | `mintConsentedGrants` — the ONE mint path. Environment-neutral: a caller passes `onError` instead of the core logging with a host-specific prefix. |
23
+ | `m1PreAuth` | `planPreAuthCapabilities` / `isPreAuthClean` (the pure §8.9 target check) + `applyPreAuth` (validate-then-mint, all-or-nothing). |
24
+
25
+ ## Consuming it
26
+
27
+ Via the **`file:` sibling pattern** site-main already uses for the sandpack fork:
28
+
29
+ ```jsonc
30
+ // consumer package.json
31
+ "@immediately-run/preauth-core": "file:../immediately-run-preauth-core"
32
+ ```
33
+
34
+ The built `dist/` is committed so consumers resolve the package without a separate
35
+ build step. After editing `src/`, rebuild:
36
+
37
+ ```bash
38
+ npm run build # tsc -> dist/ (JS + .d.ts)
39
+ npm test # jest — the §8.9 gate, the hostile-policy property, the wire layout
40
+ npm run lint
41
+ ```
@@ -0,0 +1,42 @@
1
+ import type { GrantMode, MintPath, MintStore, NetFetchHost } from './port';
2
+ /** A consent-screen selection (structurally `MountConsentSelection` from
3
+ * site-main's editor layer; redefined here so this layer stays UI-free). */
4
+ export type ConsentSelection = {
5
+ uri: string;
6
+ mode: GrantMode;
7
+ kind: 'pick';
8
+ spaceId: string;
9
+ name?: string;
10
+ } | {
11
+ uri: string;
12
+ mode: GrantMode;
13
+ kind: 'create';
14
+ name?: string;
15
+ };
16
+ export interface MintResult {
17
+ /** False if ANY grant failed to mint — the caller treats the start as failed. */
18
+ ok: boolean;
19
+ /** Whether the net:fetch host grant succeeded (vacuously true when none was
20
+ * requested) — the post-boot caller lifts the frame cap on this alone, even
21
+ * if a mount selection failed (matching its historical behavior). */
22
+ netFetchOk: boolean;
23
+ /** Successfully minted per-selection space ids (for post-boot provisioning). */
24
+ minted: {
25
+ selection: ConsentSelection;
26
+ spaceId: string;
27
+ }[];
28
+ }
29
+ /** Observe a per-item mint failure (logging only — authority is unaffected). The
30
+ * `ctx` is a stable English phrase; the host decides how/whether to log it. */
31
+ export type MintErrorSink = (ctx: string, err: unknown) => void;
32
+ /**
33
+ * Turn an Allow choice into durable grants: net:fetch hosts (all-or-nothing —
34
+ * the screen showed exactly these), then per mount selection create-or-bind the
35
+ * space to its slot and record the §8.7 grant. Never throws; per-item failures
36
+ * are surfaced through `ok`/`netFetchOk` (and `onError`, if provided).
37
+ *
38
+ * The durable §8.7 grant IS the binding now (no separate slot): `declaredUri`
39
+ * records which §11.4 declared mount it satisfies, so a later boot re-provisions
40
+ * it without re-consent.
41
+ */
42
+ export declare function mintConsentedGrants(store: MintStore, uid: string, appKey: string, selections: readonly ConsentSelection[], netFetchHosts: readonly NetFetchHost[], mintPath?: MintPath, onError?: MintErrorSink): Promise<MintResult>;
@@ -0,0 +1,67 @@
1
+ "use strict";
2
+ // The ONE grant-mint path (UI_AS_APPS_SPEC §8.15). `mintConsentedGrants` turns a
3
+ // consent choice into durable §8.7/§8.15 grants. It is shared by EVERY surface
4
+ // that records a grant so they cannot drift:
5
+ // - site-main's before-boot gate + the post-boot fallback (SandboxListener) at
6
+ // `interactive` (M3) provenance,
7
+ // - the M1 pre-auth write path (`applyPreAuth`) at `policy` provenance, in both
8
+ // the browser (settings UI) and the backend `POST /preauth` executor.
9
+ //
10
+ // `mintPath` only stamps the §8.7 grant's PROVENANCE (the §8.11 audit label +
11
+ // revoke-cascade key); it does not change WHAT is minted — the §8.9 target check
12
+ // that bounds M1 lives in its caller (`m1PreAuth.ts`).
13
+ //
14
+ // Pure orchestration over the narrow `MintStore` port (3 methods). No React, no
15
+ // Firebase, no environment-specific logging: a caller passes `onError` to observe
16
+ // per-item failures (site-main wraps it with its `[Main-iframe]` logger; the
17
+ // backend logs with its own prefix), so this module never masquerades as a
18
+ // particular runtime.
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.mintConsentedGrants = mintConsentedGrants;
21
+ /**
22
+ * Turn an Allow choice into durable grants: net:fetch hosts (all-or-nothing —
23
+ * the screen showed exactly these), then per mount selection create-or-bind the
24
+ * space to its slot and record the §8.7 grant. Never throws; per-item failures
25
+ * are surfaced through `ok`/`netFetchOk` (and `onError`, if provided).
26
+ *
27
+ * The durable §8.7 grant IS the binding now (no separate slot): `declaredUri`
28
+ * records which §11.4 declared mount it satisfies, so a later boot re-provisions
29
+ * it without re-consent.
30
+ */
31
+ async function mintConsentedGrants(store, uid, appKey, selections, netFetchHosts, mintPath = 'interactive', onError) {
32
+ let ok = true;
33
+ let netFetchOk = true;
34
+ if (netFetchHosts.length > 0) {
35
+ try {
36
+ await store.grantNetFetchHosts({ uid, appKey, hosts: netFetchHosts });
37
+ }
38
+ catch (err) {
39
+ onError?.('net:fetch grant failed', err);
40
+ ok = false;
41
+ netFetchOk = false;
42
+ }
43
+ }
44
+ const minted = [];
45
+ for (const sel of selections) {
46
+ try {
47
+ const spaceId = sel.kind === 'create'
48
+ ? await store.createSpace({ owner: uid, name: sel.name, appKey })
49
+ : sel.spaceId;
50
+ await store.grantSpaceToApp({
51
+ uid,
52
+ appKey,
53
+ spaceId,
54
+ name: sel.name,
55
+ mode: sel.mode,
56
+ declaredUri: sel.uri,
57
+ mintPath,
58
+ });
59
+ minted.push({ selection: sel, spaceId });
60
+ }
61
+ catch (err) {
62
+ onError?.('consent grant minting failed', err);
63
+ ok = false;
64
+ }
65
+ }
66
+ return { ok, netFetchOk, minted };
67
+ }
@@ -0,0 +1,57 @@
1
+ export type CapabilityKind = 'read' | 'action';
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' | 'mounts:read' | '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' | 'secrets:add' | 'secrets:list' | 'secrets:revoke' | 'agent:session';
4
+ export interface CapabilityDef {
5
+ kind: CapabilityKind;
6
+ tier: CapabilityTier;
7
+ /** Lowest platform/registry version that knows this capability (§5.11). */
8
+ since: string;
9
+ /** Carries a bounded argument set (host gate checks verb AND argument). */
10
+ parameterized?: boolean;
11
+ /** **App-scoped** consent-path annotation on the ELEVATED tier (NOT a fourth
12
+ * tier — CAPABILITY_REFERENCE §"How to read this", §6a CR-1). An app-scoped
13
+ * elevated capability can be EARNED by a URL-loaded/previewed app via lazy
14
+ * first-use or manifest-`requests` consent and recorded as a per-`(user,
15
+ * appKey)` grant; non-app-scoped elevated caps are never earnable that way
16
+ * (region binding only). The app-scoped set is `net:fetch`, `task:invoke`, and
17
+ * `contribute:self` (decision #1 — its baseline→elevated reclassification landed
18
+ * in R3-33d; the durable grant participates in the §8.15 90-day expiry like any
19
+ * app-scoped grant). */
20
+ appScoped?: boolean;
21
+ /** Render this capability's consent line with the platform's **maximally-
22
+ * explicit** (scariest) styling, never bundled into a combined prompt
23
+ * (decision #2). The most dangerous writes carry it: `contribute:direct`
24
+ * (commit without review) and `editor:write` (mutate the working tree).
25
+ * Independent of tier — it governs HOW the line is shown, not WHO may hold the
26
+ * capability (a first-party-only cap is still refused to a fork regardless). */
27
+ maximallyExplicit?: boolean;
28
+ }
29
+ export declare const CAPABILITIES: Record<Capability, CapabilityDef>;
30
+ /** The current registry/vocabulary version (§5.11). Bumped to 1.2.0 with the
31
+ * per-user settings-space capabilities (`settings:app`/`settings:fork`/
32
+ * `settings:all`), mirroring capabilities.json. */
33
+ export declare const REGISTRY_VERSION = "1.2.0";
34
+ /** Is `cap` a known kernel capability? (Closed vocabulary — §5.12.) */
35
+ export declare function isKnownCapability(cap: string): cap is Capability;
36
+ export declare function tierOf(cap: Capability): CapabilityTier;
37
+ /** Baseline = what the previewed app and any unconsented binding may hold. */
38
+ export declare const BASELINE_CAPABILITIES: readonly Capability[];
39
+ export declare function isBaseline(cap: Capability): boolean;
40
+ /** App-scoped consentables — the elevated caps a previewed/forked app may EARN
41
+ * via lazy first-use or manifest-`requests` consent (§8.9/§8.15), as opposed to
42
+ * region-binding-only elevated caps. (`contribute:self` joined this set in R3-33d
43
+ * per decision #1.) */
44
+ export declare const APP_SCOPED_CAPABILITIES: readonly Capability[];
45
+ export declare function isAppScoped(cap: Capability): boolean;
46
+ /** Compare dotted numeric versions: <0 if a<b, 0 if equal, >0 if a>b. Missing
47
+ * segments are treated as 0 ("1.2" === "1.2.0"); non-numeric segments as 0. */
48
+ export declare function compareVersions(a: string, b: string): number;
49
+ /** A capability is supported iff it is known AND its `since` ≤ the host version. */
50
+ export declare function isSupportedCapability(cap: string, hostVersion?: string): boolean;
51
+ /**
52
+ * The subset of `caps` this host cannot enforce — unknown to its vocabulary, or
53
+ * declared at a `since` newer than `hostVersion`. A non-empty result means the
54
+ * region must refuse to mount with "update immediately.run (missing: …)" (T26).
55
+ * `hostVersion` is injectable so an older host can be simulated in tests.
56
+ */
57
+ export declare function unsupportedCapabilities(caps: readonly string[], hostVersion?: string): string[];
@@ -0,0 +1,217 @@
1
+ "use strict";
2
+ // The capability-definition registry — the KERNEL source of truth
3
+ // (UI_AS_APPS_SPEC §5.11 / §8.2). Capabilities are a CLOSED vocabulary: apps
4
+ // cannot mint one, only be granted one. This module mirrors docs/capabilities.json
5
+ // (the machine-readable companion); the host build is authoritative.
6
+ //
7
+ // Two enforcement points consume this (later slices): reads are gated per-grant
8
+ // with a view() projection on a channel (§8.3); actions are gated before the
9
+ // handler (§8.4). Parameterized capabilities additionally bound an argument set.
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.APP_SCOPED_CAPABILITIES = exports.BASELINE_CAPABILITIES = exports.REGISTRY_VERSION = exports.CAPABILITIES = void 0;
12
+ exports.isKnownCapability = isKnownCapability;
13
+ exports.tierOf = tierOf;
14
+ exports.isBaseline = isBaseline;
15
+ exports.isAppScoped = isAppScoped;
16
+ exports.compareVersions = compareVersions;
17
+ exports.isSupportedCapability = isSupportedCapability;
18
+ exports.unsupportedCapabilities = unsupportedCapabilities;
19
+ exports.CAPABILITIES = {
20
+ 'theme:read': { kind: 'read', tier: 'baseline', since: '1.0.0' },
21
+ 'theme:set': { kind: 'action', tier: 'elevated', since: '1.0.0' },
22
+ 'auth:status': { kind: 'read', tier: 'baseline', since: '1.0.0' },
23
+ 'auth:identity': { kind: 'read', tier: 'elevated', since: '1.0.0' },
24
+ 'route:read': { kind: 'read', tier: 'baseline', since: '1.0.0' },
25
+ 'formFactor:read': { kind: 'read', tier: 'baseline', since: '1.0.0' },
26
+ 'mounts:read': { kind: 'read', tier: 'baseline', since: '1.0.0' },
27
+ 'spaces:app': { kind: 'action', tier: 'baseline', since: '1.0.0', parameterized: true },
28
+ 'spaces:user': { kind: 'action', tier: 'elevated', since: '1.0.0' },
29
+ 'spaces:admin': { kind: 'action', tier: 'elevated', since: '1.0.0' },
30
+ // Per-user settings space (UI_AS_APPS_SPEC §3.3/§3.5/§8.2; settings-space plan).
31
+ // The app's OWN `~/.config`-style subdir, auto-provisioned + chroot'd by appKey.
32
+ // Baseline: every app may open its own config with no consent (the host derives
33
+ // the appKey from the caller, so a different `settings:` locator can't be named).
34
+ 'settings:app': { kind: 'action', tier: 'baseline', since: '1.2.0' },
35
+ // One-time SEED of the declared `forkOf` parent's settings into the app's own
36
+ // subdir (§3.4 lineage). Baseline action — the target is locked to the manifest
37
+ // `forkOf`, so it can never name another app — but the HANDLER gates each call on
38
+ // a user confirm (full explicit consent when cross-owner, a light confirm when
39
+ // the same owner published both apps). The consent is a per-action prompt, not a
40
+ // durable §8.15 grant (the copy lands in the app's own dir; nothing to revoke).
41
+ 'settings:fork': { kind: 'action', tier: 'baseline', since: '1.2.0' },
42
+ // Mount ANY app's settings subdir / enumerate the whole `settings-store/{uid}`
43
+ // tree — the filesystem-manager ("file commander") surface. Permanently
44
+ // first-party-only: cross-app config is an activity oracle (like a future
45
+ // `mounts:registry`), so a fork/preview can never hold it.
46
+ 'settings:all': { kind: 'action', tier: 'first-party-only', since: '1.2.0' },
47
+ 'contribute:self': { kind: 'action', tier: 'elevated', since: '1.0.0', appScoped: true },
48
+ 'contribute:any': { kind: 'action', tier: 'elevated', since: '1.0.0', parameterized: true },
49
+ // Decision #2 (R3-33d, landed): contribute:direct is the platform's scariest
50
+ // write, rendered maximally-explicit. The tier is now **elevated/consentable**
51
+ // (no longer first-party-only) so source-control panels stay forkable (value 4) —
52
+ // a fork CAN hold it, but only behind the distinct scary consent line, never
53
+ // bundled. `maximallyExplicit` (not the tier) is what keeps the line scary.
54
+ 'contribute:direct': { kind: 'action', tier: 'elevated', since: '1.0.0', parameterized: true, maximallyExplicit: true },
55
+ 'editor:read': { kind: 'read', tier: 'elevated', since: '1.0.0' },
56
+ // Ask the host to open a working-tree file in the CodeMirror editor (§4 — the
57
+ // file explorer's click-to-open). An INTENT, not editor ownership (§2): the host
58
+ // validates the path and drives Sandpack. Elevated — it moves the host's focus,
59
+ // so a previewed app must not hold it; only a consented/build-default binding.
60
+ 'editor:open': { kind: 'action', tier: 'elevated', since: '1.0.0' },
61
+ // Mutate the editor session's working tree — create/delete/rename/upload a file
62
+ // (migrate-sidebars Phase 04, EDITOR_AS_APP_SPEC §5.2). A NARROW, per-path gated
63
+ // action: the file explorer NAMES a path and the HOST performs the COW write
64
+ // (and notifies Sandpack) — the COW/journal stays in the kernel (§2/§4). The
65
+ // explorer holds no working-tree write PORT (that broad authority is the
66
+ // editor app's `editor:document`); it must ask. **Elevated, not first-party-only**
67
+ // (EDITOR_AS_APP_SPEC §5.1 forkability rule): mutating the user's OWN working copy
68
+ // does not escape the user's session (no push, no host token, no cross-user reach —
69
+ // saves stay separately gated, the diff is host-computed), so it is a consentable
70
+ // grant a user may extend to a fork of the file explorer. `first-party-only` is
71
+ // reserved for session-ESCAPING authority (e.g. `contribute:direct`).
72
+ // Decision #2: editor:write is consentable (elevated, forkable — mutating the
73
+ // user's OWN working copy doesn't escape their session), but behind the
74
+ // maximally-explicit scary line.
75
+ 'editor:write': { kind: 'action', tier: 'elevated', since: '1.0.0', maximallyExplicit: true },
76
+ // The editor APP's own session-management capability (EDITOR_AS_APP_SPEC §5.1;
77
+ // editor-as-app plan Phase 03). Gates the `protocol-editor close`/`setActive`
78
+ // intents — mutating the editor's OWN open-tab set + active file, which only the
79
+ // bound editor should drive (NOT the file explorer, which holds `editor:open` to
80
+ // *ask* the host to open a file, a distinct cross-app intent). Elevated and
81
+ // forkable via consent: managing the user's own editor view does not escape their
82
+ // session (no push, no token, no cross-user reach), so a user may extend it to a
83
+ // forked editor. It will additionally gate the editor app's session/diagnostics
84
+ // channels as those land (plan Phase 02). NOT the rw working-tree port — that is
85
+ // the region property `exposesWorkingTree:'rw'`, not this capability.
86
+ 'editor:document': { kind: 'action', tier: 'elevated', since: '1.1.0' },
87
+ // Enter the EDIT EXPERIENCE for the running app — the present→edit transition
88
+ // (`/present/...` → `/edit/...`) an app cannot make itself (EDITOR_FIRST_EDITING_SPEC
89
+ // §6 Delta A). An INTENT (§2): the host performs the visible, user-observable
90
+ // navigation and draws all editor chrome; the app never navigates or paints chrome.
91
+ //
92
+ // Tier — BASELINE, deliberately, and only safe because the transition is STRICTLY
93
+ // self-scoped: the HANDLER reads only a same-repo, traversal-free `path` and
94
+ // navigates within the CURRENT route (it cannot be pointed at another repo — the
95
+ // gate validates the `path` shape, T4). Unlike `editor:open` (elevated — it can
96
+ // yank focus to an ARBITRARY file from a hostile preview), entering edit on the
97
+ // repo the user is ALREADY viewing is no escalation: edit mode adds host-drawn
98
+ // chrome around the same sandboxed app, granting it NO new capability, and "view
99
+ // your own source" is already the §0 promise. Baseline is also the ONLY tier that
100
+ // lets a standalone present-mode app (which holds only baseline) offer an "edit
101
+ // this" affordance instead of shipping a bespoke editor — the whole point of the
102
+ // delta. **PROPOSED tier, pending capability-owner sign-off (EDITOR_FIRST_EDITING_
103
+ // SPEC §8 open question #1):** flipping to elevated is a one-token change here.
104
+ 'editor:requestEdit': { kind: 'action', tier: 'baseline', since: '1.1.0' },
105
+ // Source-control state read surface (migrate-sidebars Phase 05) — the diff
106
+ // summary + branch info + open-PR list the `panel.contribute` app needs to reach
107
+ // parity with the native `SourceControlPanel`. Elevated: it exposes the repo's
108
+ // branch/PR/diff state (no token ever crosses — derived host-side), so a
109
+ // baseline/previewed frame gets an empty `VcsState`, never a leak.
110
+ 'vcs:read': { kind: 'read', tier: 'elevated', since: '1.1.0' },
111
+ // Discard the working tree — `resetWorkingTree()` wipes the COW writable layer +
112
+ // clears the journal, destroying the user's UNSAVED work irreversibly and
113
+ // UNREVIEWABLY. **First-party-only** (the first cap to re-enter this tier after
114
+ // R3-33d emptied it): only a pinned build-default `panel.contribute` binding may
115
+ // hold it — a fork/preview/third-party binding can NEVER discard the user's work,
116
+ // enforced by tier (`buildConsent` refuses it to a non-first-party binding,
117
+ // `overridePolicy` strips it on a repoint). Marked maximally-explicit so the one
118
+ // first-party line that does carry it renders with the scariest styling.
119
+ 'vcs:reset': { kind: 'action', tier: 'first-party-only', since: '1.1.0', maximallyExplicit: true },
120
+ // Initiate a host-mediated cross-app DRAG-OUT into the previewed app
121
+ // (FILE_EXPLORER_SPEC §7, R3-83). The source app calls `startItemDrag(item)`;
122
+ // the host draws the trusted drag ghost, tracks the pointer across the
123
+ // cross-origin iframe boundary (which native HTML5 DnD cannot cross), and on a
124
+ // drop over the preview delivers `{ item, from, position }` to a SUBSCRIBED
125
+ // receiver. Synthesizing a drag INTO a sibling app is an injection / clickjacking
126
+ // primitive (FE-DND-1), so this is **first-party-only**: only a pinned
127
+ // build-default chrome binding (the file explorer) may hold it — a fork / preview
128
+ // / third-party binding can NEVER initiate a cross-app drag, enforced by tier
129
+ // (`buildConsent` refuses it to a non-first-party binding, never offering a
130
+ // consent line, exactly like `vcs:reset`). Marked maximally-explicit so the one
131
+ // first-party line that carries it renders with the scariest styling (same tier
132
+ // as `editor:open`/`editor:write`/`vcs:reset`). Receiving a drop needs NO new
133
+ // grant — the previewed app opts in by subscribing (`onItemDrop`).
134
+ 'dnd:source': { kind: 'action', tier: 'first-party-only', since: '1.2.0', maximallyExplicit: true },
135
+ // The §5.5 method catalog (the app's own filtered RPC surface) — baseline:
136
+ // every app may discover what IT can call; the list is grant-filtered so it
137
+ // reveals nothing the app couldn't already invoke.
138
+ 'catalog:read': { kind: 'read', tier: 'baseline', since: '1.0.0' },
139
+ 'commands:read': { kind: 'read', tier: 'elevated', since: '1.0.0' },
140
+ 'commands:run': { kind: 'action', tier: 'elevated', since: '1.0.0' },
141
+ ipc: { kind: 'action', tier: 'elevated', since: '1.0.0', parameterized: true },
142
+ // Invoke another app via a task contract (§5.7). Elevated: summoning overlays +
143
+ // delegating file caps is real authority. Parameterized — the task set is bounded
144
+ // by the app's manifest `invokes` declaration (§5.8), enforced in the handler.
145
+ 'task:invoke': { kind: 'action', tier: 'elevated', since: '1.0.0', parameterized: true, appScoped: true },
146
+ 'net:fetch': { kind: 'action', tier: 'elevated', since: '1.0.0', parameterized: true, appScoped: true },
147
+ // Host-owned secret store (SECRETS_SPEC §4). All elevated; the value is never
148
+ // readable by any app (`secrets:list` exposes metadata only). `secrets:add`
149
+ // opens a host-drawn modal; `secrets:revoke` deletes + cascades use-grants. The
150
+ // per-(app,secret) USE grant is NOT a capability row — it is minted via the
151
+ // `requestSecret` powerbox (gated by `net:fetch`, since a secret is only usable
152
+ // through §6 injection).
153
+ 'secrets:add': { kind: 'action', tier: 'elevated', since: '1.1.0' },
154
+ 'secrets:list': { kind: 'read', tier: 'elevated', since: '1.1.0' },
155
+ 'secrets:revoke': { kind: 'action', tier: 'elevated', since: '1.1.0' },
156
+ 'agent:session': { kind: 'action', tier: 'elevated', since: '1.0.0' },
157
+ };
158
+ /** The current registry/vocabulary version (§5.11). Bumped to 1.2.0 with the
159
+ * per-user settings-space capabilities (`settings:app`/`settings:fork`/
160
+ * `settings:all`), mirroring capabilities.json. */
161
+ exports.REGISTRY_VERSION = '1.2.0';
162
+ /** Is `cap` a known kernel capability? (Closed vocabulary — §5.12.) */
163
+ function isKnownCapability(cap) {
164
+ return Object.prototype.hasOwnProperty.call(exports.CAPABILITIES, cap);
165
+ }
166
+ function tierOf(cap) {
167
+ return exports.CAPABILITIES[cap].tier;
168
+ }
169
+ /** Baseline = what the previewed app and any unconsented binding may hold. */
170
+ exports.BASELINE_CAPABILITIES = Object.keys(exports.CAPABILITIES).filter((c) => exports.CAPABILITIES[c].tier === 'baseline');
171
+ function isBaseline(cap) {
172
+ return exports.CAPABILITIES[cap].tier === 'baseline';
173
+ }
174
+ /** App-scoped consentables — the elevated caps a previewed/forked app may EARN
175
+ * via lazy first-use or manifest-`requests` consent (§8.9/§8.15), as opposed to
176
+ * region-binding-only elevated caps. (`contribute:self` joined this set in R3-33d
177
+ * per decision #1.) */
178
+ exports.APP_SCOPED_CAPABILITIES = Object.keys(exports.CAPABILITIES).filter((c) => exports.CAPABILITIES[c].appScoped === true);
179
+ function isAppScoped(cap) {
180
+ return exports.CAPABILITIES[cap].appScoped === true;
181
+ }
182
+ // ── §5.11 capability version gate (threat T26) ──────────────────────────────
183
+ //
184
+ // Each capability declares the lowest registry version that knows it (`since`).
185
+ // A binding may reference a capability this host is too old to enforce (an
186
+ // override / synced binding authored against a newer immediately.run). Mounting
187
+ // it would mount-then-break, so the loader must refuse with an actionable
188
+ // "update immediately.run" message (§6.3) — never a half-working region.
189
+ /** Compare dotted numeric versions: <0 if a<b, 0 if equal, >0 if a>b. Missing
190
+ * segments are treated as 0 ("1.2" === "1.2.0"); non-numeric segments as 0. */
191
+ function compareVersions(a, b) {
192
+ const pa = a.split('.');
193
+ const pb = b.split('.');
194
+ const n = Math.max(pa.length, pb.length);
195
+ for (let i = 0; i < n; i++) {
196
+ const x = Number.parseInt(pa[i] ?? '0', 10) || 0;
197
+ const y = Number.parseInt(pb[i] ?? '0', 10) || 0;
198
+ if (x !== y)
199
+ return x < y ? -1 : 1;
200
+ }
201
+ return 0;
202
+ }
203
+ /** A capability is supported iff it is known AND its `since` ≤ the host version. */
204
+ function isSupportedCapability(cap, hostVersion = exports.REGISTRY_VERSION) {
205
+ if (!isKnownCapability(cap))
206
+ return false;
207
+ return compareVersions(exports.CAPABILITIES[cap].since, hostVersion) <= 0;
208
+ }
209
+ /**
210
+ * The subset of `caps` this host cannot enforce — unknown to its vocabulary, or
211
+ * declared at a `since` newer than `hostVersion`. A non-empty result means the
212
+ * region must refuse to mount with "update immediately.run (missing: …)" (T26).
213
+ * `hostVersion` is injectable so an older host can be simulated in tests.
214
+ */
215
+ function unsupportedCapabilities(caps, hostVersion = exports.REGISTRY_VERSION) {
216
+ return caps.filter((c) => !isSupportedCapability(c, hostVersion));
217
+ }
@@ -0,0 +1,57 @@
1
+ import type { CreateSpaceParams, GrantSpaceParams, NetFetchHost } from './port';
2
+ /** A Firestore document path as alternating collection/doc segments, e.g.
3
+ * `['user-app-spaces', uid, 'apps', appKey, 'spaces', spaceId]`. */
4
+ export type DocPath = string[];
5
+ /** The environment-specific Firestore sentinels the field builders inject. The
6
+ * Web SDK passes `{ serverTimestamp, increment }` from `firebase/firestore`; the
7
+ * admin SDK passes the `FieldValue.*` equivalents. */
8
+ export interface MintSentinels {
9
+ serverTimestamp(): unknown;
10
+ increment(n: number): unknown;
11
+ }
12
+ /** Stable per-user identifier for a grant `(appKey, spaceId)`, used as the value
13
+ * of a delegated grant's `parentGrantId`. `::` is delimiter-safe: `appKey` uses
14
+ * `__` separators and a Firestore `spaceId` is alphanumeric. */
15
+ export declare const grantKey: (appKey: string, spaceId: string) => string;
16
+ /** Durable elevated/app-scoped grants expire after 90 days WITHOUT USE; first
17
+ * use after expiry re-prompts. Baseline needs no grant record, so this never
18
+ * touches it. */
19
+ export declare const GRANT_EXPIRY_MS: number;
20
+ /** A principal that can be granted access to a space. */
21
+ export declare const userPrincipal: (uid: string) => string;
22
+ /** Drop undefined values — Firestore rejects them. The two adapters historically
23
+ * each had their own copy of this; sharing it keeps the "omit absent optionals"
24
+ * rule identical on both sides. */
25
+ export declare const defined: <T extends Record<string, unknown>>(obj: T) => T;
26
+ export declare const spacePath: (spaceId: string) => DocPath;
27
+ export declare const memberPath: (spaceId: string, principal: string) => DocPath;
28
+ export declare const userSpacePath: (uid: string, spaceId: string) => DocPath;
29
+ export declare const appKeyPath: (uid: string, appKey: string) => DocPath;
30
+ export declare const appSpacePath: (uid: string, appKey: string, spaceId: string) => DocPath;
31
+ export declare const userCountPath: (uid: string) => DocPath;
32
+ export declare const appCountPath: (uid: string, appKey: string) => DocPath;
33
+ /** `spaces/{spaceId}` — the root doc (written WITHOUT merge). */
34
+ export declare const spaceDocFields: (params: Pick<CreateSpaceParams, "owner" | "name" | "createdInNamespace" | "createdInRepository">, s: MintSentinels) => Record<string, unknown>;
35
+ /** `spaces/{spaceId}/members/{user:owner}` — the owner membership (no merge). */
36
+ export declare const ownerMemberFields: (s: MintSentinels) => Record<string, unknown>;
37
+ /** `user-spaces/{owner}/spaces/{spaceId}` — EFFECTIVE access (no merge). */
38
+ export declare const ownerUserSpaceFields: (params: Pick<CreateSpaceParams, "owner" | "name">) => Record<string, unknown>;
39
+ /** `space-counts/{uid}` — per-user owned counter (merge). */
40
+ export declare const userCountFields: (s: MintSentinels) => Record<string, unknown>;
41
+ /** `space-counts/{uid}/apps/{appKey}` — per-app created counter (merge). */
42
+ export declare const appCountFields: (s: MintSentinels) => Record<string, unknown>;
43
+ /** `user-app-spaces/{uid}/apps/{appKey}` — the enumerable app-key marker doc
44
+ * touched when a grant is written (merge). */
45
+ export declare const appKeyTouchFields: (s: MintSentinels) => Record<string, unknown>;
46
+ /** `user-app-spaces/{uid}/apps/{appKey}/spaces/{spaceId}` — the durable §8.7
47
+ * grant doc (merge). `mintPath` defaults to `interactive`; `grantedAt`/`lastUsedAt`
48
+ * drive the §8.15 90-day-unused expiry. */
49
+ export declare const appSpaceGrantFields: (params: Pick<GrantSpaceParams, "name" | "subtree" | "mode" | "rules" | "declaredUri" | "mintPath" | "parentGrantId">, s: MintSentinels) => Record<string, unknown>;
50
+ /** Union net:fetch host rules by origin (incoming wins) — the "consent
51
+ * accumulates" merge both adapters apply before writing the host set. */
52
+ export declare const mergeNetFetchHosts: (existing: readonly NetFetchHost[], incoming: readonly NetFetchHost[]) => NetFetchHost[];
53
+ /** `user-app-spaces/{uid}/apps/{appKey}` — the net:fetch host grant (merge).
54
+ * `hadGrantedAt` is whether the doc already carried a `netFetchGrantedAt` (so the
55
+ * grant time is stamped ONCE, on first mint, and `netFetchLastUsedAt` refreshes
56
+ * on every (re-)consent). */
57
+ export declare const netFetchGrantFields: (mergedHosts: readonly NetFetchHost[], hadGrantedAt: boolean, s: MintSentinels) => Record<string, unknown>;
@@ -0,0 +1,156 @@
1
+ "use strict";
2
+ // The byte-faithful document layout (UI_AS_APPS_SPEC §8.6/§8.7/§8.15) — the
3
+ // SINGLE source of the Firestore paths and field objects every grant-mint write
4
+ // produces. The browser `FirestoreSpaceStore` (Web SDK) and the backend
5
+ // `AdminMintStore` (admin SDK) write against two different Firestore client APIs
6
+ // and therefore cannot share *call* code — but they MUST write byte-identical
7
+ // documents (same collection paths, same field names, same `grantKey` /
8
+ // `mintPath` / expiry stamping). If they drift, grants minted by the
9
+ // CLI/backend would not be the grants site-main's boot gate reads — a silent,
10
+ // security-relevant failure.
11
+ //
12
+ // The guarantee: both adapters compute their paths and assemble their field
13
+ // objects HERE, injecting only the environment-specific timestamp/increment
14
+ // SENTINELS (Web `serverTimestamp()`/`increment()` vs admin
15
+ // `FieldValue.serverTimestamp()`/`FieldValue.increment()`). The raw
16
+ // `.set()`/`.update()` is the only thing each adapter does itself. Drift is then
17
+ // impossible without editing a helper both consume.
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.netFetchGrantFields = exports.mergeNetFetchHosts = exports.appSpaceGrantFields = exports.appKeyTouchFields = exports.appCountFields = exports.userCountFields = exports.ownerUserSpaceFields = exports.ownerMemberFields = exports.spaceDocFields = exports.appCountPath = exports.userCountPath = exports.appSpacePath = exports.appKeyPath = exports.userSpacePath = exports.memberPath = exports.spacePath = exports.defined = exports.userPrincipal = exports.GRANT_EXPIRY_MS = exports.grantKey = void 0;
20
+ /** Stable per-user identifier for a grant `(appKey, spaceId)`, used as the value
21
+ * of a delegated grant's `parentGrantId`. `::` is delimiter-safe: `appKey` uses
22
+ * `__` separators and a Firestore `spaceId` is alphanumeric. */
23
+ const grantKey = (appKey, spaceId) => `${appKey}::${spaceId}`;
24
+ exports.grantKey = grantKey;
25
+ /** Durable elevated/app-scoped grants expire after 90 days WITHOUT USE; first
26
+ * use after expiry re-prompts. Baseline needs no grant record, so this never
27
+ * touches it. */
28
+ exports.GRANT_EXPIRY_MS = 90 * 24 * 60 * 60 * 1000;
29
+ /** A principal that can be granted access to a space. */
30
+ const userPrincipal = (uid) => `user:${uid}`;
31
+ exports.userPrincipal = userPrincipal;
32
+ /** Drop undefined values — Firestore rejects them. The two adapters historically
33
+ * each had their own copy of this; sharing it keeps the "omit absent optionals"
34
+ * rule identical on both sides. */
35
+ const defined = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
36
+ exports.defined = defined;
37
+ // --- document paths (pure, sentinel-free) -----------------------------------
38
+ const spacePath = (spaceId) => ['spaces', spaceId];
39
+ exports.spacePath = spacePath;
40
+ const memberPath = (spaceId, principal) => [
41
+ 'spaces',
42
+ spaceId,
43
+ 'members',
44
+ principal,
45
+ ];
46
+ exports.memberPath = memberPath;
47
+ const userSpacePath = (uid, spaceId) => [
48
+ 'user-spaces',
49
+ uid,
50
+ 'spaces',
51
+ spaceId,
52
+ ];
53
+ exports.userSpacePath = userSpacePath;
54
+ const appKeyPath = (uid, appKey) => [
55
+ 'user-app-spaces',
56
+ uid,
57
+ 'apps',
58
+ appKey,
59
+ ];
60
+ exports.appKeyPath = appKeyPath;
61
+ const appSpacePath = (uid, appKey, spaceId) => [
62
+ 'user-app-spaces',
63
+ uid,
64
+ 'apps',
65
+ appKey,
66
+ 'spaces',
67
+ spaceId,
68
+ ];
69
+ exports.appSpacePath = appSpacePath;
70
+ const userCountPath = (uid) => ['space-counts', uid];
71
+ exports.userCountPath = userCountPath;
72
+ const appCountPath = (uid, appKey) => [
73
+ 'space-counts',
74
+ uid,
75
+ 'apps',
76
+ appKey,
77
+ ];
78
+ exports.appCountPath = appCountPath;
79
+ // --- field objects (inject the timestamp/increment sentinels) ---------------
80
+ /** `spaces/{spaceId}` — the root doc (written WITHOUT merge). */
81
+ const spaceDocFields = (params, s) => (0, exports.defined)({
82
+ owner: params.owner,
83
+ createdAt: s.serverTimestamp(),
84
+ name: params.name,
85
+ createdInNamespace: params.createdInNamespace,
86
+ createdInRepository: params.createdInRepository,
87
+ });
88
+ exports.spaceDocFields = spaceDocFields;
89
+ /** `spaces/{spaceId}/members/{user:owner}` — the owner membership (no merge). */
90
+ const ownerMemberFields = (s) => ({
91
+ role: 'owner',
92
+ addedAt: s.serverTimestamp(),
93
+ });
94
+ exports.ownerMemberFields = ownerMemberFields;
95
+ /** `user-spaces/{owner}/spaces/{spaceId}` — EFFECTIVE access (no merge). */
96
+ const ownerUserSpaceFields = (params) => (0, exports.defined)({ role: 'owner', name: params.name, owner: params.owner });
97
+ exports.ownerUserSpaceFields = ownerUserSpaceFields;
98
+ /** `space-counts/{uid}` — per-user owned counter (merge). */
99
+ const userCountFields = (s) => ({
100
+ owned: s.increment(1),
101
+ });
102
+ exports.userCountFields = userCountFields;
103
+ /** `space-counts/{uid}/apps/{appKey}` — per-app created counter (merge). */
104
+ const appCountFields = (s) => ({
105
+ created: s.increment(1),
106
+ });
107
+ exports.appCountFields = appCountFields;
108
+ /** `user-app-spaces/{uid}/apps/{appKey}` — the enumerable app-key marker doc
109
+ * touched when a grant is written (merge). */
110
+ const appKeyTouchFields = (s) => ({
111
+ touchedAt: s.serverTimestamp(),
112
+ });
113
+ exports.appKeyTouchFields = appKeyTouchFields;
114
+ /** `user-app-spaces/{uid}/apps/{appKey}/spaces/{spaceId}` — the durable §8.7
115
+ * grant doc (merge). `mintPath` defaults to `interactive`; `grantedAt`/`lastUsedAt`
116
+ * drive the §8.15 90-day-unused expiry. */
117
+ const appSpaceGrantFields = (params, s) => (0, exports.defined)({
118
+ boundAt: s.serverTimestamp(),
119
+ grantedAt: s.serverTimestamp(),
120
+ lastUsedAt: s.serverTimestamp(),
121
+ name: params.name,
122
+ // Plan 12 §8.7: `rules` is authoritative; `subtree`/`mode` are kept as the
123
+ // deprecated `rules[0]` mirror for not-yet-migrated readers. When no rule-set
124
+ // is given, derive a single-rule set from the legacy scope so the backend
125
+ // single-scope mint path still emits `rules` (byte-identical with site-main).
126
+ subtree: params.subtree,
127
+ mode: params.mode,
128
+ rules: params.rules && params.rules.length > 0
129
+ ? params.rules
130
+ : [{ subtree: params.subtree ?? '/', mode: params.mode ?? 'rw' }],
131
+ declaredUri: params.declaredUri,
132
+ mintPath: params.mintPath ?? 'interactive',
133
+ parentGrantId: params.parentGrantId,
134
+ });
135
+ exports.appSpaceGrantFields = appSpaceGrantFields;
136
+ /** Union net:fetch host rules by origin (incoming wins) — the "consent
137
+ * accumulates" merge both adapters apply before writing the host set. */
138
+ const mergeNetFetchHosts = (existing, incoming) => {
139
+ const byOrigin = new Map();
140
+ for (const h of existing)
141
+ byOrigin.set(h.origin, h);
142
+ for (const h of incoming)
143
+ byOrigin.set(h.origin, h);
144
+ return [...byOrigin.values()];
145
+ };
146
+ exports.mergeNetFetchHosts = mergeNetFetchHosts;
147
+ /** `user-app-spaces/{uid}/apps/{appKey}` — the net:fetch host grant (merge).
148
+ * `hadGrantedAt` is whether the doc already carried a `netFetchGrantedAt` (so the
149
+ * grant time is stamped ONCE, on first mint, and `netFetchLastUsedAt` refreshes
150
+ * on every (re-)consent). */
151
+ const netFetchGrantFields = (mergedHosts, hadGrantedAt, s) => (0, exports.defined)({
152
+ netFetch: [...mergedHosts],
153
+ netFetchGrantedAt: hadGrantedAt ? undefined : s.serverTimestamp(),
154
+ netFetchLastUsedAt: s.serverTimestamp(),
155
+ });
156
+ exports.netFetchGrantFields = netFetchGrantFields;
@@ -0,0 +1,5 @@
1
+ export * from './capabilities';
2
+ export * from './port';
3
+ export * from './docLayout';
4
+ export * from './bootConsent';
5
+ export * from './m1PreAuth';
package/dist/index.js ADDED
@@ -0,0 +1,29 @@
1
+ "use strict";
2
+ // @immediately-run/preauth-core — the shared pre-auth core.
3
+ //
4
+ // ONE §8.9 gate (`planPreAuthCapabilities`), ONE mint path (`mintConsentedGrants`,
5
+ // `applyPreAuth`), ONE capability vocabulary (`./capabilities`), ONE wire layout
6
+ // (`./docLayout`). site-main (browser Firestore) and the backend (admin Firestore)
7
+ // both consume this so a CLI/backend-minted grant is byte-identical to one
8
+ // site-main mints, and no surface can mint a capability the in-browser gate would
9
+ // have refused.
10
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
11
+ if (k2 === undefined) k2 = k;
12
+ var desc = Object.getOwnPropertyDescriptor(m, k);
13
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
14
+ desc = { enumerable: true, get: function() { return m[k]; } };
15
+ }
16
+ Object.defineProperty(o, k2, desc);
17
+ }) : (function(o, m, k, k2) {
18
+ if (k2 === undefined) k2 = k;
19
+ o[k2] = m[k];
20
+ }));
21
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
22
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
23
+ };
24
+ Object.defineProperty(exports, "__esModule", { value: true });
25
+ __exportStar(require("./capabilities"), exports);
26
+ __exportStar(require("./port"), exports);
27
+ __exportStar(require("./docLayout"), exports);
28
+ __exportStar(require("./bootConsent"), exports);
29
+ __exportStar(require("./m1PreAuth"), exports);
@@ -0,0 +1,56 @@
1
+ import { type Capability } from './capabilities';
2
+ import { type ConsentSelection, type MintErrorSink, type MintResult } from './bootConsent';
3
+ import type { MintStore, NetFetchHost } from './port';
4
+ export type PreAuthRefusalReason =
5
+ /** A non-app-scoped elevated cap — region-binding-only authority (§8.9). */
6
+ 'broad-elevated'
7
+ /** Not in the closed capability vocabulary (§5.12) — fail-closed. */
8
+ | 'unknown';
9
+ export interface PreAuthRefusal {
10
+ capability: string;
11
+ reason: PreAuthRefusalReason;
12
+ }
13
+ export interface PreAuthPlan {
14
+ /** App-scoped elevated caps a policy MAY pre-authorize for a URL-loaded appKey. */
15
+ grantable: Capability[];
16
+ /** Baseline caps requested — auto-held, no grant needed (dropped silently). */
17
+ baseline: Capability[];
18
+ /** Refused by the §8.9 target check — these block the whole pre-auth. */
19
+ refused: PreAuthRefusal[];
20
+ }
21
+ /**
22
+ * The pure §8.9 target check: partition requested capability names into
23
+ * {grantable app-scoped, baseline no-op, refused}. Order-independent; total.
24
+ */
25
+ export declare function planPreAuthCapabilities(requested: readonly string[]): PreAuthPlan;
26
+ /** Safe to apply iff the §8.9 check refused nothing (fail-closed, all-or-nothing). */
27
+ export declare const isPreAuthClean: (plan: PreAuthPlan) => boolean;
28
+ /** What a policy/settings surface asks M1 to pre-authorize for `(uid, appKey)` —
29
+ * structurally the same shape M3's consent screen produces (the declared
30
+ * `requests`), so the two paths mint identical grants. */
31
+ export interface PreAuthRequest {
32
+ /** Capability names being pre-authorized — validated by the §8.9 target check. */
33
+ capabilities: readonly string[];
34
+ /** Mount selections (create/bind a space per slot), mirroring the M3 screen. */
35
+ mounts: readonly ConsentSelection[];
36
+ /** net:fetch hosts to pre-grant (the headless/BYOK case). */
37
+ netFetchHosts: readonly NetFetchHost[];
38
+ }
39
+ export interface PreAuthResult {
40
+ /** True iff the pre-auth passed the §8.9 check AND every grant minted. */
41
+ ok: boolean;
42
+ /** §8.9 refusals — non-empty ⇒ NOTHING was minted (all-or-nothing). */
43
+ refused: PreAuthRefusal[];
44
+ /** The mint outcome, when the §8.9 check passed (absent on refusal). */
45
+ mint?: MintResult;
46
+ }
47
+ /**
48
+ * The M1 write path: validate the requested capabilities against the §8.9 target
49
+ * check, then — only if clean — mint the mounts + net:fetch hosts as durable
50
+ * grants with `policy` provenance, through the same `mintConsentedGrants` M3 uses.
51
+ *
52
+ * Refusal is terminal and silent of side effects: when any requested capability
53
+ * is broad-elevated or unknown, the function mints NOTHING and returns the
54
+ * refusals — the caller surfaces them (the policy is malformed/over-broad).
55
+ */
56
+ export declare function applyPreAuth(store: MintStore, uid: string, appKey: string, request: PreAuthRequest, onError?: MintErrorSink): Promise<PreAuthResult>;
@@ -0,0 +1,80 @@
1
+ "use strict";
2
+ // M1 — programmatic / policy pre-authorization (UI_AS_APPS_SPEC §8.15 "M1 —
3
+ // Pre-authorization / policy", clamped by the §8.9 target check).
4
+ //
5
+ // M1 lets a policy (operator-tier) or settings (user-tier) write path record the
6
+ // SAME durable consent-grant M3 writes — ahead of time, instead of at a modal —
7
+ // so a headless/CI/cron/`immediately-run dev` run finds the grant already present
8
+ // and boots with NO prompt. It is not a region-repointing registry layer (§3.3):
9
+ // it only writes the §8.6/§8.7 grant set the gate already reads, so M1 minting
10
+ // flows through the ONE existing mint path (`mintConsentedGrants`, stamped
11
+ // `mintPath:'policy'`) and cannot drift from M3.
12
+ //
13
+ // THE SECURITY INVARIANT — the §8.9 target check. A pre-auth for a URL-loaded
14
+ // `appKey` may only cover **app-scoped** elevated capabilities (`net:fetch`,
15
+ // `task:invoke`, `contribute:self` — the set an ordinary previewed/forked app can
16
+ // EARN per §8.9/§8.15) plus mounts (app-scoped by construction). A **broad-elevated**
17
+ // capability — any non-app-scoped elevated cap (`spaces:user`/`spaces:admin`,
18
+ // `editor:write`, `contribute:direct`/`contribute:any`, `editor:open`, …) — is
19
+ // REFUSED: M1 cannot mint it for a URL-loaded appKey. Unknown capabilities are
20
+ // refused (fail-closed). Baseline capabilities need no grant and are dropped.
21
+ //
22
+ // The check is **all-or-nothing**: if a policy names ANY refused capability the
23
+ // whole pre-auth is rejected and NOTHING is minted — a partial apply would
24
+ // silently drop the scary capability and look like it had been honored.
25
+ //
26
+ // Pure decision (`planPreAuthCapabilities`/`isPreAuthClean`) + a thin store-glue
27
+ // write path (`applyPreAuth`) that reuses `mintConsentedGrants`. No React, no UI.
28
+ Object.defineProperty(exports, "__esModule", { value: true });
29
+ exports.isPreAuthClean = void 0;
30
+ exports.planPreAuthCapabilities = planPreAuthCapabilities;
31
+ exports.applyPreAuth = applyPreAuth;
32
+ const capabilities_1 = require("./capabilities");
33
+ const bootConsent_1 = require("./bootConsent");
34
+ /**
35
+ * The pure §8.9 target check: partition requested capability names into
36
+ * {grantable app-scoped, baseline no-op, refused}. Order-independent; total.
37
+ */
38
+ function planPreAuthCapabilities(requested) {
39
+ const grantable = [];
40
+ const baseline = [];
41
+ const refused = [];
42
+ for (const cap of requested) {
43
+ if (!(0, capabilities_1.isKnownCapability)(cap)) {
44
+ refused.push({ capability: cap, reason: 'unknown' });
45
+ continue;
46
+ }
47
+ if ((0, capabilities_1.isBaseline)(cap)) {
48
+ baseline.push(cap);
49
+ continue;
50
+ }
51
+ if ((0, capabilities_1.isAppScoped)(cap)) {
52
+ grantable.push(cap);
53
+ continue;
54
+ }
55
+ // Known + elevated + NOT app-scoped ⇒ broad-elevated: region-binding-only,
56
+ // never minted for a URL-loaded appKey (the §8.9 clamp).
57
+ refused.push({ capability: cap, reason: 'broad-elevated' });
58
+ }
59
+ return { grantable, baseline, refused };
60
+ }
61
+ /** Safe to apply iff the §8.9 check refused nothing (fail-closed, all-or-nothing). */
62
+ const isPreAuthClean = (plan) => plan.refused.length === 0;
63
+ exports.isPreAuthClean = isPreAuthClean;
64
+ /**
65
+ * The M1 write path: validate the requested capabilities against the §8.9 target
66
+ * check, then — only if clean — mint the mounts + net:fetch hosts as durable
67
+ * grants with `policy` provenance, through the same `mintConsentedGrants` M3 uses.
68
+ *
69
+ * Refusal is terminal and silent of side effects: when any requested capability
70
+ * is broad-elevated or unknown, the function mints NOTHING and returns the
71
+ * refusals — the caller surfaces them (the policy is malformed/over-broad).
72
+ */
73
+ async function applyPreAuth(store, uid, appKey, request, onError) {
74
+ const plan = planPreAuthCapabilities(request.capabilities);
75
+ if (!(0, exports.isPreAuthClean)(plan)) {
76
+ return { ok: false, refused: plan.refused };
77
+ }
78
+ const mint = await (0, bootConsent_1.mintConsentedGrants)(store, uid, appKey, request.mounts, request.netFetchHosts, 'policy', onError);
79
+ return { ok: mint.ok, refused: [], mint };
80
+ }
package/dist/port.d.ts ADDED
@@ -0,0 +1,84 @@
1
+ /** The filesystem scope a grant confers (UI_AS_APPS_SPEC §8.7). Absent fields
2
+ * mean whole-space, read-write. */
3
+ export type GrantMode = 'ro' | 'rw';
4
+ /** One granted scope within a mount (UI_AS_APPS_SPEC §8.7, plan 12): an absolute
5
+ * `subtree` in the backing fs and the access `mode` there. A grant carries a SET
6
+ * of these (`rules`); the most specific (longest-prefix) rule governs a path. The
7
+ * wire shape, so both mint adapters write byte-identical `rules` arrays. */
8
+ export interface ScopeRule {
9
+ subtree: string;
10
+ mode: GrantMode;
11
+ }
12
+ /** How a durable grant was minted (UI_AS_APPS_SPEC §8.15 provenance). `interactive`
13
+ * = the M3 consent screen; `policy` = M1 pre-authorization; `delegated` = M2
14
+ * attenuated delegation from a parent grant. Drives the §8.11 audit view and, for
15
+ * `delegated`, the revoke cascade (see `parentGrantId`). */
16
+ export type MintPath = 'interactive' | 'policy' | 'delegated';
17
+ /** A consented `net:fetch` host rule (§5.11) — structurally the registry's
18
+ * `FetchRule`, redefined here so this backend-agnostic port stays import-free. */
19
+ export interface NetFetchHost {
20
+ origin: string;
21
+ paths?: string[];
22
+ methods?: string[];
23
+ /** §8.15 provenance — how this host was granted (default `interactive`). For an
24
+ * M2-`delegated` host this is the attenuated subset of a caller's net:fetch grant. */
25
+ mintPath?: MintPath;
26
+ /** §8.15 — for an M2 `delegated` host, the `netFetchGrantKey` of the caller's
27
+ * net:fetch grant it was attenuated from; revoking that parent cascades here. */
28
+ parentGrantId?: string;
29
+ }
30
+ /** Parameters for `MintStore.createSpace` (named, `db`-free — the adapter holds
31
+ * the connection). */
32
+ export interface CreateSpaceParams {
33
+ owner: string;
34
+ name?: string;
35
+ /** Informational breadcrumbs only — never used for identity or access. */
36
+ createdInNamespace?: string;
37
+ createdInRepository?: string;
38
+ /** Record the new space in this app's binding list for the owner (the durable
39
+ * app↔space link the grant scopes). No longer a slot pointer. */
40
+ appKey?: string;
41
+ }
42
+ /** Parameters for `MintStore.grantSpaceToApp` — the durable §8.7 grant for one
43
+ * (app, mount): what the app may mount and how. */
44
+ export interface GrantSpaceParams {
45
+ uid: string;
46
+ appKey: string;
47
+ spaceId: string;
48
+ subtree?: string;
49
+ mode?: GrantMode;
50
+ /** Plan 12 §8.7: the FULL rule-set to write (≥1). When given it is authoritative
51
+ * (site-main's read-modify-merge passes the merged set); when omitted the grant
52
+ * doc derives a single-rule `[{ subtree ?? '/', mode ?? 'rw' }]` from the legacy
53
+ * `subtree`/`mode` (the backend single-scope path), so both adapters emit `rules`. */
54
+ rules?: ScopeRule[];
55
+ name?: string;
56
+ /** §11.4 — the declared required-mount uri this grant satisfies, so a later
57
+ * boot re-provisions it without re-consent (the durable slot replacement). */
58
+ declaredUri?: string;
59
+ /** §8.15 provenance; defaults to `interactive` when omitted. */
60
+ mintPath?: MintPath;
61
+ /** §8.15 — parent `grantKey` for an M2 `delegated` grant. */
62
+ parentGrantId?: string;
63
+ }
64
+ /** Parameters for `MintStore.grantNetFetchHosts` — the per-(user, app) granted
65
+ * host set, the grant half of the `manifest ∩ grant` net:fetch allowlist. */
66
+ export interface GrantNetFetchParams {
67
+ uid: string;
68
+ appKey: string;
69
+ hosts: readonly NetFetchHost[];
70
+ }
71
+ /**
72
+ * The mint port: exactly the methods `mintConsentedGrants` calls. Every backend
73
+ * (browser Firestore, admin Firestore, the in-memory test double) implements
74
+ * these three the SAME way — serializing through the shared `docLayout` builders
75
+ * so the documents are byte-identical regardless of which SDK wrote them.
76
+ */
77
+ export interface MintStore {
78
+ /** Create a new space owned by `owner`, returning its opaque generated id. */
79
+ createSpace(params: CreateSpaceParams): Promise<string>;
80
+ /** Record the durable §8.7 grant binding `spaceId` to `appKey` for `uid`. */
81
+ grantSpaceToApp(params: GrantSpaceParams): Promise<void>;
82
+ /** Union the given net:fetch hosts into the app's consented host set. */
83
+ grantNetFetchHosts(params: GrantNetFetchParams): Promise<void>;
84
+ }
package/dist/port.js ADDED
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+ // The mint PORT — the narrow persistence interface the ONE grant-mint path
3
+ // (`mintConsentedGrants`) depends on, plus the shared domain types its params
4
+ // carry. Both site-main's `FirestoreSpaceStore` (Firebase Web SDK) and the
5
+ // backend's `AdminMintStore` (firebase-admin) structurally satisfy `MintStore`,
6
+ // so the same orchestration drives both environments.
7
+ //
8
+ // This is a deliberate 3-method SUBSET of site-main's ~30-method `SpaceStore`:
9
+ // `mintConsentedGrants` only ever calls `grantNetFetchHosts`, `createSpace`, and
10
+ // `grantSpaceToApp` (the binding/grant doc IS the binding now — there is no
11
+ // separate `bindSpaceToApp` slot write). The broad `SpaceStore` (subscriptions,
12
+ // sharing, soft-delete, audit reads, …) stays in site-main and is NOT extracted.
13
+ Object.defineProperty(exports, "__esModule", { value: true });
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@immediately-run/preauth-core",
3
+ "version": "0.1.0",
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
+ "license": "UNLICENSED",
6
+ "type": "commonjs",
7
+ "main": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
+ },
14
+ "./capabilities": {
15
+ "types": "./dist/capabilities.d.ts",
16
+ "default": "./dist/capabilities.js"
17
+ }
18
+ },
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "scripts": {
23
+ "build": "tsc -p tsconfig.build.json",
24
+ "test": "jest",
25
+ "lint": "eslint src test",
26
+ "prepare": "npm run build"
27
+ },
28
+ "devDependencies": {
29
+ "@eslint/js": "^9.18.0",
30
+ "@types/jest": "^29.5.14",
31
+ "@types/node": "^20.17.0",
32
+ "eslint": "^9.18.0",
33
+ "jest": "^29.7.0",
34
+ "ts-jest": "^29.2.5",
35
+ "typescript": "^5.9.3",
36
+ "typescript-eslint": "^8.20.0"
37
+ }
38
+ }