@cotal-ai/workspace 0.15.0 → 0.17.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.
@@ -0,0 +1,89 @@
1
+ import { type SpaceAuth } from "@cotal-ai/core";
2
+ /**
3
+ * The class-3 ($SYS) renewal owner's half, the counterpart to `renewal.ts`, which owns the class-2
4
+ * standing renewal and deliberately EXCLUDES these two files.
5
+ *
6
+ * `membership-observer.creds` and `connection-evictor.creds` are `rotation-renewed`: they are signed
7
+ * by the system-account seed, which is never persisted (`putSpaceAuth` strips it), so no running
8
+ * process can re-sign them for their existing identity the way `remintDaemonCreds` does. Their only
9
+ * renewal is a system-account ROTATION: a fresh $SYS account under the SAME broker operator, fresh
10
+ * creds minted from its in-memory seed, and a broker that reloads the new operator/system JWTs.
11
+ *
12
+ * That rotation is NOT destructive. `rotateSystemAccount` re-signs the operator JWT with a new
13
+ * `system_account` using the operator's own (unchanged) seed, so the space's DATA account, its
14
+ * signing key, every agent credential minted from it, and the JetStream store all survive untouched.
15
+ * What dies is exactly what should: the retired system account and any copy of the old $SYS creds.
16
+ *
17
+ * The one thing this module cannot do is make the broker load the result. Rotation is only complete
18
+ * once the broker restarts on a `server.conf` rendered from the rotated record, which is why the
19
+ * operator surface is `cotal up --rotate-sys` (the one command that already renders that config and
20
+ * boots the broker + daemons from it) rather than a standalone verb that would leave the mesh in a
21
+ * half-rotated state.
22
+ *
23
+ * WORKSTATION ONLY, deliberately: unlike `renewal.ts`, nothing here takes a {@link SecretStore}. The
24
+ * $SYS pair lives on the raw FS with no store seam, and the multi-tenant guard below reads the FS
25
+ * account records, which an injected store could neither supply nor be enumerated for. See
26
+ * {@link rotateSystemCreds}.
27
+ */
28
+ /** The two $SYS credential files, by the same key↔filename convention `renewal.ts` uses. They stay on
29
+ * the raw FS, with no secret-store seam: renewing them means rewriting the broker config too, so the
30
+ * pair and the trust record move together or not at all, which is not something a store can hold half
31
+ * of. Named here so the rotation writer, the staleness check and `cotal clean`'s removal list cannot
32
+ * drift apart. */
33
+ export declare const SYSTEM_CREDS_FILES: readonly ["membership-observer.creds", "connection-evictor.creds"];
34
+ export interface SystemRotationResult {
35
+ /** The broker record's new system-account generation (the successor discriminator `putSpaceAuth` guards). */
36
+ gen: number;
37
+ /** The rotated bundle. The caller MUST render `server.conf` from this, not from its pre-rotation copy. */
38
+ auth: SpaceAuth;
39
+ /** Expiry (epoch sec) of the freshly minted $SYS creds, so the caller can print the next rotation date. */
40
+ expiresAt?: number;
41
+ }
42
+ /**
43
+ * Rotate the space's system account and re-mint both $SYS creds against it.
44
+ *
45
+ * `expectedSpace` is validated by `getSpaceAuth` against the store's signer, the same cross-space
46
+ * guard `remintDaemonCreds` runs: rotating with a foreign space's operator would re-issue an operator
47
+ * JWT this broker does not trust and strand every account under it.
48
+ *
49
+ * Ordering is the safety property, and it is a DIRECTIONAL one, not atomicity. All MINTING (the
50
+ * rotation and both creds) happens in memory before anything is persisted, so a mint failure changes
51
+ * nothing on disk. Then the trust record commits, where `putSpaceAuth`'s generation guard refuses a
52
+ * stale or non-successor write. Then the creds land. What this buys is a single failure direction: a
53
+ * cred file is never overwritten for a system account the record does not already carry, because the
54
+ * inverse order could clobber the last-good creds with creds for an authority the broker will never
55
+ * load, which is the availability loss `remintDaemonCreds` guards on its own class.
56
+ *
57
+ * It does NOT make the persistence atomic. `putSpaceAuth` is two puts and the creds are two more, so
58
+ * a crash leaves the record AHEAD of the creds. That state is detected rather than prevented (see
59
+ * {@link staleSystemCreds}) and is repaired by re-running the rotation.
60
+ *
61
+ * THROWS rather than degrading: a stripped signer (no operator seed) cannot rotate, and a caller that
62
+ * ignored the throw would boot a broker whose config still carries the retired system account.
63
+ */
64
+ export declare function rotateSystemCreds(root: string, expectedSpace: string): Promise<SystemRotationResult>;
65
+ /** A $SYS credential file on disk that the persisted trust record does NOT authorize. */
66
+ export interface StaleSystemCred {
67
+ file: string;
68
+ /** The retired system account that signed it; absent when the file could not be parsed. */
69
+ iss?: string;
70
+ }
71
+ /**
72
+ * The $SYS cred files whose issuer is not `sysPub`: the ONE staleness question, asked in one place
73
+ * so every surface answers it identically.
74
+ *
75
+ * This exists because expiry cannot see the failure. A rotation that committed the trust record and
76
+ * then died leaves credential files that parse, are nowhere near their expiry, and are refused by the
77
+ * broker, because the account that signed them no longer exists as far as the successor config is
78
+ * concerned. Comparing the two files to each other is not enough either: a crash BEFORE either write
79
+ * leaves them stale but mutually consistent. Only the persisted record settles it, which is why this
80
+ * takes `sysPub` and why the callers are the two surfaces that hold it: the boot path, which warns
81
+ * before it renders a config the files cannot serve, and `cotal doctor auth`, which must never call
82
+ * this state healthy. The delivery daemon is deliberately NOT a caller: it never loads the signer.
83
+ *
84
+ * Absent files are not stale (an unprovisioned space is a different, separately reported state), and
85
+ * an unreadable file is reported with no `iss` rather than throwing, because a diagnosis surface must not
86
+ * crash on the corruption it exists to describe.
87
+ */
88
+ export declare function staleSystemCreds(root: string, sysPub: string): StaleSystemCred[];
89
+ //# sourceMappingURL=system-rotation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"system-rotation.d.ts","sourceRoot":"","sources":["../src/system-rotation.ts"],"names":[],"mappings":"AAEA,OAAO,EAOL,KAAK,SAAS,EACf,MAAM,gBAAgB,CAAC;AAIxB;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH;;;;mBAImB;AACnB,eAAO,MAAM,kBAAkB,oEAAqE,CAAC;AAErG,MAAM,WAAW,oBAAoB;IACnC,6GAA6G;IAC7G,GAAG,EAAE,MAAM,CAAC;IACZ,0GAA0G;IAC1G,IAAI,EAAE,SAAS,CAAC;IAChB,2GAA2G;IAC3G,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAsB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAkD1G;AAED,yFAAyF;AACzF,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,2FAA2F;IAC3F,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,eAAe,EAAE,CAehF"}
@@ -0,0 +1,142 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { credsClaims, mintConnectionEvictorCreds, mintMembershipObserverCreds, newIdentity, rotateSystemAccount, writeSecretFileAtomic, } from "@cotal-ai/core";
4
+ import { assertSingleSpaceBroker, authDir, getSpaceAuth, putSpaceAuth } from "./auth-paths.js";
5
+ import { workspaceSecretStore } from "./secret-store-fs.js";
6
+ /**
7
+ * The class-3 ($SYS) renewal owner's half, the counterpart to `renewal.ts`, which owns the class-2
8
+ * standing renewal and deliberately EXCLUDES these two files.
9
+ *
10
+ * `membership-observer.creds` and `connection-evictor.creds` are `rotation-renewed`: they are signed
11
+ * by the system-account seed, which is never persisted (`putSpaceAuth` strips it), so no running
12
+ * process can re-sign them for their existing identity the way `remintDaemonCreds` does. Their only
13
+ * renewal is a system-account ROTATION: a fresh $SYS account under the SAME broker operator, fresh
14
+ * creds minted from its in-memory seed, and a broker that reloads the new operator/system JWTs.
15
+ *
16
+ * That rotation is NOT destructive. `rotateSystemAccount` re-signs the operator JWT with a new
17
+ * `system_account` using the operator's own (unchanged) seed, so the space's DATA account, its
18
+ * signing key, every agent credential minted from it, and the JetStream store all survive untouched.
19
+ * What dies is exactly what should: the retired system account and any copy of the old $SYS creds.
20
+ *
21
+ * The one thing this module cannot do is make the broker load the result. Rotation is only complete
22
+ * once the broker restarts on a `server.conf` rendered from the rotated record, which is why the
23
+ * operator surface is `cotal up --rotate-sys` (the one command that already renders that config and
24
+ * boots the broker + daemons from it) rather than a standalone verb that would leave the mesh in a
25
+ * half-rotated state.
26
+ *
27
+ * WORKSTATION ONLY, deliberately: unlike `renewal.ts`, nothing here takes a {@link SecretStore}. The
28
+ * $SYS pair lives on the raw FS with no store seam, and the multi-tenant guard below reads the FS
29
+ * account records, which an injected store could neither supply nor be enumerated for. See
30
+ * {@link rotateSystemCreds}.
31
+ */
32
+ /** The two $SYS credential files, by the same key↔filename convention `renewal.ts` uses. They stay on
33
+ * the raw FS, with no secret-store seam: renewing them means rewriting the broker config too, so the
34
+ * pair and the trust record move together or not at all, which is not something a store can hold half
35
+ * of. Named here so the rotation writer, the staleness check and `cotal clean`'s removal list cannot
36
+ * drift apart. */
37
+ export const SYSTEM_CREDS_FILES = ["membership-observer.creds", "connection-evictor.creds"];
38
+ /**
39
+ * Rotate the space's system account and re-mint both $SYS creds against it.
40
+ *
41
+ * `expectedSpace` is validated by `getSpaceAuth` against the store's signer, the same cross-space
42
+ * guard `remintDaemonCreds` runs: rotating with a foreign space's operator would re-issue an operator
43
+ * JWT this broker does not trust and strand every account under it.
44
+ *
45
+ * Ordering is the safety property, and it is a DIRECTIONAL one, not atomicity. All MINTING (the
46
+ * rotation and both creds) happens in memory before anything is persisted, so a mint failure changes
47
+ * nothing on disk. Then the trust record commits, where `putSpaceAuth`'s generation guard refuses a
48
+ * stale or non-successor write. Then the creds land. What this buys is a single failure direction: a
49
+ * cred file is never overwritten for a system account the record does not already carry, because the
50
+ * inverse order could clobber the last-good creds with creds for an authority the broker will never
51
+ * load, which is the availability loss `remintDaemonCreds` guards on its own class.
52
+ *
53
+ * It does NOT make the persistence atomic. `putSpaceAuth` is two puts and the creds are two more, so
54
+ * a crash leaves the record AHEAD of the creds. That state is detected rather than prevented (see
55
+ * {@link staleSystemCreds}) and is repaired by re-running the rotation.
56
+ *
57
+ * THROWS rather than degrading: a stripped signer (no operator seed) cannot rotate, and a caller that
58
+ * ignored the throw would boot a broker whose config still carries the retired system account.
59
+ */
60
+ export async function rotateSystemCreds(root, expectedSpace) {
61
+ // A rotation is BROKER-wide, not space-wide, however it is spelled: the system account lives in
62
+ // the shared broker record and the re-issued operator JWT names the successor for every space
63
+ // under it, while the two $SYS cred files are per-ROOT (one pair, its observer permissions pinned
64
+ // to ONE data account). So on a multi-tenant root, "rotate space A" would retire space B's system
65
+ // account and leave no cred that can observe B. Same guard, same reason, as `cotal down`,
66
+ // `cotal backup`, `cotal clean` and `cotal up --restore`.
67
+ //
68
+ // The guard reads the FS account records, and that is exactly why this function takes NO
69
+ // SecretStore. `SecretStore` cannot enumerate, so an injected multi-tenant store paired with an
70
+ // empty local root would sail past this check and retire the system account for every tenant in
71
+ // it: a guard that looks broker-wide while enforcing nothing. Taking the store away makes the
72
+ // mismatch impossible to express rather than merely refused at runtime, and it costs nothing real:
73
+ // the $SYS pair is FS-only anyway (a hosted composition has nowhere in the store to put it), so
74
+ // this operation was never store-composable to begin with. If store enumeration ever exists, this
75
+ // becomes a real hosted seam; until then it is a workstation operation and says so.
76
+ assertSingleSpaceBroker(authDir(root), "a system-account rotation (`cotal up --rotate-sys`)");
77
+ const s = workspaceSecretStore(root);
78
+ const auth = await getSpaceAuth(s, expectedSpace);
79
+ if (!auth)
80
+ throw new Error(`rotateSystemCreds: no trust record for space "${expectedSpace}" - there is no system account to rotate`);
81
+ if (!auth.operator.seed)
82
+ throw new Error("rotateSystemCreds: the broker operator seed is required to rotate the system account (this root holds a stripped signer) - run the rotation where the broker trust record lives");
83
+ // In-memory first: the rotation and BOTH mints must succeed before anything is persisted.
84
+ const rotated = await rotateSystemAccount(auth);
85
+ const observer = await mintMembershipObserverCreds(rotated, newIdentity());
86
+ const evictor = await mintConnectionEvictorCreds(rotated, newIdentity());
87
+ // Commit the trust record FIRST: the generation guard lives in this call, and it is what makes the
88
+ // successor real. Only then the creds that record authorizes.
89
+ //
90
+ // This is NOT one atomic act, and the code must not pretend otherwise: `putSpaceAuth` is itself two
91
+ // puts, and the two cred writes are two more. A crash anywhere in this sequence leaves the record
92
+ // AHEAD of the creds: both files stale (crash before either write) or one stale (crash between
93
+ // them). The window is not closed here; it is DETECTED, by {@link staleSystemCreds}, which every
94
+ // boot and every `doctor auth` runs against the persisted record. It cannot be repaired in place
95
+ // either: the successor's signing seed is gone the moment it is persisted, so the only repair is
96
+ // another rotation, which is idempotent and costs one generation. A durable rotation journal would
97
+ // close the window instead of reporting it; that is deliberately not built for a display-only feed
98
+ // plus an eviction rail whose recovery is one re-run.
99
+ //
100
+ // Each file is still written atomically, so no reader can ever see a half-written credential.
101
+ await putSpaceAuth(s, rotated);
102
+ writeSecretFileAtomic(join(root, ".cotal", SYSTEM_CREDS_FILES[0]), observer);
103
+ writeSecretFileAtomic(join(root, ".cotal", SYSTEM_CREDS_FILES[1]), evictor);
104
+ return { gen: rotated.gen ?? 0, auth: rotated, expiresAt: credsClaims(observer).exp };
105
+ }
106
+ /**
107
+ * The $SYS cred files whose issuer is not `sysPub`: the ONE staleness question, asked in one place
108
+ * so every surface answers it identically.
109
+ *
110
+ * This exists because expiry cannot see the failure. A rotation that committed the trust record and
111
+ * then died leaves credential files that parse, are nowhere near their expiry, and are refused by the
112
+ * broker, because the account that signed them no longer exists as far as the successor config is
113
+ * concerned. Comparing the two files to each other is not enough either: a crash BEFORE either write
114
+ * leaves them stale but mutually consistent. Only the persisted record settles it, which is why this
115
+ * takes `sysPub` and why the callers are the two surfaces that hold it: the boot path, which warns
116
+ * before it renders a config the files cannot serve, and `cotal doctor auth`, which must never call
117
+ * this state healthy. The delivery daemon is deliberately NOT a caller: it never loads the signer.
118
+ *
119
+ * Absent files are not stale (an unprovisioned space is a different, separately reported state), and
120
+ * an unreadable file is reported with no `iss` rather than throwing, because a diagnosis surface must not
121
+ * crash on the corruption it exists to describe.
122
+ */
123
+ export function staleSystemCreds(root, sysPub) {
124
+ const stale = [];
125
+ for (const file of SYSTEM_CREDS_FILES) {
126
+ const path = join(root, ".cotal", file);
127
+ if (!existsSync(path))
128
+ continue;
129
+ let iss;
130
+ try {
131
+ iss = credsClaims(readFileSync(path, "utf8")).iss;
132
+ }
133
+ catch {
134
+ stale.push({ file }); // unreadable: cannot be shown to match, so it is not assumed to
135
+ continue;
136
+ }
137
+ if (iss !== sysPub)
138
+ stale.push({ file, iss });
139
+ }
140
+ return stale;
141
+ }
142
+ //# sourceMappingURL=system-rotation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"system-rotation.js","sourceRoot":"","sources":["../src/system-rotation.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@cotal-ai/workspace",
3
3
  "description": "Cotal machine-local workstation layer: the ~/.cotal mesh registry, target resolution, preflight, on-disk auth-path I/O, and the command-copy renderer — operator concerns over a local checkout, kept separate from the wire protocol in @cotal-ai/core.",
4
- "version": "0.15.0",
4
+ "version": "0.17.0",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
@@ -18,7 +18,7 @@
18
18
  }
19
19
  },
20
20
  "dependencies": {
21
- "@cotal-ai/core": "0.15.0"
21
+ "@cotal-ai/core": "0.17.0"
22
22
  },
23
23
  "files": [
24
24
  "dist"