@cotal-ai/auth 0.89.0 → 0.90.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/README.md +11 -0
- package/dist/authority-client.d.ts +8 -1
- package/dist/authority-client.d.ts.map +1 -1
- package/dist/authority-client.js +11 -9
- package/dist/authority-client.js.map +1 -1
- package/dist/callout.d.ts +3 -0
- package/dist/callout.d.ts.map +1 -1
- package/dist/callout.js +24 -2
- package/dist/callout.js.map +1 -1
- package/dist/connect-reader.d.ts +7 -0
- package/dist/connect-reader.d.ts.map +1 -1
- package/dist/connect-reader.js +16 -1
- package/dist/connect-reader.js.map +1 -1
- package/dist/credential-ledger.d.ts.map +1 -1
- package/dist/credential-ledger.js +7 -1
- package/dist/credential-ledger.js.map +1 -1
- package/dist/idp.d.ts.map +1 -1
- package/dist/idp.js +2 -0
- package/dist/idp.js.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/issuer.d.ts +11 -0
- package/dist/issuer.d.ts.map +1 -1
- package/dist/issuer.js +29 -1
- package/dist/issuer.js.map +1 -1
- package/dist/lifecycle-registry.d.ts +6 -0
- package/dist/lifecycle-registry.d.ts.map +1 -1
- package/dist/lifecycle-registry.js +13 -1
- package/dist/lifecycle-registry.js.map +1 -1
- package/dist/native-issuance.d.ts +13 -1
- package/dist/native-issuance.d.ts.map +1 -1
- package/dist/native-issuance.js +76 -10
- package/dist/native-issuance.js.map +1 -1
- package/dist/native-service-callout.d.ts +38 -0
- package/dist/native-service-callout.d.ts.map +1 -0
- package/dist/native-service-callout.js +71 -0
- package/dist/native-service-callout.js.map +1 -0
- package/dist/native-service-gate.d.ts +72 -0
- package/dist/native-service-gate.d.ts.map +1 -0
- package/dist/native-service-gate.js +219 -0
- package/dist/native-service-gate.js.map +1 -0
- package/dist/native-service-name.d.ts +17 -0
- package/dist/native-service-name.d.ts.map +1 -0
- package/dist/native-service-name.js +77 -0
- package/dist/native-service-name.js.map +1 -0
- package/dist/permissions.js +2 -0
- package/dist/permissions.js.map +1 -1
- package/dist/plane-claim.d.ts +8 -0
- package/dist/plane-claim.d.ts.map +1 -1
- package/dist/plane-claim.js +11 -1
- package/dist/plane-claim.js.map +1 -1
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +48 -4
- package/dist/provider.js.map +1 -1
- package/dist/retirement-barrier.d.ts.map +1 -1
- package/dist/retirement-barrier.js +2 -1
- package/dist/retirement-barrier.js.map +1 -1
- package/dist/root-credential.d.ts.map +1 -1
- package/dist/root-credential.js +2 -1
- package/dist/root-credential.js.map +1 -1
- package/dist/service-authority-client.d.ts +19 -0
- package/dist/service-authority-client.d.ts.map +1 -0
- package/dist/service-authority-client.js +152 -0
- package/dist/service-authority-client.js.map +1 -0
- package/dist/service-caller-policy.d.ts +32 -0
- package/dist/service-caller-policy.d.ts.map +1 -0
- package/dist/service-caller-policy.js +218 -0
- package/dist/service-caller-policy.js.map +1 -0
- package/dist/service-custody-shapes.d.ts +109 -0
- package/dist/service-custody-shapes.d.ts.map +1 -0
- package/dist/service-custody-shapes.js +138 -0
- package/dist/service-custody-shapes.js.map +1 -0
- package/dist/service-custody.d.ts +96 -0
- package/dist/service-custody.d.ts.map +1 -0
- package/dist/service-custody.js +667 -0
- package/dist/service-custody.js.map +1 -0
- package/dist/service-host-contract.d.ts +312 -0
- package/dist/service-host-contract.d.ts.map +1 -0
- package/dist/service-host-contract.js +176 -0
- package/dist/service-host-contract.js.map +1 -0
- package/dist/service-renewal.d.ts +23 -0
- package/dist/service-renewal.d.ts.map +1 -0
- package/dist/service-renewal.js +51 -0
- package/dist/service-renewal.js.map +1 -0
- package/dist/service-root-reader.d.ts +16 -0
- package/dist/service-root-reader.d.ts.map +1 -0
- package/dist/service-root-reader.js +93 -0
- package/dist/service-root-reader.js.map +1 -0
- package/dist/session-ledger.d.ts.map +1 -1
- package/dist/session-ledger.js +8 -2
- package/dist/session-ledger.js.map +1 -1
- package/dist/store.d.ts +2 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +2 -0
- package/dist/store.js.map +1 -1
- package/dist/token.d.ts +11 -1
- package/dist/token.d.ts.map +1 -1
- package/dist/token.js +31 -3
- package/dist/token.js.map +1 -1
- package/dist/tool-result-prerequisites.d.ts +55 -0
- package/dist/tool-result-prerequisites.d.ts.map +1 -0
- package/dist/tool-result-prerequisites.js +352 -0
- package/dist/tool-result-prerequisites.js.map +1 -0
- package/package.json +7 -4
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
import { EpEnvelopeError, assertLifecycleToken, canonicalJson, epAuthBucket, isAccountNkey, isCasLoss, isContractDigest, lifecycleHeadKey, mintLifecycleUid, parseNativeUidCustody, recordsBucket, uidReservationKey, } from "@cotal-ai/core";
|
|
2
|
+
import { registryStores } from "./lifecycle-registry.js";
|
|
3
|
+
import { serviceGateRows } from "./native-service-gate.js";
|
|
4
|
+
import { readNativeServiceName } from "./native-service-name.js";
|
|
5
|
+
import { genuinePlaneHold } from "./plane-claim.js";
|
|
6
|
+
import { ROOT_CREDENTIAL_TTL_MS } from "./root-credential.js";
|
|
7
|
+
import { encodeRoundTrip, parseServiceHead, parseServiceNativeGate, parseServiceNativeRoot, serviceCustodyIdentity, } from "./service-custody-shapes.js";
|
|
8
|
+
import { readRegistryServiceRoot } from "./service-root-reader.js";
|
|
9
|
+
/** One broker request, clipped to the remaining original deadline. */
|
|
10
|
+
export const S1_REQUEST_TIMEOUT_MS = 5_000;
|
|
11
|
+
/** Re-reads after a CAS loss before a step holds. */
|
|
12
|
+
export const S1_MAX_CAS_REDECIDE = 4;
|
|
13
|
+
/** Re-pins of a byte-identical open native gate after a sibling's touch. */
|
|
14
|
+
export const S1_MAX_GATE_REPIN = 8;
|
|
15
|
+
/** Classify-then-retry rounds for a write whose outcome is unknown. */
|
|
16
|
+
export const S1_MAX_UNKNOWN_ROUNDS = 3;
|
|
17
|
+
const HOSTS = new WeakMap();
|
|
18
|
+
const BOOTSTRAPS = new WeakMap();
|
|
19
|
+
const CANCELS = new WeakMap();
|
|
20
|
+
const enc = new TextEncoder();
|
|
21
|
+
const dec = new TextDecoder("utf-8", { fatal: true });
|
|
22
|
+
function denied(detail) {
|
|
23
|
+
throw new EpEnvelopeError("permission-denied", `service custody ${detail}`);
|
|
24
|
+
}
|
|
25
|
+
/** The one trusted composition: a genuine registry, a genuine hold for the same space, the
|
|
26
|
+
* configured account, and a fatal latch only the returned `trip` sets. The held check is the
|
|
27
|
+
* closure captured when the hold was acquired, never the hold's mutable `guard` field. */
|
|
28
|
+
export function openServiceCustodyHost(opts) {
|
|
29
|
+
const { space } = registryStores(opts.registry);
|
|
30
|
+
const genuine = genuinePlaneHold(opts.hold);
|
|
31
|
+
if (genuine === undefined)
|
|
32
|
+
return denied("requires a plane claim hold this process acquired");
|
|
33
|
+
if (genuine.space !== space)
|
|
34
|
+
denied(`hold belongs to another space than the registry's "${space}"`);
|
|
35
|
+
if (typeof opts.accountPublicKey !== "string" || !isAccountNkey(opts.accountPublicKey))
|
|
36
|
+
denied("requires the configured account public key");
|
|
37
|
+
const internals = { registry: opts.registry, assertHeld: genuine.assertHeld, space, accountPublicKey: opts.accountPublicKey };
|
|
38
|
+
const host = Object.freeze({ space, accountPublicKey: opts.accountPublicKey });
|
|
39
|
+
HOSTS.set(host, internals);
|
|
40
|
+
return { host, trip: (reason) => { if (internals.tripped === undefined)
|
|
41
|
+
internals.tripped = reason; } };
|
|
42
|
+
}
|
|
43
|
+
function material(host, reservation) {
|
|
44
|
+
const h = HOSTS.get(host);
|
|
45
|
+
if (h === undefined)
|
|
46
|
+
return denied("requires a sealed host");
|
|
47
|
+
const lifecycleUid = assertLifecycleToken(reservation?.native?.lifecycleUid);
|
|
48
|
+
const uidBytes = enc.encode(canonicalJson(reservation));
|
|
49
|
+
const custody = parseNativeUidCustody(uidBytes, lifecycleUid);
|
|
50
|
+
if (custody.kind !== "service")
|
|
51
|
+
return denied("requires a service UID reservation");
|
|
52
|
+
if (custody.uid.native.accountPublicKey !== h.accountPublicKey || custody.uid.native.space !== h.space)
|
|
53
|
+
denied("reservation belongs to another account or space than the host");
|
|
54
|
+
const id = serviceCustodyIdentity(custody.uid);
|
|
55
|
+
const binding = { accountPublicKey: h.accountPublicKey, space: h.space, owner: id.owner, endpoint: id.endpoint,
|
|
56
|
+
instanceId: id.instanceId, lifecycleUid, assignmentDigest: id.digest };
|
|
57
|
+
return { host: h, id, uidBytes, binding, epgate: serviceGateRows(h.registry, binding) };
|
|
58
|
+
}
|
|
59
|
+
/** Seal the original admitted bootstrap material once. `notAfterMs` is the creation authority's
|
|
60
|
+
* fixed deadline; the writer never defaults, extends or resets it. */
|
|
61
|
+
export function sealServiceBootstrapIntent(host, input) {
|
|
62
|
+
const m = material(host, input.reservation);
|
|
63
|
+
if (!Number.isSafeInteger(input.notAfterMs) || input.notAfterMs <= 0)
|
|
64
|
+
denied("bootstrap intent requires an explicit positive notAfterMs");
|
|
65
|
+
const intent = Object.freeze({ lifecycleUid: m.id.lifecycleUid, notAfterMs: input.notAfterMs });
|
|
66
|
+
BOOTSTRAPS.set(intent, { ...m, notAfterMs: input.notAfterMs });
|
|
67
|
+
return intent;
|
|
68
|
+
}
|
|
69
|
+
/** Seal an accepted cancellation of this exact reservation. The intent itself does not expire. */
|
|
70
|
+
export function sealServiceCancelIntent(host, input) {
|
|
71
|
+
const m = material(host, input.reservation);
|
|
72
|
+
const opId = assertLifecycleToken(input.opId, "opId");
|
|
73
|
+
if (typeof input.requestDigest !== "string" || !isContractDigest(input.requestDigest))
|
|
74
|
+
denied("cancel intent requires a request digest");
|
|
75
|
+
const intent = Object.freeze({ lifecycleUid: m.id.lifecycleUid, opId });
|
|
76
|
+
CANCELS.set(intent, { ...m, op: { kind: "service-cancel", opId, requestDigest: input.requestDigest } });
|
|
77
|
+
return intent;
|
|
78
|
+
}
|
|
79
|
+
// ---- held control flow, readiness and bounded broker access ----------------------------------
|
|
80
|
+
class Held extends Error {
|
|
81
|
+
reason;
|
|
82
|
+
step;
|
|
83
|
+
constructor(reason, step, detail) {
|
|
84
|
+
super(detail);
|
|
85
|
+
this.reason = reason;
|
|
86
|
+
this.step = step;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function held(reason, step, detail = reason) {
|
|
90
|
+
throw new Held(reason, step, detail);
|
|
91
|
+
}
|
|
92
|
+
const message = (e) => (e instanceof Error ? e.message : String(e));
|
|
93
|
+
function context(h, deadlineMs) {
|
|
94
|
+
const stores = registryStores(h.registry);
|
|
95
|
+
return { h, jsm: stores.jsm, kv: { records: stores.recordsKv, auth: stores.authKv },
|
|
96
|
+
bucket: { records: recordsBucket(h.space), auth: epAuthBucket(h.space) }, deadlineMs, deadlineReason: "deadline", step: "open" };
|
|
97
|
+
}
|
|
98
|
+
/** Bootstrap only, after the plan step: `liveUntilMs = min(deadline, plan.expMs)` bounds every
|
|
99
|
+
* later request. Cancellation keeps its own deadline, so an accepted cancellation can proceed
|
|
100
|
+
* after the root expires. */
|
|
101
|
+
function clipToPlan(ctx, plan) {
|
|
102
|
+
if (plan.expMs < ctx.deadlineMs) {
|
|
103
|
+
ctx.deadlineMs = plan.expMs;
|
|
104
|
+
ctx.deadlineReason = "plan-expired";
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** Run one broker request with a timeout of `min(S1_REQUEST_TIMEOUT_MS, remaining)`. A request
|
|
108
|
+
* is never started at or past the live-authority bound, and a request still in flight when that
|
|
109
|
+
* bound is reached ends the step held under the bound's reason. The abandoned request's
|
|
110
|
+
* settlement is still handled. */
|
|
111
|
+
async function bounded(ctx, start) {
|
|
112
|
+
const remaining = ctx.deadlineMs - Date.now(), ms = Math.min(S1_REQUEST_TIMEOUT_MS, remaining);
|
|
113
|
+
const bound = ctx.deadlineReason === "plan-expired" ? "the RootPlan expiry" : "the original deadline";
|
|
114
|
+
if (ms <= 0)
|
|
115
|
+
return held(ctx.deadlineReason, ctx.step, `${bound} has passed; no request starts`);
|
|
116
|
+
let timer;
|
|
117
|
+
const timeout = new Promise((r) => { timer = setTimeout(() => r({ ok: false, timedOut: true }), ms); });
|
|
118
|
+
const settled = start().then((value) => ({ ok: true, value }), (error) => ({ ok: false, error }));
|
|
119
|
+
let r;
|
|
120
|
+
try {
|
|
121
|
+
r = await Promise.race([settled, timeout]);
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
clearTimeout(timer);
|
|
125
|
+
}
|
|
126
|
+
if (!r.ok && "timedOut" in r && ms === remaining)
|
|
127
|
+
held(ctx.deadlineReason, ctx.step, `${bound} was reached during a request`);
|
|
128
|
+
return r;
|
|
129
|
+
}
|
|
130
|
+
function checkSync(ctx) {
|
|
131
|
+
if (ctx.h.tripped !== undefined)
|
|
132
|
+
held("readiness", ctx.step, `the host is fenced: ${ctx.h.tripped}`);
|
|
133
|
+
}
|
|
134
|
+
/** Awaited current-hold check before a write. */
|
|
135
|
+
async function readyBefore(ctx) {
|
|
136
|
+
checkSync(ctx);
|
|
137
|
+
const r = await bounded(ctx, () => ctx.h.assertHeld("before"));
|
|
138
|
+
if (!r.ok)
|
|
139
|
+
held("readiness", ctx.step, "timedOut" in r ? "the plane claim check timed out" : message(r.error));
|
|
140
|
+
checkSync(ctx);
|
|
141
|
+
}
|
|
142
|
+
/** Awaited current-hold re-read after a write, then the latch. Any loss here means the write may
|
|
143
|
+
* have landed under a lost or fenced host: the step reports held, never success. */
|
|
144
|
+
async function readyAfter(ctx) {
|
|
145
|
+
const r = await bounded(ctx, () => ctx.h.assertHeld("after"));
|
|
146
|
+
if (!r.ok)
|
|
147
|
+
held("post-write-readiness", ctx.step, "timedOut" in r ? "the post-write plane claim check timed out" : message(r.error));
|
|
148
|
+
if (ctx.h.tripped !== undefined)
|
|
149
|
+
held("post-write-readiness", ctx.step, `the host was fenced during the write: ${ctx.h.tripped}`);
|
|
150
|
+
}
|
|
151
|
+
/** Leader-served last message for one key. `absent` means the subject has no message at all. */
|
|
152
|
+
async function leader(ctx, bucket, key) {
|
|
153
|
+
const name = ctx.bucket[bucket];
|
|
154
|
+
const r = await bounded(ctx, () => ctx.jsm.streams.getMessage(`KV_${name}`, { last_by_subj: `$KV.${name}.${key}` }));
|
|
155
|
+
if (!r.ok) {
|
|
156
|
+
if ("error" in r && r.error?.code === 10037)
|
|
157
|
+
return { state: "absent" };
|
|
158
|
+
return held("unavailable", ctx.step, "timedOut" in r ? `the leader read of ${key} timed out` : message(r.error));
|
|
159
|
+
}
|
|
160
|
+
const m = r.value;
|
|
161
|
+
if (!m)
|
|
162
|
+
return { state: "absent" };
|
|
163
|
+
const op = m.header?.get("KV-Operation");
|
|
164
|
+
if (op)
|
|
165
|
+
return { state: "marker", op };
|
|
166
|
+
try {
|
|
167
|
+
return { state: "value", bytes: m.data, value: JSON.parse(dec.decode(m.data)), revision: m.seq };
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return { state: "malformed", revision: m.seq };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const same = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]);
|
|
174
|
+
/** One conditional write under readiness. Returns the landed revision, or `moved` when the key no
|
|
175
|
+
* longer matches this step's decision (the caller re-reads and re-decides). An unknown outcome
|
|
176
|
+
* is classified by an immediate leader read before anything is reissued; only exact absence for
|
|
177
|
+
* a create, or the exact preimage at the pinned revision for a CAS, permits the same write again.
|
|
178
|
+
* `precheck` runs before every attempt, including each reissue. */
|
|
179
|
+
async function write(ctx, bucket, key, bytes, expect, precheck) {
|
|
180
|
+
for (let round = 0; round < S1_MAX_UNKNOWN_ROUNDS; round++) {
|
|
181
|
+
if (precheck)
|
|
182
|
+
await precheck();
|
|
183
|
+
await readyBefore(ctx);
|
|
184
|
+
const kv = ctx.kv[bucket];
|
|
185
|
+
const r = await bounded(ctx, () => ("create" in expect ? kv.put(key, bytes, { previousSeq: 0 }) : kv.update(key, bytes, expect.revision)));
|
|
186
|
+
await readyAfter(ctx);
|
|
187
|
+
if (!r.ok && "error" in r && isCasLoss(r.error))
|
|
188
|
+
return "moved";
|
|
189
|
+
const cur = await leader(ctx, bucket, key);
|
|
190
|
+
if (cur.state === "value" && same(cur.bytes, bytes) && ("create" in expect || cur.revision !== expect.revision))
|
|
191
|
+
return cur.revision;
|
|
192
|
+
if (r.ok)
|
|
193
|
+
return "moved";
|
|
194
|
+
const retry = "create" in expect ? cur.state === "absent"
|
|
195
|
+
: cur.state === "value" && cur.revision === expect.revision && same(cur.bytes, expect.preimage);
|
|
196
|
+
if (!retry)
|
|
197
|
+
return "moved";
|
|
198
|
+
}
|
|
199
|
+
return held("unknown", ctx.step, `the outcome of the write to ${key} stayed unknown`);
|
|
200
|
+
}
|
|
201
|
+
/** Leader-read and classify this assignment's endpoint gate. Every endpoint-gate request is the
|
|
202
|
+
* writer's own, so none can start after it returns. */
|
|
203
|
+
async function endpoint(ctx, m) {
|
|
204
|
+
const cur = await leader(ctx, "auth", m.epgate.key);
|
|
205
|
+
if (cur.state === "absent")
|
|
206
|
+
return { kind: "absent" };
|
|
207
|
+
if (cur.state === "marker")
|
|
208
|
+
return held("endpoint-tombstone", ctx.step, `the endpoint gate carries a ${cur.op} marker`);
|
|
209
|
+
if (cur.state === "malformed")
|
|
210
|
+
return held("endpoint-malformed", ctx.step, "the endpoint gate is not JSON");
|
|
211
|
+
let row;
|
|
212
|
+
try {
|
|
213
|
+
row = m.epgate.parse(cur.bytes);
|
|
214
|
+
}
|
|
215
|
+
catch (e) {
|
|
216
|
+
if (e instanceof EpEnvelopeError && e.code === "conflict")
|
|
217
|
+
return { kind: "foreign" };
|
|
218
|
+
return held("endpoint-malformed", ctx.step, message(e));
|
|
219
|
+
}
|
|
220
|
+
const state = row.serviceAuthority.state;
|
|
221
|
+
return { kind: state === "pending-root" ? "pending" : state, row, bytes: cur.bytes, revision: cur.revision };
|
|
222
|
+
}
|
|
223
|
+
async function uid(ctx, m) {
|
|
224
|
+
const cur = await leader(ctx, "records", uidReservationKey(m.id.lifecycleUid));
|
|
225
|
+
if (cur.state === "absent")
|
|
226
|
+
return "absent";
|
|
227
|
+
if (cur.state === "marker")
|
|
228
|
+
return "marker";
|
|
229
|
+
if (cur.state === "malformed")
|
|
230
|
+
return "foreign";
|
|
231
|
+
try {
|
|
232
|
+
const custody = parseNativeUidCustody(cur.bytes, m.id.lifecycleUid);
|
|
233
|
+
return custody.kind === "service" && custody.uidDigest === m.id.digest ? "exact" : "foreign";
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return "foreign";
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
async function parsed(ctx, bucket, key, parse) {
|
|
240
|
+
const cur = await leader(ctx, bucket, key);
|
|
241
|
+
if (cur.state === "absent")
|
|
242
|
+
return cur;
|
|
243
|
+
if (cur.state === "marker")
|
|
244
|
+
return { state: "marker", detail: `${key} carries a ${cur.op} marker` };
|
|
245
|
+
if (cur.state === "malformed")
|
|
246
|
+
return { state: "other", detail: `${key} is not JSON` };
|
|
247
|
+
try {
|
|
248
|
+
return { state: "value", row: parse(cur.value), bytes: cur.bytes, revision: cur.revision };
|
|
249
|
+
}
|
|
250
|
+
catch (e) {
|
|
251
|
+
return { state: "other", detail: message(e) };
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const headKey = (m) => lifecycleHeadKey(m.id.owner, m.id.actor);
|
|
255
|
+
const gateKey = (m) => `gate.${m.id.lifecycleUid}`;
|
|
256
|
+
const rootKey = (m, credentialId) => `cred.${m.id.lifecycleUid}.${credentialId}`;
|
|
257
|
+
const head = (ctx, m) => parsed(ctx, "records", headKey(m), (v) => parseServiceHead(v, m.id));
|
|
258
|
+
const gate = (ctx, m) => parsed(ctx, "auth", gateKey(m), (v) => parseServiceNativeGate(v, m.id));
|
|
259
|
+
const root = (ctx, m, credentialId) => parsed(ctx, "auth", rootKey(m, credentialId), (v) => parseServiceNativeRoot(v, m.id, credentialId));
|
|
260
|
+
const encodeHead = (m, h) => encodeRoundTrip(h, (v) => parseServiceHead(v, m.id));
|
|
261
|
+
const encodeGate = (m, g) => encodeRoundTrip(g, (v) => parseServiceNativeGate(v, m.id));
|
|
262
|
+
const encodeRoot = (m, r) => encodeRoundTrip(r, (v) => parseServiceNativeRoot(v, m.id, r.credentialId));
|
|
263
|
+
const rootRow = (m, plan, state) => ({
|
|
264
|
+
credentialId: plan.credentialId, holderPrincipal: m.id.holderPrincipal, lifecycleUid: m.id.lifecycleUid, sourceChain: ["root"],
|
|
265
|
+
state, exp: plan.expMs, serviceCustody: { ...m.id.mirror },
|
|
266
|
+
});
|
|
267
|
+
const headBase = (m) => ({ owner: m.id.owner, actor: m.id.actor, lifecycleUid: m.id.lifecycleUid, managerInstance: m.id.instanceId, processEpoch: 1, serviceCustody: { ...m.id.mirror } });
|
|
268
|
+
class Stopped extends Error {
|
|
269
|
+
result;
|
|
270
|
+
constructor(result) {
|
|
271
|
+
super(result.kind);
|
|
272
|
+
this.result = result;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
function stop(result) {
|
|
276
|
+
throw new Stopped(result);
|
|
277
|
+
}
|
|
278
|
+
async function endpointRecheck(ctx, m) {
|
|
279
|
+
const e = await endpoint(ctx, m);
|
|
280
|
+
if (e.kind === "revoked")
|
|
281
|
+
stop({ kind: "endpoint-revoked" });
|
|
282
|
+
if (e.kind === "foreign")
|
|
283
|
+
held("foreign-assignment", ctx.step, "the endpoint gate belongs to another immutable assignment");
|
|
284
|
+
}
|
|
285
|
+
/** Classify the head for bootstrap; cancelling stops, any non-matching head is a collision. */
|
|
286
|
+
function bootstrapHead(ctx, h) {
|
|
287
|
+
if (h.state === "marker")
|
|
288
|
+
return held("head-tombstone", ctx.step, h.detail);
|
|
289
|
+
if (h.state === "other")
|
|
290
|
+
return stop({ kind: "identity-collision" });
|
|
291
|
+
if (h.state === "value" && h.row.state === "service-cancelling")
|
|
292
|
+
stop({ kind: "cancelled", opId: h.row.op.opId });
|
|
293
|
+
return h;
|
|
294
|
+
}
|
|
295
|
+
async function bootstrap(ctx, m) {
|
|
296
|
+
const creationOp = m.id.uid.creation.operationId;
|
|
297
|
+
await readyBefore(ctx);
|
|
298
|
+
ctx.step = "name";
|
|
299
|
+
const name = await bounded(ctx, () => readNativeServiceName(m.host.registry, { accountPublicKey: m.host.accountPublicKey, space: m.host.space }, m.id.endpoint));
|
|
300
|
+
if (!name.ok) {
|
|
301
|
+
if ("error" in name && name.error instanceof EpEnvelopeError && name.error.code === "conflict")
|
|
302
|
+
return { kind: "name-unbound" };
|
|
303
|
+
return held("unavailable", ctx.step, "timedOut" in name ? "the service name read timed out" : message(name.error));
|
|
304
|
+
}
|
|
305
|
+
if (name.value === undefined || name.value.owner !== m.id.owner)
|
|
306
|
+
return { kind: "name-unbound" };
|
|
307
|
+
ctx.step = "open";
|
|
308
|
+
await endpointRecheck(ctx, m);
|
|
309
|
+
ctx.step = "B0-uid";
|
|
310
|
+
for (let i = 0;; i++) {
|
|
311
|
+
if (i >= S1_MAX_CAS_REDECIDE)
|
|
312
|
+
held("contention", ctx.step);
|
|
313
|
+
const u = await uid(ctx, m);
|
|
314
|
+
if (u === "exact")
|
|
315
|
+
break;
|
|
316
|
+
if (u === "foreign")
|
|
317
|
+
return { kind: "uid-burned" };
|
|
318
|
+
if (u === "marker")
|
|
319
|
+
held("uid-tombstone", ctx.step, "the UID reservation carries a deletion marker");
|
|
320
|
+
await write(ctx, "records", uidReservationKey(m.id.lifecycleUid), m.uidBytes, { create: true });
|
|
321
|
+
}
|
|
322
|
+
ctx.step = "B1-endpoint";
|
|
323
|
+
for (let i = 0;; i++) {
|
|
324
|
+
if (i >= S1_MAX_CAS_REDECIDE)
|
|
325
|
+
held("contention", ctx.step);
|
|
326
|
+
const e = await endpoint(ctx, m);
|
|
327
|
+
if (e.kind === "pending" || e.kind === "approved")
|
|
328
|
+
break;
|
|
329
|
+
if (e.kind === "revoked")
|
|
330
|
+
return { kind: "endpoint-revoked" };
|
|
331
|
+
if (e.kind === "foreign")
|
|
332
|
+
held("foreign-assignment", ctx.step, "the endpoint gate belongs to another immutable assignment");
|
|
333
|
+
await write(ctx, "auth", m.epgate.key, m.epgate.pending, { create: true });
|
|
334
|
+
}
|
|
335
|
+
ctx.step = "B2-head";
|
|
336
|
+
let current;
|
|
337
|
+
for (let i = 0; current === undefined; i++) {
|
|
338
|
+
if (i >= S1_MAX_CAS_REDECIDE)
|
|
339
|
+
held("contention", ctx.step);
|
|
340
|
+
const h = bootstrapHead(ctx, await head(ctx, m));
|
|
341
|
+
if (h.state === "value") {
|
|
342
|
+
current = h.row;
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
const creating = { ...headBase(m), state: "service-creating", op: { kind: "service-create", opId: creationOp, requestDigest: m.id.uid.creation.requestDigest }, rootPlan: null };
|
|
346
|
+
await write(ctx, "records", headKey(m), encodeHead(m, creating), { create: true });
|
|
347
|
+
}
|
|
348
|
+
if (current.state === "active")
|
|
349
|
+
return verify(ctx, m, current.currentCredentialId);
|
|
350
|
+
ctx.step = "B3-gate";
|
|
351
|
+
for (let i = 0;; i++) {
|
|
352
|
+
if (i >= S1_MAX_CAS_REDECIDE)
|
|
353
|
+
held("contention", ctx.step);
|
|
354
|
+
const g = await gate(ctx, m);
|
|
355
|
+
if (g.state === "other" || g.state === "marker")
|
|
356
|
+
held("gate-foreign", ctx.step, g.detail);
|
|
357
|
+
if (g.state === "absent") {
|
|
358
|
+
const frozen = { lifecycleUid: m.id.lifecycleUid, state: "frozen", generation: 0, serviceCustody: { ...m.id.mirror }, op: { opId: creationOp, kind: "activation" } };
|
|
359
|
+
await write(ctx, "auth", gateKey(m), encodeGate(m, frozen), { create: true });
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
const row = g.row;
|
|
363
|
+
if (row.state === "open")
|
|
364
|
+
break;
|
|
365
|
+
if (row.op.kind === "service-cancel")
|
|
366
|
+
return { kind: "cancelled", opId: row.op.opId };
|
|
367
|
+
if (row.op.opId !== creationOp || row.generation !== 0)
|
|
368
|
+
held("gate-foreign", ctx.step, "the frozen native gate belongs to another operation");
|
|
369
|
+
ctx.step = "B4-open";
|
|
370
|
+
const open = { lifecycleUid: m.id.lifecycleUid, state: "open", generation: 1, serviceCustody: { ...m.id.mirror } };
|
|
371
|
+
await write(ctx, "auth", gateKey(m), encodeGate(m, open), { revision: g.revision, preimage: g.bytes });
|
|
372
|
+
ctx.step = "B3-gate";
|
|
373
|
+
}
|
|
374
|
+
// Endpoint rechecks before B5, B6 and B8 run once per step and again before every write attempt.
|
|
375
|
+
const recheck = () => endpointRecheck(ctx, m);
|
|
376
|
+
ctx.step = "B5-plan";
|
|
377
|
+
await recheck();
|
|
378
|
+
let plan;
|
|
379
|
+
for (let i = 0; plan === undefined; i++) {
|
|
380
|
+
if (i >= S1_MAX_CAS_REDECIDE)
|
|
381
|
+
held("contention", ctx.step);
|
|
382
|
+
const h = bootstrapHead(ctx, await head(ctx, m));
|
|
383
|
+
if (h.state === "absent")
|
|
384
|
+
held("head-vanished", ctx.step, "the creating head is no longer readable");
|
|
385
|
+
if (h.row.state === "active")
|
|
386
|
+
return verify(ctx, m, h.row.currentCredentialId);
|
|
387
|
+
if (h.row.state !== "service-creating")
|
|
388
|
+
return held("head-foreign", ctx.step);
|
|
389
|
+
if (h.row.rootPlan !== null) {
|
|
390
|
+
plan = h.row.rootPlan;
|
|
391
|
+
break;
|
|
392
|
+
}
|
|
393
|
+
// Fixed once: the id and expiry are chosen here and only a winning CAS persists them.
|
|
394
|
+
const candidate = { credentialId: mintLifecycleUid(), expMs: Date.now() + ROOT_CREDENTIAL_TTL_MS };
|
|
395
|
+
await write(ctx, "records", headKey(m), encodeHead(m, { ...h.row, rootPlan: candidate }), { revision: h.revision, preimage: h.bytes }, recheck);
|
|
396
|
+
}
|
|
397
|
+
clipToPlan(ctx, plan);
|
|
398
|
+
ctx.step = "B6-root";
|
|
399
|
+
await recheck();
|
|
400
|
+
for (let i = 0;; i++) {
|
|
401
|
+
if (i >= S1_MAX_CAS_REDECIDE)
|
|
402
|
+
held("contention", ctx.step);
|
|
403
|
+
if (Date.now() >= plan.expMs)
|
|
404
|
+
held("plan-expired", ctx.step, "the persisted root plan has expired; cancel this incarnation");
|
|
405
|
+
const h = bootstrapHead(ctx, await head(ctx, m));
|
|
406
|
+
if (h.state === "absent" || h.row.state !== "service-creating" || h.row.rootPlan?.credentialId !== plan.credentialId) {
|
|
407
|
+
if (h.state === "value" && h.row.state === "active")
|
|
408
|
+
return verify(ctx, m, h.row.currentCredentialId);
|
|
409
|
+
held("head-moved", ctx.step, "the creating head no longer carries this plan");
|
|
410
|
+
}
|
|
411
|
+
const r = await root(ctx, m, plan.credentialId);
|
|
412
|
+
if (r.state === "other" || r.state === "marker")
|
|
413
|
+
held("root-foreign", ctx.step, r.detail);
|
|
414
|
+
if (r.state === "value") {
|
|
415
|
+
if (r.row.exp !== plan.expMs)
|
|
416
|
+
held("root-foreign", ctx.step, "the planned root row carries another expiry");
|
|
417
|
+
// A revoked planned root is a cancellation's D4 fence; the re-read head names its operation.
|
|
418
|
+
if (r.row.state === "revoked") {
|
|
419
|
+
bootstrapHead(ctx, await head(ctx, m));
|
|
420
|
+
held("root-revoked", ctx.step, "the planned root is revoked under a creating head");
|
|
421
|
+
}
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
await write(ctx, "auth", rootKey(m, plan.credentialId), encodeRoot(m, rootRow(m, plan, "active")), { create: true }, recheck);
|
|
425
|
+
}
|
|
426
|
+
ctx.step = "B7-finalize";
|
|
427
|
+
for (let i = 0;; i++) {
|
|
428
|
+
if (i >= S1_MAX_GATE_REPIN)
|
|
429
|
+
held("contention", ctx.step);
|
|
430
|
+
const g = await gate(ctx, m);
|
|
431
|
+
if (g.state !== "value")
|
|
432
|
+
held("gate-foreign", ctx.step, g.state === "absent" ? "the native gate vanished" : g.detail);
|
|
433
|
+
if (g.row.state === "frozen") {
|
|
434
|
+
if (g.row.op.kind === "service-cancel")
|
|
435
|
+
return { kind: "cancelled", opId: g.row.op.opId };
|
|
436
|
+
held("gate-foreign", ctx.step, "the native gate is frozen under another operation");
|
|
437
|
+
}
|
|
438
|
+
// Identical-bytes touch: a sibling's touch is benign and re-pins; anything else refuses.
|
|
439
|
+
if ((await write(ctx, "auth", gateKey(m), g.bytes, { revision: g.revision, preimage: g.bytes })) !== "moved")
|
|
440
|
+
break;
|
|
441
|
+
}
|
|
442
|
+
ctx.step = "B8-stamp";
|
|
443
|
+
await recheck();
|
|
444
|
+
for (let i = 0;; i++) {
|
|
445
|
+
if (i >= S1_MAX_CAS_REDECIDE)
|
|
446
|
+
held("contention", ctx.step);
|
|
447
|
+
if (Date.now() >= plan.expMs)
|
|
448
|
+
held("plan-expired", ctx.step, "the persisted root plan has expired; cancel this incarnation");
|
|
449
|
+
const h = bootstrapHead(ctx, await head(ctx, m));
|
|
450
|
+
if (h.state === "absent")
|
|
451
|
+
held("head-vanished", ctx.step, "the creating head is no longer readable");
|
|
452
|
+
if (h.row.state === "active") {
|
|
453
|
+
if (h.row.currentCredentialId !== plan.credentialId)
|
|
454
|
+
held("head-foreign", ctx.step, "the active head stamps another root");
|
|
455
|
+
break;
|
|
456
|
+
}
|
|
457
|
+
if (h.row.state !== "service-creating" || h.row.rootPlan?.credentialId !== plan.credentialId)
|
|
458
|
+
held("head-moved", ctx.step);
|
|
459
|
+
const active = { ...headBase(m), state: "active", currentCredentialId: plan.credentialId };
|
|
460
|
+
await write(ctx, "records", headKey(m), encodeHead(m, active), { revision: h.revision, preimage: h.bytes }, recheck);
|
|
461
|
+
}
|
|
462
|
+
return verify(ctx, m, plan.credentialId);
|
|
463
|
+
}
|
|
464
|
+
/** B9: the root coordinate is returned only after the unchanged active-root reader proves it and
|
|
465
|
+
* the endpoint is not revoked. No connect-reader proof is claimed here. The already-active paths
|
|
466
|
+
* reach this step before any plan clip, so the reader's clock and both root reads can predate the
|
|
467
|
+
* root's expiry; a final synchronous check, with no await before the return, holds instead of
|
|
468
|
+
* reporting a root whose expiry it observes. That is not freshness at the caller's receipt. */
|
|
469
|
+
async function verify(ctx, m, credentialId) {
|
|
470
|
+
ctx.step = "B9-verify";
|
|
471
|
+
const proof = await bounded(ctx, () => readRegistryServiceRoot(m.host.registry, m.binding, credentialId, Date.now()));
|
|
472
|
+
if (!proof.ok) {
|
|
473
|
+
const h = await head(ctx, m);
|
|
474
|
+
if (h.state === "value" && h.row.state === "service-cancelling")
|
|
475
|
+
return { kind: "cancelled", opId: h.row.op.opId };
|
|
476
|
+
return held("verify", ctx.step, "timedOut" in proof ? "the root proof timed out" : message(proof.error));
|
|
477
|
+
}
|
|
478
|
+
await endpointRecheck(ctx, m);
|
|
479
|
+
const r = await root(ctx, m, credentialId);
|
|
480
|
+
if (r.state !== "value" || r.row.state !== "active")
|
|
481
|
+
held("verify", ctx.step, "the proven root row is not readable as active");
|
|
482
|
+
if (r.row.exp <= Date.now())
|
|
483
|
+
held("plan-expired", ctx.step, "the root expired before verification completed");
|
|
484
|
+
return { kind: "active", root: { credentialId, expMs: r.row.exp } };
|
|
485
|
+
}
|
|
486
|
+
/** Bootstrap the exact sealed reservation toward an active stamped root, resuming any durable
|
|
487
|
+
* progress of the same creation. Every request is bounded by the intent's original deadline. */
|
|
488
|
+
export async function runServiceBootstrap(intent) {
|
|
489
|
+
const m = BOOTSTRAPS.get(intent);
|
|
490
|
+
if (m === undefined)
|
|
491
|
+
return denied("requires a sealed bootstrap intent");
|
|
492
|
+
const ctx = context(m.host, m.notAfterMs);
|
|
493
|
+
try {
|
|
494
|
+
return await bootstrap(ctx, m);
|
|
495
|
+
}
|
|
496
|
+
catch (e) {
|
|
497
|
+
if (e instanceof Stopped)
|
|
498
|
+
return e.result;
|
|
499
|
+
if (e instanceof Held)
|
|
500
|
+
return { kind: "held", reason: e.reason, step: e.step, detail: e.message, opId: m.id.uid.creation.operationId };
|
|
501
|
+
return { kind: "held", reason: "unavailable", step: ctx.step, detail: message(e), opId: m.id.uid.creation.operationId };
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
// ---- cancellation -------------------------------------------------------------------------------
|
|
505
|
+
class Refused extends Error {
|
|
506
|
+
result;
|
|
507
|
+
constructor(result) {
|
|
508
|
+
super(result.kind);
|
|
509
|
+
this.result = result;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
function refuse(result) {
|
|
513
|
+
throw new Refused(result);
|
|
514
|
+
}
|
|
515
|
+
const sameOp = (a, b) => a.opId === b.opId && a.requestDigest === b.requestDigest;
|
|
516
|
+
async function confirmCancelling(ctx, m) {
|
|
517
|
+
const h = await head(ctx, m);
|
|
518
|
+
if (h.state !== "value" || h.row.state !== "service-cancelling")
|
|
519
|
+
return held("head-moved", ctx.step, "the cancelling head is not readable");
|
|
520
|
+
if (!sameOp(h.row.op, m.op))
|
|
521
|
+
refuse({ kind: "foreign-operation", opId: h.row.op.opId });
|
|
522
|
+
return h.row;
|
|
523
|
+
}
|
|
524
|
+
async function cancel(ctx, m) {
|
|
525
|
+
const opId = m.op.opId;
|
|
526
|
+
await readyBefore(ctx);
|
|
527
|
+
ctx.step = "U-uid";
|
|
528
|
+
const u = await uid(ctx, m);
|
|
529
|
+
if (u === "foreign" || u === "marker")
|
|
530
|
+
return { kind: "uid-foreign" };
|
|
531
|
+
ctx.step = "L1-endpoint";
|
|
532
|
+
for (let i = 0;; i++) {
|
|
533
|
+
if (i >= S1_MAX_CAS_REDECIDE)
|
|
534
|
+
held("contention", ctx.step);
|
|
535
|
+
const e = await endpoint(ctx, m);
|
|
536
|
+
if (e.kind === "revoked")
|
|
537
|
+
break;
|
|
538
|
+
if (e.kind === "foreign")
|
|
539
|
+
held("foreign-assignment", ctx.step, "the endpoint gate belongs to another immutable assignment");
|
|
540
|
+
if (e.kind === "absent") {
|
|
541
|
+
await write(ctx, "auth", m.epgate.key, m.epgate.uncreatedFence(opId), { create: true });
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
const next = m.epgate.deny(e.row, opId);
|
|
545
|
+
if (next === undefined)
|
|
546
|
+
break;
|
|
547
|
+
await write(ctx, "auth", m.epgate.key, next, { revision: e.revision, preimage: e.bytes });
|
|
548
|
+
}
|
|
549
|
+
if (u === "absent") {
|
|
550
|
+
// D3: endpoint-only denied residue under this intent. No head, gate or root authority.
|
|
551
|
+
if ((await uid(ctx, m)) !== "exact")
|
|
552
|
+
return { kind: "uid-unconfirmed", held: true, opId };
|
|
553
|
+
}
|
|
554
|
+
ctx.step = "L2-head";
|
|
555
|
+
for (let i = 0;; i++) {
|
|
556
|
+
if (i >= S1_MAX_CAS_REDECIDE)
|
|
557
|
+
held("contention", ctx.step);
|
|
558
|
+
const h = await head(ctx, m);
|
|
559
|
+
if (h.state === "other" || h.state === "marker")
|
|
560
|
+
held("head-foreign", ctx.step, h.detail);
|
|
561
|
+
let next;
|
|
562
|
+
if (h.state === "absent") {
|
|
563
|
+
next = { ...headBase(m), state: "service-cancelling", op: m.op, rootPlan: null, currentCredentialId: null };
|
|
564
|
+
await write(ctx, "records", headKey(m), encodeHead(m, next), { create: true });
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
const row = h.row;
|
|
568
|
+
if (row.state === "service-cancelling") {
|
|
569
|
+
if (!sameOp(row.op, m.op))
|
|
570
|
+
return { kind: "foreign-operation", opId: row.op.opId };
|
|
571
|
+
break;
|
|
572
|
+
}
|
|
573
|
+
if (row.state === "service-creating") {
|
|
574
|
+
next = { ...headBase(m), state: "service-cancelling", op: m.op, rootPlan: row.rootPlan, currentCredentialId: null };
|
|
575
|
+
}
|
|
576
|
+
else {
|
|
577
|
+
// active -> cancelling: derive the preserved plan from the exact stamped root row, never guess.
|
|
578
|
+
const r = await root(ctx, m, row.currentCredentialId);
|
|
579
|
+
if (r.state !== "value")
|
|
580
|
+
held("root-unreadable", ctx.step, r.state === "absent" ? "the stamped root row is absent" : r.detail);
|
|
581
|
+
next = { ...headBase(m), state: "service-cancelling", op: m.op, rootPlan: { credentialId: row.currentCredentialId, expMs: r.row.exp }, currentCredentialId: row.currentCredentialId };
|
|
582
|
+
}
|
|
583
|
+
await write(ctx, "records", headKey(m), encodeHead(m, next), { revision: h.revision, preimage: h.bytes });
|
|
584
|
+
}
|
|
585
|
+
ctx.step = "L3-gate";
|
|
586
|
+
for (let i = 0;; i++) {
|
|
587
|
+
if (i >= S1_MAX_CAS_REDECIDE)
|
|
588
|
+
held("contention", ctx.step);
|
|
589
|
+
await confirmCancelling(ctx, m);
|
|
590
|
+
const g = await gate(ctx, m);
|
|
591
|
+
if (g.state === "other" || g.state === "marker")
|
|
592
|
+
held("gate-foreign", ctx.step, g.detail);
|
|
593
|
+
const cancelGate = (generation) => ({ lifecycleUid: m.id.lifecycleUid, state: "frozen", generation, serviceCustody: { ...m.id.mirror }, op: { opId, kind: "service-cancel" } });
|
|
594
|
+
if (g.state === "absent") {
|
|
595
|
+
await write(ctx, "auth", gateKey(m), encodeGate(m, cancelGate(0)), { create: true });
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
const row = g.row;
|
|
599
|
+
if (row.state === "frozen" && row.op.kind === "service-cancel") {
|
|
600
|
+
if (row.op.opId !== opId)
|
|
601
|
+
return { kind: "foreign-operation", opId: row.op.opId };
|
|
602
|
+
break;
|
|
603
|
+
}
|
|
604
|
+
if (row.state === "frozen" && row.op.opId !== m.id.uid.creation.operationId)
|
|
605
|
+
held("gate-foreign", ctx.step, "the frozen native gate belongs to another operation");
|
|
606
|
+
await write(ctx, "auth", gateKey(m), encodeGate(m, cancelGate(row.generation)), { revision: g.revision, preimage: g.bytes });
|
|
607
|
+
}
|
|
608
|
+
ctx.step = "L4-root";
|
|
609
|
+
const h = await confirmCancelling(ctx, m);
|
|
610
|
+
const pending = (root, rootDetail) => ({ kind: "pending", phase: "cancelled-nonterminal", root, ...(rootDetail ? { rootDetail } : {}), opId });
|
|
611
|
+
if (h.currentCredentialId !== null)
|
|
612
|
+
return pending("retained-stamped");
|
|
613
|
+
if (h.rootPlan === null)
|
|
614
|
+
return pending("none");
|
|
615
|
+
const plan = h.rootPlan;
|
|
616
|
+
// D4 preconditions, freshly proven: exact reservation, endpoint deny-new, native service-cancel fence.
|
|
617
|
+
if ((await uid(ctx, m)) !== "exact")
|
|
618
|
+
held("uid-moved", ctx.step);
|
|
619
|
+
if ((await endpoint(ctx, m)).kind !== "revoked")
|
|
620
|
+
held("endpoint-not-denied", ctx.step);
|
|
621
|
+
const g = await gate(ctx, m);
|
|
622
|
+
if (g.state !== "value" || g.row.state !== "frozen" || g.row.op.kind !== "service-cancel" || g.row.op.opId !== opId)
|
|
623
|
+
held("gate-not-fenced", ctx.step);
|
|
624
|
+
let detail;
|
|
625
|
+
for (let i = 0; detail === undefined; i++) {
|
|
626
|
+
if (i >= S1_MAX_CAS_REDECIDE)
|
|
627
|
+
held("contention", ctx.step);
|
|
628
|
+
const r = await root(ctx, m, plan.credentialId);
|
|
629
|
+
if (r.state === "other" || r.state === "marker")
|
|
630
|
+
held("root-foreign", ctx.step, r.detail);
|
|
631
|
+
if (r.state === "absent") {
|
|
632
|
+
if ((await write(ctx, "auth", rootKey(m, plan.credentialId), encodeRoot(m, rootRow(m, plan, "revoked")), { create: true })) !== "moved")
|
|
633
|
+
detail = "created-revoked";
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
if (r.row.exp !== plan.expMs)
|
|
637
|
+
held("root-foreign", ctx.step, "the planned root row carries another expiry");
|
|
638
|
+
if (r.row.state === "revoked") {
|
|
639
|
+
detail = "already-revoked";
|
|
640
|
+
break;
|
|
641
|
+
}
|
|
642
|
+
if ((await write(ctx, "auth", rootKey(m, plan.credentialId), encodeRoot(m, rootRow(m, plan, "revoked")), { revision: r.revision, preimage: r.bytes })) !== "moved")
|
|
643
|
+
detail = "cas-revoked";
|
|
644
|
+
}
|
|
645
|
+
return pending("revoked", detail);
|
|
646
|
+
}
|
|
647
|
+
/** Run one bounded invocation of an accepted cancellation. `deadlineMs` bounds this invocation
|
|
648
|
+
* only; the same intent may be invoked again and resumes its own durable progress. */
|
|
649
|
+
export async function cancelServiceCustody(intent, opts) {
|
|
650
|
+
const m = CANCELS.get(intent);
|
|
651
|
+
if (m === undefined)
|
|
652
|
+
return denied("requires a sealed cancel intent");
|
|
653
|
+
if (!Number.isSafeInteger(opts.deadlineMs))
|
|
654
|
+
denied("cancel invocation requires an explicit deadline");
|
|
655
|
+
const ctx = context(m.host, opts.deadlineMs);
|
|
656
|
+
try {
|
|
657
|
+
return await cancel(ctx, m);
|
|
658
|
+
}
|
|
659
|
+
catch (e) {
|
|
660
|
+
if (e instanceof Refused)
|
|
661
|
+
return e.result;
|
|
662
|
+
if (e instanceof Held)
|
|
663
|
+
return { kind: "held", reason: e.reason, step: e.step, detail: e.message, opId: m.op.opId };
|
|
664
|
+
return { kind: "held", reason: "unavailable", step: ctx.step, detail: message(e), opId: m.op.opId };
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
//# sourceMappingURL=service-custody.js.map
|