@cotal-ai/delivery 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.
@@ -0,0 +1,163 @@
1
+ import { credsClaims } from "@cotal-ai/core";
2
+ import { assertSingleSpaceBroker, authDir, connectionEvictorCredsKey, CONNECTION_EVICTOR_CREDS_KIND, membershipObserverCredsKey, MEMBERSHIP_OBSERVER_CREDS_KIND, segmentedKey, } from "@cotal-ai/workspace";
3
+ /** The broker-wide operation `repairAdvice` would send a workstation operator to, phrased so the
4
+ * guard's own refusal reads as a sentence when it is quoted back. */
5
+ const ROTATE_SYS = "re-minting the $SYS pair (`cotal up --rotate-sys`)";
6
+ /** Why the rotation would refuse on this root, or `undefined` if it would run.
7
+ *
8
+ * ASKED OF THE GUARD ITSELF, never re-derived from a tenant count read here. The advice is only
9
+ * honest if it agrees with the thing that actually refuses, and a second implementation of the
10
+ * single-space rule is a second thing to drift. Every throw counts as a refusal, including the
11
+ * fail-CLOSED corrupt-inventory one: an operator sent to a command that refuses for a reason this
12
+ * function could not read is in exactly the state this check exists to end. */
13
+ function rotationRefusal(root) {
14
+ try {
15
+ assertSingleSpaceBroker(authDir(root), ROTATE_SYS);
16
+ return undefined;
17
+ }
18
+ catch (e) {
19
+ return e.message;
20
+ }
21
+ }
22
+ /** Repair advice for missing/unusable `$SYS` material, in the reader's own idiom.
23
+ *
24
+ * The DIAGNOSIS half (which key, which account) is identical in both compositions and is built by
25
+ * the caller; only this repair TAIL forks. On a workstation the repair is a real command; against a
26
+ * hosted store it is the mint window plus the key to `put` under, because `cotal up --rotate-sys`
27
+ * is unactionable there — and emitting it into a hosted log degrades the diagnosis even when the
28
+ * failure semantics are right. Both halves say the same true thing: the `$SYS` pair can only be
29
+ * minted while the never-persisted system signing seed is in memory.
30
+ *
31
+ * THERE IS A THIRD CASE, and until now this function could not see it: on a root hosting several
32
+ * spaces the workstation advice names two verbs that BOTH refuse. `cotal up --rotate-sys` and
33
+ * `cotal down` are broker-wide and `assertSingleSpaceBroker` turns them away naming the tenants,
34
+ * so the operator whose observer is missing is handed a command, runs it, and is refused — with
35
+ * the refusal itself explaining that the remedy does not exist yet. That is advice which cannot
36
+ * succeed, the same defect `healMembershipDataCreds`'s own comment records, and it was reachable
37
+ * on a real root (`docs/design/space-segmentation-p7-p1.md` §6, probe-executed).
38
+ *
39
+ * So the workstation arm consults the guard before naming its command, and where the guard would
40
+ * refuse it states the truth instead: the guard's own words, then the reason no other command can
41
+ * substitute. NAMING NOTHING IS THE POINT — there is no verb to offer here, and inventing a
42
+ * gentler-sounding one would restore the defect.
43
+ *
44
+ * This makes the function read the filesystem on the workstation arm, which is why the callers
45
+ * pass it as a THUNK: it is built only on a path that has already failed, never on a healthy one. */
46
+ export function repairAdvice(source, kinds) {
47
+ const named = kinds.length ? kinds.join(" + ") : "the $SYS pair";
48
+ // The hosted arm names a KEY to `put` under, so it must name the SEGMENTED one — the kind alone is
49
+ // the pre-P7 key and putting there would leave the daemon still reading an empty location. The
50
+ // workstation arm names a command, not a key, so it keeps the kinds an operator recognises.
51
+ // Non-migrating on purpose: this builds a STRING for a store the process cannot reach.
52
+ if (source.injected)
53
+ return `re-mint the $SYS pair at a system-account rotation (the seed is in memory only at that moment) and \`put\` it under ${kinds.length ? kinds.map((k) => segmentedKey(k, source.space)).join(" + ") : "the $SYS pair's per-space keys"}`;
54
+ const refusal = rotationRefusal(source.root);
55
+ if (refusal === undefined)
56
+ return "re-mint it with `cotal down` then `cotal up --rotate-sys`";
57
+ return (`${refusal}. There is no other command that mints ${named}: the $SYS signing seed is discarded at provisioning, ` +
58
+ "so nothing can re-sign it on this root until per-space segmentation lands");
59
+ }
60
+ /** The DATA account an observer cred is scoped to, read out of its own `$SYS.REQ.ACCOUNT.<id>.CONNZ`
61
+ * publish permission, or `undefined` if it carries none.
62
+ *
63
+ * This is a LOCAL read of a signed document, and it buys the DIAGNOSIS, not the guarantee: the
64
+ * broker independently refuses a CONNZ request for any other account, because the permission is in
65
+ * the JWT it validates. Doing it here, before connecting, is what turns that refusal from a bare
66
+ * "Authorization Violation" into a line naming both accounts (design §4.3). */
67
+ export function connzAccountOf(observerCreds) {
68
+ for (const subject of credsClaims(observerCreds).nats?.pub?.allow ?? []) {
69
+ const m = /^\$SYS\.REQ\.ACCOUNT\.(A[A-Z2-7]{55})\.CONNZ$/.exec(subject);
70
+ if (m)
71
+ return m[1];
72
+ }
73
+ return undefined;
74
+ }
75
+ /**
76
+ * THE TENANCY CHECK — the guard that replaces the eliminated `membership.json` cross-check.
77
+ *
78
+ * Every sweep below resolves an ACCOUNT, and a complete, well-formed sweep of the WRONG account is
79
+ * indistinguishable from "the principal is gone": a healthy-looking answer that authorizes eviction.
80
+ * Note the asymmetry that makes this necessary — an observer scoped to A used with accountId B
81
+ * under-reports (broker-denied → `unknown`, safe), but observer A used with accountId A while the
82
+ * GATE lives on B is internally consistent and answers a confident, WRONG `gone`.
83
+ *
84
+ * Checking the observer against the account the daemon's OWN cred authenticates as is strictly
85
+ * stronger than the file it replaces: the file sat in the same `.cotal/` dir as the creds, so a root
86
+ * that was wrong was wrong for both, and its independence came from `expectedAccount` being derived
87
+ * from the cred anyway. The cred was always the real authority; the file was the thing being checked.
88
+ *
89
+ * An observer carrying NO CONNZ permission is refused rather than trusted: it cannot do the job, and
90
+ * treating "no account named" as "any account" is the exact failure this guard exists to stop.
91
+ */
92
+ export function observerTenancyProblem(observerCreds, expectedAccount) {
93
+ let scoped;
94
+ try {
95
+ scoped = connzAccountOf(observerCreds);
96
+ }
97
+ catch (e) {
98
+ return `the $SYS observer cred is unreadable (${e.message})`;
99
+ }
100
+ if (scoped === undefined)
101
+ return "the $SYS observer cred carries no `$SYS.REQ.ACCOUNT.<id>.CONNZ` permission, so it names no account to sweep and cannot be checked against this daemon's own tenancy";
102
+ if (scoped !== expectedAccount)
103
+ return (`the $SYS observer cred is scoped to account ${scoped}, but this daemon's own credential authenticates as ${expectedAccount}. ` +
104
+ "That credential belongs to a different mesh, and sweeping it would return a confident, WRONG answer (a complete sweep of the wrong account is indistinguishable from a gone principal)");
105
+ return undefined;
106
+ }
107
+ /**
108
+ * THE TORN-ROTATION CHECK — two `$SYS` creds signed by DIFFERENT system accounts.
109
+ *
110
+ * `rotateSystemCreds` commits the trust record, then writes both creds, so a crash between the two
111
+ * leaves one of them on the RETIRED system account. The broker answers the same bare "Authorization
112
+ * Violation" either way, and the daemon cannot ask the trust record which account is current (it
113
+ * deliberately never loads the signer) — but it does not need to: the pair is written by ONE
114
+ * rotation, so two different issuers prove one of them is stale, with no signer read at all.
115
+ *
116
+ * Shared by both paths as of this change. It previously lived only in the feed, which left eviction
117
+ * — the path that actually kills connections — opening a half-rotated pair blind.
118
+ *
119
+ * `advice` is a THUNK because {@link repairAdvice} now reads the root's tenant list: both call sites
120
+ * sit on a healthy path (`loadCheckedSys` runs per eviction), and eagerly building advice nobody
121
+ * will read would put a directory listing on every successful call.
122
+ */
123
+ export function tornRotationProblem(observerCreds, evictorCreds, advice) {
124
+ let obsIss, evIss;
125
+ try {
126
+ obsIss = credsClaims(observerCreds).iss;
127
+ evIss = credsClaims(evictorCreds).iss;
128
+ }
129
+ catch {
130
+ return undefined; // an undecodable cred is the health check's case, reported with its own message
131
+ }
132
+ if (obsIss === undefined || evIss === undefined || obsIss === evIss)
133
+ return undefined;
134
+ return (`the two $SYS creds are signed by DIFFERENT system accounts (observer ${obsIss.slice(0, 12)}…, evictor ${evIss.slice(0, 12)}…) - ` +
135
+ `a system-account rotation did not finish, so one of them is broker-dead: ${advice()} to land a complete generation`);
136
+ }
137
+ /** Read the `$SYS` pair through the store. Absence is reported; every other failure propagates.
138
+ *
139
+ * The distinction is load-bearing: `undefined` means "not provisioned here", which each caller
140
+ * answers in its own posture, whereas a KMS timeout or a revoked role is a REFUSAL that must not be
141
+ * mistaken for an unprovisioned space and quietly degraded into deny-new-only.
142
+ *
143
+ * Reading per call (rather than once at start) is strictly better than the `readFileSync` it
144
+ * replaces: a hosted store re-keyed by a rotation is picked up on the next eviction with no daemon
145
+ * restart. No renewal timer is added, and none is wanted — these stay `rotation-renewed`. */
146
+ export async function loadSysPair(source, need) {
147
+ // The per-kind RESOLVERS (P7 §2 rule 1). The source's two arms ARE the composition they take, so
148
+ // a workstation daemon moves a legacy flat pair on this first touch and a hosted one never
149
+ // touches a filesystem. Reading the canonical location WITHOUT that move is the failure the
150
+ // resolver exists to stop: it answers `undefined`, which every caller here treats as "not
151
+ // provisioned", and sends an operator to a rotation for creds that are sitting on the same disk.
152
+ const observer = await source.secrets.get(membershipObserverCredsKey(source.space, source));
153
+ const evictor = need === "both" ? await source.secrets.get(connectionEvictorCredsKey(source.space, source)) : undefined;
154
+ // Reported as KINDS, never the segmented keys: `missing` is read by operators and compared
155
+ // against by callers (`membership.ts` filters the evictor out of its own diagnosis by name), and
156
+ // a `space.<hex>/` prefix would break both. The hosted arm's `put` key is named by `repairAdvice`.
157
+ const missing = [
158
+ observer === undefined ? MEMBERSHIP_OBSERVER_CREDS_KIND : undefined,
159
+ need === "both" && evictor === undefined ? CONNECTION_EVICTOR_CREDS_KIND : undefined,
160
+ ].filter((k) => k !== undefined);
161
+ return { ...(observer !== undefined ? { observer } : {}), ...(evictor !== undefined ? { evictor } : {}), missing };
162
+ }
163
+ //# sourceMappingURL=sys-creds.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sys-creds.js","sourceRoot":"","sources":["../src/sys-creds.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@cotal-ai/delivery",
3
3
  "description": "Cotal delivery daemon: the server-side Plane-3 durable backstop (fan-out writer + trusted reader + membership/ACL authority), a scoped least-privilege NATS client co-located with the broker.",
4
- "version": "0.35.0",
4
+ "version": "0.37.0",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
@@ -18,8 +18,8 @@
18
18
  }
19
19
  },
20
20
  "dependencies": {
21
- "@cotal-ai/workspace": "0.35.0",
22
- "@cotal-ai/core": "0.35.0"
21
+ "@cotal-ai/workspace": "0.37.0",
22
+ "@cotal-ai/core": "0.37.0"
23
23
  },
24
24
  "files": [
25
25
  "dist"
@@ -32,6 +32,7 @@
32
32
  "@cotal-ai/smoke-kit": "0.0.0"
33
33
  },
34
34
  "scripts": {
35
+ "test": "tsx smoke/delivery-store-boundary.smoke.ts",
35
36
  "typecheck": "tsc -p tsconfig.json --noEmit",
36
37
  "build": "tsc -p tsconfig.json"
37
38
  }