@cotal-ai/workspace 0.13.1 → 0.14.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.
Files changed (44) hide show
  1. package/dist/auth-paths.d.ts +180 -18
  2. package/dist/auth-paths.d.ts.map +1 -1
  3. package/dist/auth-paths.js +739 -31
  4. package/dist/auth-paths.js.map +1 -1
  5. package/dist/connect.js +3 -3
  6. package/dist/connect.js.map +1 -1
  7. package/dist/extension-mutation.d.ts +27 -0
  8. package/dist/extension-mutation.d.ts.map +1 -0
  9. package/dist/extension-mutation.js +229 -0
  10. package/dist/extension-mutation.js.map +1 -0
  11. package/dist/index.d.ts +1 -0
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +1 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/local-process.d.ts +2 -1
  16. package/dist/local-process.d.ts.map +1 -1
  17. package/dist/local-process.js +37 -3
  18. package/dist/local-process.js.map +1 -1
  19. package/dist/materialize.d.ts.map +1 -1
  20. package/dist/materialize.js +5 -5
  21. package/dist/materialize.js.map +1 -1
  22. package/dist/mesh-registry.d.ts +3 -1
  23. package/dist/mesh-registry.d.ts.map +1 -1
  24. package/dist/mesh-registry.js +59 -6
  25. package/dist/mesh-registry.js.map +1 -1
  26. package/dist/mesh-target.d.ts +1 -1
  27. package/dist/mesh-target.d.ts.map +1 -1
  28. package/dist/mesh-target.js +98 -30
  29. package/dist/mesh-target.js.map +1 -1
  30. package/dist/official-connectors.d.ts +12 -0
  31. package/dist/official-connectors.d.ts.map +1 -1
  32. package/dist/official-connectors.js +4 -0
  33. package/dist/official-connectors.js.map +1 -1
  34. package/dist/render.js +2 -0
  35. package/dist/render.js.map +1 -1
  36. package/dist/renewal.d.ts +43 -13
  37. package/dist/renewal.d.ts.map +1 -1
  38. package/dist/renewal.js +85 -21
  39. package/dist/renewal.js.map +1 -1
  40. package/dist/space.d.ts +4 -2
  41. package/dist/space.d.ts.map +1 -1
  42. package/dist/space.js +6 -4
  43. package/dist/space.js.map +1 -1
  44. package/package.json +2 -2
@@ -1,6 +1,6 @@
1
- import { existsSync, readFileSync, readdirSync } from "node:fs";
1
+ import { existsSync, lstatSync, readFileSync, readdirSync, renameSync, statSync } from "node:fs";
2
2
  import { join, dirname, resolve } from "node:path";
3
- import { mkSecretDir, writeSecretFile, writeSecretFileAtomic } from "@cotal-ai/core";
3
+ import { composeSpaceAuth, jwtIssuedAt, mkSecretDir, validateSpaceAuthForRead, writeSecretFile, writeSecretFileAtomic, } from "@cotal-ai/core";
4
4
  /**
5
5
  * On-disk auth-material I/O for a local checkout's `.cotal/` — machine-local path resolution plus
6
6
  * reading/writing the space trust material. Lives in `@cotal-ai/workspace` (not core) because it's a
@@ -15,28 +15,210 @@ const AUTH_FILE = "auth.json";
15
15
  export function authDir(root) {
16
16
  return join(root, ".cotal", "auth");
17
17
  }
18
- /** THE per-space path/key segment the single guarded encoder every space-keyed surface consumes
19
- * (this state dir AND the auth secret-store key builders). `encodeURIComponent` keeps any real
20
- * name one flat segment (`a/b` `a%2Fb`) but leaves dots alone, so `.`/`..`/empty would alias a
21
- * parent (`auth/..` normalizes OUT of the space's own segment) refused HERE, before any path is
22
- * built or state touched. Two independently-guarded encoders were the defect generator (one gains
23
- * a rule the other doesn't); keep exactly one. */
24
- export function spaceSegment(space) {
18
+ /** The ONE injective, case-safe space key: lowercase hex of the space's UTF-8 bytes. Every
19
+ * tenant-keyed namespace derives its key from this the account filename, the user-auth state
20
+ * dir, the auth secret-store keys, and the machine mesh registry because every namespace that
21
+ * invented its own encoding (raw name, `encodeURIComponent`) turned out to alias: ASCII case is
22
+ * preserved by those, so on a case-insensitive filesystem (the macOS/Windows default) `alpha` and
23
+ * `Alpha` addressed ONE path and the second tenant silently absorbed the first. Hex over
24
+ * `[0-9a-f]` cannot case-fold-collide, cannot contain a separator, and round-trips exactly. */
25
+ export function spaceKey(space) {
25
26
  if (!space)
26
27
  throw new Error("a space name is required");
27
- const enc = encodeURIComponent(space);
28
- if (enc === "." || enc === "..")
28
+ if (space === "." || space === "..")
29
29
  throw new Error(`"${space}" cannot name a space - its state would escape the space's own segment`);
30
- return enc;
31
- }
32
- /** The SPACE-SCOPED user-auth state dir (`<root>/.cotal/auth/<space>`) the one layout fact the
33
- * workstation layer owns about user auth: the auth provider persists its material under this dir
34
- * (opaque to us), and its EXISTENCE marks the space as user-auth-enabled on disk. Space-keyed now
35
- * so multi-space-per-root is a caller change, never an on-disk migration; a (broker, space) key
36
- * later extends the same shape. Fails loud on a degenerate space (see {@link spaceSegment})
37
- * BEFORE any caller can mutate at an aliased path. */
30
+ // The hex key is injective ONLY over well-formed strings: `Buffer.from(s,"utf8")` maps EVERY lone
31
+ // surrogate to U+FFFD, so `"\uD800"`, `"\uDFFF"` and `"�"` would all key to `efbfbd` - two
32
+ // such spaces collapse to one `account.<key>.json`/`space.<key>` and the undercount/aliasing
33
+ // class hex was introduced to close re-opens, sourced from malformed Unicode instead of ASCII
34
+ // case. Reject the ill-formed input at THE one builder; every legitimate name (BMP, combining,
35
+ // supplementary/emoji via PAIRED surrogates) is well-formed and passes untouched.
36
+ if (!isWellFormedUnicode(space))
37
+ throw new Error(`"${space}" is not a well-formed Unicode string (unpaired surrogate) - it cannot name a space`);
38
+ return Buffer.from(space, "utf8").toString("hex");
39
+ }
40
+ /** Whether every UTF-16 code unit is either a BMP scalar or part of a valid surrogate PAIR — i.e.
41
+ * the string has no LONE surrogate. `String.prototype.isWellFormed` is ES2024 (past this build's
42
+ * lib target), so scan the units directly. Paired surrogates (emoji/supplementary) pass; a stray
43
+ * high or low surrogate fails. This is the exact predicate that makes {@link spaceKey}'s UTF-8 hex
44
+ * injective — `Buffer.from` folds every lone surrogate to U+FFFD, collapsing distinct names. */
45
+ function isWellFormedUnicode(s) {
46
+ for (let i = 0; i < s.length; i++) {
47
+ const c = s.charCodeAt(i);
48
+ if (c >= 0xd800 && c <= 0xdbff) {
49
+ const next = s.charCodeAt(i + 1);
50
+ if (!(next >= 0xdc00 && next <= 0xdfff))
51
+ return false; // high not followed by low
52
+ i++; // valid pair — skip the low half
53
+ }
54
+ else if (c >= 0xdc00 && c <= 0xdfff) {
55
+ return false; // lone low surrogate
56
+ }
57
+ }
58
+ return true;
59
+ }
60
+ /** THE per-space path/key segment — `space.<hex>`, the {@link spaceKey} under a fixed prefix so a
61
+ * segment is self-describing on disk and can never collide with a reserved sibling of the auth
62
+ * dir (`broker.json`, `account.<hex>.json`, `server.conf`, `creds/`): none of those start with
63
+ * `space.`, and the hex body cannot smuggle one in. Consumed by the state dir AND the auth
64
+ * secret-store key builders; two independently-guarded encoders were the defect generator (one
65
+ * gains a rule the other doesn't), so keep exactly one. */
66
+ export function spaceSegment(space) {
67
+ return `space.${spaceKey(space)}`;
68
+ }
69
+ const SPACE_SEGMENT_PREFIX = "space.";
70
+ /** The space a canonical segment encodes, or undefined when the name is not one {@link spaceSegment}
71
+ * wrote (wrong prefix, non-hex body, or a body that does not round-trip). Enumeration and the
72
+ * legacy-layout shim both need this to tell the canonical namespace from strays. */
73
+ export function spaceFromSegment(name) {
74
+ if (!name.startsWith(SPACE_SEGMENT_PREFIX))
75
+ return undefined;
76
+ const key = name.slice(SPACE_SEGMENT_PREFIX.length);
77
+ if (key.length === 0 || key.length % 2 !== 0 || !/^[0-9a-f]+$/.test(key))
78
+ return undefined;
79
+ const space = Buffer.from(key, "hex").toString("utf8");
80
+ return space.length > 0 && spaceKey(space) === key ? space : undefined;
81
+ }
82
+ /** The SPACE-SCOPED user-auth state dir (`<root>/.cotal/auth/space.<hex>`) — the one layout fact
83
+ * the workstation layer owns about user auth: the auth provider persists its material under this
84
+ * dir (opaque to us), and its EXISTENCE marks the space as user-auth-enabled on disk. Keyed by
85
+ * {@link spaceSegment} so two case-differing tenants can never share one state dir and no space
86
+ * name can alias a reserved sibling of the auth dir. Fails loud on a degenerate space — BEFORE
87
+ * any caller can mutate at an aliased path.
88
+ *
89
+ * Also the ONE migration point for pre-hex layouts (`<authDir>/<encodeURIComponent(space)>`):
90
+ * every consumer of a space's user-auth state — the marker check, the provider's `dir`, and the
91
+ * secret-store keys resolved beside it — obtains this path first, so renaming the legacy dir to
92
+ * the canonical segment HERE means no flow can ever read (or worse, `ensure*`-REGENERATE) beside
93
+ * material the old layout still holds. Deliberately not in {@link hasUserAuthState} alone: a
94
+ * user-mode connect through a registry record never consults the marker before minting. */
38
95
  export function userAuthStateDir(root, space) {
39
- return join(authDir(root), spaceSegment(space));
96
+ const canonical = join(authDir(root), spaceSegment(space));
97
+ migrateLegacyUserAuthState(root, space, canonical);
98
+ return canonical;
99
+ }
100
+ /** One-time shim for state dirs written before the hex segment. Byte-exact only: the legacy name
101
+ * must appear verbatim in the directory listing — a mere `existsSync` would case-fold on
102
+ * macOS/Windows and migrate a DIFFERENT space's dir. `creds` is the agent-creds dir, the one
103
+ * legacy spelling that was always an alias rather than state, so it is excluded rather than
104
+ * renamed out from under every agent secret. Only a dir carrying a provider pin migrates — an
105
+ * empty husk is a crashed enable, not state.
106
+ *
107
+ * The one irreducibly AMBIGUOUS case fails LOUD: a space literally named `space.<validhex>` has a
108
+ * pre-hex dir whose name IS a canonical segment of a DIFFERENT space (e.g. `space.616c706861` is
109
+ * both that space's legacy dir and the canonical home of `alpha`). Nothing on disk can say which
110
+ * space owns it, so migrating either way would misattribute state - silently reading it as static
111
+ * (the old bug: a user-auth space flips, and `mint` writes static admin creds) or stealing another
112
+ * tenant's canonical dir. Refuse and make the operator disambiguate, rather than infer ownership
113
+ * from the directory spelling. */
114
+ function migrateLegacyUserAuthState(root, space, canonical) {
115
+ const legacyName = encodeURIComponent(space);
116
+ if (legacyName === "creds")
117
+ return;
118
+ const dir = authDir(root);
119
+ const legacyPath = join(dir, legacyName);
120
+ // Cheap gate first: nothing even case-insensitively at the legacy path ⇒ no pre-hex dir to weigh,
121
+ // so the canonical path (whatever state it is in) is authoritative and we skip the readdir. The
122
+ // legacy check must run BEFORE trusting a present canonical dir: `existsSync(canonical)` alone
123
+ // does NOT prove migration completed - an EMPTY canonical husk (a crashed new-layout enable)
124
+ // beside REAL pre-hex state would let a bare canonical read flip a user-auth space to static and
125
+ // `mint` write admin creds.
126
+ if (!existsSync(legacyPath))
127
+ return;
128
+ let entries;
129
+ try {
130
+ entries = readdirSync(dir, { withFileTypes: true });
131
+ }
132
+ catch (e) {
133
+ if (e.code === "ENOENT")
134
+ return;
135
+ throw e;
136
+ }
137
+ // Byte-exact (existsSync case-folds a sibling), and a real provider pin - an empty husk at the
138
+ // legacy path is a crashed enable, not state, so it neither migrates nor blocks.
139
+ const hit = entries.find((e) => e.name === legacyName && e.isDirectory());
140
+ if (!hit || !pathHasUserAuthMarker(legacyPath))
141
+ return;
142
+ // Real pre-hex state exists for this space. Two situations are irreducibly ambiguous and FAIL
143
+ // LOUD rather than guess: (1) the legacy name is also another space's canonical segment; (2) a
144
+ // canonical dir already exists (a crashed/partial new-layout enable) - migrating would either
145
+ // steal it or need a merge we cannot infer. Only when the canonical path is ABSENT is the rename
146
+ // unambiguous.
147
+ if (spaceFromSegment(legacyName) !== undefined)
148
+ throw new Error(`${legacyPath} is ambiguous: it is the pre-hex user-auth state dir of space "${space}" AND a canonical segment of space "${spaceFromSegment(legacyName)}" - refusing to guess which tenant owns it. Move it to ${canonical} yourself if it belongs to "${space}", or remove it.`);
149
+ if (existsSync(canonical))
150
+ throw new Error(`both the canonical ${canonical} and the pre-hex ${legacyPath} hold user-auth state for "${space}" - refusing to guess which is current (canonical existence alone does not prove the migration completed). Merge or remove one, then retry.`);
151
+ renameSync(legacyPath, canonical);
152
+ }
153
+ /** The provider's first-written user-auth pins. Workspace owns the LOCATION of a space's user-auth
154
+ * state ({@link userAuthStateDir}) and the fact that one of these files inside it marks the space
155
+ * user-auth-enabled; the auth provider owns their contents. `idp.json` is written first at enable and
156
+ * `callout.json` right after, so EITHER marks a real state dir. The pin check is what makes the marker
157
+ * sound where a bare `existsSync` is not: neither file can appear inside a sibling like `creds/`, nor
158
+ * can a plain file (`broker.json`, `account.<hex>.json`) that aliases the state-dir PATH ever satisfy
159
+ * it (a file has no children). */
160
+ const USER_AUTH_MARKER_FILES = ["idp.json", "callout.json"];
161
+ function pathHasUserAuthMarker(dir) {
162
+ let st;
163
+ try {
164
+ st = statSync(dir);
165
+ }
166
+ catch (e) {
167
+ if (e.code === "ENOENT")
168
+ return false;
169
+ throw e;
170
+ }
171
+ if (!st.isDirectory())
172
+ return false;
173
+ // The pin check must be errno-disciplined like the dir stat above: a bare `existsSync` maps EVERY
174
+ // failure (EACCES, ELOOP, EIO, …) to `false`, which reads a REAL but momentarily unreadable
175
+ // user-auth state dir as "static mode" — and a caller like `mint` then writes static admin creds
176
+ // onto a user-auth space. Only ENOENT means "this pin is absent"; anything else is uncertainty
177
+ // about a trust marker, and uncertainty fails CLOSED (loud), never open.
178
+ return USER_AUTH_MARKER_FILES.some((f) => {
179
+ try {
180
+ return statSync(join(dir, f)).isFile();
181
+ }
182
+ catch (e) {
183
+ if (e.code === "ENOENT")
184
+ return false;
185
+ throw e;
186
+ }
187
+ });
188
+ }
189
+ /** Whether `space` is user-auth-enabled ON DISK under `root` — the authoritative marker, NOT a bare
190
+ * `existsSync` on {@link userAuthStateDir}: only a real provider pin inside a real directory
191
+ * counts. The state-dir read goes through {@link userAuthStateDir}, so a pre-hex legacy layout
192
+ * has already been migrated by the time the marker is checked. */
193
+ export function hasUserAuthState(root, space) {
194
+ return pathHasUserAuthMarker(userAuthStateDir(root, space));
195
+ }
196
+ /** Every space with user-auth state under this auth dir, detectable WITHOUT `broker.json`/accounts:
197
+ * each enabled space is a `space.<hex>` subdirectory carrying a provider pin. The enumerating
198
+ * companion to {@link hasUserAuthState}; both share {@link pathHasUserAuthMarker} so a single
199
+ * space and the whole-dir sweep can never disagree on what "user-auth on disk" means. Pre-hex
200
+ * legacy dirs are reported too (decoded from their verbatim names), so a guard reading this stays
201
+ * fail-closed before the one-time migration has run. */
202
+ export function userAuthSpacesOnDisk(dir) {
203
+ if (!existsSync(dir))
204
+ return []; // no auth dir at all — nothing user-auth here
205
+ const out = new Set();
206
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
207
+ if (!e.isDirectory() || e.name === "creds" || !pathHasUserAuthMarker(join(dir, e.name)))
208
+ continue;
209
+ const canonical = spaceFromSegment(e.name);
210
+ if (canonical !== undefined) {
211
+ out.add(canonical);
212
+ continue;
213
+ }
214
+ try {
215
+ out.add(decodeURIComponent(e.name)); // legacy layout — verbatim name, decoded
216
+ }
217
+ catch {
218
+ /* a stray dir that decodes as neither layout is not a space */
219
+ }
220
+ }
221
+ return [...out];
40
222
  }
41
223
  // ---- per-agent standing secrets (static creds / actor token / sentinel creds) ----
42
224
  /** The dir every per-agent standing secret materializes under (`<root>/.cotal/auth/creds`) —
@@ -140,19 +322,545 @@ export function findCotalRoot(start = process.cwd()) {
140
322
  dir = parent;
141
323
  }
142
324
  }
143
- /** Persist the space trust material. The file holds the data-account signing seed — treat as a secret.
144
- * The system-account `sys.signingSeed` is STRIPPED before writing: it is broker-admin minting capability,
145
- * so it never lands on disk (it lives only in the in-memory {@link createSpaceAuth} result). */
146
- export function saveSpaceAuth(dir, auth) {
325
+ // ---- broker trust and space accounts are SEPARATE persisted authorities (W4) ----
326
+ //
327
+ // A nats-server trusts exactly one operator and one system account, so broker trust is per-BROKER
328
+ // and has exactly one owner on disk (`auth/broker.json`). Each space owns only its own data account
329
+ // (`auth/account.<key>.json`, a flat file beside `broker.json`, keyed by {@link accountFileKey})
330
+ // and REFERENCES broker trust rather than embedding it. Embedding it per space is the bug this split
331
+ // exists to prevent: a rotation done through space A would update A's embedded copy while space B
332
+ // kept loading a stale one and resurrected dead broker trust.
333
+ //
334
+ // The composed {@link SpaceAuth} is a READ view only (see core's `composeSpaceAuth`); there is no
335
+ // persisted document with that shape any more. The pre-W4 monolith is migration INPUT only.
336
+ const BROKER_FILE = "broker.json";
337
+ const SPACE_ACCOUNT_PREFIX = "account.";
338
+ const SPACE_ACCOUNT_SUFFIX = ".json";
339
+ /** Where the one broker trust record lives. */
340
+ export function brokerAuthPath(dir) {
341
+ return join(dir, BROKER_FILE);
342
+ }
343
+ /** The account file's key IS {@link spaceKey} — one injective, case-safe encoder for every
344
+ * tenant-keyed namespace. The space's real name rides in the document, never inferred from the
345
+ * key alone. */
346
+ function accountFileKey(space) {
347
+ return spaceKey(space);
348
+ }
349
+ /** Read one auth-material record: the file's raw text, or undefined when absent. lstat-disciplined
350
+ * and framed, shared by every load/save below so the readers cannot disagree:
351
+ * - a non-regular entry at a trust path (symlink, directory, fifo) is REFUSED, never followed —
352
+ * nothing in this module writes one, so following it would trust material this module cannot
353
+ * vouch for (and enumeration counts the same entry as corrupt: one answer everywhere);
354
+ * - only ENOENT means absent; any other errno is uncertainty about trust material and throws;
355
+ * - the JSON parse is wrapped so a truncated/hand-edited record surfaces as one legible sentence
356
+ * naming the file, never a raw SyntaxError deep in a caller. */
357
+ function readAuthRecord(f, what) {
358
+ let st;
359
+ try {
360
+ st = lstatSync(f);
361
+ }
362
+ catch (e) {
363
+ if (e.code === "ENOENT")
364
+ return undefined;
365
+ throw e;
366
+ }
367
+ if (!st.isFile())
368
+ throw new Error(`${f} is not a regular file - refusing to read ${what} through it; remove or restore the real record`);
369
+ try {
370
+ return JSON.parse(readFileSync(f, "utf8"));
371
+ }
372
+ catch (e) {
373
+ throw new Error(`${f} does not parse as ${what} (${e instanceof Error ? e.message : String(e)}) - restore it from backup or remove it deliberately`);
374
+ }
375
+ }
376
+ /** The space a canonical account filename encodes, or undefined when the name is not one THIS module
377
+ * wrote (wrong prefix/suffix, non-hex body, or a body that does not round-trip back to the same
378
+ * filename). Enumeration treats an undefined result as a corrupt/foreign record, never as a tenant. */
379
+ function spaceFromAccountFile(name) {
380
+ if (!name.startsWith(SPACE_ACCOUNT_PREFIX) || !name.endsWith(SPACE_ACCOUNT_SUFFIX))
381
+ return undefined;
382
+ const key = name.slice(SPACE_ACCOUNT_PREFIX.length, name.length - SPACE_ACCOUNT_SUFFIX.length);
383
+ if (key.length === 0 || key.length % 2 !== 0 || !/^[0-9a-f]+$/.test(key))
384
+ return undefined;
385
+ const space = Buffer.from(key, "hex").toString("utf8");
386
+ return space.length > 0 && accountFileKey(space) === key ? space : undefined;
387
+ }
388
+ /** Whether `v` carries the {@link SpaceAccountAuth} account material a reader will dereference. A
389
+ * record can round-trip its `space` yet be semantically empty (`{"space":"alpha"}`, no `account`),
390
+ * which a name-only check counts as a tenant - then `composeSpaceAuth`/`status` crash on
391
+ * `account.jwt` of undefined. "Validated inventory" must mean the shape those readers compose, so
392
+ * the fields they read (pub/jwt/signingSeed/signingPub, all non-empty strings) are required here. */
393
+ function isAccountShape(v) {
394
+ if (v === null || typeof v !== "object")
395
+ return false;
396
+ const a = v;
397
+ return ["pub", "jwt", "signingSeed", "signingPub"].every((k) => typeof a[k] === "string" && a[k].length > 0);
398
+ }
399
+ /** Where one space's own account record lives: a FLAT file beside `broker.json`, keyed by
400
+ * {@link accountFileKey}. Flat (not `<space>/account.json`) because `<authDir>/<space>/` is
401
+ * {@link userAuthStateDir}; hex-keyed because a raw space name in the filename both aliased that
402
+ * user-auth marker and case-folded on macOS/Windows. The name of a space is authoritative in the
403
+ * document, so enumeration never has to trust the filename beyond finding the record. */
404
+ export function spaceAccountPath(dir, space) {
405
+ return join(dir, `${SPACE_ACCOUNT_PREFIX}${accountFileKey(space)}${SPACE_ACCOUNT_SUFFIX}`);
406
+ }
407
+ /** Runtime-validate a system-account generation. `readAuthRecord` is only a type CAST over JSON,
408
+ * so a hand-edited string/float/negative/unsafe value would otherwise flow into the successor
409
+ * arithmetic and destroy the discriminator ("0"+1 is "01"; at 2^53, gen+1 === gen). ONLY an
410
+ * ABSENT field reads as 0 - that is the one shape a pre-generation record can have, because the
411
+ * writer always emits a number. An explicit JSON `null` can only come from tampering/corruption,
412
+ * and defaulting it would turn a doctored current record into a "generation-0 predecessor" and
413
+ * re-arm the very rollback this field exists to refuse (found live in review). Every PRESENT
414
+ * value must be a non-negative safe integer or the record is corrupt - fail closed BEFORE any
415
+ * idempotent/successor handling. */
416
+ function brokerGen(v, where) {
417
+ if (v === undefined)
418
+ return 0;
419
+ if (typeof v !== "number" || !Number.isSafeInteger(v) || v < 0)
420
+ throw new Error(`${where}: system-account generation ${JSON.stringify(v)} is not a non-negative integer - the record is corrupt; restore it from backup`);
421
+ return v;
422
+ }
423
+ /** The overwrite guard every broker-trust writer shares — the FS writer ({@link saveBrokerAuth})
424
+ * and the seam writer ({@link putSpaceAuth}) both reduce through here, so the refusals can never
425
+ * drift between the two (the one-guarded-encoder discipline). `f` labels the record in errors (the
426
+ * file path or the store key — both non-material). Returns the generation the write must carry.
427
+ *
428
+ * Overwriting the broker record is only safe for the SAME operator (a system-account rotation
429
+ * keeps the operator SEED and only re-issues its JWT + `sys`). A DIFFERENT operator seed means a
430
+ * fresh broker root - every space account here is signed by the current operator, so replacing it
431
+ * orphans them ALL. That is exactly what a naive `createSpaceAuth` for a second space on this root
432
+ * would do; refuse it loud rather than silently break the existing tenants. A new space must be
433
+ * signed by the existing broker, never mint its own. */
434
+ function guardBrokerOverwrite(f, existing, broker) {
435
+ if (existing.operator?.seed !== broker.operator.seed)
436
+ throw new Error(`${f} already holds a different broker operator - refusing to overwrite it: every space account on this broker is signed by the current operator, so replacing it would orphan them all. Sign the new space under the existing broker instead of minting a fresh operator.`);
437
+ // Same operator: the write must still move FORWARD. "Same seed" alone would let a stale
438
+ // pre-rotation value (e.g. a copy held in memory across a rotateSystemAccount) overwrite the
439
+ // newer record and resurrect the RETIRED system account. The discriminator is the GENERATION:
440
+ // rotateSystemAccount bumps it in memory, so a sys-changing value is writable only when it is
441
+ // the DIRECT SUCCESSOR of the current record - a pre-rotation copy is generations behind even
442
+ // when the record itself never held it (rotation persisted first). The JWT `iat` alone cannot
443
+ // order generations - it is second-resolution, so a rotation minted within the same second as
444
+ // its predecessor is `iat`-equal yet a different authority (found live in review); it stays
445
+ // only as a belt against a hand-edited gen smuggling a provably OLDER sys back in.
446
+ if (!existing.sys?.jwt)
447
+ throw new Error(`${f} holds broker trust without a system-account JWT - the record is corrupt; restore it from backup before overwriting`);
448
+ // BOTH generations validate before EITHER branch: a malformed value refuses even on the
449
+ // byte-identical idempotent path, rather than being silently absorbed and rewritten - a
450
+ // corrupt input never gets a success exit from a trust write.
451
+ const persistedGen = brokerGen(existing.gen, f);
452
+ const incomingGen = brokerGen(broker.gen, "the value being written");
453
+ const sysUnchanged = existing.sys.pub === broker.sys.pub && existing.sys.jwt === broker.sys.jwt;
454
+ if (sysUnchanged) {
455
+ return persistedGen; // idempotent re-save of the current authority - the generation holds
456
+ }
457
+ if (incomingGen !== persistedGen + 1)
458
+ throw new Error(`${f} is at system-account generation ${persistedGen} and a system-account change must be its direct successor (generation ${persistedGen + 1}), but the value being written carries generation ${incomingGen} - refusing the stale write: it would resurrect a retired system account. Rotate from the current broker record and save that result.`);
459
+ if (jwtIssuedAt(broker.sys.jwt) < jwtIssuedAt(existing.sys.jwt))
460
+ throw new Error(`${f} holds a NEWER system account (issued ${jwtIssuedAt(existing.sys.jwt)}) than the value being written (issued ${jwtIssuedAt(broker.sys.jwt)}) - refusing the rollback: it would resurrect a retired system account.`);
461
+ return incomingGen; // the successor generation the rotation minted
462
+ }
463
+ /** Refuse to persist a stripped/blank broker half through ANY writer: `stripSpaceAuth` blanks the
464
+ * operator, and a blank value must never own (or blank) the broker record. Shared by the FS and
465
+ * seam writers for the same no-drift reason as {@link guardBrokerOverwrite}. */
466
+ function assertWritableBrokerTrust(broker, who) {
467
+ if (!broker.operator.seed || !broker.operator.jwt || !broker.sys.pub)
468
+ throw new Error(`${who}: refusing to persist blank broker trust - a stripped auth value cannot own the broker record`);
469
+ }
470
+ /** Persist BROKER trust. `sys.signingSeed` is STRIPPED before writing: it is broker-admin minting
471
+ * capability, so it never lands on disk (it lives only in the in-memory {@link createBrokerAuth}
472
+ * result). A composition that must add spaces after first boot has to hold that seed in a
473
+ * BROKER-scoped secret store instead; see core's `BrokerAuth`. */
474
+ export function saveBrokerAuth(dir, broker) {
475
+ assertWritableBrokerTrust(broker, "saveBrokerAuth");
476
+ const f = brokerAuthPath(dir);
477
+ const existing = readAuthRecord(f, "broker trust");
478
+ let gen;
479
+ if (existing) {
480
+ gen = guardBrokerOverwrite(f, existing, broker);
481
+ }
482
+ else {
483
+ // No broker record - but that alone must not authorize a FRESH operator: with account records
484
+ // present (broker.json lost, tenants intact) an unconditional write would install a new
485
+ // operator and orphan every tenant. The write is a legitimate REPAIR only when the incoming
486
+ // operator provably signed the existing accounts, so verify each one; and the check must range
487
+ // over the VALIDATED inventory - an unreadable record might be a real tenant, so any corrupt
488
+ // entry keeps the refusal (the same fail-closed posture as the broker-wide guard).
489
+ // Residual (accepted): two concurrent FIRST-TIME writes on a genuinely fresh root both pass
490
+ // this check and last-writer-wins; real usage serializes on the single `up`/store per root.
491
+ gen = brokerGen(broker.gen, "the value being written"); // a fresh record keeps the value's generation (0 if new)
492
+ const { spaces, corrupt } = accountInventory(dir);
493
+ if (corrupt.length > 0)
494
+ throw new Error(`${dir} has no broker record but holds unreadable account record(s) (${corrupt.join(", ")}) - refusing to install an operator while the tenant list is uncertain; repair or remove them first`);
495
+ for (const space of spaces) {
496
+ const account = loadSpaceAccountAuth(dir, space);
497
+ if (!account)
498
+ continue; // raced away since the inventory read - nothing left to orphan
499
+ try {
500
+ composeSpaceAuth(broker, account);
501
+ }
502
+ catch (e) {
503
+ throw new Error(`${dir} has no broker record but holds accounts for ${spaces.join(", ")}, and the operator being written did not sign "${space}" (${e instanceof Error ? e.message : String(e)}) - refusing to install it: the existing tenants would be orphaned. Restore the original broker.json from backup instead.`);
504
+ }
505
+ }
506
+ }
147
507
  mkSecretDir(dir); // harden the auth dir BEFORE the secret lands (private ACL on win32, 0700 POSIX)
148
- const onDisk = { ...auth, sys: { pub: auth.sys.pub, jwt: auth.sys.jwt } };
149
- writeSecretFile(join(dir, AUTH_FILE), JSON.stringify(onDisk, null, 2));
508
+ const onDisk = { operator: broker.operator, sys: { pub: broker.sys.pub, jwt: broker.sys.jwt }, gen };
509
+ writeSecretFile(f, JSON.stringify(onDisk, null, 2));
510
+ }
511
+ /** Load BROKER trust, or undefined if auth was never set up here. Reads the pre-W4 monolith as
512
+ * MIGRATION INPUT when the broker record does not exist yet. */
513
+ export function loadBrokerAuth(dir) {
514
+ const broker = readAuthRecord(brokerAuthPath(dir), "broker trust");
515
+ if (broker)
516
+ return broker;
517
+ const legacy = loadLegacySpaceAuth(dir);
518
+ return legacy ? { operator: legacy.operator, sys: legacy.sys } : undefined;
519
+ }
520
+ /** Persist ONE space's account record. Never carries broker material. Refuses to overwrite a record
521
+ * that already holds a DIFFERENT space: with an injective hex key that only happens on a corrupted
522
+ * or hand-swapped file, and silently replacing one tenant's signing authority with another's must
523
+ * fail loud. */
524
+ export function saveSpaceAccountAuth(dir, spaceAccount) {
525
+ const target = spaceAccountPath(dir, spaceAccount.space);
526
+ const existing = readAuthRecord(target, "a space account record");
527
+ if (existing && existing.space !== spaceAccount.space)
528
+ throw new Error(`${target} holds space "${existing.space}"; refusing to overwrite it with "${spaceAccount.space}"`);
529
+ mkSecretDir(dirname(target));
530
+ const onDisk = { space: spaceAccount.space, account: spaceAccount.account };
531
+ writeSecretFile(target, JSON.stringify(onDisk, null, 2));
532
+ }
533
+ /** Load ONE space's account record, or undefined. Reads the pre-W4 monolith as MIGRATION INPUT when
534
+ * the per-space record does not exist yet AND that monolith is for this same space - a monolith for
535
+ * a DIFFERENT space must never satisfy a load for this one. */
536
+ export function loadSpaceAccountAuth(dir, space) {
537
+ const doc = readAuthRecord(spaceAccountPath(dir, space), "a space account record");
538
+ if (doc) {
539
+ if (doc.space !== space)
540
+ throw new Error(`${spaceAccountPath(dir, space)} holds space "${doc.space}", not "${space}" - the account record was renamed or corrupted`);
541
+ if (!isAccountShape(doc.account))
542
+ throw new Error(`${spaceAccountPath(dir, space)} is missing its account material (pub/jwt/signingSeed/signingPub) - the record is corrupt; restore it from backup`);
543
+ return doc;
544
+ }
545
+ const legacy = loadLegacySpaceAuth(dir);
546
+ if (!legacy || legacy.space !== space)
547
+ return undefined;
548
+ return { space: legacy.space, account: legacy.account };
549
+ }
550
+ /** The pre-W4 single-document trust material. MIGRATION INPUT ONLY - nothing writes this shape now. */
551
+ function loadLegacySpaceAuth(dir) {
552
+ return readAuthRecord(join(dir, AUTH_FILE), "pre-W4 trust material");
553
+ }
554
+ /** Load the COMPOSED read view of one space's trust chain, or undefined if either authority is
555
+ * missing. The space key is REQUIRED: once a broker holds N accounts, a root-wide "the auth" load is
556
+ * intrinsically ambiguous, and picking a default silently would let (for example) a manager bound to
557
+ * space B mint B's agents into space A's account. Composition also asserts that this account really
558
+ * was signed by this broker's operator. */
559
+ export function loadSpaceAuth(dir, space) {
560
+ // The STRIPPED SIGNER bundle (`cotal mint --signer`, mounted read-only at `auth/auth.json` in a
561
+ // containerized manager) is the one legacy shape that can never compose: `stripSpaceAuth`
562
+ // deliberately blanks the broker root-of-trust and keeps only this space's account signing
563
+ // material, so there is no signature chain to verify - its trust IS the operator's decision to
564
+ // mount it (unchanged from pre-split). Recognize it precisely - split records absent, monolith
565
+ // present for THIS space, operator blanked, signing seed present - and return it as-is; any
566
+ // other shape must compose and verify the account really was signed by this broker's operator.
567
+ if (!existsSync(brokerAuthPath(dir)) && !existsSync(spaceAccountPath(dir, space))) {
568
+ const legacy = loadLegacySpaceAuth(dir);
569
+ if (legacy && legacy.space === space && !legacy.operator?.seed && legacy.account?.signingSeed)
570
+ return legacy;
571
+ }
572
+ const broker = loadBrokerAuth(dir);
573
+ const spaceAccount = loadSpaceAccountAuth(dir, space);
574
+ if (!broker || !spaceAccount)
575
+ return undefined;
576
+ return composeSpaceAuth(broker, spaceAccount);
577
+ }
578
+ /** The composed trust of the ONE space this auth dir holds - the space-blind convenience for callers
579
+ * that predate multi-space (a folder's own mesh, `mint` in a checkout, a status read). Fails loud
580
+ * when the root holds several, via {@link soleSpaceOf}. Prefer {@link loadSpaceAuth} with an
581
+ * explicit space wherever the caller can know it. */
582
+ export function loadSoleSpaceAuth(dir) {
583
+ const space = soleSpaceOf(dir);
584
+ return space ? loadSpaceAuth(dir, space) : undefined;
585
+ }
586
+ /** Persist a composed value by DECOMPOSING it into its two authorities. This is the migration-era
587
+ * writer for the many existing callers that hold a composed {@link SpaceAuth}; it is safe because
588
+ * there is exactly ONE broker record, so writing broker trust through any space updates the single
589
+ * owner rather than a per-space copy. It refuses a stripped value outright: `stripSpaceAuth` blanks
590
+ * the operator and system account, and decomposing that would blank broker persistence. */
591
+ export function saveSpaceAuth(dir, auth) {
592
+ saveBrokerAuth(dir, auth); // throws on a stripped/blank broker half
593
+ saveSpaceAccountAuth(dir, auth);
594
+ }
595
+ /** The ONE space this auth dir holds, for the legacy paths that predate multi-space and carry no
596
+ * explicit space (a folder's "its own" space, a root-wide daemon remint). Undefined when the root
597
+ * has no auth at all.
598
+ *
599
+ * FAILS LOUD when the root holds several, rather than picking the first or a "current": a
600
+ * space-blind caller that silently picks is exactly how a component bound to space B mints into
601
+ * space A's account. Callers that can know their space must pass it explicitly instead of using
602
+ * this; this exists so the remaining space-blind paths become a loud error rather than a wrong
603
+ * answer the day a root holds two. */
604
+ export function soleSpaceOf(dir) {
605
+ const { spaces, corrupt } = accountInventory(dir);
606
+ if (corrupt.length > 0)
607
+ throw new Error(`${dir} holds ${corrupt.length} unreadable account record(s) (${corrupt.join(", ")}) - refusing to name a sole space while the tenant count is uncertain; repair or remove them`);
608
+ if (spaces.length === 0)
609
+ return undefined;
610
+ if (spaces.length > 1)
611
+ throw new Error(`${dir} holds accounts for ${spaces.length} spaces (${spaces.join(", ")}) - this operation has no explicit space and refuses to pick one; pass the space explicitly`);
612
+ return spaces[0];
613
+ }
614
+ /** Refuse a BROKER-WIDE operation on a root that hosts several spaces - or whose tenant list cannot
615
+ * be read with certainty.
616
+ *
617
+ * Distinct from {@link soleSpaceOf}'s ambiguity, and the distinction is the whole point: the
618
+ * broker process, its JetStream store and the single `.cotal/auth` broker record are shared by
619
+ * every space on the root, so naming a space cannot scope `down`, `clean store|all`, `backup` or a
620
+ * restore - they would apply to all of them regardless. Sending the operator after a `--space` they
621
+ * cannot use (two of these commands do not even take one) is a dead end dressed as advice, so this
622
+ * refusal names the blast radius and stops, and stays a refusal until per-space teardown exists.
623
+ *
624
+ * A corrupt/foreign account record is refused too, NOT skipped: an under-count is the fail-open
625
+ * this guard exists to prevent - a file that occupies the account namespace but will not validate
626
+ * might be a real tenant, so the blast radius is unknown and a broker-wide delete must not proceed. */
627
+ export function assertSingleSpaceBroker(dir, operation) {
628
+ const { spaces, corrupt } = accountInventory(dir);
629
+ if (corrupt.length > 0)
630
+ throw new Error(`${operation} is broker-wide and this broker's tenant list is not fully readable (${corrupt.join(", ")}) - refusing to act while the blast radius is uncertain; repair or remove those account records first`);
631
+ if (spaces.length > 1)
632
+ throw new Error(`${operation} is broker-wide, and this broker hosts ${spaces.length} spaces (${spaces.join(", ")}) - it would apply to every one of them, and naming a single space cannot scope it; a per-space form does not exist yet`);
633
+ }
634
+ /** The VALIDATED tenant inventory of an auth dir - the ONE read of "how many tenants" every other
635
+ * surface derives from (the broker-wide guards, `soleSpaceOf`, `cotal status`, the target
636
+ * resolver). Four surfaces each reading the disk their own way is how an under-count slips past
637
+ * exactly one of them; there must be a single answer.
638
+ *
639
+ * `spaces` are the tenants whose account records are regular files that parse, carry a `space`
640
+ * equal to their own injective filename key, and round-trip. `corrupt` is every entry that
641
+ * OCCUPIES the account namespace (`account.*.json`) yet fails any of that - including a
642
+ * non-regular entry (symlink, directory): `Dirent.isFile()` is lstat-semantics, so skipping those
643
+ * would drop a tenant whose record was symlinked while `loadSpaceAccountAuth`'s path still
644
+ * resolved it - the exact under-count the guards exist to refuse. The authoritative name is the
645
+ * record's own `space`, never inferred from the filename. `corrupt` is what turns the guards
646
+ * fail-CLOSED: an unreadable record is uncertainty about how many tenants exist, and a
647
+ * broker-wide operation must not proceed on an under-count. */
648
+ export function accountInventory(dir) {
649
+ const spaces = [];
650
+ const corrupt = [];
651
+ if (existsSync(dir)) {
652
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
653
+ if (!entry.name.startsWith(SPACE_ACCOUNT_PREFIX) || !entry.name.endsWith(SPACE_ACCOUNT_SUFFIX))
654
+ continue;
655
+ // From here the entry CLAIMS the account namespace; anything wrong is corruption, never a skip.
656
+ const fromName = spaceFromAccountFile(entry.name);
657
+ if (fromName === undefined || !entry.isFile()) {
658
+ corrupt.push(entry.name);
659
+ continue;
660
+ }
661
+ let doc;
662
+ try {
663
+ doc = JSON.parse(readFileSync(join(dir, entry.name), "utf8"));
664
+ }
665
+ catch {
666
+ corrupt.push(entry.name);
667
+ continue;
668
+ }
669
+ // The name must round-trip AND the record must carry the account material it claims to.
670
+ // "Validated inventory" has to mean the shape a reader will compose, or a semantically-empty
671
+ // record (`{"space":"alpha"}`, no `account`) counts as a tenant here and then crashes the very
672
+ // `status`/compose path this inventory exists to keep fail-closed. Match the SpaceAccountAuth
673
+ // fields those readers dereference; a missing/blank one is corruption, not a tenant.
674
+ if (typeof doc.space !== "string" || doc.space !== fromName || !isAccountShape(doc.account)) {
675
+ corrupt.push(entry.name);
676
+ continue;
677
+ }
678
+ spaces.push(doc.space);
679
+ }
680
+ }
681
+ // The pre-W4 monolith counts as a tenant too; one that will not read counts as CORRUPT, not as
682
+ // absent - a reader like `status` must see the uncertainty, not crash or under-count on it.
683
+ try {
684
+ const legacy = loadLegacySpaceAuth(dir);
685
+ if (legacy && typeof legacy.space === "string" && legacy.space && !spaces.includes(legacy.space))
686
+ spaces.push(legacy.space);
687
+ }
688
+ catch {
689
+ corrupt.push(AUTH_FILE);
690
+ }
691
+ return { spaces: spaces.sort(), corrupt: corrupt.sort() };
150
692
  }
151
- /** Load the space trust material, or undefined if auth was never set up here. */
152
- export function loadSpaceAuth(dir) {
153
- const f = join(dir, AUTH_FILE);
154
- if (!existsSync(f))
693
+ /** Every space that has a VALID account record under this auth dir. The broker's tenant list on disk;
694
+ * names come from each record's authoritative `space`. Callers that must act on the blast radius use
695
+ * {@link assertSingleSpaceBroker} / {@link soleSpaceOf}, which also refuse on an unreadable record. */
696
+ export function listSpaceAccounts(dir) {
697
+ return accountInventory(dir).spaces;
698
+ }
699
+ // ---- the split trust records as SecretStore kinds (the signer-bearing seam) ----
700
+ //
701
+ // The trust material is the highest-blast-radius secret kind - whoever holds a space's account
702
+ // signing seed mints any cred in that account - so signer-bearing paths read and write it through
703
+ // the SecretStore seam: a hosted composition injects its own store (KMS/Vault) and no signing seed
704
+ // lands on the hosted disk, while the local default resolves byte-for-byte to the same files the FS
705
+ // readers/writers above use. The seam speaks the SPLIT layout (one broker record + per-space account
706
+ // records); the pre-W4 monolith key exists only as migration input and as the documented container
707
+ // signer mount (`cotal mint --signer` → `.cotal/auth/auth.json`, read-only, for `supervise`).
708
+ /** THE store key of the broker trust record. Under the workspace store (rooted at `.cotal`) it
709
+ * resolves byte-for-byte to `.cotal/auth/broker.json`. One source for every signer-bearing tier -
710
+ * a drifted literal would silently split the kind. */
711
+ export const BROKER_AUTH_KEY = `auth/${BROKER_FILE}`;
712
+ /** The LEGACY monolith store key (`auth/auth.json`) - migration INPUT and the container signer
713
+ * mount only; nothing writes this shape now (see the layout note above). */
714
+ export const SPACE_AUTH_KEY = `auth/${AUTH_FILE}`;
715
+ /** THE store key of one space's account record - `auth/account.<key>.json`, the same injective
716
+ * {@link spaceKey} filename the FS layout uses. */
717
+ export function spaceAccountKey(space) {
718
+ return `auth/${SPACE_ACCOUNT_PREFIX}${accountFileKey(space)}${SPACE_ACCOUNT_SUFFIX}`;
719
+ }
720
+ /** Parse one store record. Errors name ONLY the key, never the stored bytes: unlike the FS reader
721
+ * ({@link readAuthRecord}, whose errors describe files on the operator's own disk), a store value
722
+ * may be hosted or attacker-influenced, and nothing sourced from it may reach a caller's log. */
723
+ function parseStoreRecord(raw, key, what) {
724
+ try {
725
+ return JSON.parse(raw);
726
+ }
727
+ catch {
728
+ throw new Error(`the ${what} (${key}) is not valid JSON - repair or replace the store value`);
729
+ }
730
+ }
731
+ /** Validate a legacy-monolith store value without letting ANY stored field into the error: the
732
+ * wrapped message names only the key and the caller-provided (trusted) space label. Accepts both a
733
+ * full bundle (validated over the whole JWT chain, which also catches a relabel) and the stripped
734
+ * container signer projection (validated over its mintable account material) - see core's
735
+ * {@link validateSpaceAuthForRead}. */
736
+ function validateStoredSpaceAuth(value, key, space) {
737
+ try {
738
+ return validateSpaceAuthForRead(value, space);
739
+ }
740
+ catch {
741
+ throw new Error(`the space trust bundle (${key}) failed trust-chain validation for space "${space}" - the store value is corrupt or mislabeled; repair or replace it`);
742
+ }
743
+ }
744
+ /** Load one space's COMPOSED trust view through the seam - THE reader for signer-bearing paths (the
745
+ * manager's signer hold, the renewal owner, CLI mint/backup/restore/…). The store is the sole
746
+ * authority for the material; the space is REQUIRED for the same reason as {@link loadSpaceAuth}'s:
747
+ * a root-wide "the auth" read is ambiguous the moment a broker holds two accounts (space-blind CLI
748
+ * paths resolve theirs first via {@link getSoleSpaceAuth}). Mirrors the FS reader's shapes exactly -
749
+ * split records compose (and verify the account really was signed by this broker's operator), either
750
+ * half may still come from the legacy monolith during migration, and with both split records absent
751
+ * the monolith is read whole (full bundle or the stripped container signer) and validated against
752
+ * the caller's space; there a wrong-space or malformed value fails LOUD rather than reading as
753
+ * absent, because the monolith names exactly one tenant. Every error names only store keys and the
754
+ * caller's own space label, never the stored bytes. */
755
+ export async function getSpaceAuth(store, space) {
756
+ const accountKey = spaceAccountKey(space);
757
+ const brokerRaw = await store.get(BROKER_AUTH_KEY);
758
+ const accountRaw = await store.get(accountKey);
759
+ const legacyRaw = brokerRaw === undefined || accountRaw === undefined ? await store.get(SPACE_AUTH_KEY) : undefined;
760
+ if (brokerRaw === undefined && accountRaw === undefined) {
761
+ if (legacyRaw === undefined)
762
+ return undefined;
763
+ const legacy = parseStoreRecord(legacyRaw, SPACE_AUTH_KEY, "space trust bundle");
764
+ return validateStoredSpaceAuth(legacy, SPACE_AUTH_KEY, space);
765
+ }
766
+ const legacy = legacyRaw !== undefined ? parseStoreRecord(legacyRaw, SPACE_AUTH_KEY, "space trust bundle") : undefined;
767
+ const broker = brokerRaw !== undefined
768
+ ? parseStoreRecord(brokerRaw, BROKER_AUTH_KEY, "broker trust record")
769
+ : legacy
770
+ ? { operator: legacy.operator, sys: legacy.sys }
771
+ : undefined;
772
+ let account;
773
+ if (accountRaw !== undefined) {
774
+ const doc = parseStoreRecord(accountRaw, accountKey, "space account record");
775
+ if (doc.space !== space)
776
+ throw new Error(`the space account record (${accountKey}) does not name space "${space}" - it was renamed or corrupted; repair or replace it`);
777
+ if (!isAccountShape(doc.account))
778
+ throw new Error(`the space account record (${accountKey}) is missing its account material - the record is corrupt; restore it from backup`);
779
+ account = doc;
780
+ }
781
+ else if (legacy && legacy.space === space) {
782
+ account = { space: legacy.space, account: legacy.account };
783
+ }
784
+ if (!broker || !account)
155
785
  return undefined;
156
- return JSON.parse(readFileSync(f, "utf8"));
786
+ try {
787
+ return composeSpaceAuth(broker, account);
788
+ }
789
+ catch {
790
+ throw new Error(`the trust records (${BROKER_AUTH_KEY} + ${accountKey}) failed trust-chain validation for space "${space}" - the store holds an account this broker's operator did not sign; repair or replace it`);
791
+ }
792
+ }
793
+ /** The seam companion of {@link loadSoleSpaceAuth} for the space-blind CLI paths: the FS inventory
794
+ * (tenant NAMES only - non-secret) supplies the one space this root holds, failing loud on several
795
+ * via {@link soleSpaceOf}, and the store stays the sole authority for the trust material itself.
796
+ * Every space-blind caller is a workstation CLI verb with the root's auth dir on this disk; a
797
+ * hosted composition knows its space and calls {@link getSpaceAuth} directly. */
798
+ export async function getSoleSpaceAuth(store, dir) {
799
+ const space = soleSpaceOf(dir);
800
+ return space ? getSpaceAuth(store, space) : undefined;
801
+ }
802
+ /** Persist a composed value through the seam by DECOMPOSING it into its two records - the seam
803
+ * writer with the SAME refusals as the FS pair ({@link saveBrokerAuth}/{@link saveSpaceAccountAuth},
804
+ * shared via {@link guardBrokerOverwrite}): a stripped value, a foreign-operator overwrite, a stale
805
+ * or rolled-back system-account generation, and another tenant's account record all refuse loud.
806
+ * All guards run BEFORE either put, so a refusal never leaves a half-written pair. `sys.signingSeed`
807
+ * never lands at rest (broker-admin minting capability; the record shape drops it).
808
+ *
809
+ * With NO broker record in the store, the FS writer's whole-inventory orphan check cannot run - a
810
+ * {@link SecretStore} has no enumeration - so it ranges over the ADDRESSABLE records instead: this
811
+ * space's own account and the legacy monolith, each of which the incoming operator must have signed.
812
+ * That is complete for a hosted store (scoped per-tenant inside its own resolve, per the seam's
813
+ * design) and for every same-space local repair; a lost broker.json beside a DIFFERENT tenant's
814
+ * account on the local FS stays covered by the FS writer wherever {@link saveSpaceAuth} is the
815
+ * writer (accepted residual, documented here rather than silently absorbed). */
816
+ export async function putSpaceAuth(store, auth) {
817
+ assertWritableBrokerTrust(auth, "putSpaceAuth");
818
+ const accountKey = spaceAccountKey(auth.space);
819
+ const existingBrokerRaw = await store.get(BROKER_AUTH_KEY);
820
+ const existingBroker = existingBrokerRaw !== undefined ? parseStoreRecord(existingBrokerRaw, BROKER_AUTH_KEY, "broker trust record") : undefined;
821
+ let gen;
822
+ if (existingBroker) {
823
+ gen = guardBrokerOverwrite(BROKER_AUTH_KEY, existingBroker, auth);
824
+ }
825
+ else {
826
+ gen = brokerGen(auth.gen, "the value being written");
827
+ for (const [key, what] of [
828
+ [accountKey, "space account record"],
829
+ [SPACE_AUTH_KEY, "space trust bundle"],
830
+ ]) {
831
+ const raw = await store.get(key);
832
+ if (raw === undefined)
833
+ continue;
834
+ const doc = parseStoreRecord(raw, key, what);
835
+ if (typeof doc.space !== "string" || !isAccountShape(doc.account))
836
+ throw new Error(`the store has no broker record but holds an unreadable ${what} (${key}) - refusing to install an operator while the tenant list is uncertain; repair or remove it first`);
837
+ try {
838
+ composeSpaceAuth(auth, { space: doc.space, account: doc.account });
839
+ }
840
+ catch {
841
+ throw new Error(`the store has no broker record but holds a ${what} (${key}) the operator being written did not sign - refusing to install it: the existing tenant would be orphaned. Restore the original broker trust instead.`);
842
+ }
843
+ }
844
+ }
845
+ const existingAccountRaw = await store.get(accountKey);
846
+ if (existingAccountRaw !== undefined) {
847
+ const doc = parseStoreRecord(existingAccountRaw, accountKey, "space account record");
848
+ if (doc.space !== auth.space)
849
+ throw new Error(`the space account record (${accountKey}) does not name space "${auth.space}" - refusing to overwrite another tenant's account record`);
850
+ }
851
+ const brokerAtRest = { operator: auth.operator, sys: { pub: auth.sys.pub, jwt: auth.sys.jwt }, gen };
852
+ const accountAtRest = { space: auth.space, account: auth.account };
853
+ await store.put(BROKER_AUTH_KEY, JSON.stringify(brokerAtRest, null, 2));
854
+ await store.put(accountKey, JSON.stringify(accountAtRest, null, 2));
855
+ }
856
+ /** Remove the space trust material through the seam - the authoritative delete for a non-FS store.
857
+ * BROKER-WIDE by nature (the broker record dies with its last space's account), so its one caller
858
+ * is `clean all` AFTER the failure gate and the {@link assertSingleSpaceBroker} guard - on a
859
+ * multi-tenant root that guard refuses long before this runs. Deletes the space's account record,
860
+ * the broker record, and the legacy monolith. Idempotent. */
861
+ export async function deleteSpaceAuth(store, space) {
862
+ await store.delete(spaceAccountKey(space));
863
+ await store.delete(BROKER_AUTH_KEY);
864
+ await store.delete(SPACE_AUTH_KEY);
157
865
  }
158
866
  //# sourceMappingURL=auth-paths.js.map