@agenticprimitives/fabric 0.0.0-alpha.39 → 0.0.0-alpha.40
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/CLAUDE.md +14 -22
- package/dist/cloudflare/exchange-store.d.ts +20 -1
- package/dist/cloudflare/exchange-store.d.ts.map +1 -1
- package/dist/cloudflare/exchange-store.js +26 -4
- package/dist/cloudflare/exchange-store.js.map +1 -1
- package/dist/cloudflare/index.d.ts +4 -4
- package/dist/cloudflare/index.d.ts.map +1 -1
- package/dist/cloudflare/index.js +2 -2
- package/dist/cloudflare/index.js.map +1 -1
- package/dist/cloudflare/interaction-store.d.ts +26 -0
- package/dist/cloudflare/interaction-store.d.ts.map +1 -1
- package/dist/cloudflare/interaction-store.js +52 -1
- package/dist/cloudflare/interaction-store.js.map +1 -1
- package/dist/cloudflare/orchestration-runtime.d.ts +19 -5
- package/dist/cloudflare/orchestration-runtime.d.ts.map +1 -1
- package/dist/cloudflare/orchestration-runtime.js +11 -6
- package/dist/cloudflare/orchestration-runtime.js.map +1 -1
- package/dist/cloudflare/principal-gateway-do.d.ts +227 -31
- package/dist/cloudflare/principal-gateway-do.d.ts.map +1 -1
- package/dist/cloudflare/principal-gateway-do.js +398 -58
- package/dist/cloudflare/principal-gateway-do.js.map +1 -1
- package/dist/cloudflare/receipt-signer.d.ts.map +1 -1
- package/dist/cloudflare/receipt-signer.js +6 -3
- package/dist/cloudflare/receipt-signer.js.map +1 -1
- package/dist/cloudflare/vault-exchange-store.d.ts.map +1 -1
- package/dist/cloudflare/vault-exchange-store.js +17 -0
- package/dist/cloudflare/vault-exchange-store.js.map +1 -1
- package/dist/cloudflare/vault-interaction-store.d.ts +1 -1
- package/dist/cloudflare/vault-interaction-store.d.ts.map +1 -1
- package/dist/cloudflare/vault-interaction-store.js +14 -0
- package/dist/cloudflare/vault-interaction-store.js.map +1 -1
- package/dist/email/index.d.ts +4 -0
- package/dist/email/index.d.ts.map +1 -1
- package/dist/email/index.js +19 -5
- package/dist/email/index.js.map +1 -1
- package/dist/messaging/board.d.ts +1 -1
- package/dist/messaging/consult-routing.d.ts +92 -19
- package/dist/messaging/consult-routing.d.ts.map +1 -1
- package/dist/messaging/consult-routing.js +136 -35
- package/dist/messaging/consult-routing.js.map +1 -1
- package/dist/messaging/index.d.ts +1 -1
- package/dist/messaging/index.d.ts.map +1 -1
- package/dist/messaging/index.js +1 -1
- package/dist/messaging/index.js.map +1 -1
- package/dist/messaging/vault-store.d.ts.map +1 -1
- package/dist/messaging/vault-store.js +8 -0
- package/dist/messaging/vault-store.js.map +1 -1
- package/package.json +9 -9
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { canonicalizeJson } from '@agenticprimitives/types';
|
|
2
|
+
import { TERMINAL_INTERACTION_STATES } from '../interaction-machines.js';
|
|
1
3
|
import { admitDeliveryEnvelope, deliveryToExchangeRecord, } from '../delivery-envelope.js';
|
|
2
4
|
import { createDurableObjectSqlVault } from './sql-vault.js';
|
|
3
5
|
import { createDurableObjectOutboxStore } from './outbox-store.js';
|
|
@@ -8,13 +10,55 @@ import { TOPIC_PROFILE } from '../interaction-profile.js';
|
|
|
8
10
|
import { createDurableObjectAuthorityVerdictCache, } from './authority-verdict-cache.js';
|
|
9
11
|
import { createDurableObjectRequestIdLedger } from './request-id-ledger.js';
|
|
10
12
|
import { createDurableObjectReceiptSigner, } from './receipt-signer.js';
|
|
11
|
-
import { runGatewayOrchestration } from './orchestration-runtime.js';
|
|
13
|
+
import { runGatewayOrchestration, gatewayToolSpecs } from './orchestration-runtime.js';
|
|
12
14
|
import { projectExchangeProvenance, projectRunProvenance, } from '@agenticprimitives/provenance';
|
|
13
15
|
import { createDurableObjectProvenanceStore } from './provenance-store.js';
|
|
14
16
|
import { createDurableObjectChannelStore, projectionAuthorizes } from './channel-store.js';
|
|
17
|
+
/** An ingress was refused before anything was read or written (spec 445 §3.4, DATA-3). */
|
|
18
|
+
export class GatewayAuthError extends Error {
|
|
19
|
+
reason;
|
|
20
|
+
constructor(reason) {
|
|
21
|
+
super(`PrincipalGatewayDO: ${reason}`);
|
|
22
|
+
this.reason = reason;
|
|
23
|
+
this.name = 'GatewayAuthError';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** `openInteraction` was asked to open an interaction that already exists as something else — a
|
|
27
|
+
* different shape, or one that has ended (spec 445 §3.4, DATB-2). Nothing was written. */
|
|
28
|
+
export class InteractionExistsError extends Error {
|
|
29
|
+
interactionId;
|
|
30
|
+
state;
|
|
31
|
+
reason = 'interaction-exists';
|
|
32
|
+
constructor(interactionId, state) {
|
|
33
|
+
super(`PrincipalGatewayDO: interaction ${interactionId} already exists (${state}); open never overwrites`);
|
|
34
|
+
this.interactionId = interactionId;
|
|
35
|
+
this.state = state;
|
|
36
|
+
this.name = 'InteractionExistsError';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
15
39
|
function json(body, status = 200) {
|
|
16
40
|
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
|
17
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Is `offered` the SAME signed envelope the stored exchange was seeded with?
|
|
44
|
+
*
|
|
45
|
+
* The identity is the envelope's full canonical bytes — `canonicalizeJson` over the whole envelope,
|
|
46
|
+
* `signature` INCLUDED. That is strictly stronger than any of the three weaker identities and implies
|
|
47
|
+
* them all: same `id`, same `bodyHash`, same signed payload (`hashExchangeEnvelope` covers everything
|
|
48
|
+
* but the signature) and the same proof over it. An envelope that matches on id and body hash but
|
|
49
|
+
* carries a different signature, recipient list or timestamp is a DIFFERENT envelope and is refused.
|
|
50
|
+
* Compared against `envelopes[0]`: later envelopes are responses the exchange accrued, and a
|
|
51
|
+
* redelivery is of the one that opened it.
|
|
52
|
+
*/
|
|
53
|
+
function isSameSignedEnvelope(stored, offered) {
|
|
54
|
+
const seed = stored.envelopes[0];
|
|
55
|
+
return seed !== undefined && canonicalizeJson(seed) === canonicalizeJson(offered);
|
|
56
|
+
}
|
|
57
|
+
const sameAgent = (a, b) => a.toLowerCase() === b.toLowerCase();
|
|
58
|
+
/** The account part of an id (`eip155:1:0xabc` → `0xabc`), lowercased — a verdict names its principal
|
|
59
|
+
* as a bare address while a gateway is configured with a CAIP-10. */
|
|
60
|
+
const accountOf = (id) => (id.split(':').pop() ?? '').toLowerCase();
|
|
61
|
+
const sameAccount = (a, b) => accountOf(a) !== '' && accountOf(a) === accountOf(b);
|
|
18
62
|
export class PrincipalGatewayDO {
|
|
19
63
|
state;
|
|
20
64
|
env;
|
|
@@ -28,6 +72,8 @@ export class PrincipalGatewayDO {
|
|
|
28
72
|
outbox;
|
|
29
73
|
provenance;
|
|
30
74
|
channels;
|
|
75
|
+
/** The callers `admitCaller` minted. Membership IS the proof of minting — nothing else is. */
|
|
76
|
+
minted = new WeakSet();
|
|
31
77
|
constructor(state, env, deps) {
|
|
32
78
|
this.state = state;
|
|
33
79
|
this.env = env;
|
|
@@ -63,7 +109,12 @@ export class PrincipalGatewayDO {
|
|
|
63
109
|
*/
|
|
64
110
|
async createChannel(channel) {
|
|
65
111
|
await this.channels.createChannel(channel);
|
|
66
|
-
//
|
|
112
|
+
// A RENAME CHANGES THE NAME AND NOTHING ELSE (spec 445 §3.4, audit DATB-2/DATB-6). The topic's
|
|
113
|
+
// interaction already exists under its derived id; whatever state it is in — active, suspended,
|
|
114
|
+
// concluded — and whatever `rev`/`createdAt` it carries are the lifecycle's, moved only by
|
|
115
|
+
// `transitionInteraction`. So an existing interaction is left exactly as it is, in EVERY state.
|
|
116
|
+
if (await this.interactionStore.getInteraction(topicInteractionId(channel.channelId)))
|
|
117
|
+
return;
|
|
67
118
|
await this.openInteraction({
|
|
68
119
|
interactionId: topicInteractionId(channel.channelId),
|
|
69
120
|
profileVersion: TOPIC_PROFILE.profileVersion,
|
|
@@ -77,9 +128,19 @@ export class PrincipalGatewayDO {
|
|
|
77
128
|
* satisfies it via the injected `verifyEntitlement` (fail-closed — no credential / no verifier / invalid ⇒
|
|
78
129
|
* rejected). An ungated channel joins openly.
|
|
79
130
|
*/
|
|
80
|
-
async joinChannel(channelId, member, opts = {}) {
|
|
131
|
+
async joinChannel(channelId, member, caller, opts = {}) {
|
|
132
|
+
// WHO is being seated is not the caller's to assert (DATA-3): an agent seats ITSELF, or this
|
|
133
|
+
// gateway's own principal (the organization) seats a member.
|
|
134
|
+
if (!this.isMinted(caller))
|
|
135
|
+
return { ok: false, reason: 'unauthenticated' };
|
|
136
|
+
if (!this.selfOrPrincipal(caller, member))
|
|
137
|
+
return { ok: false, reason: 'actor_mismatch' };
|
|
81
138
|
const channel = await this.channels.getChannel(channelId);
|
|
82
|
-
|
|
139
|
+
// A channel that does not exist has no gate to check, so a join before creation would seat an
|
|
140
|
+
// agent ahead of the gate it is later created with (audit DATA-10). Refused.
|
|
141
|
+
if (!channel)
|
|
142
|
+
return { ok: false, reason: 'unknown_channel' };
|
|
143
|
+
if (channel.joinGate) {
|
|
83
144
|
const verify = this.deps?.verifyEntitlement;
|
|
84
145
|
if (!verify)
|
|
85
146
|
return { ok: false, reason: 'gate_unverifiable' };
|
|
@@ -94,8 +155,13 @@ export class PrincipalGatewayDO {
|
|
|
94
155
|
return { ok: true };
|
|
95
156
|
}
|
|
96
157
|
/** Remove a member — **kick = the org revokes** its role delegation (C2). */
|
|
97
|
-
async leaveChannel(channelId, member) {
|
|
98
|
-
|
|
158
|
+
async leaveChannel(channelId, member, caller) {
|
|
159
|
+
if (!this.isMinted(caller))
|
|
160
|
+
return { ok: false, reason: 'unauthenticated' };
|
|
161
|
+
if (!this.selfOrPrincipal(caller, member))
|
|
162
|
+
return { ok: false, reason: 'actor_mismatch' };
|
|
163
|
+
await this.channels.leave(channelId, member);
|
|
164
|
+
return { ok: true };
|
|
99
165
|
}
|
|
100
166
|
async channelMembers(channelId) {
|
|
101
167
|
return this.channels.members(channelId);
|
|
@@ -110,38 +176,52 @@ export class PrincipalGatewayDO {
|
|
|
110
176
|
async projectConversationRole(channelId, projection) {
|
|
111
177
|
await this.channels.putRoleProjection({ ...projection, conversationId: channelId });
|
|
112
178
|
}
|
|
113
|
-
/** MANAGE-gated (spec 324 §8): project a member's conversation role.
|
|
179
|
+
/** MANAGE-gated (spec 324 §8): project a member's conversation role. The AUTHENTICATED actor (never a
|
|
180
|
+
* caller-named `by`, DATA-3) must hold a cached projection
|
|
114
181
|
* authorizing `manage`. This CACHES an org-issued RoleAssignment + its materializing delegation — it does
|
|
115
182
|
* not itself grant authority (ADR-0048 #4); the org issues the delegation, then the projection mirrors it. */
|
|
116
|
-
async putConversationRoleProjection(channelId, projection,
|
|
117
|
-
if (!
|
|
183
|
+
async putConversationRoleProjection(channelId, projection, caller, at) {
|
|
184
|
+
if (!this.isMinted(caller))
|
|
185
|
+
return { ok: false, reason: 'unauthenticated' };
|
|
186
|
+
if (!projectionAuthorizes(await this.channels.roleProjection(channelId, caller.actor), 'manage', at))
|
|
118
187
|
return { ok: false, reason: 'not_authorized' };
|
|
119
188
|
await this.channels.putRoleProjection({ ...projection, conversationId: channelId });
|
|
120
189
|
return { ok: true };
|
|
121
190
|
}
|
|
122
191
|
/** MANAGE-gated: clear a member's conversation-role projection (the org revoked the underlying delegation). */
|
|
123
|
-
async clearConversationRoleProjection(channelId, member,
|
|
124
|
-
if (!
|
|
192
|
+
async clearConversationRoleProjection(channelId, member, caller, at) {
|
|
193
|
+
if (!this.isMinted(caller))
|
|
194
|
+
return { ok: false, reason: 'unauthenticated' };
|
|
195
|
+
if (!projectionAuthorizes(await this.channels.roleProjection(channelId, caller.actor), 'manage', at))
|
|
125
196
|
return { ok: false, reason: 'not_authorized' };
|
|
126
197
|
await this.channels.clearRoleProjection(channelId, member);
|
|
127
198
|
return { ok: true };
|
|
128
199
|
}
|
|
129
|
-
/** MODERATE-gated (spec 324 §8): remove a post from the channel board.
|
|
200
|
+
/** MODERATE-gated (spec 324 §8): remove a post from the channel board. The AUTHENTICATED actor must hold a cached projection
|
|
130
201
|
* authorizing `moderate`. The exchange itself is NOT deleted (immutable stream + provenance); only its
|
|
131
202
|
* channel index entry is removed. */
|
|
132
|
-
async removeChannelPost(channelId, exchangeId,
|
|
133
|
-
if (!
|
|
203
|
+
async removeChannelPost(channelId, exchangeId, caller, at) {
|
|
204
|
+
if (!this.isMinted(caller))
|
|
205
|
+
return { ok: false, reason: 'unauthenticated' };
|
|
206
|
+
if (!projectionAuthorizes(await this.channels.roleProjection(channelId, caller.actor), 'moderate', at))
|
|
134
207
|
return { ok: false, reason: 'not_authorized' };
|
|
135
208
|
await this.channels.removePost(channelId, exchangeId);
|
|
136
209
|
return { ok: true };
|
|
137
210
|
}
|
|
138
211
|
/**
|
|
139
|
-
* Post to a channel — MEMBER-GATED, fail-closed: only a current member
|
|
212
|
+
* Post to a channel — MEMBER-GATED, fail-closed: only a current member may post, and the poster is the
|
|
213
|
+
* AUTHENTICATED actor: `record.parties.from` must be `caller.actor` (DATA-3), so membership is asked
|
|
214
|
+
* about who is actually posting, never about a name in the record. A topic whose interaction is not
|
|
215
|
+
* `active` takes no posts (`interaction-not-active`, DATB-2).
|
|
140
216
|
* The post is persisted on the org's exchange stream via `deliver()` (so it accretes a provenance trace like
|
|
141
217
|
* any exchange) and indexed into the channel. Fan-out to members is the org re-delivering (§4 / Blocker 6).
|
|
142
218
|
*/
|
|
143
|
-
async postToChannel(channelId, record) {
|
|
219
|
+
async postToChannel(channelId, record, caller) {
|
|
220
|
+
if (!this.isMinted(caller))
|
|
221
|
+
return { ok: false, reason: 'unauthenticated' };
|
|
144
222
|
const from = record.parties.from;
|
|
223
|
+
if (!sameAgent(String(from), String(caller.actor)))
|
|
224
|
+
return { ok: false, reason: 'actor-mismatch' };
|
|
145
225
|
// A poster is either a channel MEMBER (person/org, C1/C2) or an admitted channel BOT (Service SA, C4).
|
|
146
226
|
// Kept as an explicit pre-check so the caller-facing reason stays `not_a_member`: ingress would also
|
|
147
227
|
// refuse, but as `not-a-participant`, and a post rejection should say the thing the poster can act on.
|
|
@@ -157,7 +237,7 @@ export class PrincipalGatewayDO {
|
|
|
157
237
|
};
|
|
158
238
|
// Threading the refusal rather than discarding it: a post naming an interaction this gateway does
|
|
159
239
|
// not hold must not be indexed into the channel as though it landed.
|
|
160
|
-
const delivered = await this.deliver(posted);
|
|
240
|
+
const delivered = await this.deliver(posted, caller);
|
|
161
241
|
if (!delivered.ok)
|
|
162
242
|
return { ok: false, reason: delivered.reason };
|
|
163
243
|
await this.channels.addPost(channelId, String(posted.exchangeId), posted.updatedAt);
|
|
@@ -216,8 +296,10 @@ export class PrincipalGatewayDO {
|
|
|
216
296
|
async receiptPubkey() {
|
|
217
297
|
return this.receipts.pubkey();
|
|
218
298
|
}
|
|
219
|
-
/** Sign a `DeliveryReceiptV1` with the DO-local key
|
|
220
|
-
*
|
|
299
|
+
/** Sign a `DeliveryReceiptV1` with the DO-local key. PRIVATE: a receipt attests to a delivery this
|
|
300
|
+
* gateway RECORDED, so the only callers are the two delivery paths, after the `delivered` fact is in
|
|
301
|
+
* the stream. A public signer would be a receipt oracle for any fields a caller cared to name. Verify
|
|
302
|
+
* with `verifyDeliveryReceipt`. */
|
|
221
303
|
async signDeliveryReceipt(input) {
|
|
222
304
|
return this.receipts.sign(input);
|
|
223
305
|
}
|
|
@@ -225,52 +307,156 @@ export class PrincipalGatewayDO {
|
|
|
225
307
|
* RECIPIENT-side peer delivery (spec 316 §4 / W4). Transport-admits an inbound `DeliveryEnvelopeV1` (routing
|
|
226
308
|
* + addressing + structural), then persists the exchange, records the `delivered` fact, and returns a
|
|
227
309
|
* DO-signed `DeliveryReceiptV1`. Sender AUTHORITY (envelope signature) is re-verified separately by the gateway
|
|
228
|
-
* (ADR-0041) — admission here is transport-level only.
|
|
310
|
+
* (ADR-0041) — admission here is transport-level only.
|
|
311
|
+
*
|
|
312
|
+
* `recipient` and `tier` are the AUTHENTICATED caller's `principal` and `tier`, and the envelope's `from`
|
|
313
|
+
* must be its `actor` (spec 445 §3.4, DATA-3): who a delivery is for, which lane it rides and who
|
|
314
|
+
* sent it are admission's findings, never fields of the call.
|
|
315
|
+
*
|
|
316
|
+
* A DELIVERY NEVER OVERWRITES (spec 445 §3.4, audit H-10 / DATA-1). The exchange id comes from the
|
|
317
|
+
* peer and is known to every counterparty, so it identifies nothing about who is sending. If a record
|
|
318
|
+
* is already held under it, exactly two things can be true:
|
|
319
|
+
*
|
|
320
|
+
* · the offered envelope IS the stored one (`isSameSignedEnvelope` — full canonical bytes, signature
|
|
321
|
+
* included) → an idempotent redelivery. Nothing is rewritten and no second `delivered` event is
|
|
322
|
+
* appended; the receipt returned is for the ORIGINAL delivery. Receipts are not stored, so it is
|
|
323
|
+
* re-signed over the original `delivered` event's time — same exchange, message, recipient and
|
|
324
|
+
* `at`, hence the same attested fact, never a fresh claim that it arrived again now.
|
|
325
|
+
* · anything else → refused `exchange-exists`, and NO receipt is signed: a receipt for a delivery
|
|
326
|
+
* we did not store would attest to something false.
|
|
229
327
|
*/
|
|
230
|
-
async receiveDelivery(delivery, opts) {
|
|
231
|
-
|
|
328
|
+
async receiveDelivery(delivery, caller, opts) {
|
|
329
|
+
if (!this.isMinted(caller))
|
|
330
|
+
return { ok: false, reason: 'unauthenticated' };
|
|
331
|
+
if (!sameAgent(String(delivery.envelope.from), String(caller.actor)))
|
|
332
|
+
return { ok: false, reason: 'actor-mismatch' };
|
|
333
|
+
const admission = admitDeliveryEnvelope(delivery, { recipient: caller.principal });
|
|
232
334
|
if (!admission.ok)
|
|
233
335
|
return admission;
|
|
234
|
-
const rec = deliveryToExchangeRecord(delivery, { tier:
|
|
336
|
+
const rec = deliveryToExchangeRecord(delivery, { tier: caller.tier, at: opts.at });
|
|
337
|
+
const held = await this.exchanges.get(rec.exchangeId);
|
|
338
|
+
if (held) {
|
|
339
|
+
if (!isSameSignedEnvelope(held, delivery.envelope))
|
|
340
|
+
return { ok: false, reason: 'exchange-exists' };
|
|
341
|
+
return this.redeliveryReceipt(held, delivery, opts.at);
|
|
342
|
+
}
|
|
235
343
|
// A peer naming an interaction we do not hold, or one it is not in, is refused BEFORE the receipt:
|
|
236
344
|
// signing a delivery receipt for an exchange we did not store would attest to something false.
|
|
237
|
-
const delivered = await this.
|
|
345
|
+
const delivered = await this.ingest(rec);
|
|
238
346
|
if (!delivered.ok)
|
|
239
347
|
return { ok: false, reason: delivered.reason };
|
|
240
|
-
|
|
348
|
+
// A RECEIPT ATTESTS TO A RECORDED DELIVERY. If the `delivered` fact is not in the stream — the
|
|
349
|
+
// reducer refused it, or the exchange is not there — nothing is signed. The stored record stays;
|
|
350
|
+
// a redelivery of the same envelope completes it (`redeliveryReceipt`).
|
|
351
|
+
const recorded = await this.exchanges.append(rec.exchangeId, {
|
|
241
352
|
kind: 'message', seq: 0, at: opts.at, eventType: 'delivered', actor: delivery.envelope.from, messageId: delivery.envelope.id,
|
|
242
353
|
});
|
|
354
|
+
if (!recorded || !recorded.ok)
|
|
355
|
+
return { ok: false, reason: recorded ? recorded.reason : 'unknown_exchange' };
|
|
243
356
|
const receipt = await this.signDeliveryReceipt({
|
|
244
357
|
exchangeId: String(delivery.exchangeId), messageId: delivery.envelope.id, recipient: delivery.toGateway, at: opts.at,
|
|
245
358
|
});
|
|
246
359
|
return { ok: true, receipt };
|
|
247
360
|
}
|
|
361
|
+
/**
|
|
362
|
+
* The receipt for a redelivery of an envelope this gateway already holds (spec 445 §3.4).
|
|
363
|
+
*
|
|
364
|
+
* The time attested is the ORIGINAL `delivered` event's. One case needs a write: a first attempt that
|
|
365
|
+
* stored the record and stopped before recording `delivered`. The redelivery then completes that
|
|
366
|
+
* delivery — one event, through the reducer — rather than signing for a fact the stream does not hold.
|
|
367
|
+
*/
|
|
368
|
+
async redeliveryReceipt(held, delivery, at) {
|
|
369
|
+
const messageId = delivery.envelope.id;
|
|
370
|
+
const first = held.events.find((e) => e.kind === 'message' && e.eventType === 'delivered' && e.messageId === messageId);
|
|
371
|
+
let deliveredAt = first?.at;
|
|
372
|
+
if (deliveredAt === undefined) {
|
|
373
|
+
const applied = await this.exchanges.append(held.exchangeId, {
|
|
374
|
+
kind: 'message', seq: 0, at, eventType: 'delivered', actor: delivery.envelope.from, messageId,
|
|
375
|
+
});
|
|
376
|
+
if (!applied || !applied.ok)
|
|
377
|
+
return { ok: false, reason: applied ? applied.reason : 'unknown_exchange' };
|
|
378
|
+
deliveredAt = at;
|
|
379
|
+
}
|
|
380
|
+
const receipt = await this.signDeliveryReceipt({
|
|
381
|
+
exchangeId: String(held.exchangeId), messageId, recipient: delivery.toGateway, at: deliveredAt,
|
|
382
|
+
});
|
|
383
|
+
return { ok: true, receipt };
|
|
384
|
+
}
|
|
248
385
|
/**
|
|
249
386
|
* T2 orchestrated lane (spec 316 §15) — a `request`/agent-task INTENT is fulfilled by the Ring-0
|
|
250
387
|
* orchestration loop running IN this gateway, with the co-resident MCP tools as the loop's ToolInvoker
|
|
251
|
-
* (in-process over the DO-local vault
|
|
252
|
-
*
|
|
253
|
-
*
|
|
388
|
+
* (in-process over the DO-local vault). Only the LLM `planner` is injected; the loop, the tools, and the
|
|
389
|
+
* vault are the gateway's own. (Absorbing `A2aTaskDO` + driving this from `alarm()` via a durable task
|
|
390
|
+
* queue is the remaining async-scheduling wrap.)
|
|
391
|
+
*
|
|
392
|
+
* THE SAME §3a GATE AS `invokeFast`, PER STEP (spec 445 §3.4, audit DATA-4). A T2 intent is by design
|
|
393
|
+
* a peer's request and its text reaches the planner, so nothing here trusts the plan:
|
|
394
|
+
*
|
|
395
|
+
* (A) the cached args-independent verdict for `key` — a miss runs NOTHING (cold path first). The
|
|
396
|
+
* principal the tools run as, and whose KEK is resolved, is the VERDICT's. It must be this
|
|
397
|
+
* gateway's principal when the deployment names one, and the one the caller says it expects
|
|
398
|
+
* (`principal`) when it says: a verdict for A never reaches B's records.
|
|
399
|
+
* (B) `runCaveatPass(verdict, tool, args)` for EVERY planned step against the concrete tool and
|
|
400
|
+
* arguments — never cached; a denied step throws inside the loop and its tool does not run.
|
|
401
|
+
* (C) a single-use `requestId` (+ `now`) for the run. A WRITE tool does not run without one — it is
|
|
402
|
+
* not even offered to the planner — and a replayed id is refused before anything runs, so a
|
|
403
|
+
* retried intent is not applied twice. The id is released only when no step ran (a legitimate
|
|
404
|
+
* retry); once any tool ran it stays consumed, whatever the outcome.
|
|
254
405
|
*/
|
|
255
406
|
async orchestrate(input) {
|
|
256
407
|
const deps = this.requireDeps();
|
|
257
408
|
if (!deps.planner)
|
|
258
409
|
throw new Error('PrincipalGatewayDO: no planner configured for T2 orchestration');
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
if (input.
|
|
270
|
-
|
|
271
|
-
|
|
410
|
+
if (typeof input.runCaveatPass !== 'function')
|
|
411
|
+
throw new Error('PrincipalGatewayDO: orchestrate requires runCaveatPass');
|
|
412
|
+
const verdict = await this.verdicts.get(input.key); // (A)
|
|
413
|
+
if (!verdict)
|
|
414
|
+
return { ok: false, reason: 'verdict_miss_cold_path_required' };
|
|
415
|
+
const own = this.env.PRINCIPAL_SA?.trim();
|
|
416
|
+
if (own && !sameAccount(own, verdict.principal))
|
|
417
|
+
return { ok: false, reason: 'principal_mismatch' };
|
|
418
|
+
if (input.principal !== undefined && !sameAccount(input.principal, verdict.principal))
|
|
419
|
+
return { ok: false, reason: 'principal_mismatch' };
|
|
420
|
+
if ((input.requestId === undefined) !== (input.now === undefined) || input.requestId === '') {
|
|
421
|
+
return { ok: false, reason: 'request_id_incomplete' };
|
|
422
|
+
}
|
|
423
|
+
const reservation = input.requestId !== undefined ? await this.requestIds.reserve(input.requestId, input.now) : null; // (C)
|
|
424
|
+
if (input.requestId !== undefined && !reservation)
|
|
425
|
+
return { ok: false, reason: 'replay' };
|
|
426
|
+
let ran = 0;
|
|
427
|
+
try {
|
|
428
|
+
const vault = this.vault(await deps.wrapperFor(verdict.principal));
|
|
429
|
+
const offered = gatewayToolSpecs(deps.tools).filter((t) => reservation !== null || deps.tools.get(t.id)?.effect === 'read');
|
|
430
|
+
const result = await runGatewayOrchestration(input.intent, {
|
|
431
|
+
planner: deps.planner,
|
|
432
|
+
registry: deps.tools,
|
|
433
|
+
vault,
|
|
434
|
+
principal: verdict.principal,
|
|
435
|
+
tools: offered,
|
|
436
|
+
maxSteps: input.maxSteps,
|
|
437
|
+
authorizeStep: async (tool, args) => {
|
|
438
|
+
if (tool.effect !== 'read' && !reservation)
|
|
439
|
+
throw new Error('request_id_required');
|
|
440
|
+
if (!(await input.runCaveatPass(verdict, tool.name, args)))
|
|
441
|
+
throw new Error('caveat_denied'); // (B), never cached
|
|
442
|
+
ran += 1;
|
|
443
|
+
},
|
|
444
|
+
});
|
|
445
|
+
// W5 — bridge the executed plan into the exchange's provenance A-box as a P-Plan (the SAME trace as the
|
|
446
|
+
// OrchestratedTask activity, §15). Merged by `exchangeId` when the caller ties the run to an exchange.
|
|
447
|
+
if (input.exchangeId && input.at) {
|
|
448
|
+
const { plan } = projectRunProvenance({ exchangeId: input.exchangeId, goal: input.intent.goal, steps: result.plan.steps.map((s) => ({ toolId: s.toolId })) });
|
|
449
|
+
await this.provenance.put(input.exchangeId, { version: 'ap.provenance.v1', agents: [], activities: [], entities: [], plans: [plan] }, input.at);
|
|
450
|
+
}
|
|
451
|
+
if (reservation)
|
|
452
|
+
await (ran > 0 ? reservation.commit() : reservation.release());
|
|
453
|
+
return { ok: true, principal: verdict.principal, result };
|
|
454
|
+
}
|
|
455
|
+
catch (e) {
|
|
456
|
+
if (reservation)
|
|
457
|
+
await (ran > 0 ? reservation.commit() : reservation.release());
|
|
458
|
+
throw e;
|
|
272
459
|
}
|
|
273
|
-
return result;
|
|
274
460
|
}
|
|
275
461
|
/** SENDER-side store-and-forward (spec 316 §4): queue a peer delivery that could not be handed to the
|
|
276
462
|
* recipient gateway now; `alarm()` retries with backoff until delivered (offline-first, §11a). */
|
|
@@ -302,15 +488,44 @@ export class PrincipalGatewayDO {
|
|
|
302
488
|
/**
|
|
303
489
|
* T0 communication lane — persist a delivered exchange (sender-proof-only; no case, no RPC — §3). The
|
|
304
490
|
* record is built upstream (envelope validated + `bodyHash`-verified at ingress); the DO is the durable
|
|
305
|
-
* single writer.
|
|
306
|
-
*
|
|
307
|
-
*
|
|
491
|
+
* single writer.
|
|
492
|
+
*
|
|
493
|
+
* AUTHENTICATED (spec 445 §3.4, audit DATA-3): `caller` is minted by `admitCaller`; the record's
|
|
494
|
+
* `principal` must be the caller's, its `parties.from` the caller's `actor`, and its `tier` is the
|
|
495
|
+
* caller's whatever the record says.
|
|
496
|
+
*
|
|
497
|
+
* INSERT-ONLY (spec 445 §3.4, audit H-10 / DATA-1). Every way into this method carries an exchange id
|
|
498
|
+
* somebody else chose — a peer's delivery, a member's channel post, a `/deliver` body — so it never
|
|
499
|
+
* overwrites: the store's `insert` either writes a new record or returns the one already held. An
|
|
500
|
+
* offer that is canonically identical to the held record is a retry and succeeds without writing;
|
|
501
|
+
* anything else under a held id is refused `exchange-exists`. (An exchange that has since accrued
|
|
502
|
+
* events no longer equals its seed, so a late retry of the seed is refused — `receiveDelivery`
|
|
503
|
+
* decides redelivery by the signed envelope instead, which is the identity that survives.) Changing a
|
|
504
|
+
* held exchange is the reducer's job (`applyEvent`), never a second delivery's.
|
|
308
505
|
*/
|
|
309
|
-
async deliver(record) {
|
|
506
|
+
async deliver(record, caller) {
|
|
507
|
+
// WHOSE record, on WHICH lane, from WHOM — admission's findings, never the body's (DATA-3). A record
|
|
508
|
+
// naming another principal or another sender is refused; its `tier` is replaced, not believed.
|
|
509
|
+
if (!this.isMinted(caller))
|
|
510
|
+
return { ok: false, reason: 'unauthenticated' };
|
|
511
|
+
if (!sameAgent(String(record.principal), String(caller.principal)))
|
|
512
|
+
return { ok: false, reason: 'principal-mismatch' };
|
|
513
|
+
if (!sameAgent(String(record.parties.from), String(caller.actor)))
|
|
514
|
+
return { ok: false, reason: 'actor-mismatch' };
|
|
515
|
+
return this.ingest(record.tier === caller.tier ? record : { ...record, tier: caller.tier });
|
|
516
|
+
}
|
|
517
|
+
/** The store half of `deliver`, after the caller has been established. */
|
|
518
|
+
async ingest(record) {
|
|
310
519
|
const admission = await this.admitIntoInteraction(record);
|
|
311
520
|
if (!admission.ok)
|
|
312
521
|
return admission;
|
|
313
|
-
await this.exchanges.
|
|
522
|
+
const stored = await this.exchanges.insert(record);
|
|
523
|
+
if (!stored.inserted) {
|
|
524
|
+
if (canonicalizeJson(stored.existing) !== canonicalizeJson(record))
|
|
525
|
+
return { ok: false, reason: 'exchange-exists' };
|
|
526
|
+
// The identical record again: already stored, already projected. Nothing to write.
|
|
527
|
+
return admission;
|
|
528
|
+
}
|
|
314
529
|
// W5 — project the exchange into the PRIVATE provenance A-box (one trace, §15). Off the sender's hot path
|
|
315
530
|
// conceptually, but co-located here (demo-scale); a public row is derived SEPARATELY via the S1 firewall.
|
|
316
531
|
await this.provenance.put(String(record.exchangeId), projectExchangeProvenance(this.exchangeToProvenanceInput(record)), record.updatedAt);
|
|
@@ -326,12 +541,17 @@ export class PrincipalGatewayDO {
|
|
|
326
541
|
* one. Auto-creating an interaction per message would make "a thread is a projection, not a
|
|
327
542
|
* container" false by turning every message into a container.
|
|
328
543
|
*
|
|
544
|
+
* Not unconditionally, though (spec 445 §3.4): with no interaction there is no membership to ask,
|
|
545
|
+
* so the record must at least be true about its own parties — `admitUnpinned`. Like outcome 3
|
|
546
|
+
* this is the record's shape, never authority.
|
|
547
|
+
*
|
|
329
548
|
* 2. UNKNOWN `interactionId` → REFUSED. Interactions are opened by their custodian
|
|
330
549
|
* (`openInteraction`), never conjured by a sender. If ingress created one from a sender-supplied
|
|
331
550
|
* id, any peer could seed arbitrary interactions with arbitrary participations on someone else's
|
|
332
551
|
* gateway, then post into them — inventing a governed situation the owner never entered.
|
|
333
552
|
*
|
|
334
|
-
* 3. KNOWN `interactionId` → the
|
|
553
|
+
* 3. KNOWN `interactionId` → the interaction must be `active` (DATB-2), and the sender must be a
|
|
554
|
+
* current participant. An exchange claiming to occur
|
|
335
555
|
* in an interaction whose membership does not include its sender is a record that misrepresents
|
|
336
556
|
* the situation. This is an integrity check on the record's shape, NOT authority: being a
|
|
337
557
|
* participant is not permission to do anything (ADR-0048 §4).
|
|
@@ -345,10 +565,17 @@ export class PrincipalGatewayDO {
|
|
|
345
565
|
*/
|
|
346
566
|
async admitIntoInteraction(record) {
|
|
347
567
|
if (!record.interactionId)
|
|
348
|
-
return
|
|
568
|
+
return this.admitUnpinned(record);
|
|
349
569
|
const interaction = await this.interactionStore.getInteraction(record.interactionId);
|
|
350
570
|
if (!interaction)
|
|
351
571
|
return { ok: false, reason: 'unknown-interaction' };
|
|
572
|
+
// THE LIFECYCLE IS ENFORCED HERE (spec 445 §3.4, audit DATB-2). `suspend`, `conclude` and `abandon`
|
|
573
|
+
// are the custodian's stop controls; they stop something only if ingress asks. Only `active` takes
|
|
574
|
+
// exchanges — checked BEFORE membership, so the refusal names the situation rather than the sender.
|
|
575
|
+
// Leaving `suspended` is `resume` (custodial, through the machine); nothing leaves a terminal state.
|
|
576
|
+
if (interaction.lifecycleState !== 'active') {
|
|
577
|
+
return { ok: false, reason: 'interaction-not-active', state: interaction.lifecycleState };
|
|
578
|
+
}
|
|
352
579
|
// ONE membership question, answered by whichever source actually owns it (spec 340 W11). For an
|
|
353
580
|
// `asserted` interaction the record IS the membership. For a `derived` one — an open org topic —
|
|
354
581
|
// the organization owns it, and this asks the org's own projection rather than a per-topic copy
|
|
@@ -368,6 +595,29 @@ export class PrincipalGatewayDO {
|
|
|
368
595
|
}
|
|
369
596
|
return { ok: true, interactionId: interaction.interactionId, notes };
|
|
370
597
|
}
|
|
598
|
+
/**
|
|
599
|
+
* An exchange with no interaction is admitted only if its sender is a party of the record it
|
|
600
|
+
* delivers (spec 445 §3.4, audit H-10 / DATA-1). Two checks, both fail-closed:
|
|
601
|
+
*
|
|
602
|
+
* · the delivered envelope's `from` IS the record's `parties.from` — a record cannot attribute
|
|
603
|
+
* somebody's envelope to somebody else;
|
|
604
|
+
* · this gateway's principal is a party (`from`, or in `to`/`cc`) — a gateway stores its own
|
|
605
|
+
* principal's exchanges, as sender or as recipient, and nobody else's.
|
|
606
|
+
*
|
|
607
|
+
* The principal is `PRINCIPAL_SA` when the deployment names it, else the record's own `principal`
|
|
608
|
+
* (a gateway that cannot name itself can only check the record against itself).
|
|
609
|
+
*/
|
|
610
|
+
admitUnpinned(record) {
|
|
611
|
+
const from = String(record.parties.from);
|
|
612
|
+
const seed = record.envelopes[0];
|
|
613
|
+
if (seed && !sameAgent(String(seed.from), from))
|
|
614
|
+
return { ok: false, reason: 'not-a-party' };
|
|
615
|
+
const holder = this.env.PRINCIPAL_SA?.trim() || String(record.principal);
|
|
616
|
+
const parties = [from, ...record.parties.to.map(String), ...(record.parties.cc ?? []).map(String)];
|
|
617
|
+
if (!parties.some((p) => sameAgent(p, holder)))
|
|
618
|
+
return { ok: false, reason: 'not-a-party' };
|
|
619
|
+
return { ok: true, notes: [{ kind: 'unpinned-exchange' }] };
|
|
620
|
+
}
|
|
371
621
|
/**
|
|
372
622
|
* Membership for a `derived` interaction.
|
|
373
623
|
*
|
|
@@ -389,6 +639,14 @@ export class PrincipalGatewayDO {
|
|
|
389
639
|
* Open a governed situation on this gateway. The custodian is always THIS principal — an interaction
|
|
390
640
|
* is custodied by the gateway that holds it, and accepting a caller-supplied principal would let a
|
|
391
641
|
* peer name someone else as custodian of a situation on our storage.
|
|
642
|
+
*
|
|
643
|
+
* CREATE-ONLY (spec 445 §3.4, audit DATB-2). Opening never overwrites: an interaction already held
|
|
644
|
+
* under this id is returned as it stands when it is LIVE and was opened with the same profile,
|
|
645
|
+
* participation mode, participations and bindings (a retry); anything else — a different shape, or
|
|
646
|
+
* an interaction that has ended — throws `InteractionExistsError` and writes nothing. In particular
|
|
647
|
+
* there is no reopening a concluded interaction: the machine has no transition out of a terminal
|
|
648
|
+
* state, and `open` is not a way round it. (`suspended → active` is `resume`, via
|
|
649
|
+
* `interactions.transitionInteraction`, custodial and counted in `rev`.)
|
|
392
650
|
*/
|
|
393
651
|
async openInteraction(input) {
|
|
394
652
|
const participationMode = input.participationMode ?? 'asserted';
|
|
@@ -397,6 +655,17 @@ export class PrincipalGatewayDO {
|
|
|
397
655
|
// matters, and letting the write succeed would leave them believing a stale copy is authoritative.
|
|
398
656
|
throw new Error('openInteraction: a derived interaction must not carry participations');
|
|
399
657
|
}
|
|
658
|
+
const held = await this.interactionStore.getInteraction(input.interactionId);
|
|
659
|
+
if (held) {
|
|
660
|
+
const sameOpen = !TERMINAL_INTERACTION_STATES.includes(held.lifecycleState) &&
|
|
661
|
+
held.profileVersion === input.profileVersion &&
|
|
662
|
+
held.participationMode === participationMode &&
|
|
663
|
+
canonicalizeJson(held.participations) === canonicalizeJson(input.participations ?? []) &&
|
|
664
|
+
canonicalizeJson(held.protocolBindings ?? []) === canonicalizeJson(input.protocolBindings ?? []);
|
|
665
|
+
if (!sameOpen)
|
|
666
|
+
throw new InteractionExistsError(String(input.interactionId), held.lifecycleState);
|
|
667
|
+
return held;
|
|
668
|
+
}
|
|
400
669
|
const record = {
|
|
401
670
|
version: 'ap.interaction.v1',
|
|
402
671
|
interactionId: input.interactionId,
|
|
@@ -413,10 +682,52 @@ export class PrincipalGatewayDO {
|
|
|
413
682
|
await this.interactionStore.putInteraction(record);
|
|
414
683
|
return record;
|
|
415
684
|
}
|
|
416
|
-
/**
|
|
417
|
-
|
|
685
|
+
/**
|
|
686
|
+
* Append one event to an existing exchange (case transitions / transport moves), reduced fail-closed.
|
|
687
|
+
*
|
|
688
|
+
* The case machine authorizes a transition by comparing `event.actor` with the record's parties, so
|
|
689
|
+
* the actor must not be the caller's to choose (DATA-3): `event.actor` must be the AUTHENTICATED
|
|
690
|
+
* actor, on a minted caller for this gateway's principal. `null` ⇒ no such exchange. (`event.at` is
|
|
691
|
+
* still the caller's — audit DATB-11, open: the embedder stamps it.)
|
|
692
|
+
*/
|
|
693
|
+
async applyEvent(exchangeId, event, caller) {
|
|
694
|
+
if (!this.isMinted(caller))
|
|
695
|
+
return { ok: false, reason: 'unauthenticated' };
|
|
696
|
+
// `message`/`case` events name their actor; `artifact`/`transport` events are the runtime's own
|
|
697
|
+
// moves on this stream and name none, so only the gateway's principal makes them.
|
|
698
|
+
const claimed = event.kind === 'message' || event.kind === 'case' ? event.actor : caller.principal;
|
|
699
|
+
if (!sameAgent(String(claimed), String(caller.actor)))
|
|
700
|
+
return { ok: false, reason: 'actor-mismatch' };
|
|
418
701
|
return this.exchanges.append(exchangeId, event);
|
|
419
702
|
}
|
|
703
|
+
/**
|
|
704
|
+
* Mint the caller for one admitted request (spec 445 §3.4, audit DATA-3) — the ONLY way to obtain an
|
|
705
|
+
* `AuthenticatedCaller`. Runs the injected authenticator; throws `GatewayAuthError` when none is
|
|
706
|
+
* configured (fail closed), when the request is not admitted, or when it was admitted for a
|
|
707
|
+
* principal other than this gateway's (`PRINCIPAL_SA`). A gateway that does not name itself takes the
|
|
708
|
+
* authenticator's principal — the embedder that built the port is the one that addressed this DO.
|
|
709
|
+
*/
|
|
710
|
+
async admitCaller(request) {
|
|
711
|
+
const authenticate = this.deps?.authenticate;
|
|
712
|
+
if (typeof authenticate !== 'function')
|
|
713
|
+
throw new GatewayAuthError('authenticator_not_configured');
|
|
714
|
+
const found = await authenticate(request);
|
|
715
|
+
if (!found || !found.principal || !found.actor || !found.tier)
|
|
716
|
+
throw new GatewayAuthError('unauthenticated');
|
|
717
|
+
const own = this.env.PRINCIPAL_SA?.trim();
|
|
718
|
+
if (own && !sameAgent(own, String(found.principal)))
|
|
719
|
+
throw new GatewayAuthError('principal_mismatch');
|
|
720
|
+
const caller = Object.freeze({ principal: found.principal, tier: found.tier, actor: found.actor });
|
|
721
|
+
this.minted.add(caller);
|
|
722
|
+
return caller;
|
|
723
|
+
}
|
|
724
|
+
isMinted(caller) {
|
|
725
|
+
return typeof caller === 'object' && caller !== null && this.minted.has(caller);
|
|
726
|
+
}
|
|
727
|
+
/** `member` is the authenticated actor, or the actor is this gateway's own principal acting on it. */
|
|
728
|
+
selfOrPrincipal(caller, member) {
|
|
729
|
+
return sameAgent(String(caller.actor), member) || sameAgent(String(caller.actor), String(caller.principal));
|
|
730
|
+
}
|
|
420
731
|
// ─── The interaction spine (spec 340 W6) ────────────────────────────────────────────────────────────
|
|
421
732
|
// Exposed as ONE accessor rather than a dozen forwarding methods. The DO is already large, and each
|
|
422
733
|
// forwarder would be a place for the store's invariants to be quietly re-implemented — the point of
|
|
@@ -449,9 +760,11 @@ export class PrincipalGatewayDO {
|
|
|
449
760
|
return sa;
|
|
450
761
|
}
|
|
451
762
|
requireDeps() {
|
|
452
|
-
|
|
763
|
+
const d = this.deps;
|
|
764
|
+
if (!d || !d.verifyToken || !d.wrapperFor || !d.tools) {
|
|
453
765
|
throw new Error('PrincipalGatewayDO: co-resident MCP deps not configured (verifyToken/wrapperFor/tools)');
|
|
454
|
-
|
|
766
|
+
}
|
|
767
|
+
return d;
|
|
455
768
|
}
|
|
456
769
|
/** The verdict cache (revoke / epoch-sweep hooks + read for tests). */
|
|
457
770
|
get authority() {
|
|
@@ -483,6 +796,10 @@ export class PrincipalGatewayDO {
|
|
|
483
796
|
const verdict = await this.verdicts.get(input.key); // (A)
|
|
484
797
|
if (!verdict)
|
|
485
798
|
return { ok: false, reason: 'verdict_miss_cold_path_required' };
|
|
799
|
+
// A verdict for another principal never reaches this gateway's vault (spec 445 §3.4, DATA-4).
|
|
800
|
+
const own = this.env.PRINCIPAL_SA?.trim();
|
|
801
|
+
if (own && !sameAccount(own, verdict.principal))
|
|
802
|
+
return { ok: false, reason: 'principal_mismatch' };
|
|
486
803
|
if (!(await input.runCaveatPass(verdict, input.tool, input.args)))
|
|
487
804
|
return { ok: false, reason: 'caveat_denied' }; // (B), never cached
|
|
488
805
|
const tool = deps.tools.get(input.tool);
|
|
@@ -507,24 +824,47 @@ export class PrincipalGatewayDO {
|
|
|
507
824
|
async inbox() {
|
|
508
825
|
return this.exchanges.list();
|
|
509
826
|
}
|
|
827
|
+
/**
|
|
828
|
+
* The HTTP surface. EVERY route authenticates first (spec 445 §3.4, audit DATA-3): `admitCaller`
|
|
829
|
+
* runs the injected authenticator before the path is even looked at, so with no authenticator the
|
|
830
|
+
* answer is 503 for everything, and an unadmitted request is 401 whatever it asked for. `principal`,
|
|
831
|
+
* `tier` and `actor` then come from that result alone — a body naming another principal or another
|
|
832
|
+
* actor is 403, and a body's `tier` is ignored.
|
|
833
|
+
*/
|
|
510
834
|
async fetch(request) {
|
|
511
835
|
const url = new URL(request.url);
|
|
512
836
|
try {
|
|
837
|
+
let caller;
|
|
838
|
+
try {
|
|
839
|
+
caller = await this.admitCaller(request);
|
|
840
|
+
}
|
|
841
|
+
catch (e) {
|
|
842
|
+
if (!(e instanceof GatewayAuthError))
|
|
843
|
+
throw e;
|
|
844
|
+
const status = e.reason === 'authenticator_not_configured' ? 503 : e.reason === 'unauthenticated' ? 401 : 403;
|
|
845
|
+
return json({ ok: false, error: e.reason }, status);
|
|
846
|
+
}
|
|
847
|
+
const refusedIdentity = (reason) => reason === 'principal-mismatch' || reason === 'actor-mismatch' || reason === 'unauthenticated';
|
|
513
848
|
if (request.method === 'POST' && url.pathname === '/deliver') {
|
|
514
|
-
const outcome = await this.deliver((await request.json()));
|
|
515
|
-
//
|
|
849
|
+
const outcome = await this.deliver((await request.json()), caller);
|
|
850
|
+
// 403 for an identity the body asserted and admission did not; otherwise 409, not 400: the
|
|
851
|
+
// request is well-formed and the situation is what refuses it.
|
|
516
852
|
if (!outcome.ok)
|
|
517
|
-
return json({ ok: false, error: outcome.reason }, 409);
|
|
853
|
+
return json({ ok: false, error: outcome.reason, ...(outcome.state ? { state: outcome.state } : {}) }, refusedIdentity(outcome.reason) ? 403 : 409);
|
|
518
854
|
return json({ ok: true, interactionId: outcome.interactionId, notes: outcome.notes });
|
|
519
855
|
}
|
|
520
856
|
if (request.method === 'POST' && url.pathname === '/event') {
|
|
521
857
|
const body = (await request.json());
|
|
522
|
-
const result = await this.applyEvent(body.exchangeId, body.event);
|
|
858
|
+
const result = await this.applyEvent(body.exchangeId, body.event, caller);
|
|
523
859
|
if (result === null)
|
|
524
860
|
return json({ ok: false, error: 'unknown_exchange' }, 404);
|
|
525
|
-
return result.ok ? json({ ok: true }) : json({ ok: false, error: result.reason }, 409);
|
|
861
|
+
return result.ok ? json({ ok: true }) : json({ ok: false, error: result.reason }, refusedIdentity(result.reason) ? 403 : 409);
|
|
526
862
|
}
|
|
527
863
|
if (request.method === 'GET' && url.pathname === '/inbox') {
|
|
864
|
+
// The whole stream is the principal's own: an admitted third party (a peer delivering, a member
|
|
865
|
+
// posting) is a participant, and that does not make it a reader.
|
|
866
|
+
if (!sameAgent(String(caller.actor), String(caller.principal)))
|
|
867
|
+
return json({ ok: false, error: 'actor-mismatch' }, 403);
|
|
528
868
|
return json({ ok: true, exchanges: await this.inbox() });
|
|
529
869
|
}
|
|
530
870
|
// TODO (W2 follow-steps, spec 316 §3/§4/§15):
|