@substrat-run/adapter-cloudflare 0.88.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/dist/control-plane-do.d.ts +8 -0
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +25 -1
- package/dist/control-plane-do.js.map +1 -1
- package/dist/host.d.ts +15 -2
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +109 -6
- package/dist/host.js.map +1 -1
- package/dist/scope-do.d.ts +1 -0
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +203 -21
- package/dist/scope-do.js.map +1 -1
- package/package.json +4 -4
package/dist/scope-do.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { DurableObject } from 'cloudflare:workers';
|
|
2
|
-
import { ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, domainEvent, domainEventInput, eventId, instant, objectRef, toWireFailure, grantRefFromProof, principalId, platformRequestInput, platformRequestId, MAX_PENDING_PLATFORM_REQUESTS, platformRequest, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, listLimitOf, } from '@substrat-run/contracts';
|
|
3
|
-
import { ulid, assertAllowed, ConnectionSealingKeyUnavailableError, noSealingKeyMessage, sealTo, assertReadOnlyQuery, entitlementDenial, platformRequestHistoryQuery, PLATFORM_REQUEST_COLUMNS, denialListQuery, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialBucketRow, PermissionDenied, createAtomic, NotSearchable, isSearchIndexTable, searchIndexDdl, searchIndexMigrations, searchIndexPlans, NotListable, listIndexMigrations, listIndexPlans, listQuery, cursorOf, searchLimit, searchMatchExpression, searchQuery, entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX, } from '@substrat-run/kernel';
|
|
2
|
+
import { ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, domainEvent, domainEventInput, eventId, instant, objectRef, toWireFailure, grantRefFromProof, principalId, platformRequestInput, platformRequestId, MAX_PENDING_PLATFORM_REQUESTS, platformRequest, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, listLimitOf, requestFingerprint, substratError, } from '@substrat-run/contracts';
|
|
3
|
+
import { ulid, assertAllowed, ConnectionSealingKeyUnavailableError, noSealingKeyMessage, sealTo, assertReadOnlyQuery, entitlementDenial, platformRequestHistoryQuery, PLATFORM_REQUEST_COLUMNS, denialListQuery, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialBucketRow, PermissionDenied, assertImpersonationWrites, impersonationStampOf, createAtomic, NotSearchable, isSearchIndexTable, searchIndexDdl, searchIndexMigrations, searchIndexPlans, NotListable, listIndexMigrations, listIndexPlans, listQuery, cursorOf, searchLimit, searchMatchExpression, searchQuery, IDEMPOTENCY_DDL, assertIdempotencyKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX, } from '@substrat-run/kernel';
|
|
4
4
|
import { OperationQueue } from './serialization.js';
|
|
5
5
|
import { doScopedSql } from './sql.js';
|
|
6
6
|
import { createDoTupleChecker, createLocalControlPlaneReader } from './checker.js';
|
|
@@ -23,6 +23,10 @@ const KERNEL_DDL = `
|
|
|
23
23
|
-- K-34: the checks the emitting operation passed (JSON [{permission, grant?}]).
|
|
24
24
|
-- NULL on rows written before the field existed -- honestly unrecorded, not empty.
|
|
25
25
|
authorization TEXT,
|
|
26
|
+
-- K-42: the staff actor + session an impersonated operation ran under (JSON
|
|
27
|
+
-- session/by). NULL is the ordinary case -- nobody was impersonating -- and the
|
|
28
|
+
-- actor column above stays the principal the permission model answered about.
|
|
29
|
+
impersonation TEXT,
|
|
26
30
|
drained_at TEXT
|
|
27
31
|
);
|
|
28
32
|
-- platform-intents.md: durable intents a vertical enqueues (ctx.requestPlatform) for the platform
|
|
@@ -33,6 +37,8 @@ const KERNEL_DDL = `
|
|
|
33
37
|
kind TEXT NOT NULL,
|
|
34
38
|
payload TEXT NOT NULL,
|
|
35
39
|
requested_by TEXT NOT NULL,
|
|
40
|
+
-- K-42: the staff actor + session, when the intent was raised under one.
|
|
41
|
+
impersonation TEXT,
|
|
36
42
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
37
43
|
attempts INTEGER NOT NULL DEFAULT 0,
|
|
38
44
|
last_error TEXT,
|
|
@@ -57,6 +63,8 @@ const KERNEL_DDL = `
|
|
|
57
63
|
tenant_id TEXT NOT NULL,
|
|
58
64
|
scope_id TEXT,
|
|
59
65
|
operation TEXT,
|
|
66
|
+
-- K-42: WHICH of the two actors was refused is exactly what this log is for.
|
|
67
|
+
impersonation TEXT,
|
|
60
68
|
at TEXT NOT NULL,
|
|
61
69
|
drained_at TEXT
|
|
62
70
|
);
|
|
@@ -215,6 +223,8 @@ const KERNEL_DDL = `
|
|
|
215
223
|
-- MAX(id) per (entity_type, entity_id) is the read. The id column sits last so
|
|
216
224
|
-- SQLite walks to the end of the matched range instead of aggregating over it.
|
|
217
225
|
${OUTBOX_ENTITY_INDEX}
|
|
226
|
+
-- #116: the request-dedupe table, kernel-owned so no vertical migrates for it.
|
|
227
|
+
${IDEMPOTENCY_DDL}
|
|
218
228
|
`;
|
|
219
229
|
/**
|
|
220
230
|
* Workers RPC carries a plain `Error`'s MESSAGE faithfully and nothing else. A custom
|
|
@@ -284,6 +294,7 @@ function rowToPlatformRequest(r) {
|
|
|
284
294
|
kind: r.kind,
|
|
285
295
|
payload: JSON.parse(r.payload),
|
|
286
296
|
requestedBy: JSON.parse(r.requested_by),
|
|
297
|
+
impersonation: r.impersonation == null ? null : JSON.parse(r.impersonation),
|
|
287
298
|
status: r.status,
|
|
288
299
|
attempts: r.attempts,
|
|
289
300
|
lastError: r.last_error,
|
|
@@ -405,6 +416,8 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
405
416
|
operationInput = new Map();
|
|
406
417
|
/** #129: name → the entity whose version an `If-Match` is compared against. */
|
|
407
418
|
operationConcurrency = new Map();
|
|
419
|
+
/** #116: the operations that declared `idempotency: false` — refusals, not participants. */
|
|
420
|
+
operationIdempotencyOptOut = new Set();
|
|
408
421
|
modules = new Map();
|
|
409
422
|
guards = new Map();
|
|
410
423
|
predicates = new Map();
|
|
@@ -451,6 +464,13 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
451
464
|
// intent settled before this column reads as "nobody classified this" rather
|
|
452
465
|
// than claiming an origin the drain never decided.
|
|
453
466
|
'ALTER TABLE _substrat_platform_requests ADD COLUMN last_failure TEXT',
|
|
467
|
+
// K-42: the two-actor stamp on the three spine tables that record who did
|
|
468
|
+
// what, for a scope DO created before impersonation existed. Nullable
|
|
469
|
+
// everywhere, and the null means "nobody was impersonating" rather than
|
|
470
|
+
// "unrecorded" — every one of those rows predates the possibility.
|
|
471
|
+
'ALTER TABLE _substrat_outbox ADD COLUMN impersonation TEXT',
|
|
472
|
+
'ALTER TABLE _substrat_platform_requests ADD COLUMN impersonation TEXT',
|
|
473
|
+
'ALTER TABLE _substrat_denials ADD COLUMN impersonation TEXT',
|
|
454
474
|
]) {
|
|
455
475
|
try {
|
|
456
476
|
this.sql.exec(alter);
|
|
@@ -559,6 +579,15 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
559
579
|
throw new Error(`${manifest.id} declares operationConcurrency for unbound operation(s): ` +
|
|
560
580
|
`${unguarded.sort().join(', ')} — a precondition on nothing reads as a guard that is not there`);
|
|
561
581
|
}
|
|
582
|
+
// #116: same rule again for an opt-out — one on an unbound name reads as a
|
|
583
|
+
// deliberate exclusion of an operation that is not there.
|
|
584
|
+
const declaredOptOuts = registration.operationIdempotencyOptOuts ?? [];
|
|
585
|
+
const unboundOptOuts = declaredOptOuts.filter((name) => !ownOps.has(name));
|
|
586
|
+
if (unboundOptOuts.length > 0) {
|
|
587
|
+
throw new Error(`${manifest.id} declares idempotency: false for unbound operation(s): ` +
|
|
588
|
+
`${[...unboundOptOuts].sort().join(', ')} — an opt-out on nothing reads as an ` +
|
|
589
|
+
'exclusion someone decided, of an operation that does not exist');
|
|
590
|
+
}
|
|
562
591
|
for (const [name, handler] of Object.entries(registration.operations ?? {})) {
|
|
563
592
|
this.defineOperation(name, handler);
|
|
564
593
|
// The schema follows the HANDLER, not the name: a withdrawn operation
|
|
@@ -569,6 +598,9 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
569
598
|
const guarded = declaredConcurrency[name];
|
|
570
599
|
if (guarded && this.operations.has(name))
|
|
571
600
|
this.operationConcurrency.set(name, guarded);
|
|
601
|
+
if (declaredOptOuts.includes(name) && this.operations.has(name)) {
|
|
602
|
+
this.operationIdempotencyOptOut.add(name);
|
|
603
|
+
}
|
|
572
604
|
}
|
|
573
605
|
}
|
|
574
606
|
defineOperation(name, handler) {
|
|
@@ -813,19 +845,27 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
813
845
|
* sent an `If-Match` and does not see it refuses the success. An old DO
|
|
814
846
|
* cannot set the acknowledgement, which is exactly how the skew is caught.
|
|
815
847
|
*/
|
|
816
|
-
invokeOptions
|
|
848
|
+
invokeOptions,
|
|
849
|
+
/**
|
|
850
|
+
* K-42: the session this call runs under, resolved by the coordinator from
|
|
851
|
+
* the directory (which this DO cannot read) and passed WHOLE rather than as
|
|
852
|
+
* an id. The coordinator has already checked it is live, unexpired and for
|
|
853
|
+
* this scope; the DO uses it for the two-actor stamp and for the read-only
|
|
854
|
+
* bound, and nothing here can be reached without it having been minted.
|
|
855
|
+
*/
|
|
856
|
+
impersonation) {
|
|
817
857
|
if (!failureEnvelope) {
|
|
818
858
|
// Legacy path, byte-for-byte what it was: rewrapped so a non-plain error (a
|
|
819
859
|
// ZodError, whose `message` is a getter) still arrives with its message.
|
|
820
860
|
try {
|
|
821
|
-
return await this.invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions);
|
|
861
|
+
return await this.invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions, impersonation);
|
|
822
862
|
}
|
|
823
863
|
catch (err) {
|
|
824
864
|
throw toRpcError(err);
|
|
825
865
|
}
|
|
826
866
|
}
|
|
827
867
|
try {
|
|
828
|
-
return await this.invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions);
|
|
868
|
+
return await this.invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions, impersonation);
|
|
829
869
|
}
|
|
830
870
|
catch (err) {
|
|
831
871
|
// The ONE place the error keeps its structure: flattened here, rebuilt by the
|
|
@@ -835,11 +875,16 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
835
875
|
}
|
|
836
876
|
}
|
|
837
877
|
/** The operation path itself. Throws; `invoke` decides how that reaches the caller. */
|
|
838
|
-
async invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions
|
|
878
|
+
async invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions,
|
|
879
|
+
/** K-42: the session, resolved coordinator-side. See `invoke` above. */
|
|
880
|
+
impersonation) {
|
|
839
881
|
await this.ensureMigrations();
|
|
840
882
|
const handler = this.operations.get(operation);
|
|
883
|
+
// `not_found`, not a bare throw (#113): every vertical hand-matched this message
|
|
884
|
+
// to reach a 404, because the platform's own refusals were as untyped as anything
|
|
885
|
+
// else. Naming the code once here is what lets those patterns go.
|
|
841
886
|
if (!handler)
|
|
842
|
-
throw
|
|
887
|
+
throw substratError('not_found', `unknown operation: ${operation}`);
|
|
843
888
|
// #304 entitlement gate, scope-local path: fail closed on the projected view exactly as
|
|
844
889
|
// the coordinator fails closed against the CP. Only active once entitlements have been
|
|
845
890
|
// projected (the `entitlements_enforced` marker) — before that the scope trusts upstream,
|
|
@@ -857,7 +902,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
857
902
|
.exec(`SELECT entitlement_key, expires_at FROM _substrat_entitlements
|
|
858
903
|
WHERE tenant_id = ? ORDER BY entitlement_key`, tenantId)
|
|
859
904
|
.toArray();
|
|
860
|
-
throw
|
|
905
|
+
throw substratError('not_found', entitlementDenial(operation, requiredEntitlement, all.map((r) => ({
|
|
861
906
|
key: r.entitlement_key,
|
|
862
907
|
expired: r.expires_at !== null && r.expires_at <= now,
|
|
863
908
|
}))));
|
|
@@ -878,20 +923,73 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
878
923
|
'nothing would have been compared. Declare it, or drop the header');
|
|
879
924
|
}
|
|
880
925
|
const guardedRef = guarded ? concurrencyRefOf(operation, guarded, parsed) : undefined;
|
|
926
|
+
// #116. Refused rather than ignored, for the reason two paragraphs up: an
|
|
927
|
+
// operation that opted out never records its response, so a caller who sent
|
|
928
|
+
// a key and got a 200 believes a retry is safe when it would execute again.
|
|
929
|
+
const idempotencyKey = invokeOptions?.idempotencyKey;
|
|
930
|
+
if (idempotencyKey !== undefined) {
|
|
931
|
+
if (this.operationIdempotencyOptOut.has(operation)) {
|
|
932
|
+
throw new Error(idempotencyOptedOutMessage(operation));
|
|
933
|
+
}
|
|
934
|
+
assertIdempotencyKey(idempotencyKey);
|
|
935
|
+
}
|
|
936
|
+
// The subject a key is scoped to — the same three-way read `recordDenial`
|
|
937
|
+
// makes below, hoisted because both need it. A key belongs to whoever sent
|
|
938
|
+
// it: two principals choosing `1` must not reach each other's response.
|
|
939
|
+
const idempotencySubjectRef = systemModuleId
|
|
940
|
+
? { kind: 'system', id: systemModuleId }
|
|
941
|
+
: connectionId
|
|
942
|
+
? { kind: 'connection', id: connectionId }
|
|
943
|
+
: { kind: 'principal', id: principal };
|
|
944
|
+
// Fingerprinted from the PARSED input (defaults applied), before the queue:
|
|
945
|
+
// a pure hash of what the caller sent has no business inside a transaction.
|
|
946
|
+
const fingerprint = idempotencyKey === undefined ? undefined : await requestFingerprint(operation, parsed);
|
|
881
947
|
return this.queue.enqueue(async () => {
|
|
882
948
|
let result;
|
|
883
949
|
let committedVersion = null;
|
|
950
|
+
// #116: set when this invocation was answered from a recording rather
|
|
951
|
+
// than run. Read after the transaction, where it decides both the
|
|
952
|
+
// envelope's acknowledgement and whether there is anything to dispatch.
|
|
953
|
+
let replayed = false;
|
|
884
954
|
// #458: how many platform intents THIS invoke enqueued. Counted inside the
|
|
885
955
|
// transaction, reported only after commit — a rolled-back intent is no signal.
|
|
886
956
|
// The envelope return (below) is the DO↔coordinator wire for it; both sides
|
|
887
957
|
// live in this package and deploy as one script, so the shape never skews.
|
|
888
958
|
const signals = { platformRequests: 0 };
|
|
959
|
+
/**
|
|
960
|
+
* K-42: how a read-only session is made to BE read-only.
|
|
961
|
+
*
|
|
962
|
+
* Thrown on success, so the transaction never commits — the same
|
|
963
|
+
* mechanism `queryScope`'s console uses, for the same reason: the DO's
|
|
964
|
+
* `exec` exposes no read-only flag, so refusing to commit is the only
|
|
965
|
+
* enforcement that survives a handler writing rows with plain SQL.
|
|
966
|
+
* `ctx.emit` and the other effecting verbs refuse outright as well; that
|
|
967
|
+
* is the half a support engineer sees, this is the half that holds.
|
|
968
|
+
*/
|
|
969
|
+
const rollback = new Error('read-only impersonation rollback');
|
|
889
970
|
// The async transaction is the K-4 boundary: guards + handler + emits
|
|
890
971
|
// commit together, or a throw (from either) rolls domain writes AND
|
|
891
972
|
// emitted events back as one — verified across `await` in workerd.
|
|
892
973
|
try {
|
|
893
974
|
await this.ctx.storage.transaction(async () => {
|
|
894
|
-
const ctx = this.operationContext(principal, tenantId, scopeId, undefined, connectionId, systemModuleId, signals);
|
|
975
|
+
const ctx = this.operationContext(principal, tenantId, scopeId, undefined, connectionId, systemModuleId, signals, impersonation);
|
|
976
|
+
// #116: a retry is answered from the recording, and nothing else runs
|
|
977
|
+
// — not the guards, not the handler, not the permission check inside
|
|
978
|
+
// it. Keyed by SUBJECT, so a caller only ever reaches its own
|
|
979
|
+
// responses; `idempotency.ts` states what that does not promise.
|
|
980
|
+
if (idempotencyKey !== undefined && fingerprint !== undefined) {
|
|
981
|
+
const lookup = idempotencyLookupQuery(idempotencySubjectRef, idempotencyKey);
|
|
982
|
+
const prior = this.sql.exec(lookup.sql, ...lookup.params).toArray()[0];
|
|
983
|
+
if (prior) {
|
|
984
|
+
const replay = replayFor(idempotencyKey, fingerprint, prior);
|
|
985
|
+
result = replay.result;
|
|
986
|
+
// Only a guarded operation may report a tag (#129).
|
|
987
|
+
if (guardedRef)
|
|
988
|
+
committedVersion = replay.entityVersion;
|
|
989
|
+
replayed = true;
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
895
993
|
// #129: snapshot the version BEFORE the handler, compare AFTER it.
|
|
896
994
|
// Before, because the handler's own `emit` moves it; after, because the
|
|
897
995
|
// permission check lives inside the handler and must answer first — a
|
|
@@ -910,9 +1008,47 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
910
1008
|
// the handler and still inside the transaction.
|
|
911
1009
|
if (guardedRef)
|
|
912
1010
|
committedVersion = this.versionAt(guardedRef);
|
|
1011
|
+
// #116: recorded INSIDE the transaction, which is what makes a failed
|
|
1012
|
+
// request retried rather than replayed — it rolls back with the writes
|
|
1013
|
+
// it describes. The prune rides along, on the only path that adds a row.
|
|
1014
|
+
if (idempotencyKey !== undefined && fingerprint !== undefined) {
|
|
1015
|
+
const at = new Date().toISOString();
|
|
1016
|
+
const record = idempotencyRecordStatement(idempotencySubjectRef, idempotencyKey, operation, fingerprint, result, committedVersion, at);
|
|
1017
|
+
this.sql.exec(record.sql, ...record.params);
|
|
1018
|
+
const prune = idempotencyPruneStatement(at);
|
|
1019
|
+
this.sql.exec(prune.sql, ...prune.params);
|
|
1020
|
+
}
|
|
1021
|
+
if (impersonation?.mode === 'read-only')
|
|
1022
|
+
throw rollback;
|
|
913
1023
|
});
|
|
914
1024
|
}
|
|
915
1025
|
catch (err) {
|
|
1026
|
+
// The read-only unwind, not a failure: `result` was assigned before the
|
|
1027
|
+
// throw and is the answer, while every row the handler wrote is gone.
|
|
1028
|
+
//
|
|
1029
|
+
// The acknowledgements ride along unchanged, because they are about
|
|
1030
|
+
// whether this DO UNDERSTOOD the arguments, not about what committed —
|
|
1031
|
+
// and a coordinator that sent `If-Match` to a read-only session must not
|
|
1032
|
+
// be told the host is too old to have evaluated it. The version reported
|
|
1033
|
+
// is the rolled-back one, which is the honest answer: nothing moved.
|
|
1034
|
+
if (err === rollback) {
|
|
1035
|
+
return {
|
|
1036
|
+
result,
|
|
1037
|
+
platformRequests: 0,
|
|
1038
|
+
impersonation: { honoured: true },
|
|
1039
|
+
...(idempotencyKey !== undefined
|
|
1040
|
+
? { idempotency: { keyHonoured: true, replayed } }
|
|
1041
|
+
: {}),
|
|
1042
|
+
...(guardedRef
|
|
1043
|
+
? {
|
|
1044
|
+
concurrency: {
|
|
1045
|
+
version: committedVersion,
|
|
1046
|
+
ifMatchChecked: invokeOptions?.ifMatch !== undefined,
|
|
1047
|
+
},
|
|
1048
|
+
}
|
|
1049
|
+
: {}),
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
916
1052
|
// K-35: the transaction has rolled back; record a refused check now, as its own
|
|
917
1053
|
// write (outside that transaction), so the denial survives the rollback.
|
|
918
1054
|
if (err instanceof PermissionDenied) {
|
|
@@ -920,7 +1056,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
920
1056
|
? { kind: 'system', id: systemModuleId }
|
|
921
1057
|
: connectionId
|
|
922
1058
|
? { kind: 'connection', id: connectionId }
|
|
923
|
-
: { kind: 'principal', id: principal }, tenantId, operation, err);
|
|
1059
|
+
: { kind: 'principal', id: principal }, tenantId, operation, err, impersonation);
|
|
924
1060
|
}
|
|
925
1061
|
// The ORIGINAL error, deliberately: `invoke` flattens it for the envelope
|
|
926
1062
|
// (which keeps its code and extensions) or rewraps it for the legacy throw
|
|
@@ -928,10 +1064,22 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
928
1064
|
throw err;
|
|
929
1065
|
}
|
|
930
1066
|
// Post-commit: drain the outbox to consumers, each delivery its own txn.
|
|
931
|
-
|
|
1067
|
+
// Skipped on a replay: nothing was written, so there is nothing this
|
|
1068
|
+
// invocation added to drain. Anything the ORIGINAL left undrained is the
|
|
1069
|
+
// outbox's own retry backstop, which is what that backstop is for.
|
|
1070
|
+
if (!replayed)
|
|
1071
|
+
await this.dispatch(tenantId, scopeId);
|
|
932
1072
|
return {
|
|
933
1073
|
result,
|
|
934
1074
|
platformRequests: signals.platformRequests,
|
|
1075
|
+
...(impersonation ? { impersonation: { honoured: true } } : {}),
|
|
1076
|
+
// The acknowledgement the coordinator's skew check reads (#116), on the
|
|
1077
|
+
// same reasoning as `ifMatchChecked` below and with a sharper failure: a
|
|
1078
|
+
// DO too old to know about keys would EXECUTE THE OPERATION AGAIN and
|
|
1079
|
+
// return 200, which is the duplicate the header was sent to prevent.
|
|
1080
|
+
...(idempotencyKey !== undefined
|
|
1081
|
+
? { idempotency: { keyHonoured: true, replayed } }
|
|
1082
|
+
: {}),
|
|
935
1083
|
// The acknowledgement the coordinator's skew check reads. Present only
|
|
936
1084
|
// for a guarded operation, so an unguarded one costs nothing.
|
|
937
1085
|
...(guardedRef
|
|
@@ -1333,9 +1481,18 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1333
1481
|
throw new Error(`too many pending platform requests (${pending}); the delivery retries once some have drained`);
|
|
1334
1482
|
}
|
|
1335
1483
|
const id = platformRequestId.parse(ulid());
|
|
1484
|
+
// K-42: the intent inherits the SOURCE EVENT's stamp rather than being written
|
|
1485
|
+
// unstamped. Nobody is impersonating at this moment — the drain runs long after
|
|
1486
|
+
// the session's invoke returned — but the intent exists BECAUSE of an event that
|
|
1487
|
+
// was raised under one, and the platform drain is where an operator asks who
|
|
1488
|
+
// caused an outbound effect. Read here rather than passed over the RPC: the DO
|
|
1489
|
+
// owns the outbox, so a coordinator cannot claim a stamp or drop one.
|
|
1490
|
+
const source = this.sql
|
|
1491
|
+
.exec('SELECT impersonation FROM _substrat_outbox WHERE id = ?', eventId)
|
|
1492
|
+
.toArray()[0];
|
|
1336
1493
|
this.sql.exec(`INSERT INTO _substrat_platform_requests
|
|
1337
|
-
(id, kind, payload, requested_by, status, attempts, requested_at)
|
|
1338
|
-
VALUES (?, ?, ?, ?, 'pending', 0, ?)`, id, kind, payload, requestedBy, instant.parse(new Date().toISOString()));
|
|
1494
|
+
(id, kind, payload, requested_by, impersonation, status, attempts, requested_at)
|
|
1495
|
+
VALUES (?, ?, ?, ?, ?, 'pending', 0, ?)`, id, kind, payload, requestedBy, source?.impersonation ?? null, instant.parse(new Date().toISOString()));
|
|
1339
1496
|
this.recordExecutorAttempt(eventId, deliveryId, null, null);
|
|
1340
1497
|
return id;
|
|
1341
1498
|
}
|
|
@@ -1682,7 +1839,8 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1682
1839
|
* catch after the storage transaction has rolled back, so this write is its own and
|
|
1683
1840
|
* survives — the whole point, since the denial is the write the operation could not make.
|
|
1684
1841
|
*/
|
|
1685
|
-
|
|
1842
|
+
/** K-42: the session a refused call ran under travels with the denial row. */
|
|
1843
|
+
recordDenial(subject, tenantId, operation, err, impersonation) {
|
|
1686
1844
|
// Only an ENFORCED denial (assertAllowed, which attaches the checked permission +
|
|
1687
1845
|
// node) is recorded. A module's own hand-thrown `new PermissionDenied('…')` carries
|
|
1688
1846
|
// no permission key and is left to the module.
|
|
@@ -1694,8 +1852,8 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1694
1852
|
? { connection: subject.id }
|
|
1695
1853
|
: subject.id;
|
|
1696
1854
|
this.sql.exec(`INSERT INTO _substrat_denials
|
|
1697
|
-
(id, actor, permission, tenant_id, scope_id, operation, at)
|
|
1698
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)`, ulid(), JSON.stringify(actor), err.permission, err.node.tenantId, err.node.scopeId ?? null, operation, new Date().toISOString());
|
|
1855
|
+
(id, actor, permission, tenant_id, scope_id, operation, impersonation, at)
|
|
1856
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, ulid(), JSON.stringify(actor), err.permission, err.node.tenantId, err.node.scopeId ?? null, operation, impersonation ? JSON.stringify(impersonationStampOf(impersonation)) : null, new Date().toISOString());
|
|
1699
1857
|
}
|
|
1700
1858
|
parseOutboxRow(row) {
|
|
1701
1859
|
return domainEvent.parse({
|
|
@@ -1710,13 +1868,26 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1710
1868
|
piiClass: row.pii_class,
|
|
1711
1869
|
...(row.subject_id ? { subjectId: row.subject_id } : {}),
|
|
1712
1870
|
...(row.authorization ? { authorization: JSON.parse(row.authorization) } : {}),
|
|
1871
|
+
// K-42: the stamp survives the read, so a consumer's event and an executor's
|
|
1872
|
+
// are the same fact the stored row is. Absent rather than null when nobody
|
|
1873
|
+
// was impersonating, because `DomainEvent.impersonation` is optional — the
|
|
1874
|
+
// shape module code never sees is also the shape it cannot branch on.
|
|
1875
|
+
...(row.impersonation ? { impersonation: JSON.parse(row.impersonation) } : {}),
|
|
1713
1876
|
payload: row.payload === null ? undefined : JSON.parse(row.payload),
|
|
1714
1877
|
});
|
|
1715
1878
|
}
|
|
1716
1879
|
// -- operation context (port of operationContext) -------------------------
|
|
1717
1880
|
operationContext(principal, tenantId, scopeId, systemActor, connectionId, systemModuleId,
|
|
1718
1881
|
/** #458: per-invoke tally of `ctx.requestPlatform` calls; absent for consumer dispatch. */
|
|
1719
|
-
signals
|
|
1882
|
+
signals,
|
|
1883
|
+
/**
|
|
1884
|
+
* K-42: the session this operation runs under, resolved by the coordinator
|
|
1885
|
+
* from the directory and handed down. Read here and nowhere module code can
|
|
1886
|
+
* reach — a vertical that could see it could branch on it, and "hide this row
|
|
1887
|
+
* when support is looking" is the one behaviour this feature must make
|
|
1888
|
+
* impossible.
|
|
1889
|
+
*/
|
|
1890
|
+
impersonation) {
|
|
1720
1891
|
const checker = this.checker;
|
|
1721
1892
|
const relations = this.relations;
|
|
1722
1893
|
const searchPlans = this.searchPlans;
|
|
@@ -1751,6 +1922,10 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1751
1922
|
// invoke, so this does not leak across operations). `emit` snapshots it; a
|
|
1752
1923
|
// system/override actor is unconditionally allowed, so its checks are not recorded.
|
|
1753
1924
|
const passed = [];
|
|
1925
|
+
// K-42: the two-actor stamp, computed once per operation. Every record this
|
|
1926
|
+
// operation writes about who did what carries it, and module code can
|
|
1927
|
+
// neither add it nor drop it.
|
|
1928
|
+
const stamp = impersonation ? impersonationStampOf(impersonation) : undefined;
|
|
1754
1929
|
/**
|
|
1755
1930
|
* The scope host's half of `ctx.atomic` (#770) — everything else is the
|
|
1756
1931
|
* kernel's (`createAtomic`).
|
|
@@ -1797,6 +1972,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1797
1972
|
sql: doScopedSql(sql),
|
|
1798
1973
|
now: () => at,
|
|
1799
1974
|
emit: (event) => {
|
|
1975
|
+
assertImpersonationWrites(impersonation, 'ctx.emit');
|
|
1800
1976
|
const parsed = domainEventInput.parse(event);
|
|
1801
1977
|
const full = domainEvent.parse({
|
|
1802
1978
|
...parsed,
|
|
@@ -1806,13 +1982,16 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1806
1982
|
scopeId,
|
|
1807
1983
|
actor: systemActor ?? derivedActor,
|
|
1808
1984
|
...(passed.length ? { authorization: passed.map((p) => ({ ...p })) } : {}),
|
|
1985
|
+
...(stamp ? { impersonation: stamp } : {}),
|
|
1809
1986
|
});
|
|
1810
1987
|
sql.exec(`INSERT INTO _substrat_outbox
|
|
1811
1988
|
(id, type, schema_version, occurred_at, tenant_id, scope_id, actor,
|
|
1812
|
-
entity_type, entity_id, pii_class, subject_id, authorization,
|
|
1813
|
-
|
|
1989
|
+
entity_type, entity_id, pii_class, subject_id, authorization,
|
|
1990
|
+
impersonation, payload)
|
|
1991
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, full.id, full.type, full.schemaVersion, full.occurredAt, full.tenantId, full.scopeId, JSON.stringify(full.actor), full.entity.entityType, full.entity.entityId, full.piiClass, full.subjectId ?? null, full.authorization ? JSON.stringify(full.authorization) : null, full.impersonation ? JSON.stringify(full.impersonation) : null, full.payload === undefined ? null : JSON.stringify(full.payload));
|
|
1814
1992
|
},
|
|
1815
1993
|
requestPlatform: (request) => {
|
|
1994
|
+
assertImpersonationWrites(impersonation, 'ctx.requestPlatform');
|
|
1816
1995
|
const input = platformRequestInput.parse(request);
|
|
1817
1996
|
// Backpressure (platform-intents.md): refuse when the scope already holds too many pending
|
|
1818
1997
|
// intents, so a stuck or runaway vertical cannot flood the platform drain.
|
|
@@ -1825,8 +2004,8 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1825
2004
|
const id = platformRequestId.parse(ulid());
|
|
1826
2005
|
const requestedBy = systemActor ?? derivedActor;
|
|
1827
2006
|
sql.exec(`INSERT INTO _substrat_platform_requests
|
|
1828
|
-
(id, kind, payload, requested_by, status, attempts, requested_at)
|
|
1829
|
-
VALUES (?, ?, ?, ?, 'pending', 0, ?)`, id, input.kind, JSON.stringify(input.payload ?? null), JSON.stringify(requestedBy), at);
|
|
2007
|
+
(id, kind, payload, requested_by, impersonation, status, attempts, requested_at)
|
|
2008
|
+
VALUES (?, ?, ?, ?, ?, 'pending', 0, ?)`, id, input.kind, JSON.stringify(input.payload ?? null), JSON.stringify(requestedBy), stamp ? JSON.stringify(stamp) : null, at);
|
|
1830
2009
|
if (signals)
|
|
1831
2010
|
signals.platformRequests += 1;
|
|
1832
2011
|
return id;
|
|
@@ -1895,6 +2074,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1895
2074
|
* than elevates.
|
|
1896
2075
|
*/
|
|
1897
2076
|
grant: async (principal, permission, entity) => {
|
|
2077
|
+
assertImpersonationWrites(impersonation, 'ctx.grant');
|
|
1898
2078
|
const held = await runCheck(permission, entity);
|
|
1899
2079
|
if (!held.allowed) {
|
|
1900
2080
|
throw new PermissionDenied(`cannot grant '${permission}' on ${entity.entityType}:${entity.entityId} — ` +
|
|
@@ -1903,6 +2083,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1903
2083
|
sql.exec(`INSERT OR IGNORE INTO _substrat_tuples (subject, relation, object) VALUES (?, ?, ?)`, `principal:${principal}`, `granted:${permission}`, `${entity.entityType}:${entity.entityId}`);
|
|
1904
2084
|
},
|
|
1905
2085
|
revoke: async (principal, permission, entity) => {
|
|
2086
|
+
assertImpersonationWrites(impersonation, 'ctx.revoke');
|
|
1906
2087
|
const held = await runCheck(permission, entity);
|
|
1907
2088
|
if (!held.allowed) {
|
|
1908
2089
|
throw new PermissionDenied(`cannot revoke '${permission}' on ${entity.entityType}:${entity.entityId} — ` +
|
|
@@ -1912,6 +2093,7 @@ export function defineScopeDO(modules, bareOps) {
|
|
|
1912
2093
|
},
|
|
1913
2094
|
atomic: createAtomic(runSub, { passed, signals }),
|
|
1914
2095
|
link: (child, parent) => {
|
|
2096
|
+
assertImpersonationWrites(impersonation, 'ctx.link');
|
|
1915
2097
|
const allowed = relations.get(child.entityType);
|
|
1916
2098
|
if (!allowed?.has(parent.entityType)) {
|
|
1917
2099
|
throw new Error(`undeclared entity relation: ${child.entityType} → ${parent.entityType} ` +
|