@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.
- package/dist/auth-paths.d.ts +180 -18
- package/dist/auth-paths.d.ts.map +1 -1
- package/dist/auth-paths.js +739 -31
- package/dist/auth-paths.js.map +1 -1
- package/dist/connect.js +3 -3
- package/dist/connect.js.map +1 -1
- package/dist/extension-mutation.d.ts +27 -0
- package/dist/extension-mutation.d.ts.map +1 -0
- package/dist/extension-mutation.js +229 -0
- package/dist/extension-mutation.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/local-process.d.ts +2 -1
- package/dist/local-process.d.ts.map +1 -1
- package/dist/local-process.js +37 -3
- package/dist/local-process.js.map +1 -1
- package/dist/materialize.d.ts.map +1 -1
- package/dist/materialize.js +5 -5
- package/dist/materialize.js.map +1 -1
- package/dist/mesh-registry.d.ts +3 -1
- package/dist/mesh-registry.d.ts.map +1 -1
- package/dist/mesh-registry.js +59 -6
- package/dist/mesh-registry.js.map +1 -1
- package/dist/mesh-target.d.ts +1 -1
- package/dist/mesh-target.d.ts.map +1 -1
- package/dist/mesh-target.js +98 -30
- package/dist/mesh-target.js.map +1 -1
- package/dist/official-connectors.d.ts +12 -0
- package/dist/official-connectors.d.ts.map +1 -1
- package/dist/official-connectors.js +4 -0
- package/dist/official-connectors.js.map +1 -1
- package/dist/render.js +2 -0
- package/dist/render.js.map +1 -1
- package/dist/renewal.d.ts +43 -13
- package/dist/renewal.d.ts.map +1 -1
- package/dist/renewal.js +85 -21
- package/dist/renewal.js.map +1 -1
- package/dist/space.d.ts +4 -2
- package/dist/space.d.ts.map +1 -1
- package/dist/space.js +6 -4
- package/dist/space.js.map +1 -1
- package/package.json +2 -2
package/dist/auth-paths.js
CHANGED
|
@@ -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
|
-
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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 = {
|
|
149
|
-
writeSecretFile(
|
|
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
|
-
/**
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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
|