@immediately-run/preauth-core 0.1.8 → 0.1.10

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.
@@ -37,6 +37,32 @@ export interface ParsedGrantKey {
37
37
  * (first + last), so the cascade fails safe (child self-revokes) rather than
38
38
  * crashing. */
39
39
  export declare const parseGrantKey: (key: string) => ParsedGrantKey;
40
+ /** The doc-id delimiter between a qualifying principal and the spaceId. Safe: a
41
+ * named principal is lowercase-dotted/hyphenated (CA-3 reserves `~`) and a
42
+ * Firestore spaceId is alphanumeric, so `~` appears in NEITHER — a single,
43
+ * unambiguous split point. */
44
+ export declare const GRANT_DOCID_DELIM = "~";
45
+ /** Build a space-grant doc-id (design 05a §3.1 step 2). Pass the QUALIFYING named
46
+ * principal to get `${principal}~${spaceId}`; pass `undefined` (stage / legacy /
47
+ * no principal) for the bare `spaceId`. The caller resolves "does this principal
48
+ * qualify" (site-main maps stage/legacy → undefined) so this stays a pure string
49
+ * builder with no sentinel knowledge. */
50
+ export declare const grantDocId: (spaceId: string, qualifyingPrincipal?: string) => string;
51
+ /** A parsed grant doc-id — the §3.5 reader-parse discipline. `principal` is set
52
+ * only for a QUALIFIED (`${principal}~${spaceId}`) id; a bare id (a stage/legacy
53
+ * grant) yields `{ spaceId }` with `principal` undefined. */
54
+ export interface ParsedGrantDocId {
55
+ /** The qualifying principal, or undefined for a bare (stage/legacy) doc-id. */
56
+ principal?: string;
57
+ spaceId: string;
58
+ }
59
+ /** Parse a space-grant doc-id back into `{ principal?, spaceId }` — the §3.5
60
+ * reader-parse discipline every app-space-grant collection reader routes `d.id`
61
+ * through so it never mistakes `${principal}~${spaceId}` for a bare spaceId (which
62
+ * would corrupt the derived `mountId` and leak grants across principals). Splits
63
+ * on the FIRST delimiter; a named principal never contains `~`, so this recovers
64
+ * the exact principal + spaceId. A bare id (no delimiter) ⇒ `{ spaceId }`. */
65
+ export declare const parseGrantDocId: (docId: string) => ParsedGrantDocId;
40
66
  /** Durable elevated/app-scoped grants expire after 90 days WITHOUT USE; first
41
67
  * use after expiry re-prompts. Baseline needs no grant record, so this never
42
68
  * touches it. */
@@ -51,11 +77,29 @@ export declare const granteeId: (uid: string) => string;
51
77
  * each had their own copy of this; sharing it keeps the "omit absent optionals"
52
78
  * rule identical on both sides. */
53
79
  export declare const defined: <T extends Record<string, unknown>>(obj: T) => T;
80
+ /** Thrown when an `appKey` is not a single Firestore path segment. Carries a
81
+ * machine `code` so a caller can map it to its own error vocabulary. */
82
+ export declare class InvalidAppKeyError extends Error {
83
+ readonly code = "invalid-app-key";
84
+ constructor(appKey: string, why: string);
85
+ }
86
+ /** Is `appKey` usable as exactly one Firestore path segment? Empty, `/`-bearing,
87
+ * and the two relative-path doc-ids Firestore reserves are all refused. */
88
+ export declare const isAppKeySegment: (appKey: string) => boolean;
89
+ /** Refuse an `appKey` that is not one path segment — the shared chokepoint every
90
+ * grant-store path builder runs first (R3-285). Returns the key so it can wrap a
91
+ * segment in place. */
92
+ export declare const assertAppKeySegment: (appKey: string) => string;
54
93
  export declare const spacePath: (spaceId: string) => DocPath;
55
94
  export declare const memberPath: (spaceId: string, grantee: string) => DocPath;
56
95
  export declare const userSpacePath: (uid: string, spaceId: string) => DocPath;
57
96
  export declare const appKeyPath: (uid: string, appKey: string) => DocPath;
58
- export declare const appSpacePath: (uid: string, appKey: string, spaceId: string) => DocPath;
97
+ /** `user-app-spaces/{uid}/apps/{appKey}/spaces/{docId}` the durable §8.7 grant
98
+ * doc. R3-98 S5: the doc-id is principal-qualified — pass the QUALIFYING named
99
+ * principal for `${principal}~${spaceId}`, or omit it (stage / legacy) for the
100
+ * bare `spaceId`. Backward-compatible: a 3-arg call (no principal) yields exactly
101
+ * the pre-S5 path, so the backend/CLI stage mint is byte-identical. */
102
+ export declare const appSpacePath: (uid: string, appKey: string, spaceId: string, qualifyingPrincipal?: string) => DocPath;
59
103
  export declare const userCountPath: (uid: string) => DocPath;
60
104
  export declare const appCountPath: (uid: string, appKey: string) => DocPath;
61
105
  /** `spaces/{spaceId}` — the root doc (written WITHOUT merge). */
package/dist/docLayout.js CHANGED
@@ -15,8 +15,18 @@
15
15
  // `FieldValue.serverTimestamp()`/`FieldValue.increment()`). The raw
16
16
  // `.set()`/`.update()` is the only thing each adapter does itself. Drift is then
17
17
  // impossible without editing a helper both consume.
18
+ //
19
+ // HONESTY NOTE (R3-285): that guarantee holds TODAY for the FIELD builders only.
20
+ // The browser `FirestoreSpaceStore` builds its refs with the Web SDK's variadic
21
+ // `doc(db, 'user-app-spaces', uid, 'apps', appKey, …)` and does NOT call the
22
+ // `*Path` builders below — they are backend-only. The two constructions are
23
+ // equivalent (`doc()` joins with `/` and re-parses exactly as the backend's
24
+ // `segments.join('/')` does), but "one source for the paths" is an aspiration
25
+ // here, not a fact, and a bug fixed in a `*Path` builder does not reach the
26
+ // browser. What both sides DO share is `assertAppKeySegment` — the one property
27
+ // a wrong path would violate. Unifying the ref construction is tracked debt.
18
28
  Object.defineProperty(exports, "__esModule", { value: true });
19
- exports.appCapabilitiesGrantFields = exports.mergeCapabilities = 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.granteeId = exports.GRANT_EXPIRY_MS = exports.parseGrantKey = exports.grantKeyWithPrincipal = exports.grantKey = void 0;
29
+ exports.appCapabilitiesGrantFields = exports.mergeCapabilities = 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.assertAppKeySegment = exports.isAppKeySegment = exports.InvalidAppKeyError = exports.defined = exports.granteeId = exports.GRANT_EXPIRY_MS = exports.parseGrantDocId = exports.grantDocId = exports.GRANT_DOCID_DELIM = exports.parseGrantKey = exports.grantKeyWithPrincipal = exports.grantKey = void 0;
20
30
  /** Stable per-user identifier for a grant `(appKey, spaceId)`, used as the value
21
31
  * of a delegated grant's `parentGrantId`. `::` is delimiter-safe: `appKey` uses
22
32
  * `__` separators and a Firestore `spaceId` is alphanumeric. */
@@ -45,6 +55,41 @@ const parseGrantKey = (key) => {
45
55
  return { appKey: parts[0], spaceId: parts[parts.length - 1] };
46
56
  };
47
57
  exports.parseGrantKey = parseGrantKey;
58
+ // --- R3-98 S5 — the principal-qualified space-grant doc-id (design 05a §3.1/§3.5) --
59
+ //
60
+ // A space grant's Firestore doc-id encodes the named principal it was minted
61
+ // under, so two principals granting the SAME space live at DIFFERENT docs and are
62
+ // invisible to each other (structural disjointness). The rule (design 05a §3.1
63
+ // step 2): a **qualifying** (real, named) principal → `${principal}~${spaceId}`;
64
+ // the **stage** principal, a **legacy** (no-principal) grant, or none → the bare
65
+ // `spaceId`, so no existing/stage doc ever moves. This module is GRAMMAR ONLY: the
66
+ // caller decides which principals qualify (site-main owns the stage/legacy
67
+ // sentinels — a principal it treats as non-qualifying is passed as `undefined`).
68
+ /** The doc-id delimiter between a qualifying principal and the spaceId. Safe: a
69
+ * named principal is lowercase-dotted/hyphenated (CA-3 reserves `~`) and a
70
+ * Firestore spaceId is alphanumeric, so `~` appears in NEITHER — a single,
71
+ * unambiguous split point. */
72
+ exports.GRANT_DOCID_DELIM = '~';
73
+ /** Build a space-grant doc-id (design 05a §3.1 step 2). Pass the QUALIFYING named
74
+ * principal to get `${principal}~${spaceId}`; pass `undefined` (stage / legacy /
75
+ * no principal) for the bare `spaceId`. The caller resolves "does this principal
76
+ * qualify" (site-main maps stage/legacy → undefined) so this stays a pure string
77
+ * builder with no sentinel knowledge. */
78
+ const grantDocId = (spaceId, qualifyingPrincipal) => qualifyingPrincipal ? `${qualifyingPrincipal}${exports.GRANT_DOCID_DELIM}${spaceId}` : spaceId;
79
+ exports.grantDocId = grantDocId;
80
+ /** Parse a space-grant doc-id back into `{ principal?, spaceId }` — the §3.5
81
+ * reader-parse discipline every app-space-grant collection reader routes `d.id`
82
+ * through so it never mistakes `${principal}~${spaceId}` for a bare spaceId (which
83
+ * would corrupt the derived `mountId` and leak grants across principals). Splits
84
+ * on the FIRST delimiter; a named principal never contains `~`, so this recovers
85
+ * the exact principal + spaceId. A bare id (no delimiter) ⇒ `{ spaceId }`. */
86
+ const parseGrantDocId = (docId) => {
87
+ const i = docId.indexOf(exports.GRANT_DOCID_DELIM);
88
+ return i === -1
89
+ ? { spaceId: docId }
90
+ : { principal: docId.slice(0, i), spaceId: docId.slice(i + 1) };
91
+ };
92
+ exports.parseGrantDocId = parseGrantDocId;
48
93
  /** Durable elevated/app-scoped grants expire after 90 days WITHOUT USE; first
49
94
  * use after expiry re-prompts. Baseline needs no grant record, so this never
50
95
  * touches it. */
@@ -61,6 +106,64 @@ exports.granteeId = granteeId;
61
106
  * rule identical on both sides. */
62
107
  const defined = (obj) => Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
63
108
  exports.defined = defined;
109
+ // --- the appKey grammar guard (R3-285) --------------------------------------
110
+ //
111
+ // An `appKey` is ONE Firestore path segment. The canonical grammar is
112
+ // site-main's `spaceId.appKey()` — `enc(provider)__enc(namespace)__enc(repository)`
113
+ // — which is punctuation-free by construction. The DANGER is the neighbouring
114
+ // grammar: a *binding id* (`provider:namespace/repository`) is a different
115
+ // identifier that also names a repo, and feeding one to the grant store is a
116
+ // silent catastrophe rather than a loud one:
117
+ //
118
+ // • one slash (`github:acme/notes`) → the joined path has an ODD segment
119
+ // count, so `doc()` throws `invalid-argument` — noisy, fails closed;
120
+ // • two slashes (`gitlab:g/sub/notes`, a nested namespace) → the path is EVEN
121
+ // and perfectly valid, so the grant is WRITTEN — to a document no reader
122
+ // looks at, that the §8.11 audit view does not enumerate, and that the
123
+ // §8.15 revoke cascade cannot reach. A durable, invisible, unrevokable grant.
124
+ //
125
+ // So the fix is NOT to encode: encoding would make the second case succeed
126
+ // quietly at the wrong key. It is to refuse a key that is not one segment, at
127
+ // the one place both adapters can share, and let the caller be corrected.
128
+ //
129
+ // This asserts the SEGMENT property (what Firestore requires), not the `__`
130
+ // grammar (which lives in site-main and may still gain components) — the widest
131
+ // check that still catches every wrong-grammar key we have seen.
132
+ /** Thrown when an `appKey` is not a single Firestore path segment. Carries a
133
+ * machine `code` so a caller can map it to its own error vocabulary. */
134
+ class InvalidAppKeyError extends Error {
135
+ code = 'invalid-app-key';
136
+ constructor(appKey, why) {
137
+ super(`appKey ${JSON.stringify(appKey)} is not one Firestore path segment (${why}). ` +
138
+ 'Expected the grant-store key grammar (`provider__namespace__repository`), ' +
139
+ 'not a binding id (`provider:namespace/repository`).');
140
+ this.name = 'InvalidAppKeyError';
141
+ }
142
+ }
143
+ exports.InvalidAppKeyError = InvalidAppKeyError;
144
+ /** Is `appKey` usable as exactly one Firestore path segment? Empty, `/`-bearing,
145
+ * and the two relative-path doc-ids Firestore reserves are all refused. */
146
+ const isAppKeySegment = (appKey) => typeof appKey === 'string' &&
147
+ appKey.length > 0 &&
148
+ !appKey.includes('/') &&
149
+ appKey !== '.' &&
150
+ appKey !== '..';
151
+ exports.isAppKeySegment = isAppKeySegment;
152
+ /** Refuse an `appKey` that is not one path segment — the shared chokepoint every
153
+ * grant-store path builder runs first (R3-285). Returns the key so it can wrap a
154
+ * segment in place. */
155
+ const assertAppKeySegment = (appKey) => {
156
+ if (typeof appKey !== 'string' || appKey.length === 0) {
157
+ throw new InvalidAppKeyError(String(appKey), 'empty');
158
+ }
159
+ if (appKey.includes('/'))
160
+ throw new InvalidAppKeyError(appKey, 'contains "/"');
161
+ if (appKey === '.' || appKey === '..') {
162
+ throw new InvalidAppKeyError(appKey, 'is a reserved relative path');
163
+ }
164
+ return appKey;
165
+ };
166
+ exports.assertAppKeySegment = assertAppKeySegment;
64
167
  // --- document paths (pure, sentinel-free) -----------------------------------
65
168
  const spacePath = (spaceId) => ['spaces', spaceId];
66
169
  exports.spacePath = spacePath;
@@ -82,16 +185,21 @@ const appKeyPath = (uid, appKey) => [
82
185
  'user-app-spaces',
83
186
  uid,
84
187
  'apps',
85
- appKey,
188
+ (0, exports.assertAppKeySegment)(appKey),
86
189
  ];
87
190
  exports.appKeyPath = appKeyPath;
88
- const appSpacePath = (uid, appKey, spaceId) => [
191
+ /** `user-app-spaces/{uid}/apps/{appKey}/spaces/{docId}` the durable §8.7 grant
192
+ * doc. R3-98 S5: the doc-id is principal-qualified — pass the QUALIFYING named
193
+ * principal for `${principal}~${spaceId}`, or omit it (stage / legacy) for the
194
+ * bare `spaceId`. Backward-compatible: a 3-arg call (no principal) yields exactly
195
+ * the pre-S5 path, so the backend/CLI stage mint is byte-identical. */
196
+ const appSpacePath = (uid, appKey, spaceId, qualifyingPrincipal) => [
89
197
  'user-app-spaces',
90
198
  uid,
91
199
  'apps',
92
- appKey,
200
+ (0, exports.assertAppKeySegment)(appKey),
93
201
  'spaces',
94
- spaceId,
202
+ (0, exports.grantDocId)(spaceId, qualifyingPrincipal),
95
203
  ];
96
204
  exports.appSpacePath = appSpacePath;
97
205
  const userCountPath = (uid) => ['space-counts', uid];
@@ -100,7 +208,7 @@ const appCountPath = (uid, appKey) => [
100
208
  'space-counts',
101
209
  uid,
102
210
  'apps',
103
- appKey,
211
+ (0, exports.assertAppKeySegment)(appKey),
104
212
  ];
105
213
  exports.appCountPath = appCountPath;
106
214
  // --- field objects (inject the timestamp/increment sentinels) ---------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@immediately-run/preauth-core",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
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": {