@substrat-run/adapter-sqlite 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/index.d.ts +20 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +255 -22
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type BlobStoreHandle, type ConnectionId, type ModuleId, type DomainEvent, type PlatformRequestId, type PlatformRequest, type PlatformRequestFilter, type PlatformRequestStatus, type PlatformRequestFailure, type ProjectedConnectionGrant, type PlatformActorId, type PrincipalId, type ScopeDump, type ScopeId, type TenantId, type TenantStoreHandle } from '@substrat-run/contracts';
|
|
1
|
+
import { type BlobStoreHandle, type ConnectionId, type ModuleId, type DomainEvent, type PlatformRequestId, type PlatformRequest, type PlatformRequestFilter, type PlatformRequestStatus, type PlatformRequestFailure, type ProjectedConnectionGrant, type PlatformActorId, type PrincipalId, type ScopeDump, type ScopeId, type ImpersonationSessionId, type TenantId, type TenantStoreHandle } from '@substrat-run/contracts';
|
|
2
2
|
import { type BlobStoreProvisionInput, type ScopeAttachments, type ExecutorDeadLetter, type ExecutorDrainReport, type MigrateScopeOutcome, type MigrationFrontier, type ExecutorHandler, type ExecutorRetryPolicy, type ConnectorHandler, type ConnectorOptions, type Clock, type FetchLike, type SecretBox, type HostAdmin, type ModuleRegistration, type OperationHandler, type PermissionChecker, type ProvisionScopeInput, type ScheduleRegistration, type ScheduleRunReport, type ScopeHost, type ScopeStub, type ScopeStubOptions, type TenantRelationalStore, type TenantStoreProvisionInput } from '@substrat-run/kernel';
|
|
3
3
|
export interface SqliteScopeHostOptions {
|
|
4
4
|
/** Directory holding one SQLite file per scope plus the directory database. */
|
|
@@ -71,6 +71,13 @@ export declare class SqliteScopeHost implements ScopeHost {
|
|
|
71
71
|
* a bare `defineOperation` carries no declaration to read one off.
|
|
72
72
|
*/
|
|
73
73
|
private readonly operationConcurrency;
|
|
74
|
+
/**
|
|
75
|
+
* #116: the operations that declared `idempotency: false` — a set of refusals,
|
|
76
|
+
* because that is what the declaration is. Every name NOT in here honours an
|
|
77
|
+
* `Idempotency-Key`, which is the default and the reason it is a set of
|
|
78
|
+
* exceptions rather than a map of participants.
|
|
79
|
+
*/
|
|
80
|
+
private readonly operationIdempotencyOptOut;
|
|
74
81
|
private readonly roles;
|
|
75
82
|
/** Executor id → {eventType, handler} (K-22 §4.2). Host code, not module code. */
|
|
76
83
|
private readonly executors;
|
|
@@ -164,10 +171,21 @@ export declare class SqliteScopeHost implements ScopeHost {
|
|
|
164
171
|
*/
|
|
165
172
|
getConnectorScope(connectionId: ConnectionId, scopeId: ScopeId): Promise<ScopeStub>;
|
|
166
173
|
getConnectorAttachments(connectionId: ConnectionId, scopeId: ScopeId): Promise<ScopeAttachments>;
|
|
174
|
+
/** Read one session, or say so. The door and every invoke through it come here. */
|
|
175
|
+
private readImpersonation;
|
|
176
|
+
/**
|
|
177
|
+
* The impersonation door (K-42) — mirror of `getConnectorScope`/`getSystemScope`.
|
|
178
|
+
*
|
|
179
|
+
* The session is the authority and it is read from the directory, never from
|
|
180
|
+
* the caller: a stub cannot be minted by describing a session, only by naming
|
|
181
|
+
* one somebody opened and the admin log recorded. Everything after that is an
|
|
182
|
+
* ordinary invoke against the impersonated principal.
|
|
183
|
+
*/
|
|
184
|
+
getImpersonatedScope(session: ImpersonationSessionId, tenantId: TenantId, scopeId: ScopeId, options?: ScopeStubOptions): Promise<ScopeStub>;
|
|
167
185
|
getSystemScope(moduleId: ModuleId, tenantId: TenantId, scopeId: ScopeId): Promise<ScopeStub>;
|
|
168
186
|
registeredSchedules(): ScheduleRegistration[];
|
|
169
187
|
runDueSchedules(moduleId: ModuleId, tenantId: TenantId, scopeId: ScopeId): Promise<ScheduleRunReport>;
|
|
170
|
-
/** The stub body, shared by the principal and
|
|
188
|
+
/** The stub body, shared by the principal, connection, system and impersonation doors. */
|
|
171
189
|
private buildStub;
|
|
172
190
|
/**
|
|
173
191
|
* The entity a guarded operation's precondition is about (#129).
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,OAAO,EAQL,KAAK,eAAe,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import Database from 'better-sqlite3';
|
|
4
|
-
import { accessLogEntry, adminLogEntry, opsFailureEntry, ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, createTenantInput, domainEvent, domainEventInput, entitlementGrant, entitlementGrantInput, eventId, meterReading, platformRequestInput, platformRequestId, platformRequest, MAX_PENDING_PLATFORM_REQUESTS, identityLink, identityPool, instant, connection, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, subjectRef, createConnectionInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, createOrgInput, promotionAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, routeTarget, registerVerticalInput, vertical as verticalSchema, verticalChannel, verticalVersion, objectRef, grantRefFromProof, org as orgSchema, orgMembership, principalId, resolvedIdentity, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, subjectShredReceipt, SCOPE_TABLE_PAGE_DEFAULT, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, verticalServingState, outboundOfManifestJson, listLimitOf, } from '@substrat-run/contracts';
|
|
5
|
-
import { asPrincipal, assertAllowed, assertReadOnlyQuery, attachmentBlobKey, entitlementDenial, foldMeterReading, parseValidationRecords, resolveScopeRecord, ulid, OPS_FAILURE_RETENTION_DAYS, backoffAt, platformRequestHistoryQuery, PLATFORM_REQUEST_COLUMNS, denialListQuery, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialBucketRow, resolveRetryPolicy, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, noSealingKeyMessage, openSealed, sealTo, ConnectionSealingKeyUnavailableError, PermissionDenied, createAtomic, NotSearchable, NotListable, listIndexMigrations, listIndexPlans, listQuery, cursorOf, isSearchIndexTable, searchIndexDdl, searchIndexMigrations, searchIndexPlans, searchLimit, searchMatchExpression, searchQuery, entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX, } from '@substrat-run/kernel';
|
|
4
|
+
import { accessLogEntry, adminLogEntry, opsFailureEntry, ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, createTenantInput, domainEvent, domainEventInput, entitlementGrant, entitlementGrantInput, eventId, meterReading, platformRequestInput, platformRequestId, platformRequest, MAX_PENDING_PLATFORM_REQUESTS, identityLink, identityPool, instant, connection, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, subjectRef, requestFingerprint, createConnectionInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, createOrgInput, promotionAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, routeTarget, registerVerticalInput, vertical as verticalSchema, verticalChannel, verticalVersion, objectRef, grantRefFromProof, org as orgSchema, orgMembership, principalId, resolvedIdentity, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, subjectShredReceipt, SCOPE_TABLE_PAGE_DEFAULT, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, verticalServingState, outboundOfManifestJson, listLimitOf, substratError, } from '@substrat-run/contracts';
|
|
5
|
+
import { asPrincipal, assertAllowed, assertReadOnlyQuery, attachmentBlobKey, entitlementDenial, foldMeterReading, parseValidationRecords, resolveScopeRecord, ulid, OPS_FAILURE_RETENTION_DAYS, backoffAt, platformRequestHistoryQuery, PLATFORM_REQUEST_COLUMNS, denialListQuery, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialBucketRow, IMPERSONATION_COLUMNS, IMPERSONATION_DDL, ImpersonationRefused, assertImpersonationWrites, assertSessionUsable, impersonationByIdQuery, impersonationListQuery, impersonationRowValues, impersonationStampOf, mapImpersonationRow, newImpersonationSession, resolveRetryPolicy, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, noSealingKeyMessage, openSealed, sealTo, ConnectionSealingKeyUnavailableError, PermissionDenied, createAtomic, NotSearchable, NotListable, listIndexMigrations, listIndexPlans, listQuery, cursorOf, isSearchIndexTable, searchIndexDdl, searchIndexMigrations, searchIndexPlans, searchLimit, searchMatchExpression, searchQuery, entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX, IDEMPOTENCY_DDL, assertIdempotencyKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, } from '@substrat-run/kernel';
|
|
6
6
|
import { ScopeActor } from './actor.js';
|
|
7
7
|
import { createTupleChecker } from './checker.js';
|
|
8
8
|
const KERNEL_DDL = `
|
|
@@ -22,6 +22,10 @@ const KERNEL_DDL = `
|
|
|
22
22
|
-- K-34: the checks the emitting operation passed (JSON [{permission, grant?}]).
|
|
23
23
|
-- NULL on rows written before the field existed — honestly unrecorded, not empty.
|
|
24
24
|
authorization TEXT,
|
|
25
|
+
-- K-42: the staff actor + session an impersonated operation ran under (JSON
|
|
26
|
+
-- session/by). NULL is the ordinary case — nobody was impersonating — and the
|
|
27
|
+
-- actor column above stays the principal the permission model answered about.
|
|
28
|
+
impersonation TEXT,
|
|
25
29
|
drained_at TEXT
|
|
26
30
|
);
|
|
27
31
|
-- platform-intents.md: durable intents a vertical enqueues (ctx.requestPlatform) for the platform
|
|
@@ -32,6 +36,8 @@ const KERNEL_DDL = `
|
|
|
32
36
|
kind TEXT NOT NULL,
|
|
33
37
|
payload TEXT NOT NULL,
|
|
34
38
|
requested_by TEXT NOT NULL,
|
|
39
|
+
-- K-42: the staff actor + session, when the intent was raised under one.
|
|
40
|
+
impersonation TEXT,
|
|
35
41
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
36
42
|
attempts INTEGER NOT NULL DEFAULT 0,
|
|
37
43
|
last_error TEXT,
|
|
@@ -57,9 +63,12 @@ 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
|
);
|
|
71
|
+
${IDEMPOTENCY_DDL}
|
|
63
72
|
CREATE TABLE IF NOT EXISTS _substrat_migrations (
|
|
64
73
|
module_id TEXT NOT NULL,
|
|
65
74
|
version TEXT NOT NULL,
|
|
@@ -194,6 +203,7 @@ function rowToPlatformRequest(r) {
|
|
|
194
203
|
kind: r.kind,
|
|
195
204
|
payload: JSON.parse(r.payload),
|
|
196
205
|
requestedBy: JSON.parse(r.requested_by),
|
|
206
|
+
impersonation: r.impersonation == null ? null : JSON.parse(r.impersonation),
|
|
197
207
|
status: r.status,
|
|
198
208
|
attempts: r.attempts,
|
|
199
209
|
lastError: r.last_error,
|
|
@@ -281,6 +291,13 @@ export class SqliteScopeHost {
|
|
|
281
291
|
* a bare `defineOperation` carries no declaration to read one off.
|
|
282
292
|
*/
|
|
283
293
|
operationConcurrency = new Map();
|
|
294
|
+
/**
|
|
295
|
+
* #116: the operations that declared `idempotency: false` — a set of refusals,
|
|
296
|
+
* because that is what the declaration is. Every name NOT in here honours an
|
|
297
|
+
* `Idempotency-Key`, which is the default and the reason it is a set of
|
|
298
|
+
* exceptions rather than a map of participants.
|
|
299
|
+
*/
|
|
300
|
+
operationIdempotencyOptOut = new Set();
|
|
284
301
|
roles = new Map(); // 'tenantId/roleKey'
|
|
285
302
|
/** Executor id → {eventType, handler} (K-22 §4.2). Host code, not module code. */
|
|
286
303
|
executors = new Map();
|
|
@@ -738,6 +755,7 @@ export class SqliteScopeHost {
|
|
|
738
755
|
shredded_at TEXT,
|
|
739
756
|
PRIMARY KEY (scope_id, subject_id)
|
|
740
757
|
);
|
|
758
|
+
${IMPERSONATION_DDL}
|
|
741
759
|
CREATE TABLE IF NOT EXISTS _substrat_admin_log (
|
|
742
760
|
id TEXT PRIMARY KEY,
|
|
743
761
|
actor TEXT NOT NULL,
|
|
@@ -1051,6 +1069,16 @@ export class SqliteScopeHost {
|
|
|
1051
1069
|
throw new Error(`${manifest.id} declares operationConcurrency for unbound operation(s): ` +
|
|
1052
1070
|
`${unguarded.sort().join(', ')} — a precondition on nothing reads as a guard that is not there`);
|
|
1053
1071
|
}
|
|
1072
|
+
// #116: an opt-out on an unbound name is the same defect one paragraph up,
|
|
1073
|
+
// and reads worse — it looks like a deliberate exclusion of something that is
|
|
1074
|
+
// not there, so nobody goes looking for what actually honours the header.
|
|
1075
|
+
const declaredOptOuts = registration.operationIdempotencyOptOuts ?? [];
|
|
1076
|
+
const unboundOptOuts = declaredOptOuts.filter((name) => !ownOperations.has(name));
|
|
1077
|
+
if (unboundOptOuts.length > 0) {
|
|
1078
|
+
throw new Error(`${manifest.id} declares idempotency: false for unbound operation(s): ` +
|
|
1079
|
+
`${[...unboundOptOuts].sort().join(', ')} — an opt-out on nothing reads as an ` +
|
|
1080
|
+
'exclusion someone decided, of an operation that does not exist');
|
|
1081
|
+
}
|
|
1054
1082
|
for (const [name, handler] of Object.entries(registration.operations ?? {})) {
|
|
1055
1083
|
this.defineOperation(name, handler);
|
|
1056
1084
|
// Record which SKU flag gates this operation (§4.3). Bare defineOperation
|
|
@@ -1064,6 +1092,9 @@ export class SqliteScopeHost {
|
|
|
1064
1092
|
const guarded = declaredConcurrency[name];
|
|
1065
1093
|
if (guarded && this.operations.has(name))
|
|
1066
1094
|
this.operationConcurrency.set(name, guarded);
|
|
1095
|
+
if (declaredOptOuts.includes(name) && this.operations.has(name)) {
|
|
1096
|
+
this.operationIdempotencyOptOut.add(name);
|
|
1097
|
+
}
|
|
1067
1098
|
}
|
|
1068
1099
|
}
|
|
1069
1100
|
defineOperation(name, handler) {
|
|
@@ -1800,6 +1831,40 @@ export class SqliteScopeHost {
|
|
|
1800
1831
|
const store = this.attachmentStore(conn.tenant_id, scope.vertical);
|
|
1801
1832
|
return this.buildAttachments(rt, { kind: 'connection', id: connectionId }, store);
|
|
1802
1833
|
}
|
|
1834
|
+
/** Read one session, or say so. The door and every invoke through it come here. */
|
|
1835
|
+
readImpersonation(session) {
|
|
1836
|
+
const q = impersonationByIdQuery(session);
|
|
1837
|
+
const row = this.directory.prepare(q.sql).get(...q.params);
|
|
1838
|
+
if (!row)
|
|
1839
|
+
throw new ImpersonationRefused(`unknown impersonation session: ${session}`);
|
|
1840
|
+
return mapImpersonationRow(row);
|
|
1841
|
+
}
|
|
1842
|
+
/**
|
|
1843
|
+
* The impersonation door (K-42) — mirror of `getConnectorScope`/`getSystemScope`.
|
|
1844
|
+
*
|
|
1845
|
+
* The session is the authority and it is read from the directory, never from
|
|
1846
|
+
* the caller: a stub cannot be minted by describing a session, only by naming
|
|
1847
|
+
* one somebody opened and the admin log recorded. Everything after that is an
|
|
1848
|
+
* ordinary invoke against the impersonated principal.
|
|
1849
|
+
*/
|
|
1850
|
+
async getImpersonatedScope(session, tenantId, scopeId, options) {
|
|
1851
|
+
const record = this.readImpersonation(session);
|
|
1852
|
+
assertSessionUsable(record, this.clock(), { tenantId, scopeId });
|
|
1853
|
+
// Same lifecycle gates as the principal door, and reached the same way: a
|
|
1854
|
+
// suspended tenant refuses a support session exactly as it refuses a user.
|
|
1855
|
+
const scope = this.directory
|
|
1856
|
+
.prepare('SELECT tenant_id, status FROM scopes WHERE scope_id = ?')
|
|
1857
|
+
.get(scopeId);
|
|
1858
|
+
if (!scope || scope.tenant_id !== tenantId) {
|
|
1859
|
+
throw new Error(`unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
1860
|
+
}
|
|
1861
|
+
if (scope.status !== 'active') {
|
|
1862
|
+
throw new Error(`scope not active (status: ${scope.status}): ${scopeId}`);
|
|
1863
|
+
}
|
|
1864
|
+
const rt = this.runtime(tenantId, scopeId);
|
|
1865
|
+
await this.applyPendingMigrations(rt);
|
|
1866
|
+
return this.buildStub(tenantId, scopeId, rt, asPrincipal(record.principal), options, record.id);
|
|
1867
|
+
}
|
|
1803
1868
|
async getSystemScope(moduleId, tenantId, scopeId) {
|
|
1804
1869
|
// The scheduler's door (#383) — mirror of getConnectorScope. The module must be
|
|
1805
1870
|
// registered on this host (a schedule can only invoke its own vertical's ops),
|
|
@@ -1901,16 +1966,39 @@ export class SqliteScopeHost {
|
|
|
1901
1966
|
}
|
|
1902
1967
|
return report;
|
|
1903
1968
|
}
|
|
1904
|
-
/** The stub body, shared by the principal and
|
|
1905
|
-
buildStub(tenantId, scopeId, rt, subject, options
|
|
1969
|
+
/** The stub body, shared by the principal, connection, system and impersonation doors. */
|
|
1970
|
+
buildStub(tenantId, scopeId, rt, subject, options,
|
|
1971
|
+
/**
|
|
1972
|
+
* K-42: the session this stub acts under. `subject` is already the
|
|
1973
|
+
* IMPERSONATED principal — that is what makes the permission model answer
|
|
1974
|
+
* about them with no override branch anywhere — and this is what every
|
|
1975
|
+
* record additionally keeps, plus the bound a read-only session is held to.
|
|
1976
|
+
*/
|
|
1977
|
+
sessionId) {
|
|
1906
1978
|
const operations = this.operations;
|
|
1907
1979
|
return {
|
|
1908
1980
|
tenantId,
|
|
1909
1981
|
scopeId,
|
|
1910
1982
|
invoke: async (operation, input, invokeOptions) => {
|
|
1911
1983
|
const handler = operations.get(operation);
|
|
1984
|
+
// `not_found`, not a bare throw (#113) — the same code `adapter-cloudflare`
|
|
1985
|
+
// gives it, so a demo and a hosted vertical answer 404 for the same reason.
|
|
1912
1986
|
if (!handler)
|
|
1913
|
-
return Promise.reject(
|
|
1987
|
+
return Promise.reject(substratError('not_found', `unknown operation: ${operation}`));
|
|
1988
|
+
// K-42: re-read the session on EVERY invoke, not once when the stub was
|
|
1989
|
+
// minted. A stub is a capability and nothing takes it away, so a session
|
|
1990
|
+
// checked only at the door would be a time box the one caller holding it
|
|
1991
|
+
// never runs out of — and an `endImpersonation` would stop nothing.
|
|
1992
|
+
let session;
|
|
1993
|
+
if (sessionId !== undefined) {
|
|
1994
|
+
try {
|
|
1995
|
+
session = this.readImpersonation(sessionId);
|
|
1996
|
+
assertSessionUsable(session, this.clock(), { tenantId, scopeId });
|
|
1997
|
+
}
|
|
1998
|
+
catch (err) {
|
|
1999
|
+
return Promise.reject(err);
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
1914
2002
|
// #129. A precondition the operation never declared is REFUSED, not
|
|
1915
2003
|
// ignored. A caller sending `If-Match` believes its write is serialised,
|
|
1916
2004
|
// and quietly dropping the header leaves that belief intact while the
|
|
@@ -1921,13 +2009,27 @@ export class SqliteScopeHost {
|
|
|
1921
2009
|
return Promise.reject(new Error(`${operation} was called with If-Match but declares no \`concurrency\` — ` +
|
|
1922
2010
|
'nothing would have been compared. Declare it, or drop the header'));
|
|
1923
2011
|
}
|
|
2012
|
+
// #116. An operation that declared `idempotency: false` REFUSES a key
|
|
2013
|
+
// rather than ignoring one, and the reason is the reason every branch of
|
|
2014
|
+
// this feature is written the way it is: a caller who sent a key and got a
|
|
2015
|
+
// 200 believes the retry is safe, and here it is not — the response was
|
|
2016
|
+
// never recorded, so the second request would execute the operation again.
|
|
2017
|
+
const idempotencyKey = invokeOptions?.idempotencyKey;
|
|
2018
|
+
if (idempotencyKey !== undefined) {
|
|
2019
|
+
if (this.operationIdempotencyOptOut.has(operation)) {
|
|
2020
|
+
return Promise.reject(new Error(idempotencyOptedOutMessage(operation)));
|
|
2021
|
+
}
|
|
2022
|
+
// Refused BEFORE the queue: a key we cannot store must not take a turn
|
|
2023
|
+
// ahead of work that can.
|
|
2024
|
+
assertIdempotencyKey(idempotencyKey);
|
|
2025
|
+
}
|
|
1924
2026
|
// Entitlement gate (control-plane.md §4.3): a module loads for a tenant
|
|
1925
2027
|
// only if the tenant holds its SKU flag. Checked per invoke — the simple,
|
|
1926
2028
|
// uncached path (K-OQ5); a DO-cached variant is a later benchmark call.
|
|
1927
2029
|
// Fails closed the same way withdrawal does: the operation is unavailable.
|
|
1928
2030
|
const requiredKey = this.operationEntitlement.get(operation);
|
|
1929
2031
|
if (requiredKey && !this.tenantHoldsEntitlement(tenantId, requiredKey)) {
|
|
1930
|
-
return Promise.reject(
|
|
2032
|
+
return Promise.reject(substratError('not_found', entitlementDenial(operation, requiredKey, this.heldEntitlements(tenantId))));
|
|
1931
2033
|
}
|
|
1932
2034
|
// #458: per-invoke tally of `ctx.requestPlatform` calls. Reported to the
|
|
1933
2035
|
// harness only after the enqueue task resolves — i.e. after COMMIT — so a
|
|
@@ -1936,10 +2038,13 @@ export class SqliteScopeHost {
|
|
|
1936
2038
|
// #129: read inside the transaction, reported after it commits — a tag
|
|
1937
2039
|
// handed out for writes that were rolled back is a tag nobody may hold.
|
|
1938
2040
|
let committedVersion;
|
|
2041
|
+
// #116: reported after the task, like the version — a replay is a fact
|
|
2042
|
+
// about the response, and the response is not one until it is returned.
|
|
2043
|
+
let replayed = false;
|
|
1939
2044
|
const invoked = await rt.actor.enqueue(async () => {
|
|
1940
2045
|
// Fresh per operation: the context carries the K-34 authorization accumulator,
|
|
1941
2046
|
// which must not leak across operations (invokes are serialized per scope).
|
|
1942
|
-
const ctx = this.operationContext(rt, subject, undefined, signals);
|
|
2047
|
+
const ctx = this.operationContext(rt, subject, undefined, signals, session);
|
|
1943
2048
|
const clonedInput = structuredClone(input);
|
|
1944
2049
|
// #893: parse, don't trust — at the scope door, from the operation's own
|
|
1945
2050
|
// declaration. BEFORE `BEGIN`, so a malformed call never opens a
|
|
@@ -1953,9 +2058,35 @@ export class SqliteScopeHost {
|
|
|
1953
2058
|
const parsed = this.operationInput.has(operation)
|
|
1954
2059
|
? this.operationInput.get(operation).parse(clonedInput)
|
|
1955
2060
|
: clonedInput;
|
|
2061
|
+
// #116: fingerprinted from the PARSED input, so a retry that omits an
|
|
2062
|
+
// optional field the original sent at its default value is still the
|
|
2063
|
+
// same request. Computed before `BEGIN` — it is a pure hash of what the
|
|
2064
|
+
// caller sent and has no business inside a transaction.
|
|
2065
|
+
const fingerprint = idempotencyKey === undefined
|
|
2066
|
+
? undefined
|
|
2067
|
+
: await requestFingerprint(operation, parsed);
|
|
1956
2068
|
rt.db.exec('BEGIN IMMEDIATE');
|
|
1957
2069
|
let result;
|
|
1958
2070
|
try {
|
|
2071
|
+
// #116: a retry is answered from the recording, and nothing else runs
|
|
2072
|
+
// — not the guards, not the handler, not the permission check inside
|
|
2073
|
+
// it. The row is keyed by SUBJECT, so a caller can only ever reach a
|
|
2074
|
+
// response it received itself; `idempotency.ts` states what that does
|
|
2075
|
+
// and does not promise about authorization.
|
|
2076
|
+
if (idempotencyKey !== undefined && fingerprint !== undefined) {
|
|
2077
|
+
const lookup = idempotencyLookupQuery(subject, idempotencyKey);
|
|
2078
|
+
const prior = rt.db.prepare(lookup.sql).all(...lookup.params)[0];
|
|
2079
|
+
if (prior) {
|
|
2080
|
+
const replay = replayFor(idempotencyKey, fingerprint, prior);
|
|
2081
|
+
rt.db.exec('COMMIT');
|
|
2082
|
+
// Only a guarded operation may report a tag (#129); an unguarded
|
|
2083
|
+
// one recorded null and must stay silent rather than announce it.
|
|
2084
|
+
if (guarded)
|
|
2085
|
+
committedVersion = replay.entityVersion;
|
|
2086
|
+
replayed = true;
|
|
2087
|
+
return replay.result;
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
1959
2090
|
// #129, and the SPLIT here is the whole subtlety: the version is
|
|
1960
2091
|
// snapshotted before the handler, and compared after it.
|
|
1961
2092
|
//
|
|
@@ -1992,28 +2123,56 @@ export class SqliteScopeHost {
|
|
|
1992
2123
|
// it, so it is read after the handler and still under `BEGIN`.
|
|
1993
2124
|
if (ref)
|
|
1994
2125
|
committedVersion = this.versionAt(rt, ref);
|
|
1995
|
-
|
|
2126
|
+
// #116: recorded INSIDE the transaction, which is what makes a failed
|
|
2127
|
+
// request retried rather than replayed — the operation threw, this row
|
|
2128
|
+
// rolls back with the writes it describes, and there is nothing for the
|
|
2129
|
+
// retry to find. The prune rides along: bounded work, on the only path
|
|
2130
|
+
// that adds a row.
|
|
2131
|
+
if (idempotencyKey !== undefined && fingerprint !== undefined) {
|
|
2132
|
+
const at = this.clock();
|
|
2133
|
+
const record = idempotencyRecordStatement(subject, idempotencyKey, operation, fingerprint, result, committedVersion ?? null, at);
|
|
2134
|
+
rt.db.prepare(record.sql).run(...record.params);
|
|
2135
|
+
const prune = idempotencyPruneStatement(at);
|
|
2136
|
+
rt.db.prepare(prune.sql).run(...prune.params);
|
|
2137
|
+
}
|
|
2138
|
+
// K-42: a read-only session's transaction is ROLLED BACK, never
|
|
2139
|
+
// committed — the half of read-only that is a mechanism rather than a
|
|
2140
|
+
// promise. `ctx.emit` and the other effecting verbs refuse outright,
|
|
2141
|
+
// which is what a support engineer sees; this is what holds when a
|
|
2142
|
+
// handler writes a row with plain `ctx.sql.exec` and calls none of
|
|
2143
|
+
// them. Same shape as the scope console's read-only query on the DO.
|
|
2144
|
+
if (session?.mode === 'read-only')
|
|
2145
|
+
rt.db.exec('ROLLBACK');
|
|
2146
|
+
else
|
|
2147
|
+
rt.db.exec('COMMIT');
|
|
1996
2148
|
}
|
|
1997
2149
|
catch (err) {
|
|
1998
2150
|
rt.db.exec('ROLLBACK');
|
|
1999
2151
|
// K-35: a refused check rolled the operation back. Record it now — a fresh
|
|
2000
2152
|
// statement in autocommit, AFTER the rollback, so the denial survives it.
|
|
2001
2153
|
if (err instanceof PermissionDenied)
|
|
2002
|
-
this.recordDenial(rt, subject, operation, err);
|
|
2154
|
+
this.recordDenial(rt, subject, operation, err, session);
|
|
2003
2155
|
throw err;
|
|
2004
2156
|
}
|
|
2005
2157
|
// Post-commit, still inside the actor task: drain outbox → consumers,
|
|
2006
2158
|
// then → executors. Prompt dispatch (K-22 §4.2): the common case
|
|
2007
2159
|
// completes inside this request, with the outbox as the retry backstop
|
|
2008
2160
|
// if it does not.
|
|
2009
|
-
|
|
2010
|
-
|
|
2161
|
+
// A read-only session committed nothing, so there is nothing of its to
|
|
2162
|
+
// drain — and draining anyway would run consumers as a side effect of a
|
|
2163
|
+
// session that may not have side effects.
|
|
2164
|
+
if (session?.mode !== 'read-only') {
|
|
2165
|
+
await this.dispatch(rt);
|
|
2166
|
+
await this.dispatchExecutors(rt);
|
|
2167
|
+
}
|
|
2011
2168
|
return structuredClone(result);
|
|
2012
2169
|
});
|
|
2013
2170
|
if (signals.platformRequests > 0)
|
|
2014
2171
|
options?.onPlatformRequests?.(signals.platformRequests);
|
|
2015
2172
|
if (committedVersion !== undefined)
|
|
2016
2173
|
invokeOptions?.onEntityVersion?.(committedVersion);
|
|
2174
|
+
if (replayed)
|
|
2175
|
+
invokeOptions?.onIdempotentReplay?.();
|
|
2017
2176
|
return invoked;
|
|
2018
2177
|
},
|
|
2019
2178
|
};
|
|
@@ -2391,7 +2550,9 @@ export class SqliteScopeHost {
|
|
|
2391
2550
|
* catch AFTER `ROLLBACK`, so this INSERT runs in autocommit and survives — the whole
|
|
2392
2551
|
* point, since the denial is exactly the write the rolled-back operation could not make.
|
|
2393
2552
|
*/
|
|
2394
|
-
recordDenial(rt, subject, operation, err
|
|
2553
|
+
recordDenial(rt, subject, operation, err,
|
|
2554
|
+
/** K-42: the session the refused call ran under, when it ran under one. */
|
|
2555
|
+
impersonation) {
|
|
2395
2556
|
// Only an ENFORCED denial (assertAllowed, which attaches the checked permission +
|
|
2396
2557
|
// node) is recorded. A module's own hand-thrown `new PermissionDenied('…')` is its
|
|
2397
2558
|
// policy, carries no permission key, and is left to the module.
|
|
@@ -2404,9 +2565,9 @@ export class SqliteScopeHost {
|
|
|
2404
2565
|
: subject.id;
|
|
2405
2566
|
rt.db
|
|
2406
2567
|
.prepare(`INSERT INTO _substrat_denials
|
|
2407
|
-
(id, actor, permission, tenant_id, scope_id, operation, at)
|
|
2408
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
|
2409
|
-
.run(ulid(), JSON.stringify(actor), err.permission, err.node.tenantId, err.node.scopeId ?? null, operation, new Date().toISOString());
|
|
2568
|
+
(id, actor, permission, tenant_id, scope_id, operation, impersonation, at)
|
|
2569
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
2570
|
+
.run(ulid(), JSON.stringify(actor), err.permission, err.node.tenantId, err.node.scopeId ?? null, operation, impersonation ? JSON.stringify(impersonationStampOf(impersonation)) : null, new Date().toISOString());
|
|
2410
2571
|
}
|
|
2411
2572
|
parseOutboxRow(row) {
|
|
2412
2573
|
return domainEvent.parse({
|
|
@@ -2421,6 +2582,11 @@ export class SqliteScopeHost {
|
|
|
2421
2582
|
piiClass: row.pii_class,
|
|
2422
2583
|
...(row.subject_id ? { subjectId: row.subject_id } : {}),
|
|
2423
2584
|
...(row.authorization ? { authorization: JSON.parse(row.authorization) } : {}),
|
|
2585
|
+
// K-42: the stamp survives the read, so a consumer's event and an executor's
|
|
2586
|
+
// are the same fact the stored row is. Absent rather than null when nobody
|
|
2587
|
+
// was impersonating, because `DomainEvent.impersonation` is optional — the
|
|
2588
|
+
// shape module code never sees is also the shape it cannot branch on.
|
|
2589
|
+
...(row.impersonation ? { impersonation: JSON.parse(row.impersonation) } : {}),
|
|
2424
2590
|
payload: row.payload === null ? undefined : JSON.parse(row.payload),
|
|
2425
2591
|
});
|
|
2426
2592
|
}
|
|
@@ -4162,6 +4328,48 @@ export class SqliteScopeHost {
|
|
|
4162
4328
|
this.recordAccess(actor, 'shredSubject', { tenantId, scopeId }, { subjectId }, redacted.changes);
|
|
4163
4329
|
return receipt;
|
|
4164
4330
|
},
|
|
4331
|
+
// -- impersonation (K-42, #868) ----------------------------------------
|
|
4332
|
+
beginImpersonation: async (actor, input) => {
|
|
4333
|
+
// Fail closed on the scope before minting anything, exactly as the
|
|
4334
|
+
// principal door does: a session naming a scope that is not there is a
|
|
4335
|
+
// credential for nothing, and issuing one anyway means the admin log
|
|
4336
|
+
// records an access that could never have happened.
|
|
4337
|
+
this.assertScope(input.tenantId, input.scopeId);
|
|
4338
|
+
const session = newImpersonationSession(ulid(), actor, input, this.clock());
|
|
4339
|
+
this.directory
|
|
4340
|
+
.prepare(`INSERT INTO _substrat_impersonations
|
|
4341
|
+
(${IMPERSONATION_COLUMNS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
4342
|
+
.run(...impersonationRowValues(session));
|
|
4343
|
+
// BEFORE the session is returned, so the log entry precedes every row it
|
|
4344
|
+
// will ever touch. The reason rides in `after` — it is the field a review
|
|
4345
|
+
// reads first, and a log saying a session opened but not why is half a
|
|
4346
|
+
// record.
|
|
4347
|
+
this.recordAdmin(actor, 'beginImpersonation', { tenantId: session.tenantId, scopeId: session.scopeId }, null, session);
|
|
4348
|
+
return session;
|
|
4349
|
+
},
|
|
4350
|
+
endImpersonation: async (actor, id) => {
|
|
4351
|
+
const before = this.readImpersonation(id);
|
|
4352
|
+
// Idempotent: "stop that session" must not fail because somebody else
|
|
4353
|
+
// already stopped it, and it must not move the moment it stopped.
|
|
4354
|
+
if (before.endedAt !== null)
|
|
4355
|
+
return before;
|
|
4356
|
+
this.directory
|
|
4357
|
+
.prepare('UPDATE _substrat_impersonations SET ended_at = ? WHERE id = ?')
|
|
4358
|
+
.run(this.clock(), id);
|
|
4359
|
+
const after = this.readImpersonation(id);
|
|
4360
|
+
this.recordAdmin(actor, 'endImpersonation', { tenantId: after.tenantId, scopeId: after.scopeId }, before, after);
|
|
4361
|
+
return after;
|
|
4362
|
+
},
|
|
4363
|
+
listImpersonations: async (actor, filter) => {
|
|
4364
|
+
const q = impersonationListQuery(filter, this.clock());
|
|
4365
|
+
const rows = this.directory.prepare(q.sql).all(...q.params);
|
|
4366
|
+
const sessions = rows.map(mapImpersonationRow);
|
|
4367
|
+
// A staff READ, logged like every other one (K-24) — including the
|
|
4368
|
+
// `result_count`, which is what separates "checked one session" from
|
|
4369
|
+
// "enumerated every support session on the platform".
|
|
4370
|
+
this.recordAccess(actor, 'listImpersonations', { tenantId: (filter?.tenantId ?? null) }, filter ?? null, sessions.length);
|
|
4371
|
+
return sessions;
|
|
4372
|
+
},
|
|
4165
4373
|
reapTenant: async (actor, tenantId) => {
|
|
4166
4374
|
// The terminal tenant reap (§4.8), the tenant analogue of reapScope. The
|
|
4167
4375
|
// caller (the reap route / grace-window sweep) has already reaped every scope's
|
|
@@ -5033,7 +5241,14 @@ export class SqliteScopeHost {
|
|
|
5033
5241
|
*/
|
|
5034
5242
|
operationContext(rt, subject, overrideActor,
|
|
5035
5243
|
/** #458: per-invoke tally of `ctx.requestPlatform` calls; absent for consumer dispatch. */
|
|
5036
|
-
signals
|
|
5244
|
+
signals,
|
|
5245
|
+
/**
|
|
5246
|
+
* K-42: the session this operation is running under, if any. Read here and
|
|
5247
|
+
* nowhere module code can reach — a vertical that could see it could branch
|
|
5248
|
+
* on it, and "hide this row when support is looking" is the one behaviour
|
|
5249
|
+
* this whole feature exists to make impossible.
|
|
5250
|
+
*/
|
|
5251
|
+
impersonation) {
|
|
5037
5252
|
const principal = subject.id;
|
|
5038
5253
|
const checker = this.checker;
|
|
5039
5254
|
const relations = this.relations;
|
|
@@ -5044,6 +5259,10 @@ export class SqliteScopeHost {
|
|
|
5044
5259
|
// whatever has passed up to that point. A system/override actor is unconditionally
|
|
5045
5260
|
// allowed, so its checks are not authorizations and are not recorded.
|
|
5046
5261
|
const passed = [];
|
|
5262
|
+
// K-42: the two-actor stamp, computed once per operation. Every record this
|
|
5263
|
+
// operation writes about who did what carries it; nothing module code passes
|
|
5264
|
+
// can add it, change it or drop it.
|
|
5265
|
+
const stamp = impersonation ? impersonationStampOf(impersonation) : undefined;
|
|
5047
5266
|
/**
|
|
5048
5267
|
* The operation's instant (#812), read ONCE when the context is built.
|
|
5049
5268
|
*
|
|
@@ -5120,6 +5339,7 @@ export class SqliteScopeHost {
|
|
|
5120
5339
|
sql: scopedSql(rt.db),
|
|
5121
5340
|
now: () => at,
|
|
5122
5341
|
emit: (event) => {
|
|
5342
|
+
assertImpersonationWrites(impersonation, 'ctx.emit');
|
|
5123
5343
|
const input = domainEventInput.parse(event);
|
|
5124
5344
|
const full = domainEvent.parse({
|
|
5125
5345
|
...input,
|
|
@@ -5134,15 +5354,18 @@ export class SqliteScopeHost {
|
|
|
5134
5354
|
? { connection: subject.id }
|
|
5135
5355
|
: principal),
|
|
5136
5356
|
...(passed.length ? { authorization: passed.map((p) => ({ ...p })) } : {}),
|
|
5357
|
+
...(stamp ? { impersonation: stamp } : {}),
|
|
5137
5358
|
});
|
|
5138
5359
|
rt.db
|
|
5139
5360
|
.prepare(`INSERT INTO _substrat_outbox
|
|
5140
5361
|
(id, type, schema_version, occurred_at, tenant_id, scope_id, actor,
|
|
5141
|
-
entity_type, entity_id, pii_class, subject_id, authorization,
|
|
5142
|
-
|
|
5143
|
-
|
|
5362
|
+
entity_type, entity_id, pii_class, subject_id, authorization,
|
|
5363
|
+
impersonation, payload)
|
|
5364
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
5365
|
+
.run(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));
|
|
5144
5366
|
},
|
|
5145
5367
|
requestPlatform: (request) => {
|
|
5368
|
+
assertImpersonationWrites(impersonation, 'ctx.requestPlatform');
|
|
5146
5369
|
const input = platformRequestInput.parse(request);
|
|
5147
5370
|
// Backpressure (platform-intents.md): refuse when the scope already holds too many pending
|
|
5148
5371
|
// intents, so a stuck or runaway vertical cannot flood the platform drain.
|
|
@@ -5161,9 +5384,9 @@ export class SqliteScopeHost {
|
|
|
5161
5384
|
: principal);
|
|
5162
5385
|
rt.db
|
|
5163
5386
|
.prepare(`INSERT INTO _substrat_platform_requests
|
|
5164
|
-
(id, kind, payload, requested_by, status, attempts, requested_at)
|
|
5165
|
-
VALUES (?, ?, ?, ?, 'pending', 0, ?)`)
|
|
5166
|
-
.run(id, input.kind, JSON.stringify(input.payload ?? null), JSON.stringify(requestedBy), at);
|
|
5387
|
+
(id, kind, payload, requested_by, impersonation, status, attempts, requested_at)
|
|
5388
|
+
VALUES (?, ?, ?, ?, ?, 'pending', 0, ?)`)
|
|
5389
|
+
.run(id, input.kind, JSON.stringify(input.payload ?? null), JSON.stringify(requestedBy), stamp ? JSON.stringify(stamp) : null, at);
|
|
5167
5390
|
if (signals)
|
|
5168
5391
|
signals.platformRequests += 1;
|
|
5169
5392
|
return id;
|
|
@@ -5247,6 +5470,7 @@ export class SqliteScopeHost {
|
|
|
5247
5470
|
* guardrail 2 the same way its `check` does.
|
|
5248
5471
|
*/
|
|
5249
5472
|
grant: async (principal, permission, entity) => {
|
|
5473
|
+
assertImpersonationWrites(impersonation, 'ctx.grant');
|
|
5250
5474
|
const held = await runCheck(permission, entity);
|
|
5251
5475
|
if (!held.allowed) {
|
|
5252
5476
|
throw new PermissionDenied(`cannot grant '${permission}' on ${entity.entityType}:${entity.entityId} — ` +
|
|
@@ -5259,6 +5483,7 @@ export class SqliteScopeHost {
|
|
|
5259
5483
|
},
|
|
5260
5484
|
/** Withdraw a grant this caller could have made. Same guardrails, same reason. */
|
|
5261
5485
|
revoke: async (principal, permission, entity) => {
|
|
5486
|
+
assertImpersonationWrites(impersonation, 'ctx.revoke');
|
|
5262
5487
|
const held = await runCheck(permission, entity);
|
|
5263
5488
|
if (!held.allowed) {
|
|
5264
5489
|
throw new PermissionDenied(`cannot revoke '${permission}' on ${entity.entityType}:${entity.entityId} — ` +
|
|
@@ -5270,6 +5495,7 @@ export class SqliteScopeHost {
|
|
|
5270
5495
|
},
|
|
5271
5496
|
atomic: createAtomic(runSub, { passed, signals }),
|
|
5272
5497
|
link: (child, parent) => {
|
|
5498
|
+
assertImpersonationWrites(impersonation, 'ctx.link');
|
|
5273
5499
|
const allowed = relations.get(child.entityType);
|
|
5274
5500
|
if (!allowed?.has(parent.entityType)) {
|
|
5275
5501
|
throw new Error(`undeclared entity relation: ${child.entityType} → ${parent.entityType} ` +
|
|
@@ -5446,6 +5672,13 @@ export class SqliteScopeHost {
|
|
|
5446
5672
|
// already settled reads as "nobody classified this" rather than claiming an origin
|
|
5447
5673
|
// the drain never actually decided.
|
|
5448
5674
|
this.ensureColumn(db, '_substrat_platform_requests', 'last_failure', 'last_failure TEXT');
|
|
5675
|
+
// K-42: the two-actor stamp on the three spine tables that record who did what,
|
|
5676
|
+
// for a scope DB created before impersonation existed. Nullable everywhere, and
|
|
5677
|
+
// the null means "nobody was impersonating" rather than "unrecorded" — every one
|
|
5678
|
+
// of those rows was written when there was no other possibility.
|
|
5679
|
+
this.ensureColumn(db, '_substrat_outbox', 'impersonation', 'impersonation TEXT');
|
|
5680
|
+
this.ensureColumn(db, '_substrat_platform_requests', 'impersonation', 'impersonation TEXT');
|
|
5681
|
+
this.ensureColumn(db, '_substrat_denials', 'impersonation', 'impersonation TEXT');
|
|
5449
5682
|
const appliedMigrations = new Set(db.prepare('SELECT module_id, version FROM _substrat_migrations').all().map((r) => `${r.module_id}@${r.version}`));
|
|
5450
5683
|
const created = { tenantId, scopeId, db, actor: new ScopeActor(), appliedMigrations };
|
|
5451
5684
|
this.scopes.set(key, created);
|