@substrat-run/adapter-sqlite 0.116.0 → 0.117.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/actor.d.ts +10 -0
- package/dist/actor.d.ts.map +1 -1
- package/dist/actor.js +25 -1
- package/dist/actor.js.map +1 -1
- package/dist/checker.d.ts.map +1 -1
- package/dist/checker.js +5 -1
- package/dist/checker.js.map +1 -1
- package/dist/index.d.ts +100 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +801 -271
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -1,10 +1,25 @@
|
|
|
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, opsFailureFingerprint, issueEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunEntry, modelUsageEntry, ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, createTenantInput, domainEvent, domainEventInput, entitlementGrant, entitlementGrantInput, eventId, meterReading, platformRequestInput, platformRequestId,
|
|
5
|
-
import { asPrincipal, assertAllowed, assertReadOnlyQuery, attachmentBlobKey, entitlementDenial, foldMeterReading, guardSpine, parseValidationRecords, resolveScopeRecord, ulid, ISSUE_RETENTION_DAYS, OPS_FAILURE_RETENTION_DAYS, SWEEP_RUN_RETENTION_DAYS, MODEL_USAGE_RETENTION_DAYS, foldModelUsage, backoffAt, platformRequestHistoryQuery, PLATFORM_REQUEST_COLUMNS, denialListQuery, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialSummaryBuckets, IMPERSONATION_COLUMNS, IMPERSONATION_DDL, ImpersonationRefused, assertImpersonationWrites, assertModuleEnqueueableKind, 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, SCHEDULE_STATE_DDL, SCHEDULE_STATE_REBUILD, scheduleStateHasKind, JOB_RUN_DDL, jobRunListLimit, jobRunOf, runDueJobRuns, startJobRun, IDEMPOTENCY_DDL, assertIdempotencyKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, globalFetch, createUlid, facetEvents, readHistory, walkEventCause, walkEventEffects, assertRedrainWindow, readInvocation, readDeadLetters, } from '@substrat-run/kernel';
|
|
4
|
+
import { accessLogEntry, adminLogEntry, opsFailureEntry, opsFailureFingerprint, issueEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunEntry, modelUsageEntry, ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, createTenantInput, domainEvent, domainEventInput, entitlementGrant, entitlementGrantInput, eventId, meterReading, platformRequestInput, platformRequestId, MAX_PENDING_PLATFORM_REQUESTS, identityLink, identityPool, instant, connection, capabilityGrant, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, 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, identityMembership, 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, assertReplayableDump, delegatedReadRecord, redrainEventsInput, REDRAIN_BATCH, } from '@substrat-run/contracts';
|
|
5
|
+
import { actorOf, asPrincipal, assertAllowed, assertNoSecret, assertPermissionKey, CAPABILITY_DDL, CAPABILITY_EXCHANGE_OPERATION, capabilityTokenHash, createCapabilityVerbs, exchangeCapability as exchangeCapabilitySecret, guardSecrets, mintBecomeCapability, plausibleSessionToken, redactSecrets, resolveCapabilitySession, revokeCapabilityAsPlatform, assertReadOnlyQuery, attachmentBlobKey, entitlementDenial, foldMeterReading, guardSpine, parseValidationRecords, resolveScopeRecord, ulid, ISSUE_RETENTION_DAYS, OPS_FAILURE_RETENTION_DAYS, SWEEP_RUN_RETENTION_DAYS, SWEEP_RUNS_INTENT_INDEX, sweepRunsIntentHasKind, MODEL_USAGE_RETENTION_DAYS, foldModelUsage, backoffAt, platformRequestHistoryQuery, platformRequestOf, PLATFORM_REQUEST_COLUMNS, PLATFORM_REQUEST_REDACTION_SQL, platformRequestRedactionParams, platformRequestRedactionQuery, intentPayloadCarriesSubject, seatScopeTuple, switchSystemSchedules, systemScheduleState, systemSwitchedOff, systemSwitchedOffMessage, denialListQuery, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialSummaryBuckets, IMPERSONATION_COLUMNS, IMPERSONATION_DDL, ImpersonationRefused, assertImpersonationWrites, assertModuleEnqueueableKind, 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, SCHEDULE_STATE_DDL, SCHEDULE_STATE_REBUILD, scheduleStateHasKind, JOB_RUN_DDL, jobRunListLimit, jobRunOf, runDueJobRuns, startJobRun, IDEMPOTENCY_DDL, assertIdempotencyKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, globalFetch, createUlid, facetEvents, readHistory, walkEventCause, walkEventEffects, assertRedrainWindow, readInvocation, readDeadLetters, domainEventOf, readUndrainedOutbox, undrainedEventsOf, } from '@substrat-run/kernel';
|
|
6
6
|
import { ScopeActor } from './actor.js';
|
|
7
7
|
import { createTupleChecker } from './checker.js';
|
|
8
|
+
/** The kernel's schedule-switch SQL (#1666), over one scope's database handle. */
|
|
9
|
+
const switchSqlOf = (db) => ({
|
|
10
|
+
all: (sql, ...params) => db.prepare(sql).all(...params),
|
|
11
|
+
run: (sql, ...params) => {
|
|
12
|
+
db.prepare(sql).run(...params);
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
/**
|
|
16
|
+
* How many scopes may hold a read connection at once (#1624). Every open scope keeps its
|
|
17
|
+
* write connection for the life of the host, so an uncapped reader per scope would double
|
|
18
|
+
* the file handles a node deployment holds — and the lake drain alone reads every scope.
|
|
19
|
+
* Past the cap the least recently read scope's reader is closed, and reopened on its next
|
|
20
|
+
* read, which costs well under a millisecond.
|
|
21
|
+
*/
|
|
22
|
+
const READER_CAP = 32;
|
|
8
23
|
/**
|
|
9
24
|
* The scope spine, as this adapter builds it — one of two hand-written copies (#969).
|
|
10
25
|
*
|
|
@@ -130,6 +145,10 @@ const KERNEL_DDL = `
|
|
|
130
145
|
drained_at TEXT
|
|
131
146
|
);
|
|
132
147
|
${IDEMPOTENCY_DDL}
|
|
148
|
+
-- #1672: capabilities — authority carried by a secret (a link share), and the sessions
|
|
149
|
+
-- an exchange trades that secret for. Spine (kernel-written), shared with the DO adapter
|
|
150
|
+
-- from @substrat-run/kernel so the two cannot part company; the column comments are there.
|
|
151
|
+
${CAPABILITY_DDL}
|
|
133
152
|
-- #383 / #1232 / #1288: the platform sweep's per-scope gating state, holding two
|
|
134
153
|
-- families of row that the kind COLUMN — not the spelling of a key — tells apart.
|
|
135
154
|
-- Spine (kernel-written), never a module migration. Shared with the DO adapter from
|
|
@@ -330,23 +349,12 @@ function modelUsageEntryOf(r) {
|
|
|
330
349
|
elapsedMs: r.elapsed_ms,
|
|
331
350
|
});
|
|
332
351
|
}
|
|
333
|
-
/**
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
requestedBy: JSON.parse(r.requested_by),
|
|
340
|
-
impersonation: r.impersonation == null ? null : JSON.parse(r.impersonation),
|
|
341
|
-
status: r.status,
|
|
342
|
-
attempts: r.attempts,
|
|
343
|
-
lastError: r.last_error,
|
|
344
|
-
failure: r.last_failure == null ? null : JSON.parse(r.last_failure),
|
|
345
|
-
result: r.result === null ? null : JSON.parse(r.result),
|
|
346
|
-
requestedAt: r.requested_at,
|
|
347
|
-
settledAt: r.settled_at,
|
|
348
|
-
});
|
|
349
|
-
}
|
|
352
|
+
/**
|
|
353
|
+
* The policy an undecodable event's executor delivery is journaled under (#1636): one
|
|
354
|
+
* attempt, so the first failure is the dead letter. A decode is pure — retrying it cannot
|
|
355
|
+
* succeed — and the Cloudflare coordinator records the same row with no next attempt.
|
|
356
|
+
*/
|
|
357
|
+
const UNDECODABLE_RETRY = { maxAttempts: 1, baseDelayMs: 0, maxDelayMs: 0 };
|
|
350
358
|
/**
|
|
351
359
|
* The dispatch capability (#726 remedy B): a connector delivery may read the attachments
|
|
352
360
|
* of the entity the DELIVERED EVENT names, and nothing else.
|
|
@@ -417,6 +425,8 @@ export class SqliteScopeHost {
|
|
|
417
425
|
directory;
|
|
418
426
|
scopes = new Map();
|
|
419
427
|
scopesById = new Map();
|
|
428
|
+
/** The runtimes holding an open `reader` (#1624), least recently read first — `READER_CAP`'s LRU. */
|
|
429
|
+
readers = new Map();
|
|
420
430
|
/** Per-tenant relational stores (#301), opened lazily and cached by `ref` (the bare
|
|
421
431
|
* `.sqlite` filename). The pure-adapter analogue of a per-tenant D1 — one file per
|
|
422
432
|
* (tenant, vertical, binding), physically separate from the scope DBs. */
|
|
@@ -1048,7 +1058,9 @@ export class SqliteScopeHost {
|
|
|
1048
1058
|
at TEXT NOT NULL
|
|
1049
1059
|
);
|
|
1050
1060
|
CREATE INDEX IF NOT EXISTS _substrat_sweep_runs_unit ON _substrat_sweep_runs (kind, unit, id);
|
|
1051
|
-
|
|
1061
|
+
-- (request_id, kind, unit) since #1572; a directory holding the older
|
|
1062
|
+
-- (request_id, unit) is rebuilt by ensureSweepRunsIntentKind, not by this line.
|
|
1063
|
+
${SWEEP_RUNS_INTENT_INDEX}
|
|
1052
1064
|
CREATE INDEX IF NOT EXISTS _substrat_sweep_runs_tenant ON _substrat_sweep_runs (tenant_id, id);
|
|
1053
1065
|
CREATE INDEX IF NOT EXISTS _substrat_sweep_runs_at ON _substrat_sweep_runs (at);
|
|
1054
1066
|
-- Model usage (#1054, meter 3): one line per model call a vertical made through
|
|
@@ -1436,21 +1448,29 @@ export class SqliteScopeHost {
|
|
|
1436
1448
|
await this.applyPendingMigrations(rt);
|
|
1437
1449
|
// Project each registered module's SCHEDULE grants (#383): a system principal
|
|
1438
1450
|
// holds exactly the permissions its schedules declared, on this scope. This is
|
|
1439
|
-
// what makes `ctx.check` resolve for scheduled work — the gate stays the check
|
|
1440
|
-
//
|
|
1441
|
-
//
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1451
|
+
// what makes `ctx.check` resolve for scheduled work — the gate stays the check.
|
|
1452
|
+
// Idempotent, so a re-provision re-asserts the same grants — SEATED (#1659), with the
|
|
1453
|
+
// statement the Cloudflare adapter seats with: a missing grant is recreated, a revoked
|
|
1454
|
+
// one stays revoked. Turning a scope's schedules off is `revokeFromSystem` (#1666), and
|
|
1455
|
+
// its OFF marker is not a grant, so no re-provision can seat it away; `restoreToSystem`
|
|
1456
|
+
// is the only way back (`grantToSystem` clears a tuple's tombstone, not the switch).
|
|
1457
|
+
//
|
|
1458
|
+
// One turn on the scope actor for the whole loop (#1678): a seat issued while an
|
|
1459
|
+
// invoke held its transaction open joined it, and a rollback un-seated the grant
|
|
1460
|
+
// after provisioning had returned. The switch check lives inside the seat statement.
|
|
1461
|
+
await rt.actor.turn(() => {
|
|
1462
|
+
for (const mod of this.modules.values()) {
|
|
1463
|
+
const perms = new Set();
|
|
1464
|
+
for (const s of mod.schedules)
|
|
1465
|
+
for (const p of s.permissions)
|
|
1466
|
+
perms.add(p);
|
|
1467
|
+
for (const perm of perms) {
|
|
1468
|
+
// A module switched off (#1666) gets nothing seated — see `seatScopeTuple`.
|
|
1469
|
+
const seat = seatScopeTuple(`system:${mod.id}`, `granted:${perm}`, `scope:${input.scopeId}`, null);
|
|
1470
|
+
rt.db.prepare(seat.sql).run(...seat.params);
|
|
1471
|
+
}
|
|
1452
1472
|
}
|
|
1453
|
-
}
|
|
1473
|
+
});
|
|
1454
1474
|
// Audit a real provision only; an idempotent re-provision changed nothing.
|
|
1455
1475
|
if (!existing) {
|
|
1456
1476
|
this.recordAdmin(actor, 'provisionScope', { tenantId: input.tenantId, scopeId: input.scopeId, vertical: record.vertical }, null, record);
|
|
@@ -1857,12 +1877,12 @@ export class SqliteScopeHost {
|
|
|
1857
1877
|
forkedFrom: input.forkedFrom ?? dump.scopeId,
|
|
1858
1878
|
forkedAt: input.forkedAt ?? dump.capturedAt,
|
|
1859
1879
|
});
|
|
1860
|
-
this.loadDump(input.tenantId, input.scopeId, dump.tables);
|
|
1880
|
+
await this.loadDump(input.tenantId, input.scopeId, dump.tables);
|
|
1861
1881
|
await this.admin.activateScope(actor, input.tenantId, input.scopeId);
|
|
1862
1882
|
this.recordAdmin(actor, 'importScope', { tenantId: input.tenantId, scopeId: input.scopeId }, null, { sourceScopeId: dump.scopeId, tables: dump.tables.length, capturedAt: dump.capturedAt });
|
|
1863
1883
|
}
|
|
1864
1884
|
/** Drop-then-replay a dump into a scope's db, refreshing the migration frontier. */
|
|
1865
|
-
loadDump(tenantId, scopeId, tables) {
|
|
1885
|
+
async loadDump(tenantId, scopeId, tables) {
|
|
1866
1886
|
const rt = this.runtime(tenantId, scopeId);
|
|
1867
1887
|
const db = rt.db;
|
|
1868
1888
|
const load = db.transaction((dumped) => {
|
|
@@ -1946,20 +1966,25 @@ export class SqliteScopeHost {
|
|
|
1946
1966
|
db.prepare(`UPDATE OR REPLACE _substrat_tuples SET object = ?
|
|
1947
1967
|
WHERE object LIKE 'scope:%' AND object <> ?`).run(`scope:${scopeId}`, `scope:${scopeId}`);
|
|
1948
1968
|
});
|
|
1949
|
-
|
|
1950
|
-
//
|
|
1951
|
-
//
|
|
1952
|
-
rt.
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1969
|
+
// On the scope actor (#1678): issued while an invoke held its transaction open, the
|
|
1970
|
+
// load was a SAVEPOINT inside it, and that invoke's rollback undid the restore after
|
|
1971
|
+
// this verb had audited it.
|
|
1972
|
+
await rt.actor.turn(() => {
|
|
1973
|
+
load(tables);
|
|
1974
|
+
// The frontier came in with the dump — refresh the cached applied-migration set so
|
|
1975
|
+
// a later bind/migrate builds on the loaded state, not the previous one.
|
|
1976
|
+
rt.appliedMigrations.clear();
|
|
1977
|
+
for (const r of db.prepare('SELECT module_id, version FROM _substrat_migrations').all()) {
|
|
1978
|
+
rt.appliedMigrations.add(`${r.module_id}@${r.version}`);
|
|
1979
|
+
}
|
|
1980
|
+
});
|
|
1956
1981
|
}
|
|
1957
1982
|
async restoreScope(actor, tenantId, scopeId, dump) {
|
|
1958
1983
|
// Restore never creates a scope (that is importScope) — an unknown target fails closed.
|
|
1959
1984
|
const existing = await this.admin.getScopeRecord(actor, tenantId, scopeId);
|
|
1960
1985
|
if (!existing)
|
|
1961
1986
|
throw new Error(`unknown scope ${scopeId} in tenant ${tenantId}`);
|
|
1962
|
-
this.loadDump(tenantId, scopeId, dump.tables);
|
|
1987
|
+
await this.loadDump(tenantId, scopeId, dump.tables);
|
|
1963
1988
|
this.recordAdmin(actor, 'restoreScope', { tenantId, scopeId }, null, { sourceScopeId: dump.scopeId, tables: dump.tables.length, capturedAt: dump.capturedAt });
|
|
1964
1989
|
}
|
|
1965
1990
|
async snapshotScope(actor, tenantId, scopeId, opts) {
|
|
@@ -1996,13 +2021,9 @@ export class SqliteScopeHost {
|
|
|
1996
2021
|
`archive a primary scope instead`);
|
|
1997
2022
|
}
|
|
1998
2023
|
// Close and evict the runtime handle before touching the file.
|
|
1999
|
-
const
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
rt.db.close();
|
|
2003
|
-
this.scopes.delete(key);
|
|
2004
|
-
this.scopesById.delete(scopeId);
|
|
2005
|
-
}
|
|
2024
|
+
const rt = this.scopes.get(`${tenantId}/${scopeId}`);
|
|
2025
|
+
if (rt)
|
|
2026
|
+
this.closeRuntime(rt);
|
|
2006
2027
|
// Order is the retry story: hostnames first (a reaped preview URL must stop
|
|
2007
2028
|
// resolving), then the STORAGE, then the directory row — so a crash mid-way leaves
|
|
2008
2029
|
// a visible row over empty storage (re-running deleteSnapshot converges), never
|
|
@@ -2180,6 +2201,82 @@ export class SqliteScopeHost {
|
|
|
2180
2201
|
await this.applyPendingMigrations(rt);
|
|
2181
2202
|
return this.buildStub(tenantId, scopeId, rt, { kind: 'system', id: moduleId });
|
|
2182
2203
|
}
|
|
2204
|
+
/**
|
|
2205
|
+
* The lifecycle gate the capability doors share (#1672): the scope must exist in this
|
|
2206
|
+
* tenant, the tenant must be active, the scope must be active — K-3's fail-closed pair
|
|
2207
|
+
* check, worded as the principal door words it, so a scope in another tenant reads the
|
|
2208
|
+
* same as one that does not exist. Migrations are applied as on every door.
|
|
2209
|
+
*/
|
|
2210
|
+
async openActiveScope(tenantId, scopeId) {
|
|
2211
|
+
const scope = this.directory
|
|
2212
|
+
.prepare('SELECT tenant_id, status FROM scopes WHERE scope_id = ?')
|
|
2213
|
+
.get(scopeId);
|
|
2214
|
+
if (!scope || scope.tenant_id !== tenantId) {
|
|
2215
|
+
throw new Error(`unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
2216
|
+
}
|
|
2217
|
+
const tenant = this.directory
|
|
2218
|
+
.prepare('SELECT status FROM tenants WHERE tenant_id = ?')
|
|
2219
|
+
.get(tenantId);
|
|
2220
|
+
if (!tenant || tenant.status !== 'active') {
|
|
2221
|
+
throw new Error(`tenant not active (status: ${tenant?.status ?? 'missing'}): ${tenantId}`);
|
|
2222
|
+
}
|
|
2223
|
+
if (scope.status !== 'active') {
|
|
2224
|
+
throw new Error(`scope not active (status: ${scope.status}): ${scopeId}`);
|
|
2225
|
+
}
|
|
2226
|
+
const rt = this.runtime(tenantId, scopeId);
|
|
2227
|
+
await this.applyPendingMigrations(rt);
|
|
2228
|
+
return rt;
|
|
2229
|
+
}
|
|
2230
|
+
/**
|
|
2231
|
+
* The exchange (#1672) — a secret traded for a session, or for the principal a `become`
|
|
2232
|
+
* capability yields. One serialized scope task and one transaction, so the use it takes
|
|
2233
|
+
* and the `capability.exercised` event recording it commit together or not at all; the
|
|
2234
|
+
* event's consumers then dispatch as an invoke's would.
|
|
2235
|
+
*/
|
|
2236
|
+
async exchangeCapability(tenantId, scopeId, secret, options) {
|
|
2237
|
+
const rt = await this.openActiveScope(tenantId, scopeId);
|
|
2238
|
+
return rt.actor.enqueue(async () => {
|
|
2239
|
+
// ONE instant for the whole exchange: the row's `last_used_at`, the session's times and
|
|
2240
|
+
// the event's `occurredAt` are one fact, and two clock reads could disagree about it.
|
|
2241
|
+
const now = this.clock();
|
|
2242
|
+
rt.db.exec('BEGIN IMMEDIATE');
|
|
2243
|
+
let outcome;
|
|
2244
|
+
try {
|
|
2245
|
+
outcome = await exchangeCapabilitySecret({
|
|
2246
|
+
sql: spineSql(rt.db),
|
|
2247
|
+
now,
|
|
2248
|
+
// Stamped `{ capability }` by the ordinary emit path, under the exchange's own
|
|
2249
|
+
// pseudo-operation name — the actor is the capability, as on every event it
|
|
2250
|
+
// goes on to cause.
|
|
2251
|
+
emit: (capability, event) => this.operationContext(rt, { kind: 'capability', id: capability }, undefined, undefined, undefined, CAPABILITY_EXCHANGE_OPERATION, [], now).emit(event),
|
|
2252
|
+
}, secret, options?.mode);
|
|
2253
|
+
rt.db.exec('COMMIT');
|
|
2254
|
+
}
|
|
2255
|
+
catch (err) {
|
|
2256
|
+
rt.db.exec('ROLLBACK');
|
|
2257
|
+
throw err;
|
|
2258
|
+
}
|
|
2259
|
+
if (outcome) {
|
|
2260
|
+
await this.dispatch(rt, null);
|
|
2261
|
+
await this.dispatchExecutors(rt, null);
|
|
2262
|
+
}
|
|
2263
|
+
return outcome;
|
|
2264
|
+
});
|
|
2265
|
+
}
|
|
2266
|
+
/**
|
|
2267
|
+
* The capability door (#1672) — mirror of the connection and system doors. The token is
|
|
2268
|
+
* shape-checked and hashed HERE, and only its hash travels on: `buildStub` re-resolves it
|
|
2269
|
+
* to its capability on every invoke, so a revoke or an expiry refuses the very next call
|
|
2270
|
+
* on a stub minted before it.
|
|
2271
|
+
*/
|
|
2272
|
+
async getCapabilityScope(sessionToken, tenantId, scopeId, options) {
|
|
2273
|
+
if (!plausibleSessionToken(sessionToken)) {
|
|
2274
|
+
throw substratError('unauthenticated', 'not a capability session token');
|
|
2275
|
+
}
|
|
2276
|
+
const rt = await this.openActiveScope(tenantId, scopeId);
|
|
2277
|
+
const hash = await capabilityTokenHash(sessionToken);
|
|
2278
|
+
return this.buildStub(tenantId, scopeId, rt, { kind: 'capability-session', hash }, options);
|
|
2279
|
+
}
|
|
2183
2280
|
registeredSchedules() {
|
|
2184
2281
|
const out = [];
|
|
2185
2282
|
for (const mod of this.modules.values()) {
|
|
@@ -2241,45 +2338,51 @@ export class SqliteScopeHost {
|
|
|
2241
2338
|
}
|
|
2242
2339
|
if (windows.size === 0)
|
|
2243
2340
|
return report;
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
.
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
.
|
|
2281
|
-
|
|
2282
|
-
|
|
2341
|
+
// The WHOLE evaluation is one turn on the scope actor (#1678 review): the outbox probe,
|
|
2342
|
+
// the previous verdict and the write that records the new one. Read outside the turn, the
|
|
2343
|
+
// probe saw a suspended operation's UNCOMMITTED event on this shared connection, then
|
|
2344
|
+
// waited here and persisted an `ok` for an event its rollback was about to remove.
|
|
2345
|
+
await rt.actor.turn(() => {
|
|
2346
|
+
const types = [...windows.keys()];
|
|
2347
|
+
const observed = new Map();
|
|
2348
|
+
for (const row of rt.db
|
|
2349
|
+
.prepare(`SELECT type, MAX(occurred_at) AS at FROM _substrat_outbox
|
|
2350
|
+
WHERE type IN (${types.map(() => '?').join(', ')}) GROUP BY type`)
|
|
2351
|
+
.all(...types)) {
|
|
2352
|
+
if (row.at !== null)
|
|
2353
|
+
observed.set(row.type, row.at);
|
|
2354
|
+
}
|
|
2355
|
+
const nowIso = this.clock();
|
|
2356
|
+
const now = Date.parse(nowIso);
|
|
2357
|
+
for (const [eventType, withinHours] of windows) {
|
|
2358
|
+
const observedAt = observed.get(eventType) ?? null;
|
|
2359
|
+
const outcome = observedAt === null
|
|
2360
|
+
? 'skipped' // never observed — the never-run analogue, not a failure
|
|
2361
|
+
: now - Date.parse(observedAt) <= withinHours * 3_600_000
|
|
2362
|
+
? 'ok'
|
|
2363
|
+
: 'failed';
|
|
2364
|
+
const stateKey = `freshness:${eventType}`;
|
|
2365
|
+
// #1288: the evaluator's own row, named by kind. Before the column, a module
|
|
2366
|
+
// declaring a schedule called `freshness:<this type>` shared it — and the
|
|
2367
|
+
// verdict read back here was whichever of the two wrote last.
|
|
2368
|
+
const state = rt.db
|
|
2369
|
+
.prepare(`SELECT last_run_at, last_status FROM _substrat_schedule_state
|
|
2370
|
+
WHERE kind = 'freshness' AND schedule_op = ?`)
|
|
2371
|
+
.get(stateKey);
|
|
2372
|
+
const changed = state?.last_status !== outcome;
|
|
2373
|
+
const heartbeatDue = !state?.last_run_at ||
|
|
2374
|
+
now - Date.parse(state.last_run_at) > FRESHNESS_HEARTBEAT_MINUTES * 60_000;
|
|
2375
|
+
if (!changed && !heartbeatDue)
|
|
2376
|
+
continue;
|
|
2377
|
+
rt.db
|
|
2378
|
+
.prepare(`INSERT INTO _substrat_schedule_state (kind, schedule_op, last_run_at, last_status)
|
|
2379
|
+
VALUES ('freshness', ?, ?, ?)
|
|
2380
|
+
ON CONFLICT(kind, schedule_op) DO UPDATE SET last_run_at = excluded.last_run_at,
|
|
2381
|
+
last_status = excluded.last_status`)
|
|
2382
|
+
.run(stateKey, nowIso, outcome);
|
|
2383
|
+
report.checks.push({ eventType, outcome, observedAt, withinHours });
|
|
2384
|
+
}
|
|
2385
|
+
});
|
|
2283
2386
|
return report;
|
|
2284
2387
|
}
|
|
2285
2388
|
async runDueSchedules(moduleId, tenantId, scopeId) {
|
|
@@ -2298,18 +2401,48 @@ export class SqliteScopeHost {
|
|
|
2298
2401
|
return report;
|
|
2299
2402
|
const rt = this.runtime(tenantId, scopeId);
|
|
2300
2403
|
await this.applyPendingMigrations(rt);
|
|
2301
|
-
// The grant IS the switch (#383)
|
|
2302
|
-
//
|
|
2303
|
-
//
|
|
2304
|
-
//
|
|
2404
|
+
// The grant IS the switch (#383), and the kill switch is its lever (#1666): the
|
|
2405
|
+
// kernel's `systemScheduleState`, the one predicate both adapters run. A scope that
|
|
2406
|
+
// never held the module's grant (a foreign vertical's) is a quiet no-op, exactly as
|
|
2407
|
+
// before. A scope switched OFF reports every schedule `skipped`, never `failed`, and
|
|
2408
|
+
// does not touch its cadence rows — so a restore fires a due schedule on the next pass.
|
|
2305
2409
|
const nowIso = this.clock();
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2410
|
+
// The whole gate — the switch state and every cadence row — is ONE turn, on the write
|
|
2411
|
+
// connection (#1624). Outside a turn it saw a suspended operation's uncommitted grant,
|
|
2412
|
+
// revoke or cadence row, and fired (or skipped) on a state its rollback was about to
|
|
2413
|
+
// undo. Not the read connection either: these reads decide a write (a fire, then its
|
|
2414
|
+
// cadence row), and a snapshot read ahead of the write's turn is the split #1678 closed.
|
|
2415
|
+
// One turn rather than one per read, because the first turn already waits out any
|
|
2416
|
+
// stranger; nothing but this loop writes a `kind = 'schedule'` row, so reading them all
|
|
2417
|
+
// up front answers what reading each just before its fire did.
|
|
2418
|
+
//
|
|
2419
|
+
// #1288: each schedule's own row. `kind` is what makes that true even when the operation
|
|
2420
|
+
// is spelled `freshness:<eventType>` — without it, the cadence gate read the evaluator's
|
|
2421
|
+
// last recorded verdict as a run of this operation.
|
|
2422
|
+
const gate = await rt.actor.turn(() => {
|
|
2423
|
+
const switched = systemScheduleState(switchSqlOf(rt.db), moduleId, nowIso);
|
|
2424
|
+
const lastRuns = new Map();
|
|
2425
|
+
if (switched !== 'on')
|
|
2426
|
+
return { state: switched, lastRuns };
|
|
2427
|
+
const read = rt.db.prepare(`SELECT last_run_at FROM _substrat_schedule_state
|
|
2428
|
+
WHERE kind = 'schedule' AND schedule_op = ?`);
|
|
2429
|
+
for (const schedule of mod.schedules) {
|
|
2430
|
+
const row = read.get(schedule.operation);
|
|
2431
|
+
lastRuns.set(schedule.operation, row?.last_run_at ?? null);
|
|
2432
|
+
}
|
|
2433
|
+
return { state: switched, lastRuns };
|
|
2434
|
+
});
|
|
2435
|
+
const state = gate.state;
|
|
2436
|
+
if (state === 'ungranted')
|
|
2312
2437
|
return report;
|
|
2438
|
+
if (state === 'off') {
|
|
2439
|
+
for (const schedule of mod.schedules) {
|
|
2440
|
+
report.skipped += 1;
|
|
2441
|
+
report.runs.push({ operation: schedule.operation, outcome: 'skipped' });
|
|
2442
|
+
}
|
|
2443
|
+
report.switchedOff = true;
|
|
2444
|
+
return report;
|
|
2445
|
+
}
|
|
2313
2446
|
// The spine state table is KERNEL_DDL's (#1288) — kernel-owned (`_substrat_*`),
|
|
2314
2447
|
// never a module migration, and created by `runtime()` above rather than lazily
|
|
2315
2448
|
// here. This loop's rows are `kind = 'schedule'`, keyed by the operation
|
|
@@ -2318,14 +2451,8 @@ export class SqliteScopeHost {
|
|
|
2318
2451
|
// for what the same two columns mean there.
|
|
2319
2452
|
const now = Date.parse(nowIso);
|
|
2320
2453
|
for (const schedule of mod.schedules) {
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
// below read the evaluator's last recorded verdict as a run of this operation.
|
|
2324
|
-
const row = rt.db
|
|
2325
|
-
.prepare(`SELECT last_run_at FROM _substrat_schedule_state
|
|
2326
|
-
WHERE kind = 'schedule' AND schedule_op = ?`)
|
|
2327
|
-
.get(schedule.operation);
|
|
2328
|
-
const lastRun = row?.last_run_at ? Date.parse(row.last_run_at) : null;
|
|
2454
|
+
const lastRunAt = gate.lastRuns.get(schedule.operation) ?? null;
|
|
2455
|
+
const lastRun = lastRunAt ? Date.parse(lastRunAt) : null;
|
|
2329
2456
|
const dueAt = lastRun === null ? -Infinity : lastRun + schedule.cadence.everyMinutes * 60_000;
|
|
2330
2457
|
if (now < dueAt) {
|
|
2331
2458
|
report.skipped += 1;
|
|
@@ -2348,18 +2475,27 @@ export class SqliteScopeHost {
|
|
|
2348
2475
|
error: err instanceof Error ? err.message : String(err),
|
|
2349
2476
|
});
|
|
2350
2477
|
}
|
|
2351
|
-
|
|
2478
|
+
// On the scope actor (#1678): the cadence row joined whatever transaction an invoke
|
|
2479
|
+
// held open, so its rollback forgot the run and the next pass fired it AGAIN.
|
|
2480
|
+
await rt.actor.turn(() => rt.db
|
|
2352
2481
|
.prepare(`INSERT INTO _substrat_schedule_state (kind, schedule_op, last_run_at, last_status)
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
.run(schedule.operation, new Date(now).toISOString(), status);
|
|
2482
|
+
VALUES ('schedule', ?, ?, ?)
|
|
2483
|
+
ON CONFLICT(kind, schedule_op) DO UPDATE SET last_run_at = excluded.last_run_at,
|
|
2484
|
+
last_status = excluded.last_status`)
|
|
2485
|
+
.run(schedule.operation, new Date(now).toISOString(), status));
|
|
2357
2486
|
report.runs.push({ operation: schedule.operation, outcome: status === 'ok' ? 'ok' : 'failed' });
|
|
2358
2487
|
}
|
|
2359
2488
|
return report;
|
|
2360
2489
|
}
|
|
2361
|
-
/** The stub body, shared by the principal, connection, system and
|
|
2362
|
-
buildStub(tenantId, scopeId, rt,
|
|
2490
|
+
/** The stub body, shared by the principal, connection, system, impersonation and capability doors. */
|
|
2491
|
+
buildStub(tenantId, scopeId, rt,
|
|
2492
|
+
/**
|
|
2493
|
+
* Who the stub acts as — or, for the capability door (#1672), the SESSION it holds,
|
|
2494
|
+
* which every invoke re-resolves to its capability inside the scope's actor task. A
|
|
2495
|
+
* capability resolved once at mint time would outlive its own revocation for exactly
|
|
2496
|
+
* the one caller holding the stub.
|
|
2497
|
+
*/
|
|
2498
|
+
authority, options,
|
|
2363
2499
|
/**
|
|
2364
2500
|
* K-42: the session this stub acts under. `subject` is already the
|
|
2365
2501
|
* IMPERSONATED principal — that is what makes the permission model answer
|
|
@@ -2441,9 +2577,22 @@ export class SqliteScopeHost {
|
|
|
2441
2577
|
// which is the leak `causedBy` had to be moved off the host to avoid.
|
|
2442
2578
|
rt.invocationId = invokeOptions?.invocationId ?? null;
|
|
2443
2579
|
try {
|
|
2580
|
+
// #1672: the capability door's session, re-resolved on EVERY invoke and inside the
|
|
2581
|
+
// actor task — so nothing can revoke between this read and the transaction below.
|
|
2582
|
+
// Refuses a stale session, a revoked or expired capability and an operation off its
|
|
2583
|
+
// allowlist before anything opens; none of those is a K-35 denial (no key checked).
|
|
2584
|
+
const subject = authority.kind === 'capability-session'
|
|
2585
|
+
? {
|
|
2586
|
+
kind: 'capability',
|
|
2587
|
+
id: resolveCapabilitySession(spineSql(rt.db), authority.hash, this.clock(), operation),
|
|
2588
|
+
}
|
|
2589
|
+
: authority;
|
|
2590
|
+
// #1672: the secrets this invocation mints — withheld from its idempotency recording,
|
|
2591
|
+
// and what the tripwire on its writes looks for.
|
|
2592
|
+
const minted = [];
|
|
2444
2593
|
// Fresh per operation: the context carries the K-34 authorization accumulator,
|
|
2445
2594
|
// which must not leak across operations (invokes are serialized per scope).
|
|
2446
|
-
const ctx = this.operationContext(rt, subject, undefined, signals, session, operation);
|
|
2595
|
+
const ctx = this.operationContext(rt, subject, undefined, signals, session, operation, minted);
|
|
2447
2596
|
const clonedInput = structuredClone(input);
|
|
2448
2597
|
// #893: parse, don't trust — at the scope door, from the operation's own
|
|
2449
2598
|
// declaration. BEFORE `BEGIN`, so a malformed call never opens a
|
|
@@ -2529,7 +2678,9 @@ export class SqliteScopeHost {
|
|
|
2529
2678
|
// that adds a row.
|
|
2530
2679
|
if (idempotencyKey !== undefined && fingerprint !== undefined) {
|
|
2531
2680
|
const at = this.clock();
|
|
2532
|
-
const record = idempotencyRecordStatement(subject, idempotencyKey, operation, fingerprint,
|
|
2681
|
+
const record = idempotencyRecordStatement(subject, idempotencyKey, operation, fingerprint,
|
|
2682
|
+
// #1672: a replay of a mint returns the placeholder, never the secret.
|
|
2683
|
+
redactSecrets(result, minted), committedVersion ?? null, at);
|
|
2533
2684
|
rt.db.prepare(record.sql).run(...record.params);
|
|
2534
2685
|
const prune = idempotencyPruneStatement(at);
|
|
2535
2686
|
rt.db.prepare(prune.sql).run(...prune.params);
|
|
@@ -2635,10 +2786,8 @@ export class SqliteScopeHost {
|
|
|
2635
2786
|
}
|
|
2636
2787
|
}
|
|
2637
2788
|
async close() {
|
|
2638
|
-
for (const
|
|
2639
|
-
|
|
2640
|
-
this.scopes.clear();
|
|
2641
|
-
this.scopesById.clear();
|
|
2789
|
+
for (const rt of [...this.scopes.values()])
|
|
2790
|
+
this.closeRuntime(rt);
|
|
2642
2791
|
this.directory.close();
|
|
2643
2792
|
}
|
|
2644
2793
|
// -------------------------------------------------------------------------
|
|
@@ -2696,8 +2845,22 @@ export class SqliteScopeHost {
|
|
|
2696
2845
|
ORDER BY o.id`)
|
|
2697
2846
|
.all(deliveryId, executor.eventType, now);
|
|
2698
2847
|
for (const row of rows) {
|
|
2699
|
-
const event = this.parseOutboxRow(row);
|
|
2700
2848
|
report.attempted += 1;
|
|
2849
|
+
let event;
|
|
2850
|
+
try {
|
|
2851
|
+
event = domainEventOf(row);
|
|
2852
|
+
}
|
|
2853
|
+
catch (err) {
|
|
2854
|
+
// #1636: an event that does not decode is dead-lettered for this executor at
|
|
2855
|
+
// once, and the handler never sees it. The decode sat above this loop's `try`,
|
|
2856
|
+
// so one bad row threw the executor's whole pending list on every pass. Terminal
|
|
2857
|
+
// on the FIRST failure, unlike a handler's: the decode is pure, of text already
|
|
2858
|
+
// in hand, so a retry cannot succeed — and only the decode is caught here, so a
|
|
2859
|
+
// handler's transient failure keeps its backoff below.
|
|
2860
|
+
this.recordExecutorDelivery(rt, row.id, deliveryId, String(err), UNDECODABLE_RETRY, invocationId);
|
|
2861
|
+
report.deadLettered += 1;
|
|
2862
|
+
continue;
|
|
2863
|
+
}
|
|
2701
2864
|
this.causedBy = event.id;
|
|
2702
2865
|
try {
|
|
2703
2866
|
if (executor.kind === 'connector') {
|
|
@@ -2936,7 +3099,7 @@ export class SqliteScopeHost {
|
|
|
2936
3099
|
// "not granted" where the checker answers allow — a read-back that disagrees with
|
|
2937
3100
|
// enforcement is worse than none, since it is the read an operator would trust.
|
|
2938
3101
|
const rows = [
|
|
2939
|
-
...rt
|
|
3102
|
+
...this.readDb(rt)
|
|
2940
3103
|
.prepare(`SELECT subject, relation, expires_at FROM _substrat_tuples
|
|
2941
3104
|
WHERE subject LIKE 'connection:%' AND relation LIKE 'granted:%'
|
|
2942
3105
|
AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)`)
|
|
@@ -3016,7 +3179,7 @@ export class SqliteScopeHost {
|
|
|
3016
3179
|
async executorDeadLetters(tenantId, scopeId) {
|
|
3017
3180
|
const rt = this.runtime(tenantId, scopeId);
|
|
3018
3181
|
await this.applyPendingMigrations(rt);
|
|
3019
|
-
const rows = rt
|
|
3182
|
+
const rows = this.readDb(rt)
|
|
3020
3183
|
.prepare(`SELECT d.event_id, d.consumer_module, d.attempts, d.error, d.delivered_at, o.type
|
|
3021
3184
|
FROM _substrat_deliveries d
|
|
3022
3185
|
JOIN _substrat_outbox o ON o.id = d.event_id
|
|
@@ -3037,27 +3200,36 @@ export class SqliteScopeHost {
|
|
|
3037
3200
|
async listPlatformRequests(tenantId, scopeId) {
|
|
3038
3201
|
const rt = this.runtime(tenantId, scopeId);
|
|
3039
3202
|
await this.applyPendingMigrations(rt);
|
|
3040
|
-
const rows = rt
|
|
3203
|
+
const rows = this.readDb(rt)
|
|
3041
3204
|
.prepare(`SELECT ${PLATFORM_REQUEST_COLUMNS}
|
|
3042
3205
|
FROM _substrat_platform_requests WHERE status = 'pending' ORDER BY id`)
|
|
3043
3206
|
.all();
|
|
3044
|
-
|
|
3207
|
+
// Tolerant (#1588): one undecodable row comes back naming why, never throws for the list.
|
|
3208
|
+
return rows.map(platformRequestOf);
|
|
3045
3209
|
}
|
|
3046
3210
|
async listPlatformRequestHistory(tenantId, scopeId, filter) {
|
|
3047
3211
|
const rt = this.runtime(tenantId, scopeId);
|
|
3048
3212
|
await this.applyPendingMigrations(rt);
|
|
3049
3213
|
const q = platformRequestHistoryQuery(filter);
|
|
3050
|
-
return rt.
|
|
3214
|
+
return this.readDb(rt).prepare(q.sql).all(...q.params).map(platformRequestOf);
|
|
3051
3215
|
}
|
|
3052
3216
|
async settlePlatformRequest(tenantId, scopeId, id, outcome) {
|
|
3053
3217
|
const rt = this.runtime(tenantId, scopeId);
|
|
3054
3218
|
await this.applyPendingMigrations(rt);
|
|
3055
3219
|
await rt.actor.enqueue(() => {
|
|
3220
|
+
// Compare-and-set on `pending` (#1600 review). The drain reads pending rows, runs a
|
|
3221
|
+
// handler, then settles — and between the read and the settle a subject erasure can
|
|
3222
|
+
// redact the row. Settling by `id` alone let that stale pass overwrite the redaction
|
|
3223
|
+
// and write a provider's reply, which can quote the person, back into `last_error`.
|
|
3224
|
+
// Nothing legitimate is refused by this: `listPlatformRequests` returns only pending
|
|
3225
|
+
// rows, so every settle in the tree targets one that was pending when it was read.
|
|
3226
|
+
// A settle that finds the row already terminal does nothing, deliberately silently —
|
|
3227
|
+
// throwing would make the drain's blanket catch retry a row that is correctly over.
|
|
3056
3228
|
rt.db
|
|
3057
3229
|
.prepare(`UPDATE _substrat_platform_requests
|
|
3058
3230
|
SET status = ?, result = COALESCE(?, result), last_error = ?, last_failure = ?,
|
|
3059
3231
|
attempts = attempts + 1, settled_at = ?
|
|
3060
|
-
WHERE id =
|
|
3232
|
+
WHERE id = ? AND status = 'pending'`)
|
|
3061
3233
|
.run(outcome.status, outcome.result === undefined ? null : JSON.stringify(outcome.result), outcome.lastError ?? null, outcome.failure == null ? null : JSON.stringify(outcome.failure), outcome.status === 'pending' ? null : new Date().toISOString(), id);
|
|
3062
3234
|
});
|
|
3063
3235
|
}
|
|
@@ -3082,7 +3254,24 @@ export class SqliteScopeHost {
|
|
|
3082
3254
|
ORDER BY o.id`)
|
|
3083
3255
|
.all(consumer.eventType, mod.id);
|
|
3084
3256
|
for (const row of rows) {
|
|
3085
|
-
|
|
3257
|
+
let event;
|
|
3258
|
+
try {
|
|
3259
|
+
event = domainEventOf(row);
|
|
3260
|
+
}
|
|
3261
|
+
catch (err) {
|
|
3262
|
+
// #1636: dead-letter an event that does not decode, exactly as a failed
|
|
3263
|
+
// handler is — the decode sat ABOVE the `try` below, so one bad row halted
|
|
3264
|
+
// every event of this type behind it, on every pass. The consumer is never
|
|
3265
|
+
// handed it: an event built from stand-ins is not one it may act on. Only
|
|
3266
|
+
// the decode is caught; the journal write is not, so a failure to record
|
|
3267
|
+
// this still surfaces as it would for a handler's dead letter.
|
|
3268
|
+
rt.db
|
|
3269
|
+
.prepare(`INSERT INTO _substrat_deliveries
|
|
3270
|
+
(event_id, consumer_module, delivered_at, error, invocation_id)
|
|
3271
|
+
VALUES (?, ?, ?, ?, ?)`)
|
|
3272
|
+
.run(row.id, mod.id, new Date().toISOString(), String(err), invocationId);
|
|
3273
|
+
continue;
|
|
3274
|
+
}
|
|
3086
3275
|
const ctx = this.operationContext(rt, asPrincipal(this.systemPrincipal), {
|
|
3087
3276
|
system: mod.id,
|
|
3088
3277
|
});
|
|
@@ -3152,11 +3341,7 @@ export class SqliteScopeHost {
|
|
|
3152
3341
|
// policy, carries no permission key, and is left to the module.
|
|
3153
3342
|
if (!err.permission || !err.node)
|
|
3154
3343
|
return;
|
|
3155
|
-
const actor = subject
|
|
3156
|
-
? { system: subject.id }
|
|
3157
|
-
: subject.kind === 'connection'
|
|
3158
|
-
? { connection: subject.id }
|
|
3159
|
-
: subject.id;
|
|
3344
|
+
const actor = actorOf(subject);
|
|
3160
3345
|
rt.db
|
|
3161
3346
|
.prepare(`INSERT INTO _substrat_denials
|
|
3162
3347
|
(id, actor, permission, tenant_id, scope_id, operation, impersonation,
|
|
@@ -3166,29 +3351,6 @@ export class SqliteScopeHost {
|
|
|
3166
3351
|
// #1525: the call this refusal belongs to, as the caller named it.
|
|
3167
3352
|
invocationId, new Date().toISOString());
|
|
3168
3353
|
}
|
|
3169
|
-
parseOutboxRow(row) {
|
|
3170
|
-
return domainEvent.parse({
|
|
3171
|
-
id: row.id,
|
|
3172
|
-
type: row.type,
|
|
3173
|
-
schemaVersion: row.schema_version,
|
|
3174
|
-
occurredAt: row.occurred_at,
|
|
3175
|
-
tenantId: row.tenant_id,
|
|
3176
|
-
scopeId: row.scope_id,
|
|
3177
|
-
actor: JSON.parse(row.actor),
|
|
3178
|
-
entity: { entityType: row.entity_type, entityId: row.entity_id },
|
|
3179
|
-
piiClass: row.pii_class,
|
|
3180
|
-
...(row.subject_id ? { subjectId: row.subject_id } : {}),
|
|
3181
|
-
...(row.authorization ? { authorization: JSON.parse(row.authorization) } : {}),
|
|
3182
|
-
// K-42: the stamp survives the read, so a consumer's event and an executor's
|
|
3183
|
-
// are the same fact the stored row is. Absent rather than null when nobody
|
|
3184
|
-
// was impersonating, because `DomainEvent.impersonation` is optional — the
|
|
3185
|
-
// shape module code never sees is also the shape it cannot branch on.
|
|
3186
|
-
...(row.impersonation ? { impersonation: JSON.parse(row.impersonation) } : {}),
|
|
3187
|
-
// #1231: absent rather than null, the same shape rule as the stamp above.
|
|
3188
|
-
...(row.operation ? { operation: row.operation } : {}),
|
|
3189
|
-
payload: row.payload === null ? undefined : JSON.parse(row.payload),
|
|
3190
|
-
});
|
|
3191
|
-
}
|
|
3192
3354
|
// -------------------------------------------------------------------------
|
|
3193
3355
|
// Admin surface (enforcement input, §9.2.5)
|
|
3194
3356
|
// -------------------------------------------------------------------------
|
|
@@ -3615,6 +3777,9 @@ export class SqliteScopeHost {
|
|
|
3615
3777
|
(tenant_id, subject, relation, object, expires_at)
|
|
3616
3778
|
VALUES (?, ?, ?, ?, ?)`)
|
|
3617
3779
|
.run(tenantId, subject, relation, object, expiresAt ?? null);
|
|
3780
|
+
// The EXPLICIT grant: `INSERT OR REPLACE` clears a tombstone, because a re-grant must
|
|
3781
|
+
// grant. Provisioning seats with `seatScopeTuple` instead, which never un-revokes
|
|
3782
|
+
// (#1659) — keep the two apart.
|
|
3618
3783
|
const writeScopeTuple = (node, subject, relation, object, expiresAt) => {
|
|
3619
3784
|
if (!node.scopeId)
|
|
3620
3785
|
throw new Error('scope tuple requires node.scopeId');
|
|
@@ -3635,6 +3800,82 @@ export class SqliteScopeHost {
|
|
|
3635
3800
|
writeTenantTuple(node.tenantId, subject, `granted:${permission}`, `tenant:${node.tenantId}`, expiresAt);
|
|
3636
3801
|
}
|
|
3637
3802
|
};
|
|
3803
|
+
/**
|
|
3804
|
+
* A node's write, on the scope actor when the node is a scope (#1678). `invoke` holds a
|
|
3805
|
+
* raw `BEGIN IMMEDIATE` on the scope's connection across awaits, so a bare statement
|
|
3806
|
+
* issued mid-invoke joined that transaction and rolled back with it — after the verb
|
|
3807
|
+
* had answered success and audited. `body` holds the check AND the write, synchronously,
|
|
3808
|
+
* so nothing moves between them. Tenant-level: a directory write, which no scope's
|
|
3809
|
+
* transaction can hold, so it runs as it always did.
|
|
3810
|
+
*/
|
|
3811
|
+
const onNode = (node, body) => node.scopeId
|
|
3812
|
+
? this.runtime(node.tenantId, node.scopeId).actor.turn(body)
|
|
3813
|
+
: Promise.resolve().then(body);
|
|
3814
|
+
/** #1666: move one module's schedule switch on one scope — see `HostAdmin.revokeFromSystem`. */
|
|
3815
|
+
const switchSystem = async (actor, raw, to) => {
|
|
3816
|
+
const input = systemSwitch.parse(raw);
|
|
3817
|
+
const { tenantId, scopeId } = input.node;
|
|
3818
|
+
const scope = this.directory
|
|
3819
|
+
.prepare('SELECT tenant_id FROM scopes WHERE scope_id = ?')
|
|
3820
|
+
.get(scopeId);
|
|
3821
|
+
if (!scope || scope.tenant_id !== tenantId) {
|
|
3822
|
+
throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
3823
|
+
}
|
|
3824
|
+
// AUDIT FIRST (#1666 review), exactly as the Cloudflare adapter: the intent row before
|
|
3825
|
+
// anything moves, the outcome row after, on every attempt. The directory and the
|
|
3826
|
+
// scope's file are separate databases, so a crash between them leaves an intent with
|
|
3827
|
+
// no recorded outcome — never a moved switch with no audit row.
|
|
3828
|
+
const operationId = ulid();
|
|
3829
|
+
const action = to === 'off' ? 'revokeFromSystem' : 'restoreToSystem';
|
|
3830
|
+
const target = { tenantId, scopeId };
|
|
3831
|
+
const base = { operationId, moduleId: input.moduleId, schedules: to };
|
|
3832
|
+
this.recordAdmin(actor, action, target, null, { ...base, phase: 'intent', reason: input.reason });
|
|
3833
|
+
let outcome;
|
|
3834
|
+
try {
|
|
3835
|
+
// A turn on the scope actor (#1666 review), exactly as the job store (#1577): `invoke`
|
|
3836
|
+
// holds a raw `BEGIN IMMEDIATE` on this same connection across awaits, so a bare
|
|
3837
|
+
// `db.transaction` issued mid-invoke became a SAVEPOINT inside it and rolled back
|
|
3838
|
+
// with it — after this verb had already audited `applied`. `turn` is re-entrant, so a
|
|
3839
|
+
// caller already inside one of this scope's tasks joins it rather than deadlocking.
|
|
3840
|
+
const rt = this.runtime(tenantId, scopeId);
|
|
3841
|
+
outcome = await rt.actor.turn(() => rt.db.transaction(() => switchSystemSchedules(switchSqlOf(rt.db), {
|
|
3842
|
+
moduleId: input.moduleId,
|
|
3843
|
+
scopeId,
|
|
3844
|
+
to,
|
|
3845
|
+
at: new Date().toISOString(),
|
|
3846
|
+
}))());
|
|
3847
|
+
}
|
|
3848
|
+
catch (err) {
|
|
3849
|
+
try {
|
|
3850
|
+
this.recordAdmin(actor, action, target, null, {
|
|
3851
|
+
...base,
|
|
3852
|
+
phase: 'failed',
|
|
3853
|
+
error: err instanceof Error ? err.message : String(err),
|
|
3854
|
+
});
|
|
3855
|
+
}
|
|
3856
|
+
catch {
|
|
3857
|
+
// Best effort: the original error is what the caller must see.
|
|
3858
|
+
}
|
|
3859
|
+
throw err;
|
|
3860
|
+
}
|
|
3861
|
+
this.recordAdmin(actor, action, target, null, {
|
|
3862
|
+
...base,
|
|
3863
|
+
phase: outcome.held ? 'applied' : 'refused',
|
|
3864
|
+
changed: outcome.changed,
|
|
3865
|
+
permissions: outcome.permissions,
|
|
3866
|
+
});
|
|
3867
|
+
if (!outcome.held) {
|
|
3868
|
+
throw substratError('not_found', `scope ${scopeId} holds no system grant for module '${input.moduleId}' — nothing to switch ${to} ` +
|
|
3869
|
+
`(check the module id: it is the module's manifest id, e.g. '@substrat-run/engine-absence')`);
|
|
3870
|
+
}
|
|
3871
|
+
return {
|
|
3872
|
+
operationId,
|
|
3873
|
+
moduleId: input.moduleId,
|
|
3874
|
+
schedules: to,
|
|
3875
|
+
changed: outcome.changed,
|
|
3876
|
+
permissions: outcome.permissions,
|
|
3877
|
+
};
|
|
3878
|
+
};
|
|
3638
3879
|
return {
|
|
3639
3880
|
// #603: fixed at construction — a host built without a box can never store a
|
|
3640
3881
|
// credential, and saying so is what lets a transport answer 503 instead of 500.
|
|
@@ -3693,7 +3934,8 @@ export class SqliteScopeHost {
|
|
|
3693
3934
|
assignRole: async (actor, assignment) => {
|
|
3694
3935
|
const subject = `principal:${assignment.principalId}`;
|
|
3695
3936
|
if (assignment.node.scopeId) {
|
|
3696
|
-
|
|
3937
|
+
const scopeNode = assignment.node.scopeId;
|
|
3938
|
+
await onNode(assignment.node, () => writeScopeTuple(assignment.node, subject, `role:${assignment.roleKey}`, `scope:${scopeNode}`));
|
|
3697
3939
|
}
|
|
3698
3940
|
else {
|
|
3699
3941
|
writeTenantTuple(assignment.node.tenantId, subject, `role:${assignment.roleKey}`, `tenant:${assignment.node.tenantId}`);
|
|
@@ -3709,11 +3951,14 @@ export class SqliteScopeHost {
|
|
|
3709
3951
|
const now = new Date().toISOString();
|
|
3710
3952
|
let changes;
|
|
3711
3953
|
if (assignment.node.scopeId) {
|
|
3712
|
-
|
|
3713
|
-
|
|
3954
|
+
// On the scope actor (#1678): a revoke that joined a stranger's transaction and
|
|
3955
|
+
// rolled back with it left the role LIVE after the caller was told it was gone.
|
|
3956
|
+
const scopeNode = assignment.node.scopeId;
|
|
3957
|
+
const rt = this.runtime(assignment.node.tenantId, scopeNode);
|
|
3958
|
+
changes = await rt.actor.turn(() => rt.db
|
|
3714
3959
|
.prepare(`UPDATE _substrat_tuples SET revoked_at = ?
|
|
3715
|
-
|
|
3716
|
-
.run(now, subject, relation, `scope:${
|
|
3960
|
+
WHERE subject = ? AND relation = ? AND object = ? AND revoked_at IS NULL`)
|
|
3961
|
+
.run(now, subject, relation, `scope:${scopeNode}`).changes);
|
|
3717
3962
|
}
|
|
3718
3963
|
else {
|
|
3719
3964
|
changes = this.directory
|
|
@@ -3730,44 +3975,56 @@ export class SqliteScopeHost {
|
|
|
3730
3975
|
// trust: the parse is where an `expiresAt` carrying a UTC offset is normalised
|
|
3731
3976
|
// to Z text (#963), and liveness here is a lexicographic `expires_at > ?`.
|
|
3732
3977
|
const grant = capabilityGrant.parse(raw);
|
|
3733
|
-
writeGrant(`principal:${grant.principalId}`, grant.permission, grant.node, grant.entity, grant.expiresAt);
|
|
3978
|
+
await onNode(grant.node, () => writeGrant(`principal:${grant.principalId}`, grant.permission, grant.node, grant.entity, grant.expiresAt));
|
|
3734
3979
|
this.recordAdmin(actor, 'grant', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId }, null, grant);
|
|
3735
3980
|
},
|
|
3736
3981
|
grantToConnection: async (actor, raw) => {
|
|
3737
3982
|
const grant = connectionGrant.parse(raw);
|
|
3738
|
-
const
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
// The grant may not reach outside what the connection already is. A
|
|
3743
|
-
// connection is keyed (tenant, vertical, provider); letting it hold a
|
|
3744
|
-
// permission in another tenant would make the key decorative.
|
|
3745
|
-
if (conn.tenant_id !== grant.node.tenantId) {
|
|
3746
|
-
throw new Error(`connection ${grant.connectionId} belongs to tenant ${conn.tenant_id} and cannot ` +
|
|
3747
|
-
`be granted anything in ${grant.node.tenantId}`);
|
|
3748
|
-
}
|
|
3749
|
-
if (grant.node.scopeId) {
|
|
3750
|
-
const scope = this.directory
|
|
3751
|
-
.prepare('SELECT tenant_id, vertical FROM scopes WHERE scope_id = ?')
|
|
3752
|
-
.get(grant.node.scopeId);
|
|
3753
|
-
if (!scope || scope.tenant_id !== grant.node.tenantId) {
|
|
3754
|
-
throw new Error(`unknown scope ${grant.node.scopeId} in tenant ${grant.node.tenantId}`);
|
|
3983
|
+
const check = () => {
|
|
3984
|
+
const conn = this.connectionRow(grant.connectionId);
|
|
3985
|
+
if (conn.revoked_at) {
|
|
3986
|
+
throw new Error(`connection ${grant.connectionId} is revoked — grant nothing to it`);
|
|
3755
3987
|
}
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
3988
|
+
// The grant may not reach outside what the connection already is. A
|
|
3989
|
+
// connection is keyed (tenant, vertical, provider); letting it hold a
|
|
3990
|
+
// permission in another tenant would make the key decorative.
|
|
3991
|
+
if (conn.tenant_id !== grant.node.tenantId) {
|
|
3992
|
+
throw new Error(`connection ${grant.connectionId} belongs to tenant ${conn.tenant_id} and cannot ` +
|
|
3993
|
+
`be granted anything in ${grant.node.tenantId}`);
|
|
3759
3994
|
}
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
|
|
3765
|
-
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
3995
|
+
if (grant.node.scopeId) {
|
|
3996
|
+
const scope = this.directory
|
|
3997
|
+
.prepare('SELECT tenant_id, vertical FROM scopes WHERE scope_id = ?')
|
|
3998
|
+
.get(grant.node.scopeId);
|
|
3999
|
+
if (!scope || scope.tenant_id !== grant.node.tenantId) {
|
|
4000
|
+
throw new Error(`unknown scope ${grant.node.scopeId} in tenant ${grant.node.tenantId}`);
|
|
4001
|
+
}
|
|
4002
|
+
if (scope.vertical !== conn.vertical) {
|
|
4003
|
+
throw new Error(`connection ${grant.connectionId} is for vertical '${conn.vertical}' and scope ` +
|
|
4004
|
+
`${grant.node.scopeId} runs '${scope.vertical ?? 'none'}'`);
|
|
4005
|
+
}
|
|
4006
|
+
}
|
|
4007
|
+
return conn;
|
|
4008
|
+
};
|
|
4009
|
+
// Once BEFORE the turn, so a refused grant never opens (and so creates) the scope's
|
|
4010
|
+
// file; then again inside it, with the tuple and the directory row, in ONE body on
|
|
4011
|
+
// the scope actor (#1678) — so the tuple cannot roll back with a stranger's
|
|
4012
|
+
// transaction and leave the directory row (#592) claiming a grant the scope lacks.
|
|
4013
|
+
check();
|
|
4014
|
+
const conn = await onNode(grant.node, () => {
|
|
4015
|
+
const row = check();
|
|
4016
|
+
writeGrant(subjectRef({ kind: 'connection', id: grant.connectionId }), grant.permission, grant.node, undefined, grant.expiresAt);
|
|
4017
|
+
// #592: the directory-side record, alongside the tuple. The tuple is checked
|
|
4018
|
+
// where it lives; this row is what provision/reconcile gather FROM, so the
|
|
4019
|
+
// grant reaches scopes provisioned after it without a human replaying it.
|
|
4020
|
+
this.directory
|
|
4021
|
+
.prepare(`INSERT OR REPLACE INTO _substrat_connection_grants
|
|
4022
|
+
(connection_id, tenant_id, vertical, permission, scope_id, expires_at,
|
|
4023
|
+
granted_by, granted_at, revoked_at)
|
|
4024
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)`)
|
|
4025
|
+
.run(grant.connectionId, grant.node.tenantId, row.vertical, grant.permission, grant.node.scopeId ?? null, grant.expiresAt ?? null, grant.grantedBy, new Date().toISOString());
|
|
4026
|
+
return row;
|
|
4027
|
+
});
|
|
3771
4028
|
this.recordAdmin(actor, 'grantToConnection', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId, vertical: conn.vertical }, null, {
|
|
3772
4029
|
connectionId: grant.connectionId,
|
|
3773
4030
|
provider: conn.provider,
|
|
@@ -3783,15 +4040,79 @@ export class SqliteScopeHost {
|
|
|
3783
4040
|
// principal is bound to the module, and a scope only ever runs the modules
|
|
3784
4041
|
// its own host registered.
|
|
3785
4042
|
const grant = systemGrant.parse(raw);
|
|
3786
|
-
writeGrant(subjectRef({ kind: 'system', id: grant.moduleId }), grant.permission, grant.node, undefined, grant.expiresAt);
|
|
4043
|
+
const write = () => writeGrant(subjectRef({ kind: 'system', id: grant.moduleId }), grant.permission, grant.node, undefined, grant.expiresAt);
|
|
4044
|
+
const scope = grant.node.scopeId;
|
|
4045
|
+
if (scope) {
|
|
4046
|
+
// #1666: refused while the module is switched off on this scope. The check and the
|
|
4047
|
+
// write share one synchronous body inside one turn on the scope actor, so no switch
|
|
4048
|
+
// can move between them — and (#1666 review) neither can land inside an unrelated
|
|
4049
|
+
// `invoke`'s open transaction and roll back with it after this verb returned. The
|
|
4050
|
+
// turn is re-entrant: from inside one of this scope's tasks it joins that task.
|
|
4051
|
+
// Restore is the lever; a grant is not.
|
|
4052
|
+
const rt = this.runtime(grant.node.tenantId, scope);
|
|
4053
|
+
await rt.actor.turn(() => {
|
|
4054
|
+
if (systemSwitchedOff(switchSqlOf(rt.db), grant.moduleId)) {
|
|
4055
|
+
throw substratError('conflict', systemSwitchedOffMessage(grant.moduleId, scope));
|
|
4056
|
+
}
|
|
4057
|
+
write();
|
|
4058
|
+
});
|
|
4059
|
+
}
|
|
4060
|
+
else {
|
|
4061
|
+
// Tenant-level: a directory write, which no scope's transaction can hold.
|
|
4062
|
+
write();
|
|
4063
|
+
}
|
|
3787
4064
|
this.recordAdmin(actor, 'grantToSystem', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId }, null, { moduleId: grant.moduleId, permission: grant.permission, node: grant.node });
|
|
3788
4065
|
},
|
|
4066
|
+
// #1666: the schedule kill switch and its lever back — `system-switch.ts` is the
|
|
4067
|
+
// whole rule, shared with the Cloudflare adapter; this is the directory check, the
|
|
4068
|
+
// transaction and the audit row around it.
|
|
4069
|
+
revokeFromSystem: async (actor, raw) => switchSystem(actor, raw, 'off'),
|
|
4070
|
+
restoreToSystem: async (actor, raw) => switchSystem(actor, raw, 'on'),
|
|
4071
|
+
// #1672 — the platform's two capability verbs, both a turn on the scope actor
|
|
4072
|
+
// (#1678: every scope-level admin write takes one, so none lands mid-invoke inside a
|
|
4073
|
+
// stranger's transaction) and both in the admin log without the secret or its hash.
|
|
4074
|
+
mintCapability: async (actor, tenantId, scopeId, input) => {
|
|
4075
|
+
const rt = await this.openActiveScope(tenantId, scopeId);
|
|
4076
|
+
const minted = await rt.actor.turn(() => mintBecomeCapability(spineSql(rt.db), input, actor, this.clock()));
|
|
4077
|
+
this.recordAdmin(actor, 'mintCapability', { tenantId, scopeId }, null, {
|
|
4078
|
+
capabilityId: minted.id,
|
|
4079
|
+
mode: 'become',
|
|
4080
|
+
principal: input.principal,
|
|
4081
|
+
expiresAt: input.expiresAt,
|
|
4082
|
+
maxUses: input.maxUses,
|
|
4083
|
+
label: input.label ?? null,
|
|
4084
|
+
});
|
|
4085
|
+
return minted;
|
|
4086
|
+
},
|
|
4087
|
+
revokeCapability: async (actor, tenantId, scopeId, capabilityId) => {
|
|
4088
|
+
const scope = this.directory
|
|
4089
|
+
.prepare('SELECT tenant_id FROM scopes WHERE scope_id = ?')
|
|
4090
|
+
.get(scopeId);
|
|
4091
|
+
if (!scope || scope.tenant_id !== tenantId) {
|
|
4092
|
+
throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
4093
|
+
}
|
|
4094
|
+
const rt = this.runtime(tenantId, scopeId);
|
|
4095
|
+
await this.applyPendingMigrations(rt);
|
|
4096
|
+
const before = await rt.actor.turn(() => rt.db.transaction(() => revokeCapabilityAsPlatform(spineSql(rt.db), capabilityId, actor, this.clock()))());
|
|
4097
|
+
if (!before) {
|
|
4098
|
+
throw substratError('not_found', `no capability ${capabilityId} in scope ${scopeId}`);
|
|
4099
|
+
}
|
|
4100
|
+
this.recordAdmin(actor, 'revokeCapability', { tenantId, scopeId }, before, {
|
|
4101
|
+
capabilityId,
|
|
4102
|
+
revoked: true,
|
|
4103
|
+
});
|
|
4104
|
+
},
|
|
3789
4105
|
grantToOrg: async (actor, orgId, permission, node, entity) => {
|
|
3790
4106
|
// The org must exist in the node's tenant. A grant to a phantom org is
|
|
3791
4107
|
// worse than an error: it looks applied, resolves for nobody, and shows up
|
|
3792
4108
|
// in the permission diff as though access were conferred.
|
|
4109
|
+
// Checked once before the turn, so a refusal opens no scope file; then again with
|
|
4110
|
+
// the write in one body, on the scope actor when the node is a scope (#1678).
|
|
3793
4111
|
requireOrg(node.tenantId, orgId);
|
|
3794
|
-
|
|
4112
|
+
await onNode(node, () => {
|
|
4113
|
+
requireOrg(node.tenantId, orgId);
|
|
4114
|
+
writeGrant(`org:${orgId}`, permission, node, entity);
|
|
4115
|
+
});
|
|
3795
4116
|
this.recordAdmin(actor, 'grantToOrg', { tenantId: node.tenantId, scopeId: node.scopeId }, null, { orgId, permission, node, entity });
|
|
3796
4117
|
},
|
|
3797
4118
|
// -- vertical + version registry (#31) ---------------------------------
|
|
@@ -4452,24 +4773,18 @@ export class SqliteScopeHost {
|
|
|
4452
4773
|
this.recordAdmin(actor, 'setScopeExpiresAt', { tenantId, scopeId }, { expiresAt: scope.expires_at }, { expiresAt });
|
|
4453
4774
|
},
|
|
4454
4775
|
readUndrainedEvents: async (actor, tenantId, scopeId, limit) => {
|
|
4455
|
-
|
|
4456
|
-
|
|
4457
|
-
|
|
4458
|
-
|
|
4459
|
-
this.
|
|
4460
|
-
|
|
4461
|
-
|
|
4462
|
-
|
|
4463
|
-
|
|
4464
|
-
|
|
4465
|
-
|
|
4466
|
-
|
|
4467
|
-
causedBy: r.caused_by ?? null,
|
|
4468
|
-
// Same lift, same reason. `drainedEvent` requires this field, and the `as never`
|
|
4469
|
-
// below is what let it be missed: without it a SQLite-backed lake loses the
|
|
4470
|
-
// call grouping permanently, while a Cloudflare-backed one keeps it.
|
|
4471
|
-
invocationId: r.invocation_id ?? null,
|
|
4472
|
-
}));
|
|
4776
|
+
// The COMMITTED outbox (#1624). On the shared connection this read saw an in-flight
|
|
4777
|
+
// operation's uncommitted event, the drain shipped it, and the rollback left the lake
|
|
4778
|
+
// — which is append-only — holding an event the scope never had. The mark that
|
|
4779
|
+
// follows needs no such care: it stamps only rows that exist and are undrained.
|
|
4780
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
4781
|
+
const page = db.prepare(`SELECT * FROM _substrat_outbox WHERE drained_at IS NULL ORDER BY id LIMIT ? OFFSET ?`);
|
|
4782
|
+
// The kernel's read, shared with the DO: the envelope and the columns lifted beside
|
|
4783
|
+
// it (#1242, #1237) are decoded once, and a row that will not decode is stepped over
|
|
4784
|
+
// rather than stalling the scope on it (#1636).
|
|
4785
|
+
const read = readUndrainedOutbox((offset, count) => page.all(count, offset), Math.min(Math.max(limit ?? 200, 1), 1000));
|
|
4786
|
+
this.recordAccess(actor, 'readUndrainedEvents', { tenantId, scopeId }, { limit }, read.events.length);
|
|
4787
|
+
return undrainedEventsOf(read);
|
|
4473
4788
|
},
|
|
4474
4789
|
markEventsDrained: async (actor, tenantId, scopeId, eventIds) => {
|
|
4475
4790
|
// BEFORE the empty-batch shortcut, not after. "Nothing to mark" and "you may
|
|
@@ -4490,12 +4805,16 @@ export class SqliteScopeHost {
|
|
|
4490
4805
|
// cannot absorb: the next read resumes from the middle of a batch already
|
|
4491
4806
|
// in the lake, and the object it writes overlaps the one already there —
|
|
4492
4807
|
// the duplication the first-id object key exists to prevent.
|
|
4493
|
-
|
|
4808
|
+
//
|
|
4809
|
+
// On the scope actor (#1678): issued mid-invoke, this transaction was a SAVEPOINT
|
|
4810
|
+
// inside the invoke's, and its rollback un-marked a batch already in the lake — so
|
|
4811
|
+
// the next pass shipped it again, the same duplicate by another road.
|
|
4812
|
+
const drained = await this.runtime(tenantId, scopeId).actor.turn(() => db.transaction(() => {
|
|
4494
4813
|
let n = 0;
|
|
4495
4814
|
for (const id of eventIds)
|
|
4496
4815
|
n += stmt.run(at, id).changes;
|
|
4497
4816
|
return n;
|
|
4498
|
-
})();
|
|
4817
|
+
})());
|
|
4499
4818
|
// K-24's rule, one tier down (#1334): declaring domain payloads shipped is an
|
|
4500
4819
|
// EGRESS, and the admin log is where "these events left the platform, at this
|
|
4501
4820
|
// time, on this actor's say-so" is recorded. `drainAccessLog` audits the same
|
|
@@ -4532,7 +4851,8 @@ export class SqliteScopeHost {
|
|
|
4532
4851
|
// an access row — the count itself as the result count, which is the fact an
|
|
4533
4852
|
// auditor asking "who counted this tenant's outbox, and how much was there" wants.
|
|
4534
4853
|
if (countOnly) {
|
|
4535
|
-
|
|
4854
|
+
// The committed count (#1624): a stranger's uncommitted drain mark is not a fact yet.
|
|
4855
|
+
const redrainable = this.scopeReadDbFor(tenantId, scopeId)
|
|
4536
4856
|
.prepare(`SELECT COUNT(*) AS c FROM _substrat_outbox
|
|
4537
4857
|
WHERE drained_at IS NOT NULL AND drained_at < ?`)
|
|
4538
4858
|
.get(drainedBefore).c;
|
|
@@ -4556,14 +4876,17 @@ export class SqliteScopeHost {
|
|
|
4556
4876
|
// is never pruned, so the unbounded form scales with the scope's whole history.
|
|
4557
4877
|
// SQLite is built here without UPDATE...LIMIT, so the batch is a subselect over the
|
|
4558
4878
|
// primary key — oldest first, so a partial run leaves a prefix rather than holes.
|
|
4559
|
-
|
|
4879
|
+
//
|
|
4880
|
+
// On the scope actor (#1678), so a stranger's rollback cannot undo a reopen this
|
|
4881
|
+
// verb has already counted and receipted.
|
|
4882
|
+
const redrained = await this.runtime(tenantId, scopeId).actor.turn(() => db
|
|
4560
4883
|
.prepare(`UPDATE _substrat_outbox SET drained_at = NULL
|
|
4561
|
-
|
|
4562
|
-
|
|
4563
|
-
|
|
4564
|
-
|
|
4565
|
-
|
|
4566
|
-
.run(drainedBefore, REDRAIN_BATCH).changes;
|
|
4884
|
+
WHERE id IN (
|
|
4885
|
+
SELECT id FROM _substrat_outbox
|
|
4886
|
+
WHERE drained_at IS NOT NULL AND drained_at < ?
|
|
4887
|
+
ORDER BY id LIMIT ?
|
|
4888
|
+
)`)
|
|
4889
|
+
.run(drainedBefore, REDRAIN_BATCH).changes);
|
|
4567
4890
|
// The outcome beside the intent above — only when something changed, so a re-run
|
|
4568
4891
|
// over an already reopened window leaves no row claiming it did something.
|
|
4569
4892
|
if (redrained > 0) {
|
|
@@ -4574,7 +4897,7 @@ export class SqliteScopeHost {
|
|
|
4574
4897
|
facetEvents: async (actor, tenantId, scopeId, input) => {
|
|
4575
4898
|
// `facetEvents` is the sanctioned read: an erased payload yields the same
|
|
4576
4899
|
// NULL a missing field does, and only the helper counts them apart.
|
|
4577
|
-
const db = this.
|
|
4900
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
4578
4901
|
const result = facetEvents({ sql: scopedSql(db) }, input);
|
|
4579
4902
|
this.recordAccess(actor, 'facetEvents', { tenantId, scopeId }, input, result.buckets.length);
|
|
4580
4903
|
return result;
|
|
@@ -4584,7 +4907,7 @@ export class SqliteScopeHost {
|
|
|
4584
4907
|
// only one that decodes the envelope's nullable facts (an erased payload, an
|
|
4585
4908
|
// unrecorded authorization chain, nobody impersonating) as facts rather than
|
|
4586
4909
|
// as missing data.
|
|
4587
|
-
const db = this.
|
|
4910
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
4588
4911
|
const page = readHistory({ sql: scopedSql(db) }, { entityType: input.entityType, entityId: input.entityId }, { limit: input.limit, cursor: input.cursor });
|
|
4589
4912
|
this.recordAccess(actor, 'entityHistory', { tenantId, scopeId }, { entityType: input.entityType, entityId: input.entityId }, page.entries.length);
|
|
4590
4913
|
return page;
|
|
@@ -4593,7 +4916,7 @@ export class SqliteScopeHost {
|
|
|
4593
4916
|
// #1237: the sanctioned walk. The helper owns the one distinction the whole
|
|
4594
4917
|
// view rests on — a null cause with an operation is the beginning of the
|
|
4595
4918
|
// chain, a null cause without one is the trail running out.
|
|
4596
|
-
const db = this.
|
|
4919
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
4597
4920
|
const result = walkEventCause({ sql: scopedSql(db) }, input.eventId, input.maxDepth);
|
|
4598
4921
|
this.recordAccess(actor, 'eventCause', { tenantId, scopeId }, { eventId: input.eventId }, result.chain.length);
|
|
4599
4922
|
return result;
|
|
@@ -4601,27 +4924,27 @@ export class SqliteScopeHost {
|
|
|
4601
4924
|
eventEffects: async (actor, tenantId, scopeId, input) => {
|
|
4602
4925
|
// #1237 forward: the helper owns the two delivery traps — `delivered_at`
|
|
4603
4926
|
// meaning two different things, and an empty list being ambiguous.
|
|
4604
|
-
const db = this.
|
|
4927
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
4605
4928
|
const tree = walkEventEffects({ sql: scopedSql(db) }, input.eventId, input.maxNodes);
|
|
4606
4929
|
this.recordAccess(actor, 'eventEffects', { tenantId, scopeId }, { eventId: input.eventId }, tree.count);
|
|
4607
4930
|
return tree;
|
|
4608
4931
|
},
|
|
4609
4932
|
invocationEvents: async (actor, tenantId, scopeId, input) => {
|
|
4610
4933
|
// #1237: one call's events, siblings included — what neither walk reaches.
|
|
4611
|
-
const db = this.
|
|
4934
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
4612
4935
|
const read = readInvocation({ sql: scopedSql(db) }, input.invocationId, input.limit);
|
|
4613
4936
|
this.recordAccess(actor, 'invocationEvents', { tenantId, scopeId }, { invocationId: input.invocationId }, read.events.length);
|
|
4614
4937
|
return read;
|
|
4615
4938
|
},
|
|
4616
4939
|
deadLetters: async (actor, tenantId, scopeId, input) => {
|
|
4617
4940
|
// #1525: every delivery that gave up, scope-wide — what the walks reach only by event.
|
|
4618
|
-
const db = this.
|
|
4941
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
4619
4942
|
const page = readDeadLetters({ sql: scopedSql(db) }, { limit: input.limit, cursor: input.cursor });
|
|
4620
4943
|
this.recordAccess(actor, 'deadLetters', { tenantId, scopeId }, null, page.entries.length);
|
|
4621
4944
|
return page;
|
|
4622
4945
|
},
|
|
4623
4946
|
scopeAppliedMigrations: async (actor, tenantId, scopeId) => {
|
|
4624
|
-
const db = this.
|
|
4947
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
4625
4948
|
const rows = db
|
|
4626
4949
|
.prepare(`SELECT module_id, version, applied_at FROM _substrat_migrations
|
|
4627
4950
|
ORDER BY applied_at DESC, module_id, version LIMIT 100`)
|
|
@@ -4645,6 +4968,16 @@ export class SqliteScopeHost {
|
|
|
4645
4968
|
this.recordAccess(actor, 'scopeMigrationBookmarks', { tenantId, scopeId }, null, 0);
|
|
4646
4969
|
return [];
|
|
4647
4970
|
},
|
|
4971
|
+
scopeDatabaseSize: async (actor, tenantId, scopeId) => {
|
|
4972
|
+
// #1524: the SQLite twin of a DO's `databaseSize`. It counts allocated pages,
|
|
4973
|
+
// free ones included, which is what the file costs on disk, not the live row bytes.
|
|
4974
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
4975
|
+
const pages = db.pragma('page_count', { simple: true });
|
|
4976
|
+
const pageSize = db.pragma('page_size', { simple: true });
|
|
4977
|
+
const bytes = pages * pageSize;
|
|
4978
|
+
this.recordAccess(actor, 'scopeDatabaseSize', { tenantId, scopeId }, null, 1);
|
|
4979
|
+
return bytes;
|
|
4980
|
+
},
|
|
4648
4981
|
rewindScope: async () => {
|
|
4649
4982
|
throw new Error('point-in-time rewind is not available on this host (PITR is a Durable-Object-plane mechanism) — use the backup restore path');
|
|
4650
4983
|
},
|
|
@@ -4877,7 +5210,7 @@ export class SqliteScopeHost {
|
|
|
4877
5210
|
listScopeTables: async (actor, tenantId, scopeId) => {
|
|
4878
5211
|
// K-3: the (tenantId, scopeId) pair is cross-checked before we open anything;
|
|
4879
5212
|
// a scope under a different tenant is unreachable, never another tenant's DB.
|
|
4880
|
-
const db = this.
|
|
5213
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
4881
5214
|
const names = db
|
|
4882
5215
|
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name`)
|
|
4883
5216
|
.all().map((r) => r.name);
|
|
@@ -4890,7 +5223,7 @@ export class SqliteScopeHost {
|
|
|
4890
5223
|
return tables;
|
|
4891
5224
|
},
|
|
4892
5225
|
readScopeTable: async (actor, tenantId, scopeId, input) => {
|
|
4893
|
-
const db = this.
|
|
5226
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
4894
5227
|
// Validate the table against the LIVE schema — an unknown name throws, it is
|
|
4895
5228
|
// never interpolated blind. That validated name is the only thing that reaches
|
|
4896
5229
|
// the query, so the quoted identifier below carries no user input.
|
|
@@ -4907,7 +5240,7 @@ export class SqliteScopeHost {
|
|
|
4907
5240
|
return { table: input.table, columns, rows, rowCount, limit, offset };
|
|
4908
5241
|
},
|
|
4909
5242
|
queryScope: async (actor, tenantId, scopeId, input) => {
|
|
4910
|
-
const db = this.
|
|
5243
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
4911
5244
|
// Layer 1, shared: the kernel's textual gate (single statement, read verbs
|
|
4912
5245
|
// only) — the checked statement is the one that runs.
|
|
4913
5246
|
const sql = assertReadOnlyQuery(input.sql);
|
|
@@ -4942,14 +5275,14 @@ export class SqliteScopeHost {
|
|
|
4942
5275
|
// read records to the K-24 access log. What differs is that these two answer a
|
|
4943
5276
|
// question about the permission model rather than about the data.
|
|
4944
5277
|
listDenials: async (actor, tenantId, scopeId, filter) => {
|
|
4945
|
-
const db = this.
|
|
5278
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
4946
5279
|
const q = denialListQuery(filter);
|
|
4947
5280
|
const rows = db.prepare(q.sql).all(...q.params).map(mapDenialRow);
|
|
4948
5281
|
this.recordAccess(actor, 'listDenials', { tenantId, scopeId }, filter ?? null, rows.length);
|
|
4949
5282
|
return rows;
|
|
4950
5283
|
},
|
|
4951
5284
|
summarizeDenials: async (actor, tenantId, scopeId, filter) => {
|
|
4952
|
-
const db = this.
|
|
5285
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
4953
5286
|
const b = denialSummaryQuery(filter);
|
|
4954
5287
|
// Grouped as the filter asked (#1456), and the answer says which way.
|
|
4955
5288
|
const grouped = mapDenialSummaryBuckets(b.groupBy, db.prepare(b.sql).all(...b.params));
|
|
@@ -4969,7 +5302,7 @@ export class SqliteScopeHost {
|
|
|
4969
5302
|
},
|
|
4970
5303
|
exportScope: async (actor, tenantId, scopeId) => {
|
|
4971
5304
|
// K-3: cross-check the pair before opening anything (same as the introspection reads).
|
|
4972
|
-
const db = this.
|
|
5305
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
4973
5306
|
// Every real table, keeping the `_substrat_*` spine but dropping SQLite's own
|
|
4974
5307
|
// `sqlite_*` internals — those are auto-managed and `CREATE TABLE sqlite_*` is
|
|
4975
5308
|
// rejected on reload. `sql` is the CREATE statement, replayed to rebuild the schema.
|
|
@@ -5110,13 +5443,11 @@ export class SqliteScopeHost {
|
|
|
5110
5443
|
throw new Error(`scope ${scopeId} still resolves hostname '${bound.hostname}' — ` +
|
|
5111
5444
|
`unbind it before reaping (reap wipes storage and cannot be undone)`);
|
|
5112
5445
|
}
|
|
5113
|
-
|
|
5114
|
-
|
|
5115
|
-
|
|
5116
|
-
|
|
5117
|
-
this.
|
|
5118
|
-
this.scopesById.delete(scopeId);
|
|
5119
|
-
}
|
|
5446
|
+
// The reader goes with the writer (#1624): an open handle on a removed file keeps
|
|
5447
|
+
// reading the unlinked bytes, which is the reaped data answering as if it were live.
|
|
5448
|
+
const rt = this.scopes.get(`${tenantId}/${scopeId}`);
|
|
5449
|
+
if (rt)
|
|
5450
|
+
this.closeRuntime(rt);
|
|
5120
5451
|
rmSync(join(this.dir, `${tenantId}__${scopeId}.sqlite`), { force: true });
|
|
5121
5452
|
// The recoverable copy the caller stored first (#493), named in the audit entry so
|
|
5122
5453
|
// the trail answers "was there a backup" without correlating two timestamps.
|
|
@@ -5155,15 +5486,31 @@ export class SqliteScopeHost {
|
|
|
5155
5486
|
// and transaction facts remain". A consumer's timeline still shows that something
|
|
5156
5487
|
// happened, to what, and when; it no longer shows who or what was said.
|
|
5157
5488
|
const db = this.scopeDbFor(tenantId, scopeId);
|
|
5158
|
-
|
|
5159
|
-
|
|
5160
|
-
|
|
5161
|
-
.run(subjectId);
|
|
5489
|
+
// One instant for the whole erasure, read before the first write: the intent
|
|
5490
|
+
// tombstones below and the key's own tombstone should not disagree about when a
|
|
5491
|
+
// person was erased.
|
|
5162
5492
|
const at = new Date().toISOString();
|
|
5493
|
+
// Both scope-side redactions in ONE turn on the scope actor (#1678): issued while an
|
|
5494
|
+
// invoke held its transaction open, they joined it, and its rollback put the
|
|
5495
|
+
// person's PII back after this verb had destroyed the key and receipted the erasure.
|
|
5496
|
+
const { redacted, intentsRedacted } = await this.runtime(tenantId, scopeId).actor.turn(() => ({
|
|
5497
|
+
redacted: db
|
|
5498
|
+
.prepare(`UPDATE _substrat_outbox SET payload = NULL
|
|
5499
|
+
WHERE subject_id = ? AND pii_class != 'none' AND payload IS NOT NULL`)
|
|
5500
|
+
.run(subjectId),
|
|
5501
|
+
// The spine's OTHER copy of an event (#1600). A CP-less host routes a connector
|
|
5502
|
+
// delivery it cannot run into `_substrat_platform_requests` with the whole event
|
|
5503
|
+
// embedded, and nothing ever deletes those rows — so the same line has to be held
|
|
5504
|
+
// here, or the redaction above only moves the name one table across. Same
|
|
5505
|
+
// idempotence as the outbox half: a re-run finds the tombstones and changes
|
|
5506
|
+
// nothing.
|
|
5507
|
+
intentsRedacted: this.redactSubjectIntents(db, subjectId, at),
|
|
5508
|
+
}));
|
|
5163
5509
|
const { existed } = await this.subjectKeysFor(tenantId, scopeId).destroy(subjectId, at);
|
|
5164
5510
|
const receipt = subjectShredReceipt.parse({
|
|
5165
5511
|
subjectId,
|
|
5166
5512
|
eventsRedacted: redacted.changes,
|
|
5513
|
+
intentsRedacted,
|
|
5167
5514
|
keyDestroyed: existed,
|
|
5168
5515
|
tombstoned: true,
|
|
5169
5516
|
});
|
|
@@ -5171,7 +5518,9 @@ export class SqliteScopeHost {
|
|
|
5171
5518
|
// mutation, the access log because it destroys evidence. An erasure is the one
|
|
5172
5519
|
// action where "who asked for this to disappear" is itself the record.
|
|
5173
5520
|
this.recordAdmin(actor, 'shredSubject', { tenantId, scopeId }, null, receipt);
|
|
5174
|
-
|
|
5521
|
+
// BOTH counts: the access log's number is "how much evidence this destroyed", and
|
|
5522
|
+
// an intent payload is a whole event's worth of it.
|
|
5523
|
+
this.recordAccess(actor, 'shredSubject', { tenantId, scopeId }, { subjectId }, redacted.changes + intentsRedacted);
|
|
5175
5524
|
return receipt;
|
|
5176
5525
|
},
|
|
5177
5526
|
// -- impersonation (K-42, #868) ----------------------------------------
|
|
@@ -6148,6 +6497,24 @@ export class SqliteScopeHost {
|
|
|
6148
6497
|
// where the whole replay is already inside one.
|
|
6149
6498
|
db.transaction(() => db.exec(SCHEDULE_STATE_REBUILD))();
|
|
6150
6499
|
}
|
|
6500
|
+
/**
|
|
6501
|
+
* #1573: run a create-copy-drop-rename against the directory as ONE transaction.
|
|
6502
|
+
* `exec` of four statements is four autocommits, and the state between DROP and
|
|
6503
|
+
* RENAME is the one to fear: the next open runs the bootstrap first, whose `CREATE
|
|
6504
|
+
* TABLE IF NOT EXISTS` puts an EMPTY table of the new shape back, detection then
|
|
6505
|
+
* reads that shape and concludes the migration is done — and every copied row stays
|
|
6506
|
+
* orphaned in the `_new` table, with nothing ever erroring. Atomic, neither
|
|
6507
|
+
* intermediate state is reachable, so there is no recovery path to write (or to
|
|
6508
|
+
* leave untested).
|
|
6509
|
+
*
|
|
6510
|
+
* `db.transaction` nests as a SAVEPOINT, as `ensureScheduleStateKind` (#1571) relies
|
|
6511
|
+
* on. Each script leads with `DROP TABLE IF EXISTS <table>_new` to absorb a scratch
|
|
6512
|
+
* table arriving from BELOW the transaction (a torn copy of the file, a backup that
|
|
6513
|
+
* captured one mid-rebuild) — belt, not the fix.
|
|
6514
|
+
*/
|
|
6515
|
+
rebuildAtomically(script) {
|
|
6516
|
+
this.directory.transaction(() => this.directory.exec(script))();
|
|
6517
|
+
}
|
|
6151
6518
|
/**
|
|
6152
6519
|
* Rebuild `_substrat_identities` when it still carries the pre-K-22 global key.
|
|
6153
6520
|
* A PRIMARY KEY cannot be ALTERed, so this is create-copy-drop-rename.
|
|
@@ -6168,7 +6535,9 @@ export class SqliteScopeHost {
|
|
|
6168
6535
|
.get('_substrat_identities');
|
|
6169
6536
|
if (!row || row.sql.includes('PRIMARY KEY (tenant_id, provider, external_id)'))
|
|
6170
6537
|
return;
|
|
6171
|
-
|
|
6538
|
+
// #1573: one transaction — see `rebuildAtomically`.
|
|
6539
|
+
this.rebuildAtomically(`
|
|
6540
|
+
DROP TABLE IF EXISTS _substrat_identities_new;
|
|
6172
6541
|
CREATE TABLE _substrat_identities_new (
|
|
6173
6542
|
provider TEXT NOT NULL,
|
|
6174
6543
|
external_id TEXT NOT NULL,
|
|
@@ -6199,7 +6568,9 @@ export class SqliteScopeHost {
|
|
|
6199
6568
|
.get('_substrat_admin_log');
|
|
6200
6569
|
if (!row || !/tenant_id TEXT NOT NULL/.test(row.sql))
|
|
6201
6570
|
return;
|
|
6202
|
-
|
|
6571
|
+
// #1573: one transaction — see `rebuildAtomically`.
|
|
6572
|
+
this.rebuildAtomically(`
|
|
6573
|
+
DROP TABLE IF EXISTS _substrat_admin_log_new;
|
|
6203
6574
|
CREATE TABLE _substrat_admin_log_new (
|
|
6204
6575
|
id TEXT PRIMARY KEY,
|
|
6205
6576
|
actor TEXT NOT NULL,
|
|
@@ -6218,6 +6589,30 @@ export class SqliteScopeHost {
|
|
|
6218
6589
|
ALTER TABLE _substrat_admin_log_new RENAME TO _substrat_admin_log;
|
|
6219
6590
|
`);
|
|
6220
6591
|
}
|
|
6592
|
+
/**
|
|
6593
|
+
* #1572: `_substrat_sweep_runs_intent` with `kind` in it, on a directory whose index
|
|
6594
|
+
* predates that. `CREATE UNIQUE INDEX IF NOT EXISTS` matches on the name alone, so the
|
|
6595
|
+
* bootstrap cannot do this: on an existing directory it sees the old index and skips,
|
|
6596
|
+
* and the schedule/freshness collision the new key refuses stays live for good.
|
|
6597
|
+
*
|
|
6598
|
+
* Detected from `sqlite_master.sql`, like the table rebuilds above, and replaced as
|
|
6599
|
+
* DROP-then-CREATE in one transaction (`rebuildAtomically`). Less rides on that than on
|
|
6600
|
+
* a table rebuild — the index holds no rows, and one dropped but not re-created comes
|
|
6601
|
+
* back on the next open, because the bootstrap creates it by name — but there is then
|
|
6602
|
+
* no committed directory with no dedupe key at all. The CREATE cannot meet a row it
|
|
6603
|
+
* refuses: every set of rows unique on (request_id, unit) is unique on the wider key.
|
|
6604
|
+
*/
|
|
6605
|
+
ensureSweepRunsIntentKind() {
|
|
6606
|
+
const row = this.directory
|
|
6607
|
+
.prepare("SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?")
|
|
6608
|
+
.get('_substrat_sweep_runs_intent');
|
|
6609
|
+
if (row && sweepRunsIntentHasKind(row.sql))
|
|
6610
|
+
return;
|
|
6611
|
+
this.rebuildAtomically(`
|
|
6612
|
+
DROP INDEX IF EXISTS _substrat_sweep_runs_intent;
|
|
6613
|
+
${SWEEP_RUNS_INTENT_INDEX}
|
|
6614
|
+
`);
|
|
6615
|
+
}
|
|
6221
6616
|
ensureDirectoryColumns() {
|
|
6222
6617
|
this.ensureIdentityKey();
|
|
6223
6618
|
this.ensureAdminLogTenantNullable();
|
|
@@ -6286,7 +6681,7 @@ export class SqliteScopeHost {
|
|
|
6286
6681
|
this.ensureColumn(this.directory, '_substrat_sweep_runs', 'request_id', 'request_id TEXT');
|
|
6287
6682
|
this.ensureColumn(this.directory, '_substrat_sweep_runs', 'event_type', 'event_type TEXT');
|
|
6288
6683
|
this.ensureColumn(this.directory, '_substrat_sweep_runs', 'observed_at', 'observed_at TEXT');
|
|
6289
|
-
this.
|
|
6684
|
+
this.ensureSweepRunsIntentKind();
|
|
6290
6685
|
const existing = new Set(this.directory.prepare('PRAGMA table_info(scopes)').all().map((c) => c.name));
|
|
6291
6686
|
for (const [column, ddl] of [
|
|
6292
6687
|
['parent_scope_id', 'parent_scope_id TEXT'],
|
|
@@ -6363,7 +6758,23 @@ export class SqliteScopeHost {
|
|
|
6363
6758
|
* onto every event it emits. Absent for consumer dispatch: a consumer runs on
|
|
6364
6759
|
* behalf of no operation, and the emitted row's NULL says so.
|
|
6365
6760
|
*/
|
|
6366
|
-
operation
|
|
6761
|
+
operation,
|
|
6762
|
+
/**
|
|
6763
|
+
* #1672: the secrets `ctx.capabilities.mint` hands out during this invocation. The
|
|
6764
|
+
* stub owns the array (it withholds them from the idempotency recording); this
|
|
6765
|
+
* context appends to it and holds `ctx.sql`, `ctx.emit` and `ctx.requestPlatform` to
|
|
6766
|
+
* it — the tripwire that catches a module persisting one by accident (not a boundary
|
|
6767
|
+
* against one that means to; see `assertNoSecret`).
|
|
6768
|
+
*/
|
|
6769
|
+
minted = [],
|
|
6770
|
+
/**
|
|
6771
|
+
* #1672: the instant to stamp, when the caller already read one. The exchange passes the
|
|
6772
|
+
* instant its row write used, so the `capability.exercised` event's `occurredAt` and the
|
|
6773
|
+
* row's `last_used_at` are the same value — a second clock read could disagree.
|
|
6774
|
+
*/
|
|
6775
|
+
at = this.clock()) {
|
|
6776
|
+
// For a connection, system or capability subject this carries THAT id so the type
|
|
6777
|
+
// holds — it is not a person, and the event actor below says what it is instead.
|
|
6367
6778
|
const principal = subject.id;
|
|
6368
6779
|
const checker = this.checker;
|
|
6369
6780
|
const relations = this.relations;
|
|
@@ -6388,7 +6799,6 @@ export class SqliteScopeHost {
|
|
|
6388
6799
|
* `ctx.now()` deterministic only in the sense that each reading is separately
|
|
6389
6800
|
* unpredictable.
|
|
6390
6801
|
*/
|
|
6391
|
-
const at = this.clock();
|
|
6392
6802
|
/**
|
|
6393
6803
|
* The scope host's half of `ctx.atomic` (#770) — everything else is the
|
|
6394
6804
|
* kernel's (`createAtomic`). SQLite gives us savepoints, which nest inside
|
|
@@ -6424,7 +6834,10 @@ export class SqliteScopeHost {
|
|
|
6424
6834
|
// Lifted out of the object literal so `grant` can reuse it: a delegation
|
|
6425
6835
|
// check has to be the SAME check the operation itself passes, or the two
|
|
6426
6836
|
// could disagree about what the caller holds.
|
|
6427
|
-
const runCheck = async (
|
|
6837
|
+
const runCheck = async (unparsed, entity) => {
|
|
6838
|
+
// #1642: parsed before the override actor's early return, which never reaches
|
|
6839
|
+
// the checker — a cast key would otherwise become that path's proof relation.
|
|
6840
|
+
const permission = assertPermissionKey(unparsed);
|
|
6428
6841
|
if (overrideActor) {
|
|
6429
6842
|
return {
|
|
6430
6843
|
allowed: true,
|
|
@@ -6462,15 +6875,17 @@ export class SqliteScopeHost {
|
|
|
6462
6875
|
scopeId: rt.scopeId,
|
|
6463
6876
|
});
|
|
6464
6877
|
};
|
|
6465
|
-
|
|
6878
|
+
const ctxRef = {
|
|
6466
6879
|
tenantId: rt.tenantId,
|
|
6467
6880
|
scopeId: rt.scopeId,
|
|
6468
6881
|
principal,
|
|
6469
|
-
sql: scopedSql(rt.db),
|
|
6882
|
+
sql: guardSecrets(scopedSql(rt.db), minted),
|
|
6470
6883
|
now: () => at,
|
|
6471
6884
|
emit: (event) => {
|
|
6472
6885
|
assertImpersonationWrites(impersonation, 'ctx.emit');
|
|
6473
6886
|
const input = domainEventInput.parse(event);
|
|
6887
|
+
// #1672: the COMPLETE parsed event — entity id, type and subject as well as payload.
|
|
6888
|
+
assertNoSecret('ctx.emit', input, minted);
|
|
6474
6889
|
const full = domainEvent.parse({
|
|
6475
6890
|
...input,
|
|
6476
6891
|
// #956: from the operation's instant, not the wall clock. `ORDER BY id`
|
|
@@ -6481,12 +6896,7 @@ export class SqliteScopeHost {
|
|
|
6481
6896
|
occurredAt: at,
|
|
6482
6897
|
tenantId: rt.tenantId,
|
|
6483
6898
|
scopeId: rt.scopeId,
|
|
6484
|
-
actor: overrideActor ??
|
|
6485
|
-
(subject.kind === 'system'
|
|
6486
|
-
? { system: subject.id }
|
|
6487
|
-
: subject.kind === 'connection'
|
|
6488
|
-
? { connection: subject.id }
|
|
6489
|
-
: principal),
|
|
6899
|
+
actor: overrideActor ?? actorOf(subject),
|
|
6490
6900
|
...(passed.length ? { authorization: passed.map((p) => ({ ...p })) } : {}),
|
|
6491
6901
|
...(stamp ? { impersonation: stamp } : {}),
|
|
6492
6902
|
// #1231: kernel-stamped like the two above — `domainEventInput.parse`
|
|
@@ -6516,6 +6926,8 @@ export class SqliteScopeHost {
|
|
|
6516
6926
|
requestPlatform: (request) => {
|
|
6517
6927
|
assertImpersonationWrites(impersonation, 'ctx.requestPlatform');
|
|
6518
6928
|
const input = platformRequestInput.parse(request);
|
|
6929
|
+
// #1672: the COMPLETE parsed request — its `kind` is persisted as surely as its payload.
|
|
6930
|
+
assertNoSecret('ctx.requestPlatform', input, minted);
|
|
6519
6931
|
// #1474: a platform-authored kind (`sweep-runs`) is the platform's to enqueue, never
|
|
6520
6932
|
// module code's — its drain handler writes schedule verdicts the dashboard trusts.
|
|
6521
6933
|
assertModuleEnqueueableKind(input.kind);
|
|
@@ -6528,12 +6940,7 @@ export class SqliteScopeHost {
|
|
|
6528
6940
|
throw new Error(`too many pending platform requests (${pending}); retry once some have drained`);
|
|
6529
6941
|
}
|
|
6530
6942
|
const id = platformRequestId.parse(ulid());
|
|
6531
|
-
const requestedBy = overrideActor ??
|
|
6532
|
-
(subject.kind === 'system'
|
|
6533
|
-
? { system: subject.id }
|
|
6534
|
-
: subject.kind === 'connection'
|
|
6535
|
-
? { connection: subject.id }
|
|
6536
|
-
: principal);
|
|
6943
|
+
const requestedBy = overrideActor ?? actorOf(subject);
|
|
6537
6944
|
rt.db
|
|
6538
6945
|
.prepare(`INSERT INTO _substrat_platform_requests
|
|
6539
6946
|
(id, kind, payload, requested_by, impersonation, status, attempts, requested_at)
|
|
@@ -6547,7 +6954,7 @@ export class SqliteScopeHost {
|
|
|
6547
6954
|
// predicate is needed or possible: the runtime IS the scope.
|
|
6548
6955
|
platformRequests: (filter) => {
|
|
6549
6956
|
const q = platformRequestHistoryQuery(filter);
|
|
6550
|
-
return rt.db.prepare(q.sql).all(...q.params).map(
|
|
6957
|
+
return rt.db.prepare(q.sql).all(...q.params).map(platformRequestOf);
|
|
6551
6958
|
},
|
|
6552
6959
|
// #901. This scope's own spine, so no tenancy predicate is needed or
|
|
6553
6960
|
// possible — the runtime IS the scope. The version is the last event's
|
|
@@ -6647,6 +7054,21 @@ export class SqliteScopeHost {
|
|
|
6647
7054
|
.run(`principal:${principal}`, `granted:${permission}`, `${entity.entityType}:${entity.entityId}`);
|
|
6648
7055
|
},
|
|
6649
7056
|
atomic: createAtomic(runSub, { passed, signals }),
|
|
7057
|
+
// #1672: mint / revoke / list, written once in the kernel. The raw spine seam (this
|
|
7058
|
+
// is the kernel's own write to `_substrat_capabilities`), the operation's OWN check —
|
|
7059
|
+
// the predicate `grant` shares — and `ctx.emit` for the minted/revoked events. A
|
|
7060
|
+
// consumer's override actor is passed as the system actor it is, so a consumer cannot
|
|
7061
|
+
// mint: its checks allow unconditionally, which would make "the minter holds it" vacuous.
|
|
7062
|
+
capabilities: createCapabilityVerbs({
|
|
7063
|
+
sql: spineSql(rt.db),
|
|
7064
|
+
subject: overrideActor ? { kind: 'system', id: overrideActor.system } : subject,
|
|
7065
|
+
now: at,
|
|
7066
|
+
check: runCheck,
|
|
7067
|
+
emit: (event) => ctxRef.emit(event),
|
|
7068
|
+
isOperation: (name) => this.operations.has(name),
|
|
7069
|
+
assertWrites: (verb) => assertImpersonationWrites(impersonation, verb),
|
|
7070
|
+
minted,
|
|
7071
|
+
}),
|
|
6650
7072
|
link: (child, parent) => {
|
|
6651
7073
|
assertImpersonationWrites(impersonation, 'ctx.link');
|
|
6652
7074
|
const allowed = relations.get(child.entityType);
|
|
@@ -6708,6 +7130,7 @@ export class SqliteScopeHost {
|
|
|
6708
7130
|
return sealTo({ keyId: key.keyId, publicKey: key.publicKey }, plaintext);
|
|
6709
7131
|
},
|
|
6710
7132
|
};
|
|
7133
|
+
return ctxRef;
|
|
6711
7134
|
}
|
|
6712
7135
|
async applyPendingMigrations(rt) {
|
|
6713
7136
|
const pending = [];
|
|
@@ -6816,10 +7239,98 @@ export class SqliteScopeHost {
|
|
|
6816
7239
|
throw new Error(`scope ${scopeId} is reaped — its storage is gone and cannot be read`);
|
|
6817
7240
|
}
|
|
6818
7241
|
}
|
|
7242
|
+
/**
|
|
7243
|
+
* The intent-journal half of `shredSubject` (#1600) — the DO's `redactSubject` twin.
|
|
7244
|
+
*
|
|
7245
|
+
* Row-by-row rather than one `UPDATE … WHERE`: the decision is structural (does this
|
|
7246
|
+
* payload embed an event the erasure redacts?) and SQL cannot make it, because an
|
|
7247
|
+
* intent payload is opaque JSON with no `pii_class` column to test. The SQL narrows to
|
|
7248
|
+
* the rows that could possibly match; the kernel predicate decides; the kernel's
|
|
7249
|
+
* statement writes. All three are shared with the Durable-Object adapter so the two
|
|
7250
|
+
* cannot drift about what a redacted intent is.
|
|
7251
|
+
*
|
|
7252
|
+
* Returns how many rows it redacted — zero on a re-run, the tombstones having already
|
|
7253
|
+
* stopped matching.
|
|
7254
|
+
*/
|
|
7255
|
+
redactSubjectIntents(db, subjectId, at) {
|
|
7256
|
+
const q = platformRequestRedactionQuery(subjectId);
|
|
7257
|
+
const candidates = db.prepare(q.sql).all(...q.params);
|
|
7258
|
+
const write = db.prepare(PLATFORM_REQUEST_REDACTION_SQL);
|
|
7259
|
+
let redacted = 0;
|
|
7260
|
+
for (const row of candidates) {
|
|
7261
|
+
if (!intentPayloadCarriesSubject(row.payload, subjectId))
|
|
7262
|
+
continue;
|
|
7263
|
+
write.run(...platformRequestRedactionParams(row.id, subjectId, at));
|
|
7264
|
+
redacted += 1;
|
|
7265
|
+
}
|
|
7266
|
+
return redacted;
|
|
7267
|
+
}
|
|
6819
7268
|
scopeDbFor(tenantId, scopeId) {
|
|
6820
7269
|
this.assertScopeReachable(tenantId, scopeId);
|
|
6821
7270
|
return this.runtime(tenantId, scopeId).db;
|
|
6822
7271
|
}
|
|
7272
|
+
/**
|
|
7273
|
+
* The connection a `HostAdmin` READ runs on (#1624) — `scopeDbFor`'s twin, with the
|
|
7274
|
+
* same K-3 and reap gate in front of it.
|
|
7275
|
+
*
|
|
7276
|
+
* From OUTSIDE the scope's actor it is the scope's read-only connection. `invoke` holds
|
|
7277
|
+
* a `BEGIN IMMEDIATE` open on the shared connection across its awaits, so a read there
|
|
7278
|
+
* saw a stranger's uncommitted rows: a lake drain shipped an event that then rolled
|
|
7279
|
+
* back — permanently, since the lake is append-only — and an export captured one into a
|
|
7280
|
+
* backup. The file is WAL, so the reader sees exactly what was committed.
|
|
7281
|
+
*
|
|
7282
|
+
* From INSIDE one of the scope's actor tasks (an executor handed `HostAdmin`, reading
|
|
7283
|
+
* its own scope) it is the shared connection, as before. The only transaction open
|
|
7284
|
+
* there is the caller's own unit — the actor serializes everything else — so reading
|
|
7285
|
+
* it is reading your own writes, which is the answer the caller is owed and the one it
|
|
7286
|
+
* had. `ScopeActor.turn` makes the same call for the writers (#1678).
|
|
7287
|
+
*/
|
|
7288
|
+
scopeReadDbFor(tenantId, scopeId) {
|
|
7289
|
+
this.assertScopeReachable(tenantId, scopeId);
|
|
7290
|
+
return this.readDb(this.runtime(tenantId, scopeId));
|
|
7291
|
+
}
|
|
7292
|
+
/**
|
|
7293
|
+
* `scopeReadDbFor` without the directory gate, for the host-side reads that already
|
|
7294
|
+
* resolved their runtime — the platform-intent drain's `listPlatformRequests` among them,
|
|
7295
|
+
* where a stranger's uncommitted intent was a platform effect about to be carried out for
|
|
7296
|
+
* an operation that then rolled back.
|
|
7297
|
+
*/
|
|
7298
|
+
readDb(rt) {
|
|
7299
|
+
if (rt.actor.holds())
|
|
7300
|
+
return rt.db;
|
|
7301
|
+
const key = `${rt.tenantId}/${rt.scopeId}`;
|
|
7302
|
+
if (rt.reader) {
|
|
7303
|
+
// Most recently used goes to the back of the insertion order, which is the cap's LRU.
|
|
7304
|
+
this.readers.delete(key);
|
|
7305
|
+
this.readers.set(key, rt);
|
|
7306
|
+
return rt.reader;
|
|
7307
|
+
}
|
|
7308
|
+
// `runtime()` opened the writer first, in WAL, so the file and its `-shm` exist.
|
|
7309
|
+
rt.reader = new Database(rt.file, { readonly: true, fileMustExist: true });
|
|
7310
|
+
this.readers.set(key, rt);
|
|
7311
|
+
// The cap: a lake drain sweeps every scope, and without it every open scope would end
|
|
7312
|
+
// up holding two connections. The oldest reader is closed; the next read reopens it.
|
|
7313
|
+
while (this.readers.size > READER_CAP) {
|
|
7314
|
+
const [oldest, victim] = this.readers.entries().next().value;
|
|
7315
|
+
this.readers.delete(oldest);
|
|
7316
|
+
victim.reader?.close();
|
|
7317
|
+
victim.reader = null;
|
|
7318
|
+
}
|
|
7319
|
+
return rt.reader;
|
|
7320
|
+
}
|
|
7321
|
+
/**
|
|
7322
|
+
* Close a scope's connections and forget its runtime — the reader with the writer, so
|
|
7323
|
+
* neither outlives the file a reap or a snapshot delete is about to remove.
|
|
7324
|
+
*/
|
|
7325
|
+
closeRuntime(rt) {
|
|
7326
|
+
const key = `${rt.tenantId}/${rt.scopeId}`;
|
|
7327
|
+
rt.reader?.close();
|
|
7328
|
+
rt.reader = null;
|
|
7329
|
+
this.readers.delete(key);
|
|
7330
|
+
rt.db.close();
|
|
7331
|
+
this.scopes.delete(key);
|
|
7332
|
+
this.scopesById.delete(rt.scopeId);
|
|
7333
|
+
}
|
|
6823
7334
|
/**
|
|
6824
7335
|
* The additive spine-column migrations, shared by `runtime()` and the dump replay.
|
|
6825
7336
|
* KERNEL_DDL is all IF NOT EXISTS, so a scope DB created before a column keeps the
|
|
@@ -6897,7 +7408,10 @@ export class SqliteScopeHost {
|
|
|
6897
7408
|
const existing = this.scopes.get(key);
|
|
6898
7409
|
if (existing)
|
|
6899
7410
|
return existing;
|
|
6900
|
-
const
|
|
7411
|
+
const file = join(this.dir, `${tenantId}__${scopeId}.sqlite`);
|
|
7412
|
+
const db = new Database(file);
|
|
7413
|
+
// WAL is also what the read connection rests on (#1624): a reader sees the last
|
|
7414
|
+
// committed snapshot and never blocks, or is blocked by, the writer.
|
|
6901
7415
|
db.pragma('journal_mode = WAL');
|
|
6902
7416
|
db.exec(KERNEL_DDL);
|
|
6903
7417
|
this.ensureSpineColumns(db);
|
|
@@ -6914,7 +7428,9 @@ export class SqliteScopeHost {
|
|
|
6914
7428
|
const created = {
|
|
6915
7429
|
tenantId,
|
|
6916
7430
|
scopeId,
|
|
7431
|
+
file,
|
|
6917
7432
|
db,
|
|
7433
|
+
reader: null,
|
|
6918
7434
|
actor: new ScopeActor(),
|
|
6919
7435
|
appliedMigrations,
|
|
6920
7436
|
mintEventId,
|
|
@@ -6945,6 +7461,20 @@ function cellToJson(v) {
|
|
|
6945
7461
|
* `_substrat_*`" a mechanism rather than a lint rule (#954) — the kernel's own
|
|
6946
7462
|
* spine writes use `rt.db` directly and never pass through here.
|
|
6947
7463
|
*/
|
|
7464
|
+
/**
|
|
7465
|
+
* The kernel's OWN spine access (#1672) — the same seam as `scopedSql` without `guardSpine`,
|
|
7466
|
+
* because these are the kernel's writes to `_substrat_capabilities`, which module code may
|
|
7467
|
+
* never make. Never handed to module code.
|
|
7468
|
+
*/
|
|
7469
|
+
function spineSql(db) {
|
|
7470
|
+
return {
|
|
7471
|
+
query: (sql, params = []) => db.prepare(sql).all(...params),
|
|
7472
|
+
exec: (sql, params = []) => {
|
|
7473
|
+
const info = db.prepare(sql).run(...params);
|
|
7474
|
+
return { changes: info.changes };
|
|
7475
|
+
},
|
|
7476
|
+
};
|
|
7477
|
+
}
|
|
6948
7478
|
function scopedSql(db) {
|
|
6949
7479
|
return guardSpine({
|
|
6950
7480
|
query: (sql, params = []) => db.prepare(sql).all(...params),
|