@cotal-ai/workspace 0.35.0 → 0.37.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/agent-secrets.d.ts +206 -0
- package/dist/agent-secrets.d.ts.map +1 -0
- package/dist/agent-secrets.js +468 -0
- package/dist/agent-secrets.js.map +1 -0
- package/dist/auth-paths.d.ts +10 -53
- package/dist/auth-paths.d.ts.map +1 -1
- package/dist/auth-paths.js +18 -134
- package/dist/auth-paths.js.map +1 -1
- package/dist/bin-path.d.ts.map +1 -1
- package/dist/bin-path.js +4 -1
- package/dist/bin-path.js.map +1 -1
- package/dist/extensions.d.ts +13 -1
- package/dist/extensions.d.ts.map +1 -1
- package/dist/extensions.js +16 -0
- package/dist/extensions.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/local-process.d.ts +111 -1
- package/dist/local-process.d.ts.map +1 -1
- package/dist/local-process.js +204 -23
- package/dist/local-process.js.map +1 -1
- package/dist/pid.d.ts +0 -9
- package/dist/pid.d.ts.map +1 -1
- package/dist/pid.js +0 -10
- package/dist/pid.js.map +1 -1
- package/dist/progress.d.ts +18 -0
- package/dist/progress.d.ts.map +1 -0
- package/dist/progress.js +9 -0
- package/dist/progress.js.map +1 -0
- package/dist/renewal.d.ts +22 -15
- package/dist/renewal.d.ts.map +1 -1
- package/dist/renewal.js +27 -21
- package/dist/renewal.js.map +1 -1
- package/dist/space-segmentation.d.ts +260 -0
- package/dist/space-segmentation.d.ts.map +1 -0
- package/dist/space-segmentation.js +428 -0
- package/dist/space-segmentation.js.map +1 -0
- package/dist/space.d.ts +19 -0
- package/dist/space.d.ts.map +1 -1
- package/dist/space.js +31 -0
- package/dist/space.js.map +1 -1
- package/dist/system-rotation.d.ts +7 -6
- package/dist/system-rotation.d.ts.map +1 -1
- package/dist/system-rotation.js +25 -10
- package/dist/system-rotation.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared foundation for segmenting root-scoped material per space (P7, then P1) —
|
|
3
|
+
* `docs/design/space-segmentation-p7-p1.md` §2 and §3.
|
|
4
|
+
*
|
|
5
|
+
* Material that is per-tenant in MEANING sits at a root-scoped path today, so a root holds exactly
|
|
6
|
+
* one tenant's copy of it and a sibling tenant silently inherits it. Ending that means writing the
|
|
7
|
+
* owning tenant's name into the location, which raises one question this module answers ONCE for
|
|
8
|
+
* both series: what happens to the roots that already exist.
|
|
9
|
+
*
|
|
10
|
+
* The answer is MOVE ON FIRST TOUCH at a single choke point, not read-fallback, for the reason
|
|
11
|
+
* `migrateLegacyUserAuthState` (`auth-paths.ts:131`) already records: a fallback leaves flows able to
|
|
12
|
+
* read, or worse to `ensure*`-REGENERATE, beside material the old layout still holds. That hazard is
|
|
13
|
+
* sharper here, because this material has absent-means-mint writers (`up.ts:2885`, `up.ts:2889`) — a
|
|
14
|
+
* canonical read on an unmigrated root reads absent and mints a SECOND live cred beside the one the
|
|
15
|
+
* daemons are using.
|
|
16
|
+
*
|
|
17
|
+
* The choke point is {@link migrateLegacyMaterialIn}; the per-kind resolvers built on it are at
|
|
18
|
+
* the bottom of this file, and they are what every consumer of the five P7 kinds calls. Series P1
|
|
19
|
+
* consumes the same choke point from `agent-secrets.ts` — the rules are identical, only the parent
|
|
20
|
+
* directory differs, because §3 places P7's segment under `.cotal/` and P1's under `.cotal/auth/creds/`.
|
|
21
|
+
*/
|
|
22
|
+
import { existsSync, readdirSync, renameSync, rmSync } from "node:fs";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import { mkSecretDir } from "@cotal-ai/core";
|
|
25
|
+
import { accountInventory, authDir, spaceSegment } from "./auth-paths.js";
|
|
26
|
+
/** `<root>/.cotal` — the dir whose children the segment must never collide with. */
|
|
27
|
+
export function cotalDir(root) {
|
|
28
|
+
return join(root, ".cotal");
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The reserved children of `<root>/.cotal/` that the codebase itself writes.
|
|
32
|
+
*
|
|
33
|
+
* {@link spaceSegment}'s collision guarantee was written against the reserved siblings of the AUTH
|
|
34
|
+
* dir. P7 puts a segment directly under `.cotal/`, which is a WIDER namespace, so the guarantee has
|
|
35
|
+
* to hold there too. It does today — no `.cotal` child begins with `space.` — but it held by
|
|
36
|
+
* ACCIDENT until something asserted it, which is what `smoke:space-segmentation` now does.
|
|
37
|
+
*
|
|
38
|
+
* LITERAL children only. The space-KEYED children (`manager.<spaceKey>.pid`, its log and
|
|
39
|
+
* delivery-aware marker, `delivery.<spaceKey>.pid`, its log, `auth-service.<spaceKey>.pid`) are not
|
|
40
|
+
* listed because they are not fixed names; they are the nearest miss to a canonical segment, sharing
|
|
41
|
+
* the `<prefix>.<spaceKey>` shape, and the guard suite checks their real expansions directly. Their
|
|
42
|
+
* root-scoped spellings USED to be listed here — the runtime pid/log namespace was root-scoped, so
|
|
43
|
+
* one root hosted one manager and one delivery daemon by filename — and the records among them are
|
|
44
|
+
* now `PRE_SEGMENTATION_RUNTIME_RECORDS` in `local-process.ts`, which is where the readers and
|
|
45
|
+
* sweepers take them from.
|
|
46
|
+
*
|
|
47
|
+
* Keep in sync with the writers of `.cotal/` children (`cotalPath(...)` in `up.ts`, the raw removal
|
|
48
|
+
* list at `clean.ts:272-279`). A name added here that starts with `space.` is a real collision and
|
|
49
|
+
* the guard suite fails rather than the layout silently aliasing a tenant's segment.
|
|
50
|
+
*/
|
|
51
|
+
export const RESERVED_COTAL_CHILDREN = [
|
|
52
|
+
"agents", "auth", "auth-service.json", "broker-policy.json", "channels.json", "config.json",
|
|
53
|
+
"connection-evictor.creds", "delivery.creds", "maintenance", "manifests", "membership.json",
|
|
54
|
+
"membership-observer.creds", "membership-rw.creds", "meshes", "nats", "nats.log", "nats.pid",
|
|
55
|
+
"setup.log",
|
|
56
|
+
];
|
|
57
|
+
/** The delivery daemon's scoped cred — the KIND, i.e. the basename its location ends in and the
|
|
58
|
+
* name every operator-facing string spells. Its store key is `space.<hex>/delivery.creds`, built by
|
|
59
|
+
* {@link deliveryCredsKey}; the bare kind is also the LEGACY root-scoped key this series migrates
|
|
60
|
+
* away from, which is why one constant serves both (a second literal is how the two would drift).
|
|
61
|
+
* Lives in workspace because the key↔filename convention is the workspace layout's; implementations
|
|
62
|
+
* never import each other. */
|
|
63
|
+
export const DELIVERY_CREDS_KIND = "delivery.creds";
|
|
64
|
+
/** The membership feed's data-account rw cred kind — same discipline as {@link DELIVERY_CREDS_KIND}.
|
|
65
|
+
* Named (not a bare literal) so the renewal owner can map a remint result back to the daemon's
|
|
66
|
+
* `membership` component without a hand-copied string. */
|
|
67
|
+
export const MEMBERSHIP_RW_CREDS_KIND = "membership-rw.creds";
|
|
68
|
+
/** The `$SYS` CONNZ observer's kind — the graph feed's read connection and the account-scoped sweep
|
|
69
|
+
* every liveness/eviction verdict is measured against.
|
|
70
|
+
*
|
|
71
|
+
* NOT in `REMINTABLE_DAEMON_CREDS` and never will be: this is `rotation-renewed`, so no persisted
|
|
72
|
+
* seed can re-sign it (the `$SYS` signing seed is discarded at provision). The key exists so a
|
|
73
|
+
* HOSTED composition can inject the cred a system-account rotation minted; it does not make the
|
|
74
|
+
* cred renewable. See `docs/design/u3-membership-sys-injection.md` §2. */
|
|
75
|
+
export const MEMBERSHIP_OBSERVER_CREDS_KIND = "membership-observer.creds";
|
|
76
|
+
/** The `$SYS` KICK-only evictor's kind — the write half of live eviction, paired with
|
|
77
|
+
* {@link MEMBERSHIP_OBSERVER_CREDS_KIND} by one rotation and read per call.
|
|
78
|
+
*
|
|
79
|
+
* Same `rotation-renewed` posture and the same non-membership of `REMINTABLE_DAEMON_CREDS`. Its
|
|
80
|
+
* permission (`$SYS.REQ.SERVER.*.KICK`) carries NO account, so unlike the observer it cannot be
|
|
81
|
+
* tenancy-checked from its own JWT; its containment is that every cid it is handed comes from the
|
|
82
|
+
* observer's account-scoped scan. Keep the two spelled together for that reason. */
|
|
83
|
+
export const CONNECTION_EVICTOR_CREDS_KIND = "connection-evictor.creds";
|
|
84
|
+
/** The DATA account id the CONNZ/event subjects pin — non-secret, but kept 0600 beside the creds.
|
|
85
|
+
* The only P7 kind with no store reader: it is read raw by the eviction path's workstation
|
|
86
|
+
* cross-check, so its resolver returns a PATH ({@link membershipConfigPath}) and has no hosted arm
|
|
87
|
+
* at all. */
|
|
88
|
+
export const MEMBERSHIP_CONFIG_KIND = "membership.json";
|
|
89
|
+
/** The P7 kinds' ROOT-SCOPED locations — the legacy layout this series retires. The two store keys
|
|
90
|
+
* (`membership-rw.creds`, `delivery.creds`) appear as plain names because under the local FS
|
|
91
|
+
* composition a key IS a path under `.cotal/`; see {@link migrateLegacyCotalMaterial} on why the
|
|
92
|
+
* migration is FS-composition-only. `delivery.creds` is here by the §3.2 widening. */
|
|
93
|
+
export const P7_LEGACY_MATERIAL = [
|
|
94
|
+
MEMBERSHIP_OBSERVER_CREDS_KIND, CONNECTION_EVICTOR_CREDS_KIND, MEMBERSHIP_RW_CREDS_KIND,
|
|
95
|
+
MEMBERSHIP_CONFIG_KIND, DELIVERY_CREDS_KIND,
|
|
96
|
+
];
|
|
97
|
+
/**
|
|
98
|
+
* §2 RULE 4'S PRECONDITION, ASKABLE: would {@link migrateLegacyCotalMaterial} refuse on this root,
|
|
99
|
+
* and why? `undefined` means the root can be shown to hold exactly one tenant.
|
|
100
|
+
*
|
|
101
|
+
* THE ONE IMPLEMENTATION of the rule, exported so a guard that needs to know whether `cotal up` can
|
|
102
|
+
* migrate this root ASKS instead of re-deriving a tenant count. That is commit 2's `repairAdvice`
|
|
103
|
+
* lesson (`sys-creds.ts`) applied where it was still owed: a count is a second implementation of the
|
|
104
|
+
* rule, and it reads "one" on the corrupt-inventory root where this fails CLOSED. A guard built on a
|
|
105
|
+
* count would print a remedy on exactly the root where the remedy refuses.
|
|
106
|
+
*
|
|
107
|
+
* The two callers need it for opposite reasons — the choke point to refuse, the doors below to say
|
|
108
|
+
* truthfully what an operator can do next — and a rule with two implementations drifts at whichever
|
|
109
|
+
* one is not the one someone edits.
|
|
110
|
+
*/
|
|
111
|
+
export function spaceMaterialMigrationRefusal(root) {
|
|
112
|
+
const { spaces, corrupt } = accountInventory(authDir(root));
|
|
113
|
+
if (corrupt.length > 0)
|
|
114
|
+
return {
|
|
115
|
+
reason: `this root's tenant list is not fully readable (${corrupt.join(", ")}), so it cannot be shown to hold one space`,
|
|
116
|
+
// Unlike the multi-tenant case this one HAS a remedy, which is why the two are not merged into
|
|
117
|
+
// one refusal: an unreadable record is repairable, an unrecorded owner is not.
|
|
118
|
+
remedy: "Repair or remove those account records first.",
|
|
119
|
+
};
|
|
120
|
+
if (spaces.length > 1)
|
|
121
|
+
return {
|
|
122
|
+
reason: `this root holds ${spaces.length} spaces (${spaces.join(", ")}). ` +
|
|
123
|
+
"The root-scoped copy belongs to whichever tenant booted first and nothing on disk records which, " +
|
|
124
|
+
"so moving it into one tenant's segment would assert an owner that may be wrong",
|
|
125
|
+
remedy: "There is no command to offer here - `cotal up` migrates only on a single-tenant root, and " +
|
|
126
|
+
"`cotal up --rotate-sys` is broker-wide and refuses on this root too. " +
|
|
127
|
+
"Per-space segmentation must land before this material can be reminted here.",
|
|
128
|
+
};
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* THE CHOKE POINT (§2 rules 1-4): resolve one kind's per-space location, migrating a legacy
|
|
133
|
+
* root-scoped copy into it on first touch, or REFUSING when the move cannot be made honestly.
|
|
134
|
+
*
|
|
135
|
+
* Returns the canonical path. A caller must obtain the location from here and never build it
|
|
136
|
+
* itself — that is rule 1, and it is what makes "migrate on first touch" reach every flow rather
|
|
137
|
+
* than the ones someone remembered to update.
|
|
138
|
+
*
|
|
139
|
+
* FS COMPOSITION ONLY, and the signature says so rather than the comment alone: this takes a root
|
|
140
|
+
* PATH and no `SecretStore`, so a hosted composition cannot call it. Rule 2's atomicity — the move
|
|
141
|
+
* is one `renameSync`, so a crash leaves each kind wholly legacy or wholly canonical — is a property
|
|
142
|
+
* of the filesystem, and the same move against a hosted store would be a get, put and delete with no
|
|
143
|
+
* atomicity across the three. That is not a gap: a hosted composition provisions these keys
|
|
144
|
+
* externally and re-keys by the coordinated change of §3.1, never by migrating in place. Taking no
|
|
145
|
+
* store makes the unsound call impossible to express, the same reason `rotateSystemCreds` takes none
|
|
146
|
+
* (`system-rotation.ts:88-95`).
|
|
147
|
+
*
|
|
148
|
+
* `parent` is the directory the legacy copy sits DIRECTLY in and under which the segment is created.
|
|
149
|
+
* It is a parameter rather than `cotalDir(root)` because §3 settled two different placements for the
|
|
150
|
+
* two series — P7's segment is a child of `.cotal/`, P1's a child of `.cotal/auth/creds/` — and that
|
|
151
|
+
* was decided in the plan, not discovered by P1. Everything the rules turn on is the same at both
|
|
152
|
+
* placements, so parameterizing the parent is what keeps them ONE implementation; a P1-local copy of
|
|
153
|
+
* rules 2-4 is the second idiom §4 forbids. `root` stays alongside it because rule 4's tenant count
|
|
154
|
+
* is a property of the ROOT's account records, not of whichever directory the material sits in.
|
|
155
|
+
*/
|
|
156
|
+
export function migrateLegacyMaterialIn(parent, root, space, kind) {
|
|
157
|
+
const dir = parent;
|
|
158
|
+
const canonical = join(dir, spaceSegment(space), kind);
|
|
159
|
+
const legacyPath = join(dir, kind);
|
|
160
|
+
// Cheap gate first, exactly as the prior art does it: with no legacy copy there is nothing to
|
|
161
|
+
// weigh, so the canonical path is authoritative whatever state it is in, and the tenant-count read
|
|
162
|
+
// below is skipped. A root that never grew past one space, and every root created after this
|
|
163
|
+
// series, take this branch and see no refusal.
|
|
164
|
+
if (!existsSync(legacyPath))
|
|
165
|
+
return canonical;
|
|
166
|
+
// Byte-exact, never `existsSync` alone: on a case-insensitive FS a bare existence check matches a
|
|
167
|
+
// sibling with different case and would migrate a DIFFERENT kind's file.
|
|
168
|
+
let entries;
|
|
169
|
+
try {
|
|
170
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
171
|
+
}
|
|
172
|
+
catch (e) {
|
|
173
|
+
if (e.code === "ENOENT")
|
|
174
|
+
return canonical;
|
|
175
|
+
throw e;
|
|
176
|
+
}
|
|
177
|
+
if (!entries.some((e) => e.name === kind))
|
|
178
|
+
return canonical;
|
|
179
|
+
// RULE 4 — refuse to migrate on a root holding more than one space.
|
|
180
|
+
//
|
|
181
|
+
// This is not a duplicate of the `space add` door (§2.1). Migration on a multi-tenant root is
|
|
182
|
+
// WORSE than the defect it ends: the root-scoped copy belongs to whichever tenant booted first and
|
|
183
|
+
// nothing on disk records which that was, so a resolver that migrates it writes it into the
|
|
184
|
+
// segment of the tenant that happens to be BOOTING. That launders an ambient inheritance squat —
|
|
185
|
+
// legible today as a root-scoped file — into a path that ASSERTS an owner that may be wrong. False
|
|
186
|
+
// attribution is the worse end state, and unlike the squat it is irreversible, because the
|
|
187
|
+
// evidence that the attribution was a guess is gone once it is written.
|
|
188
|
+
//
|
|
189
|
+
// It also catches what a door cannot. A door is a check at one moment; roots that were already
|
|
190
|
+
// multi-tenant when this series landed never pass through `space add` again, and a backup of one
|
|
191
|
+
// can be restored at any later date. Both boot straight into this resolver.
|
|
192
|
+
//
|
|
193
|
+
// Fail-CLOSED on an unreadable record, like every other tenant-count read: an under-count here
|
|
194
|
+
// would let the laundering proceed on a root that does hold several tenants.
|
|
195
|
+
const refusal = spaceMaterialMigrationRefusal(root);
|
|
196
|
+
if (refusal)
|
|
197
|
+
throw new Error(`refusing to migrate ${kind} into "${space}"'s per-space segment: ${refusal.reason}. ${refusal.remedy}`);
|
|
198
|
+
// RULE 3 — ambiguity refuses, loudly. Canonical AND legacy both present is a partial migration
|
|
199
|
+
// this cannot arbitrate: canonical existence alone does not prove the migration completed (an
|
|
200
|
+
// empty canonical husk beside real legacy material is a crashed migration, not a finished one).
|
|
201
|
+
if (existsSync(canonical))
|
|
202
|
+
throw new Error(`both the canonical ${canonical} and the legacy ${legacyPath} hold ${kind} for "${space}" - refusing to guess which is current (canonical existence alone does not prove the migration completed). Merge or remove one, then retry.`);
|
|
203
|
+
// RULE 2 — one rename, atomic per kind.
|
|
204
|
+
//
|
|
205
|
+
// The segment dir is created FIRST and hardened, not merely `mkdir`ed: it holds `.creds` material,
|
|
206
|
+
// so it must be born under a private ACL rather than widened afterwards (the same reason
|
|
207
|
+
// `provisionMembershipCreds` hardens `.cotal/` before the creds land, `up.ts:2913`). This is the
|
|
208
|
+
// one way this differs from the prior art it generalizes — `migrateLegacyUserAuthState` renames a
|
|
209
|
+
// dir to a SIBLING dir, so it never has to materialize a parent.
|
|
210
|
+
mkSecretDir(join(dir, spaceSegment(space)));
|
|
211
|
+
renameSync(legacyPath, canonical);
|
|
212
|
+
return canonical;
|
|
213
|
+
}
|
|
214
|
+
/** {@link migrateLegacyMaterialIn} at P7's placement — a legacy copy sitting directly under
|
|
215
|
+
* `<root>/.cotal/`. The five P7 resolvers' one entry point. */
|
|
216
|
+
export function migrateLegacyCotalMaterial(root, space, kind) {
|
|
217
|
+
return migrateLegacyMaterialIn(cotalDir(root), root, space, kind);
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* THE `space add` DOOR (§2.1): refuse to add a second tenant to a root that still holds unmigrated
|
|
221
|
+
* root-scoped material.
|
|
222
|
+
*
|
|
223
|
+
* Adding a tenant to such a root creates the one state segmentation cannot resolve — legacy material
|
|
224
|
+
* whose owner is unrecorded — and it is the only door that creates it, because `up` cannot mint a
|
|
225
|
+
* second tenant on an established root (`ensureRootForSpace` refuses at `up.ts:2233`). Checking here
|
|
226
|
+
* costs one inventory read in a verb that is already taking the lock and reading the inventory
|
|
227
|
+
* (`per-space-lifecycle.md` §2.1 step 1).
|
|
228
|
+
*
|
|
229
|
+
* This keeps that state from being CREATED. It does not keep it from being ENCOUNTERED — roots
|
|
230
|
+
* already multi-tenant when this series lands, and backups of them, bypass the door entirely. Rule 4
|
|
231
|
+
* of {@link migrateLegacyCotalMaterial} is what catches those. The two are one design and neither is
|
|
232
|
+
* sufficient alone.
|
|
233
|
+
*
|
|
234
|
+
* NOT YET CALLED: `cotal space add` does not exist as a command today (the verb is designed in
|
|
235
|
+
* `per-space-lifecycle.md` §2.1 and not implemented). This is the guarantee it must call when it is
|
|
236
|
+
* built, landed with the foundation so the verb cannot be written without it.
|
|
237
|
+
*/
|
|
238
|
+
export function assertNoUnsegmentedLegacyMaterial(root, operation) {
|
|
239
|
+
const present = unsegmentedLegacyMaterial(root);
|
|
240
|
+
if (present.length === 0)
|
|
241
|
+
return;
|
|
242
|
+
throw new Error(`${operation} refuses: this root still holds root-scoped ${present.join(", ")}, which is not keyed to any space. ` +
|
|
243
|
+
"Adding a second tenant now would make that material unattributable - it belongs to the space that booted first, and nothing on disk records which that was. " +
|
|
244
|
+
migrationRemedy(root));
|
|
245
|
+
}
|
|
246
|
+
/** The P7 kinds still sitting at their root-scoped location on this root — the unmigrated set both
|
|
247
|
+
* doors weigh. Named rather than inlined twice because the two doors must agree on what counts. */
|
|
248
|
+
export function unsegmentedLegacyMaterial(root) {
|
|
249
|
+
const dir = cotalDir(root);
|
|
250
|
+
return P7_LEGACY_MATERIAL.filter((kind) => existsSync(join(dir, kind)));
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* The "what do I do about it" half of both doors' refusals — ASKED of {@link
|
|
254
|
+
* spaceMaterialMigrationRefusal}, never assumed.
|
|
255
|
+
*
|
|
256
|
+
* The obvious line here is "run `cotal up` for the sole tenant once, then retry", and it is right for
|
|
257
|
+
* the population a door usually sees: a root with one tenant, whose material migrates on that root's
|
|
258
|
+
* next first touch. It is WRONG for the population §2.1 says bypasses the doors entirely — roots
|
|
259
|
+
* already multi-tenant when this series landed, and backups of them restored later. There `cotal up`
|
|
260
|
+
* hits rule 4 and refuses, so the sentence hands the operator a command that cannot succeed. That is
|
|
261
|
+
* precisely the defect commit 2 removed from `repairAdvice`, and it was still latent here.
|
|
262
|
+
*
|
|
263
|
+
* So the remedy is composed from the answer rather than from an assumption about who is asking.
|
|
264
|
+
*/
|
|
265
|
+
function migrationRemedy(root) {
|
|
266
|
+
const refusal = spaceMaterialMigrationRefusal(root);
|
|
267
|
+
if (!refusal)
|
|
268
|
+
return "Run `cotal up` for the sole tenant once to migrate it into its own segment, then retry.";
|
|
269
|
+
return `Migrating it first is not available on this root either: ${refusal.reason}. ${refusal.remedy}`;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* The KEY SHAPE alone — `space.<hex>/<kind>` — resolving nothing and moving nothing.
|
|
273
|
+
*
|
|
274
|
+
* THE ONE SPELLING of the segmented key, so {@link spaceMaterialKey} and the two owners below cannot
|
|
275
|
+
* drift into two layouts. It is exported for the owners that must NOT move material, and there are
|
|
276
|
+
* exactly two kinds of those:
|
|
277
|
+
*
|
|
278
|
+
* - DELETERS. `clean`'s store-seam sweep names the keys it is about to remove; migrating material
|
|
279
|
+
* into the path it will then delete is work done to undo itself, and on the refusal paths (§2
|
|
280
|
+
* rules 3 and 4) it would fail the sweep for material the sweep does not care about.
|
|
281
|
+
* - THE RENEWAL OWNER. {@link REMINTABLE_DAEMON_CREDS}'s `(space) => key` builders (§3.1) feed
|
|
282
|
+
* `remintDaemonCreds`, which has NO absent-means-mint path — its absence case is a loud
|
|
283
|
+
* `skipped: "missing-file"`, never a second cred — and which may hold an INJECTED store while
|
|
284
|
+
* still being handed a workstation `root` it does not own (`manager.ts:870` defaults the store,
|
|
285
|
+
* so `store !== undefined` is not the hosted fact there). The hazard rule 1 exists to stop is not
|
|
286
|
+
* reachable from it, and `up`'s provisioners migrate before any daemon exists to renew.
|
|
287
|
+
*
|
|
288
|
+
* Every other caller wants {@link spaceMaterialKey}: reaching for this one to skip a migration is
|
|
289
|
+
* the read-fallback the design forbids.
|
|
290
|
+
*/
|
|
291
|
+
export function segmentedKey(kind, space) {
|
|
292
|
+
return `${spaceSegment(space)}/${kind}`;
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* THE PER-KIND RESOLVER (§2 rule 1), generic over the kind: the canonical store key for `kind` in
|
|
296
|
+
* `space`, having migrated a legacy root-scoped copy into it first on the FS composition.
|
|
297
|
+
*
|
|
298
|
+
* The named per-kind wrappers below are the surface callers use; this is the one body they share, so
|
|
299
|
+
* "migrate on first touch" cannot exist for four kinds and be forgotten for the fifth. Under the
|
|
300
|
+
* local FS composition the returned key IS the path under `.cotal/` that
|
|
301
|
+
* {@link migrateLegacyCotalMaterial} just moved the material to — the two agree by construction
|
|
302
|
+
* rather than by two spellings of the same layout.
|
|
303
|
+
*/
|
|
304
|
+
export function spaceMaterialKey(kind, space, composition) {
|
|
305
|
+
if (!composition.injected)
|
|
306
|
+
migrateLegacyCotalMaterial(composition.root, space, kind);
|
|
307
|
+
return segmentedKey(kind, space);
|
|
308
|
+
}
|
|
309
|
+
/** {@link DELIVERY_CREDS_KIND}'s key for `space` — see {@link spaceMaterialKey}. */
|
|
310
|
+
export function deliveryCredsKey(space, composition) {
|
|
311
|
+
return spaceMaterialKey(DELIVERY_CREDS_KIND, space, composition);
|
|
312
|
+
}
|
|
313
|
+
/** {@link MEMBERSHIP_RW_CREDS_KIND}'s key for `space` — see {@link spaceMaterialKey}. */
|
|
314
|
+
export function membershipRwCredsKey(space, composition) {
|
|
315
|
+
return spaceMaterialKey(MEMBERSHIP_RW_CREDS_KIND, space, composition);
|
|
316
|
+
}
|
|
317
|
+
/** {@link MEMBERSHIP_OBSERVER_CREDS_KIND}'s key for `space` — see {@link spaceMaterialKey}. */
|
|
318
|
+
export function membershipObserverCredsKey(space, composition) {
|
|
319
|
+
return spaceMaterialKey(MEMBERSHIP_OBSERVER_CREDS_KIND, space, composition);
|
|
320
|
+
}
|
|
321
|
+
/** {@link CONNECTION_EVICTOR_CREDS_KIND}'s key for `space` — see {@link spaceMaterialKey}. */
|
|
322
|
+
export function connectionEvictorCredsKey(space, composition) {
|
|
323
|
+
return spaceMaterialKey(CONNECTION_EVICTOR_CREDS_KIND, space, composition);
|
|
324
|
+
}
|
|
325
|
+
/** {@link MEMBERSHIP_CONFIG_KIND}'s PATH for `space`, migrated on first touch.
|
|
326
|
+
*
|
|
327
|
+
* A path and not a key, and it takes a bare `root` rather than a {@link SpaceMaterialComposition},
|
|
328
|
+
* because this kind has no hosted arm to choose between: it is read raw by the eviction path's
|
|
329
|
+
* workstation-only cross-check (`evict-exec.ts:76`) and a hosted composition never has one. */
|
|
330
|
+
export function membershipConfigPath(root, space) {
|
|
331
|
+
return migrateLegacyCotalMaterial(root, space, MEMBERSHIP_CONFIG_KIND);
|
|
332
|
+
}
|
|
333
|
+
/** The per-space area itself, `<root>/.cotal/space.<hex>/` — for the enumerating callers (the
|
|
334
|
+
* `clean` sweep) that remove a tenant's whole segment rather than one kind of it. Resolves NOTHING
|
|
335
|
+
* and migrates NOTHING: a sweeper must not move material it is about to delete. */
|
|
336
|
+
export function spaceMaterialDir(root, space) {
|
|
337
|
+
return join(cotalDir(root), spaceSegment(space));
|
|
338
|
+
}
|
|
339
|
+
/** The P7 kinds a composition holds in its {@link SecretStore} — the ones written through `put` and
|
|
340
|
+
* read back through `get`, so a reap must remove them through the seam and not by unlinking a file.
|
|
341
|
+
* {@link MEMBERSHIP_CONFIG_KIND} is absent: it has no store reader at all (it is read raw by the
|
|
342
|
+
* eviction path's workstation cross-check), so the segment removal is the whole of its reap. */
|
|
343
|
+
const P7_STORE_KINDS = [
|
|
344
|
+
DELIVERY_CREDS_KIND, MEMBERSHIP_RW_CREDS_KIND, MEMBERSHIP_OBSERVER_CREDS_KIND, CONNECTION_EVICTOR_CREDS_KIND,
|
|
345
|
+
];
|
|
346
|
+
/**
|
|
347
|
+
* `cotal space rm` STEP 1'S PRECONDITION for the step 7 reap (`per-space-lifecycle.md` §2.2), which
|
|
348
|
+
* is deliberately not checked at step 7.
|
|
349
|
+
*
|
|
350
|
+
* THE ORDERING IS THE DESIGN. Step 5 is the point of no return: it deletes the tenant's streams and
|
|
351
|
+
* buckets. Step 7 is the local reap. A precondition discovered at step 7 would refuse AFTER the data
|
|
352
|
+
* is gone and the config re-rendered, leaving the journal entry standing — and since the check would
|
|
353
|
+
* fail identically on every re-run, the removal a crash is supposed to be able to finish could never
|
|
354
|
+
* finish at all. So the question is asked at step 1, beside the inventory read that is already
|
|
355
|
+
* happening, where a refusal costs the operator nothing.
|
|
356
|
+
*
|
|
357
|
+
* WHAT IT REFUSES: a root still holding root-scoped material for any P7 kind. `space rm` runs only on
|
|
358
|
+
* a multi-tenant root (§2.2 step 2 refuses the last tenant), and on such a root that material is
|
|
359
|
+
* unattributable in the §2.1 sense — it belongs to whichever tenant booted first, unrecorded. Reaping
|
|
360
|
+
* around it strands what may be the departing tenant's live `$SYS` pair for a survivor to inherit;
|
|
361
|
+
* reaping it may take a survivor's. There is no third answer, and the honest move is to refuse before
|
|
362
|
+
* anything is destroyed rather than to pick one silently.
|
|
363
|
+
*
|
|
364
|
+
* NOT YET CALLED: `cotal space rm` does not exist as a command today (§2.2 designs it; no `space`
|
|
365
|
+
* verb is implemented). This lands with the material it guards so the verb cannot be written without
|
|
366
|
+
* it, the same reason {@link assertNoUnsegmentedLegacyMaterial} landed with commit 1.
|
|
367
|
+
*/
|
|
368
|
+
export function assertSpaceMaterialReapable(root, space, operation) {
|
|
369
|
+
const present = unsegmentedLegacyMaterial(root);
|
|
370
|
+
if (present.length === 0)
|
|
371
|
+
return;
|
|
372
|
+
throw new Error(`${operation} refuses: this root still holds root-scoped ${present.join(", ")}, which is not keyed to any space. ` +
|
|
373
|
+
`Removing "${space}" would either strand that material for a surviving tenant to inherit or delete a surviving tenant's, ` +
|
|
374
|
+
"and nothing on disk records which of them it belongs to. " +
|
|
375
|
+
migrationRemedy(root));
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* `cotal space rm` STEP 7 (`per-space-lifecycle.md` §2.2): reap ONE tenant's segmented material — the
|
|
379
|
+
* `$SYS` pair that step names, plus the rest of that tenant's segment, which P7 keyed alongside it.
|
|
380
|
+
*
|
|
381
|
+
* IT CANNOT REFUSE, and that is a contract, not an omission. It runs past step 5's point of no
|
|
382
|
+
* return, where a throw would strand the journal entry that gates every other verb on the root; §2.2
|
|
383
|
+
* relies on steps 5 to 7 being individually idempotent so a re-run after a crash FINISHES the
|
|
384
|
+
* removal. So seam failures are returned, not thrown — the same posture, for the same reason, as
|
|
385
|
+
* `remintDaemonCreds` (`renewal.ts`), and the caller must read `failed` or the material silently
|
|
386
|
+
* survives the tenant. Its precondition is {@link assertSpaceMaterialReapable}, asked at step 1.
|
|
387
|
+
*
|
|
388
|
+
* The seam deletes come FIRST and are addressed by {@link segmentedKey}, never by a resolver: a
|
|
389
|
+
* reaper is a DELETER, so it must not move material into the path it is about to remove, and a §2
|
|
390
|
+
* rule 3/4 refusal must not fail a reap over material the reap does not care about. It sweeps every
|
|
391
|
+
* store-backed kind rather than only the two `clean` does, because `clean` is a whole-root reset with
|
|
392
|
+
* a raw sweep of `.cotal/` to fall back on and this is not: for an INJECTED store the segment removal
|
|
393
|
+
* below reaches nothing, so a kind missing from the seam loop would outlive its tenant.
|
|
394
|
+
*
|
|
395
|
+
* It removes only THIS space's segment. A reap spelled `.cotal/space.*` is the shape a reader reaches
|
|
396
|
+
* for after seeing a directory removal, and on the multi-tenant root that is the only root this verb
|
|
397
|
+
* runs on it would take every surviving tenant's live material and report success.
|
|
398
|
+
*/
|
|
399
|
+
export async function reapSpaceMaterial(root, space, secrets) {
|
|
400
|
+
const removed = [];
|
|
401
|
+
const failed = [];
|
|
402
|
+
for (const kind of P7_STORE_KINDS) {
|
|
403
|
+
const key = segmentedKey(kind, space);
|
|
404
|
+
try {
|
|
405
|
+
// Idempotent on an absent key by the seam's contract, and the `get` keeps the report honest
|
|
406
|
+
// rather than claiming a removal for material that was never there.
|
|
407
|
+
if ((await secrets.get(key)) !== undefined) {
|
|
408
|
+
await secrets.delete(key);
|
|
409
|
+
removed.push(`.cotal/${key}`);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
catch (e) {
|
|
413
|
+
failed.push(`${key}: ${e instanceof Error ? e.message : String(e)}`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
const dir = spaceMaterialDir(root, space);
|
|
417
|
+
try {
|
|
418
|
+
if (existsSync(dir)) {
|
|
419
|
+
rmSync(dir, { recursive: true, force: true });
|
|
420
|
+
removed.push(`.cotal/${spaceSegment(space)} (this space's material)`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
catch (e) {
|
|
424
|
+
failed.push(`${spaceSegment(space)}: ${e instanceof Error ? e.message : String(e)}`);
|
|
425
|
+
}
|
|
426
|
+
return { removed, failed };
|
|
427
|
+
}
|
|
428
|
+
//# sourceMappingURL=space-segmentation.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"space-segmentation.js","sourceRoot":"","sources":["../src/space-segmentation.ts"],"names":[],"mappings":"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"}
|
package/dist/space.d.ts
CHANGED
|
@@ -4,4 +4,23 @@
|
|
|
4
4
|
* ambiguous, and {@link soleSpaceOf} fails loud there rather than picking one - such a caller has
|
|
5
5
|
* to name its space (`--space`). */
|
|
6
6
|
export declare function resolveSpace(cwd: string): string;
|
|
7
|
+
/**
|
|
8
|
+
* The space THIS FOLDER'S LOCAL DAEMONS belong to: the one its runtime records name, else
|
|
9
|
+
* {@link resolveSpace}.
|
|
10
|
+
*
|
|
11
|
+
* `down`, `status` and every "is the manager up" helper ask this. They must not inherit
|
|
12
|
+
* {@link resolveSpace}'s blind spot: it reads the space from the `.cotal/auth` account records, an
|
|
13
|
+
* OPEN mesh has none, and the default it then answers with is not the space the daemons here run
|
|
14
|
+
* under. The records are consulted FIRST because they are the only source that always knows - a
|
|
15
|
+
* daemon started by a container entrypoint or by `cotal supervise` typed by hand has one, whatever
|
|
16
|
+
* else the root does or does not hold.
|
|
17
|
+
*
|
|
18
|
+
* RESIDUE NEVER WEDGES THE FOLDER, and TWO LIVE STACKS ARE REFUSED. A record left by a crash names
|
|
19
|
+
* a space whose daemon is gone; if another space is running here, the running one is the answer, and
|
|
20
|
+
* a dead record alone still names its space so a stop can clear it. Two spaces running under one
|
|
21
|
+
* root is a state this cannot arbitrate - the broker, its store and this folder's stack are shared,
|
|
22
|
+
* so no single answer is right - and it throws rather than picking one, the same refusal
|
|
23
|
+
* `assertSingleSpaceBroker` makes for the tenants it can see.
|
|
24
|
+
*/
|
|
25
|
+
export declare function resolveRuntimeSpace(cwd: string): string;
|
|
7
26
|
//# sourceMappingURL=space.d.ts.map
|
package/dist/space.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"space.d.ts","sourceRoot":"","sources":["../src/space.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"space.d.ts","sourceRoot":"","sources":["../src/space.ts"],"names":[],"mappings":"AAIA;;;;qCAIqC;AACrC,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAEhD;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAWvD"}
|
package/dist/space.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { DEFAULT_SPACE } from "@cotal-ai/core";
|
|
2
2
|
import { authDir, findCotalRoot, soleSpaceOf } from "./auth-paths.js";
|
|
3
|
+
import { recordedRuntimeSpaces } from "./local-process.js";
|
|
3
4
|
/** The space this folder operates on: its `.cotal/auth` space if set up, else the default.
|
|
4
5
|
* Commands resolve it through here so they always match the folder's mesh instead of assuming the
|
|
5
6
|
* global default. A root that has grown to hold SEVERAL space accounts makes this question
|
|
@@ -8,4 +9,34 @@ import { authDir, findCotalRoot, soleSpaceOf } from "./auth-paths.js";
|
|
|
8
9
|
export function resolveSpace(cwd) {
|
|
9
10
|
return soleSpaceOf(authDir(findCotalRoot(cwd))) ?? DEFAULT_SPACE;
|
|
10
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* The space THIS FOLDER'S LOCAL DAEMONS belong to: the one its runtime records name, else
|
|
14
|
+
* {@link resolveSpace}.
|
|
15
|
+
*
|
|
16
|
+
* `down`, `status` and every "is the manager up" helper ask this. They must not inherit
|
|
17
|
+
* {@link resolveSpace}'s blind spot: it reads the space from the `.cotal/auth` account records, an
|
|
18
|
+
* OPEN mesh has none, and the default it then answers with is not the space the daemons here run
|
|
19
|
+
* under. The records are consulted FIRST because they are the only source that always knows - a
|
|
20
|
+
* daemon started by a container entrypoint or by `cotal supervise` typed by hand has one, whatever
|
|
21
|
+
* else the root does or does not hold.
|
|
22
|
+
*
|
|
23
|
+
* RESIDUE NEVER WEDGES THE FOLDER, and TWO LIVE STACKS ARE REFUSED. A record left by a crash names
|
|
24
|
+
* a space whose daemon is gone; if another space is running here, the running one is the answer, and
|
|
25
|
+
* a dead record alone still names its space so a stop can clear it. Two spaces running under one
|
|
26
|
+
* root is a state this cannot arbitrate - the broker, its store and this folder's stack are shared,
|
|
27
|
+
* so no single answer is right - and it throws rather than picking one, the same refusal
|
|
28
|
+
* `assertSingleSpaceBroker` makes for the tenants it can see.
|
|
29
|
+
*/
|
|
30
|
+
export function resolveRuntimeSpace(cwd) {
|
|
31
|
+
const root = findCotalRoot(cwd);
|
|
32
|
+
const recorded = recordedRuntimeSpaces(root);
|
|
33
|
+
const running = recorded.filter((r) => r.mayBeRunning);
|
|
34
|
+
if (running.length > 1)
|
|
35
|
+
throw new Error(`${root}/.cotal records running daemons for ${running.length} spaces (${running.map((r) => r.space).join(", ")}) - this folder's stack is not one mesh and a folder-wide command cannot scope to one; stop them by name (\`cotal down\` in each mesh's own root) or remove the record of the one that is gone`);
|
|
36
|
+
if (running.length === 1)
|
|
37
|
+
return running[0].space;
|
|
38
|
+
if (recorded.length === 1)
|
|
39
|
+
return recorded[0].space; // dead residue, unambiguous: still this folder's space
|
|
40
|
+
return resolveSpace(cwd);
|
|
41
|
+
}
|
|
11
42
|
//# sourceMappingURL=space.js.map
|
package/dist/space.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"space.js","sourceRoot":"","sources":["../src/space.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAC/C,OAAO,EAAE,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;
|
|
1
|
+
{"version":3,"file":"space.js","sourceRoot":"","sources":["../src/space.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAC/C,OAAO,EAAE,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AACtE,OAAO,EAAE,qBAAqB,EAAE,MAAM,oBAAoB,CAAC;AAE3D;;;;qCAIqC;AACrC,MAAM,UAAU,YAAY,CAAC,GAAW;IACtC,OAAO,WAAW,CAAC,OAAO,CAAC,aAAa,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,aAAa,CAAC;AACnE,CAAC;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,UAAU,mBAAmB,CAAC,GAAW;IAC7C,MAAM,IAAI,GAAG,aAAa,CAAC,GAAG,CAAC,CAAC;IAChC,MAAM,QAAQ,GAAG,qBAAqB,CAAC,IAAI,CAAC,CAAC;IAC7C,MAAM,OAAO,GAAG,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC;IACvD,IAAI,OAAO,CAAC,MAAM,GAAG,CAAC;QACpB,MAAM,IAAI,KAAK,CACb,GAAG,IAAI,uCAAuC,OAAO,CAAC,MAAM,YAAY,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,gMAAgM,CAC/S,CAAC;IACJ,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,OAAO,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC;IAClD,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,QAAQ,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,uDAAuD;IAC5G,OAAO,YAAY,CAAC,GAAG,CAAC,CAAC;AAC3B,CAAC"}
|
|
@@ -25,11 +25,12 @@ import { type SpaceAuth } from "@cotal-ai/core";
|
|
|
25
25
|
* account records, which an injected store could neither supply nor be enumerated for. See
|
|
26
26
|
* {@link rotateSystemCreds}.
|
|
27
27
|
*/
|
|
28
|
-
/** The two $SYS credential
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
28
|
+
/** The two $SYS credential KINDS, in rotation-write order. They stay on the raw FS, with no injected
|
|
29
|
+
* secret-store seam: renewing them means rewriting the broker config too, so the pair and the trust
|
|
30
|
+
* record move together or not at all, which is not something a store can hold half of. Named here so
|
|
31
|
+
* the rotation writer, the staleness check and `cotal clean`'s removal list cannot drift apart —
|
|
32
|
+
* which is also why they are the KIND constants and not a second pair of literals (P7 made the
|
|
33
|
+
* kind and the location two different strings; a literal here would silently stay the old one). */
|
|
33
34
|
export declare const SYSTEM_CREDS_FILES: readonly ["membership-observer.creds", "connection-evictor.creds"];
|
|
34
35
|
export interface SystemRotationResult {
|
|
35
36
|
/** The broker record's new system-account generation (the successor discriminator `putSpaceAuth` guards). */
|
|
@@ -85,5 +86,5 @@ export interface StaleSystemCred {
|
|
|
85
86
|
* an unreadable file is reported with no `iss` rather than throwing, because a diagnosis surface must not
|
|
86
87
|
* crash on the corruption it exists to describe.
|
|
87
88
|
*/
|
|
88
|
-
export declare function staleSystemCreds(root: string, sysPub: string): StaleSystemCred[];
|
|
89
|
+
export declare function staleSystemCreds(root: string, sysPub: string, space: string): StaleSystemCred[];
|
|
89
90
|
//# sourceMappingURL=system-rotation.d.ts.map
|
|
@@ -1 +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;
|
|
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;AAWxB;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH;;;;;oGAKoG;AACpG,eAAO,MAAM,kBAAkB,oEAA2E,CAAC;AAE3G,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,CAyD1G;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,EAAE,KAAK,EAAE,MAAM,GAAG,eAAe,EAAE,CAqB/F"}
|
package/dist/system-rotation.js
CHANGED
|
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import { credsClaims, mintConnectionEvictorCreds, mintMembershipObserverCreds, newIdentity, rotateSystemAccount, writeSecretFileAtomic, } from "@cotal-ai/core";
|
|
4
4
|
import { assertSingleSpaceBroker, authDir, getSpaceAuth, putSpaceAuth } from "./auth-paths.js";
|
|
5
5
|
import { workspaceSecretStore } from "./secret-store-fs.js";
|
|
6
|
+
import { connectionEvictorCredsKey, CONNECTION_EVICTOR_CREDS_KIND, membershipObserverCredsKey, MEMBERSHIP_OBSERVER_CREDS_KIND, migrateLegacyCotalMaterial, } from "./space-segmentation.js";
|
|
6
7
|
/**
|
|
7
8
|
* The class-3 ($SYS) renewal owner's half, the counterpart to `renewal.ts`, which owns the class-2
|
|
8
9
|
* standing renewal and deliberately EXCLUDES these two files.
|
|
@@ -29,12 +30,13 @@ import { workspaceSecretStore } from "./secret-store-fs.js";
|
|
|
29
30
|
* account records, which an injected store could neither supply nor be enumerated for. See
|
|
30
31
|
* {@link rotateSystemCreds}.
|
|
31
32
|
*/
|
|
32
|
-
/** The two $SYS credential
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
|
|
33
|
+
/** The two $SYS credential KINDS, in rotation-write order. They stay on the raw FS, with no injected
|
|
34
|
+
* secret-store seam: renewing them means rewriting the broker config too, so the pair and the trust
|
|
35
|
+
* record move together or not at all, which is not something a store can hold half of. Named here so
|
|
36
|
+
* the rotation writer, the staleness check and `cotal clean`'s removal list cannot drift apart —
|
|
37
|
+
* which is also why they are the KIND constants and not a second pair of literals (P7 made the
|
|
38
|
+
* kind and the location two different strings; a literal here would silently stay the old one). */
|
|
39
|
+
export const SYSTEM_CREDS_FILES = [MEMBERSHIP_OBSERVER_CREDS_KIND, CONNECTION_EVICTOR_CREDS_KIND];
|
|
38
40
|
/**
|
|
39
41
|
* Rotate the space's system account and re-mint both $SYS creds against it.
|
|
40
42
|
*
|
|
@@ -98,9 +100,16 @@ export async function rotateSystemCreds(root, expectedSpace) {
|
|
|
98
100
|
// plus an eviction rail whose recovery is one re-run.
|
|
99
101
|
//
|
|
100
102
|
// Each file is still written atomically, so no reader can ever see a half-written credential.
|
|
103
|
+
//
|
|
104
|
+
// Written through the per-kind resolvers (P7 §2 rule 1) rather than a hand-composed
|
|
105
|
+
// `join(root, ".cotal", …)`: the pair is per-SPACE now, and this is a workstation-only operation,
|
|
106
|
+
// so the composition is the FS one by construction. The store's `put` is the same
|
|
107
|
+
// `mkSecretDir` + `writeSecretFileAtomic` the raw write used, and it is what creates the
|
|
108
|
+
// `space.<hex>/` dir on a root whose pair has never been segmented.
|
|
109
|
+
const composition = { injected: false, root };
|
|
101
110
|
await putSpaceAuth(s, rotated);
|
|
102
|
-
|
|
103
|
-
|
|
111
|
+
await s.put(membershipObserverCredsKey(expectedSpace, composition), observer);
|
|
112
|
+
await s.put(connectionEvictorCredsKey(expectedSpace, composition), evictor);
|
|
104
113
|
return { gen: rotated.gen ?? 0, auth: rotated, expiresAt: credsClaims(observer).exp };
|
|
105
114
|
}
|
|
106
115
|
/**
|
|
@@ -120,10 +129,16 @@ export async function rotateSystemCreds(root, expectedSpace) {
|
|
|
120
129
|
* an unreadable file is reported with no `iss` rather than throwing, because a diagnosis surface must not
|
|
121
130
|
* crash on the corruption it exists to describe.
|
|
122
131
|
*/
|
|
123
|
-
export function staleSystemCreds(root, sysPub) {
|
|
132
|
+
export function staleSystemCreds(root, sysPub, space) {
|
|
124
133
|
const stale = [];
|
|
125
134
|
for (const file of SYSTEM_CREDS_FILES) {
|
|
126
|
-
|
|
135
|
+
// The CHOKE POINT, called for its path (P7 §2 rule 1). A reader, not a writer — but it must not
|
|
136
|
+
// read the canonical location without moving a legacy pair into it first: reading past an
|
|
137
|
+
// unmigrated pair would answer "absent, therefore not stale" for the exact half-rotated state
|
|
138
|
+
// this function exists to catch, and both surfaces that call it (the boot path, `doctor auth`)
|
|
139
|
+
// are the first thing to touch these creds on a root `up` has not re-provisioned. A §2 rule 3/4
|
|
140
|
+
// refusal propagates, loudly, rather than being swallowed into a healthy-looking answer.
|
|
141
|
+
const path = migrateLegacyCotalMaterial(root, space, file);
|
|
127
142
|
if (!existsSync(path))
|
|
128
143
|
continue;
|
|
129
144
|
let iss;
|