@substrat-run/adapter-sqlite 0.118.0 → 0.119.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +60 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +525 -40
- package/dist/index.js.map +1 -1
- package/dist/vertical-broker.d.ts +62 -0
- package/dist/vertical-broker.d.ts.map +1 -0
- package/dist/vertical-broker.js +87 -0
- package/dist/vertical-broker.js.map +1 -0
- package/package.json +11 -4
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import Database from 'better-sqlite3';
|
|
4
|
-
import { accessLogEntry, adminLogEntry, opsFailureEntry, 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, redactSubjectJobRuns, JOB_RUN_PATCH_SQL, JOB_STEP_RECORD_SQL, DELIVERY_ERROR_REDACTION_SQL, REDACTED_DELIVERY_NOTE, 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';
|
|
4
|
+
import { accessLogEntry, adminLogEntry, opsFailureEntry, opsFailureFingerprint, issueEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunEntry, modelUsageEntry, ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, createTenantInput, domainEvent, domainEventInput, exportReadInput, importBatch, entitlementGrant, entitlementGrantInput, eventId, meterReading, platformRequestInput, platformRequestId, MAX_PENDING_PLATFORM_REQUESTS, identityLink, identityPool, instant, connection, capabilityGrant, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, peerSwitch, verticalCaller, verticalSlug as verticalSlugOf, 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, callsOfManifestJson, outboundOfManifestJson, listLimitOf, substratError, assertReplayableDump, delegatedReadRecord, redrainEventsInput, REDRAIN_BATCH, } from '@substrat-run/contracts';
|
|
5
|
+
import { actorOf, asPrincipal, assertAllowed, assertNoSecret, assertPermissionKey, CAPABILITY_DDL, CAPABILITY_EXCHANGE_OPERATION, capabilityAttachmentWriteRefused, 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, redactSubjectJobRuns, JOB_RUN_PATCH_SQL, JOB_STEP_RECORD_SQL, DELIVERY_ERROR_REDACTION_SQL, REDACTED_DELIVERY_NOTE, seatScopeTuple, switchSystemSchedules, admitPeer, collectPeers, peerSeats, resolveVerticalInstanceFrom, switchPeer, peerGrantsStatus, systemGrantsStatus, 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, VERTICAL_EVENTS_DDL, EXPORT_HOPS_SQL, IMPORT_CURSORS_SQL, IMPORT_CURSOR_OF_SQL, IMPORT_CURSOR_ADVANCE_SQL, IMPORT_RECORD_SQL, CrossVerticalRegistry, exportReadPlan, exportReadQuery, planExportBatch, withheldNote, 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, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, } from '@substrat-run/kernel';
|
|
6
6
|
import { ScopeActor } from './actor.js';
|
|
7
7
|
import { createTupleChecker } from './checker.js';
|
|
8
8
|
/** The kernel's schedule-switch SQL (#1666), over one scope's database handle. */
|
|
@@ -167,6 +167,10 @@ const KERNEL_DDL = `
|
|
|
167
167
|
-- builds and the shape production builds cannot part company; the column
|
|
168
168
|
-- comments, and the reason coalescing is NOT a unique index, are in there.
|
|
169
169
|
${JOB_RUN_DDL}
|
|
170
|
+
-- #1705: cross-vertical delivery. The consumer's journal of what it received (envelope
|
|
171
|
+
-- only) and its watermark per producer, plus the producer-side (type, id) outbox index the
|
|
172
|
+
-- export read seeks on. Shared with the DO adapter from @substrat-run/kernel.
|
|
173
|
+
${VERTICAL_EVENTS_DDL}
|
|
170
174
|
CREATE TABLE IF NOT EXISTS _substrat_migrations (
|
|
171
175
|
module_id TEXT NOT NULL,
|
|
172
176
|
version TEXT NOT NULL,
|
|
@@ -433,6 +437,8 @@ export class SqliteScopeHost {
|
|
|
433
437
|
tenantStoreDbs = new Map();
|
|
434
438
|
operations = new Map();
|
|
435
439
|
modules = new Map();
|
|
440
|
+
/** #1705: what this deployment exports to other verticals and imports from them. */
|
|
441
|
+
crossVertical = new CrossVerticalRegistry();
|
|
436
442
|
/** operation name → guards declared before it, in registration order (K-17). */
|
|
437
443
|
guards = new Map();
|
|
438
444
|
/** predicate name → the module-contributed implementation. Names are global. */
|
|
@@ -488,6 +494,7 @@ export class SqliteScopeHost {
|
|
|
488
494
|
systemPrincipal = principalId.parse(ulid());
|
|
489
495
|
secretBox;
|
|
490
496
|
fetchImpl;
|
|
497
|
+
connectorCalls;
|
|
491
498
|
clock;
|
|
492
499
|
versionId;
|
|
493
500
|
// The mint for event ids (#956) is NOT here: it lives on `ScopeRuntime`, one per
|
|
@@ -498,6 +505,7 @@ export class SqliteScopeHost {
|
|
|
498
505
|
constructor(options) {
|
|
499
506
|
this.secretBox = options.secretBox ?? unconfiguredSecretBox;
|
|
500
507
|
this.fetchImpl = options.fetch ?? globalFetch;
|
|
508
|
+
this.connectorCalls = options.connectorCalls ?? noopConnectorCallRecorder;
|
|
501
509
|
this.clock = options.clock ?? (() => instant.parse(new Date().toISOString()));
|
|
502
510
|
this.versionId = options.versionId ?? null;
|
|
503
511
|
this.dir = options.dir;
|
|
@@ -1183,6 +1191,9 @@ export class SqliteScopeHost {
|
|
|
1183
1191
|
// request pending across a rotation still opens.
|
|
1184
1192
|
unseal: async (sealed) => openSealed(await this.openSealingKeys(open.id), sealed),
|
|
1185
1193
|
fetch: async (input, init) => {
|
|
1194
|
+
// #1691: timed here, where the call is made, so the data point the host
|
|
1195
|
+
// records beside the health line carries a duration.
|
|
1196
|
+
const started = Date.now();
|
|
1186
1197
|
try {
|
|
1187
1198
|
const res = await fetchImpl(input, {
|
|
1188
1199
|
...init,
|
|
@@ -1191,22 +1202,11 @@ export class SqliteScopeHost {
|
|
|
1191
1202
|
// A 5xx is the provider failing; a 4xx is usually us. Both are
|
|
1192
1203
|
// worth recording, because "the connection stopped working" is the
|
|
1193
1204
|
// question a health view answers.
|
|
1194
|
-
|
|
1195
|
-
await admin.recordConnectionUse(open.id, {
|
|
1196
|
-
ok: false,
|
|
1197
|
-
error: `HTTP ${res.status} from ${provider}`,
|
|
1198
|
-
});
|
|
1199
|
-
}
|
|
1200
|
-
else {
|
|
1201
|
-
await admin.recordConnectionUse(open.id, { ok: true });
|
|
1202
|
-
}
|
|
1205
|
+
await admin.recordConnectionUse(open.id, settleConnectionUse(provider, Date.now() - started, { response: res }));
|
|
1203
1206
|
return res;
|
|
1204
1207
|
}
|
|
1205
1208
|
catch (err) {
|
|
1206
|
-
await admin.recordConnectionUse(open.id, {
|
|
1207
|
-
ok: false,
|
|
1208
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1209
|
-
});
|
|
1209
|
+
await admin.recordConnectionUse(open.id, settleConnectionUse(provider, Date.now() - started, { error: err }));
|
|
1210
1210
|
throw err;
|
|
1211
1211
|
}
|
|
1212
1212
|
},
|
|
@@ -1227,7 +1227,10 @@ export class SqliteScopeHost {
|
|
|
1227
1227
|
}
|
|
1228
1228
|
seen.add(m.version);
|
|
1229
1229
|
}
|
|
1230
|
-
|
|
1230
|
+
// IN-SCOPE consumes only (#1705). A `from` entry names another vertical's export, and its
|
|
1231
|
+
// handler lives in `imports`. Letting it satisfy this check would let a local consumer of
|
|
1232
|
+
// the same type name register against a declaration that was reviewed as an import.
|
|
1233
|
+
const declaredConsumes = new Set(manifest.events.consumes.filter((c) => c.from === undefined).map((c) => c.type));
|
|
1231
1234
|
const consumers = Object.entries(registration.consumers ?? {}).map(([eventType, handler]) => {
|
|
1232
1235
|
if (!declaredConsumes.has(eventType)) {
|
|
1233
1236
|
throw new Error(`${manifest.id} registers a consumer for undeclared event type: ${eventType}`);
|
|
@@ -1296,12 +1299,15 @@ export class SqliteScopeHost {
|
|
|
1296
1299
|
}
|
|
1297
1300
|
this.listPlans.set(plan.entityType, plan);
|
|
1298
1301
|
}
|
|
1302
|
+
// #1705: validated whole and kept only if it passes, so a refused module leaves nothing.
|
|
1303
|
+
this.crossVertical.register(manifest, registration.imports);
|
|
1299
1304
|
this.modules.set(manifest.id, {
|
|
1300
1305
|
id: manifest.id,
|
|
1301
1306
|
migrations: [...migrations, ...searchMigrations, ...listMigrations],
|
|
1302
1307
|
consumers,
|
|
1303
1308
|
schedules: manifest.schedules ?? [],
|
|
1304
1309
|
freshness: manifest.freshness ?? [],
|
|
1310
|
+
peers: manifest.peers ?? [],
|
|
1305
1311
|
});
|
|
1306
1312
|
for (const rel of manifest.entityRelations ?? []) {
|
|
1307
1313
|
const parents = this.relations.get(rel.entityType) ?? new Set();
|
|
@@ -1470,6 +1476,14 @@ export class SqliteScopeHost {
|
|
|
1470
1476
|
rt.db.prepare(seat.sql).run(...seat.params);
|
|
1471
1477
|
}
|
|
1472
1478
|
}
|
|
1479
|
+
// #1706: every declared PEER holds its keys on the scope from provisioning on — the one
|
|
1480
|
+
// call that makes "declared and installed" mean "live" (`peerSeats`). Same seat, same
|
|
1481
|
+
// rules: a grant a switch tombstoned stays tombstoned, and a switched-off peer gets
|
|
1482
|
+
// nothing seated.
|
|
1483
|
+
for (const peer of peerSeats(this.peerDeclarations(), input.scopeId)) {
|
|
1484
|
+
const seat = seatScopeTuple(peer.subject, peer.relation, peer.object, null);
|
|
1485
|
+
rt.db.prepare(seat.sql).run(...seat.params);
|
|
1486
|
+
}
|
|
1473
1487
|
});
|
|
1474
1488
|
// Audit a real provision only; an idempotent re-provision changed nothing.
|
|
1475
1489
|
if (!existing) {
|
|
@@ -1679,7 +1693,22 @@ export class SqliteScopeHost {
|
|
|
1679
1693
|
* the per-entity ref, with a spine event in the same transaction. Bytes go straight to
|
|
1680
1694
|
* the per-tenant blob store — never through the scope pipe (the issue's point).
|
|
1681
1695
|
*/
|
|
1682
|
-
buildAttachments(rt,
|
|
1696
|
+
buildAttachments(rt, authority, store, opts = {}) {
|
|
1697
|
+
// #1686: a capability session is resolved per CALL, inside the call's own actor task —
|
|
1698
|
+
// the invoke door's discipline, so a revoke or an expiry refuses the next read on a
|
|
1699
|
+
// surface built before it. Throws `unauthenticated`/`forbidden` for a dead session or
|
|
1700
|
+
// an allowlist, before any key is checked (not a K-35 denial). A read takes no use.
|
|
1701
|
+
// THE ORDER, for every verb: the session (here, in the runner), then the target lookup,
|
|
1702
|
+
// then the check — so every `targetGate` call on a capability path sits inside a
|
|
1703
|
+
// `guarded` callback, and a dead link is told `unauthenticated` and nothing else.
|
|
1704
|
+
const capabilitySession = authority.kind === 'capability-session' ? authority.hash : undefined;
|
|
1705
|
+
const actingAs = (operation) => authority.kind === 'capability-session'
|
|
1706
|
+
? {
|
|
1707
|
+
kind: 'capability',
|
|
1708
|
+
id: resolveCapabilitySession(spineSql(rt.db), authority.hash, this.clock(), operation),
|
|
1709
|
+
}
|
|
1710
|
+
: authority;
|
|
1711
|
+
const node = { tenantId: rt.tenantId, scopeId: rt.scopeId };
|
|
1683
1712
|
const targetGate = (entityType) => {
|
|
1684
1713
|
const gate = this.attachmentTargets.get(entityType);
|
|
1685
1714
|
if (!gate) {
|
|
@@ -1707,11 +1736,12 @@ export class SqliteScopeHost {
|
|
|
1707
1736
|
// like an operation invoke (including K-35 denial recording and prompt dispatch of
|
|
1708
1737
|
// the events it emitted).
|
|
1709
1738
|
const serialized = (operation, fn) => rt.actor.enqueue(async () => {
|
|
1739
|
+
const subject = actingAs(operation);
|
|
1710
1740
|
const ctx = this.operationContext(rt, subject, undefined, undefined, undefined, operation);
|
|
1711
1741
|
rt.db.exec('BEGIN IMMEDIATE');
|
|
1712
1742
|
let result;
|
|
1713
1743
|
try {
|
|
1714
|
-
result = await fn(ctx);
|
|
1744
|
+
result = await fn(ctx, subject);
|
|
1715
1745
|
rt.db.exec('COMMIT');
|
|
1716
1746
|
}
|
|
1717
1747
|
catch (err) {
|
|
@@ -1742,8 +1772,9 @@ export class SqliteScopeHost {
|
|
|
1742
1772
|
* runner does it after its ROLLBACK.
|
|
1743
1773
|
*/
|
|
1744
1774
|
const reentrant = async (operation, fn) => {
|
|
1775
|
+
const subject = actingAs(operation);
|
|
1745
1776
|
try {
|
|
1746
|
-
return await fn(this.operationContext(rt, subject, undefined, undefined, undefined, operation));
|
|
1777
|
+
return await fn(this.operationContext(rt, subject, undefined, undefined, undefined, operation), subject);
|
|
1747
1778
|
}
|
|
1748
1779
|
catch (err) {
|
|
1749
1780
|
// #1525: the ENCLOSING call's id, deliberately. This runner takes no turn of its
|
|
@@ -1758,7 +1789,18 @@ export class SqliteScopeHost {
|
|
|
1758
1789
|
const guarded = opts.reentrant ? reentrant : serialized;
|
|
1759
1790
|
return {
|
|
1760
1791
|
upload: async (input) => {
|
|
1792
|
+
if (capabilitySession !== undefined) {
|
|
1793
|
+
// #1686: refused BEFORE the bytes go anywhere — through the serialized runner, so
|
|
1794
|
+
// the session is resolved first (a dead one is `unauthenticated`, no K-35 row) and
|
|
1795
|
+
// the refusal is recorded against `{ capability }` like any enforced denial. The
|
|
1796
|
+
// target lookup is INSIDE, after the session: a dead link learns nothing about
|
|
1797
|
+
// which entity types take attachments.
|
|
1798
|
+
return guarded('attachments.upload', async (_ctx, subject) => {
|
|
1799
|
+
throw capabilityAttachmentWriteRefused(subject.id, 'attachments.upload', node, targetGate(input.entity.entityType).write);
|
|
1800
|
+
});
|
|
1801
|
+
}
|
|
1761
1802
|
const gate = targetGate(input.entity.entityType);
|
|
1803
|
+
const creator = authority;
|
|
1762
1804
|
const id = ulid();
|
|
1763
1805
|
const key = attachmentBlobKey(rt.scopeId, id);
|
|
1764
1806
|
const record = attachmentRecord.parse({
|
|
@@ -1769,7 +1811,7 @@ export class SqliteScopeHost {
|
|
|
1769
1811
|
size: input.body.byteLength,
|
|
1770
1812
|
sha256: await sha256Hex(input.body),
|
|
1771
1813
|
visibility: input.visibility,
|
|
1772
|
-
createdBy:
|
|
1814
|
+
createdBy: creator.id,
|
|
1773
1815
|
createdAt: new Date().toISOString(),
|
|
1774
1816
|
});
|
|
1775
1817
|
// Bytes first, row second: a crash between the two leaves an orphaned object
|
|
@@ -1800,17 +1842,18 @@ export class SqliteScopeHost {
|
|
|
1800
1842
|
throw err;
|
|
1801
1843
|
}
|
|
1802
1844
|
},
|
|
1803
|
-
list: async (entity) =>
|
|
1845
|
+
list: async (entity) =>
|
|
1846
|
+
// The target lookup inside the runner, after `actingAs` (#1686): a capability's
|
|
1847
|
+
// session is decided before anything about the entity type is.
|
|
1848
|
+
guarded('attachments.list', async (ctx) => {
|
|
1804
1849
|
const gate = targetGate(entity.entityType);
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
.prepare(`SELECT * FROM _substrat_attachments WHERE entity_type = ? AND entity_id = ?
|
|
1850
|
+
assertAllowed(await ctx.check(gate.read, entity));
|
|
1851
|
+
const rows = rt.db
|
|
1852
|
+
.prepare(`SELECT * FROM _substrat_attachments WHERE entity_type = ? AND entity_id = ?
|
|
1809
1853
|
ORDER BY id DESC`)
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
},
|
|
1854
|
+
.all(entity.entityType, entity.entityId);
|
|
1855
|
+
return rows.map(rowToRecord);
|
|
1856
|
+
}),
|
|
1814
1857
|
open: async (attachmentId) => {
|
|
1815
1858
|
const record = await guarded('attachments.open', async (ctx) => {
|
|
1816
1859
|
const row = rt.db
|
|
@@ -1839,10 +1882,15 @@ export class SqliteScopeHost {
|
|
|
1839
1882
|
return { record, body: obj.body, contentType: obj.contentType ?? record.contentType };
|
|
1840
1883
|
},
|
|
1841
1884
|
remove: async (attachmentId) => {
|
|
1842
|
-
const removed = await guarded('attachments.remove', async (ctx) => {
|
|
1885
|
+
const removed = await guarded('attachments.remove', async (ctx, subject) => {
|
|
1843
1886
|
const row = rt.db
|
|
1844
1887
|
.prepare('SELECT * FROM _substrat_attachments WHERE id = ?')
|
|
1845
1888
|
.get(attachmentId);
|
|
1889
|
+
if (capabilitySession !== undefined) {
|
|
1890
|
+
// #1686: refused whether or not the id exists — one answer, so a holder learns
|
|
1891
|
+
// nothing about ids — but recorded only when there is a write key to record.
|
|
1892
|
+
throw capabilityAttachmentWriteRefused(subject.id, 'attachments.remove', node, row ? targetGate(row.entity_type).write : undefined);
|
|
1893
|
+
}
|
|
1846
1894
|
if (!row)
|
|
1847
1895
|
return null;
|
|
1848
1896
|
const gate = targetGate(row.entity_type);
|
|
@@ -2201,6 +2249,168 @@ export class SqliteScopeHost {
|
|
|
2201
2249
|
await this.applyPendingMigrations(rt);
|
|
2202
2250
|
return this.buildStub(tenantId, scopeId, rt, { kind: 'system', id: moduleId });
|
|
2203
2251
|
}
|
|
2252
|
+
/**
|
|
2253
|
+
* #1705: the producer's release, decided by THIS deployment's own declarations. The admin
|
|
2254
|
+
* verb wraps it with the directory gate and the access row.
|
|
2255
|
+
*
|
|
2256
|
+
* The order is the safety argument. What leaves is `exports ∩ wants`, computed from the
|
|
2257
|
+
* modules registered here, and then only if the consumer's principal holds every key that
|
|
2258
|
+
* intersection needs. Only then is a row read. A missing key reads nothing, so the
|
|
2259
|
+
* watermark cannot move past events that authority would have released later.
|
|
2260
|
+
*/
|
|
2261
|
+
async readExports(tenantId, scopeId, input) {
|
|
2262
|
+
const plan = exportReadPlan(this.crossVertical.exports(), input.wants);
|
|
2263
|
+
const quiet = {
|
|
2264
|
+
events: [],
|
|
2265
|
+
withheld: [],
|
|
2266
|
+
unexported: plan.unexported,
|
|
2267
|
+
paused: null,
|
|
2268
|
+
next: input.after,
|
|
2269
|
+
more: false,
|
|
2270
|
+
};
|
|
2271
|
+
if (plan.types.length === 0)
|
|
2272
|
+
return quiet;
|
|
2273
|
+
const missing = await this.peerMissing(tenantId, scopeId, input.consumer, plan.keys);
|
|
2274
|
+
if (missing.length > 0)
|
|
2275
|
+
return { ...quiet, paused: { missing } };
|
|
2276
|
+
// The committed outbox (#1624): an in-flight operation's event must not reach another
|
|
2277
|
+
// vertical before its transaction decides whether it exists.
|
|
2278
|
+
const db = this.scopeReadDbFor(tenantId, scopeId);
|
|
2279
|
+
const q = exportReadQuery(plan.types, input.after, input.limit);
|
|
2280
|
+
const rows = db.prepare(q.sql).all(...q.params);
|
|
2281
|
+
const hops = db.prepare(EXPORT_HOPS_SQL);
|
|
2282
|
+
const batch = planExportBatch({
|
|
2283
|
+
rows,
|
|
2284
|
+
wanted: plan.wanted,
|
|
2285
|
+
after: input.after,
|
|
2286
|
+
limit: input.limit,
|
|
2287
|
+
hopsBefore: (row) => row.caused_by ? (hops.get(row.caused_by)?.hops ?? 0) : 0,
|
|
2288
|
+
});
|
|
2289
|
+
return { ...batch, unexported: plan.unexported, paused: null };
|
|
2290
|
+
}
|
|
2291
|
+
/**
|
|
2292
|
+
* #1705: the read keys `consumer`'s principal does NOT hold at this scope — #1706's
|
|
2293
|
+
* `peerCovers`, the checker's own `covers`. A peer whose kill switch is off holds nothing,
|
|
2294
|
+
* so it pauses the edge here with no marker read of its own.
|
|
2295
|
+
*/
|
|
2296
|
+
async peerMissing(tenantId, scopeId, consumer, keys) {
|
|
2297
|
+
const coverage = await this.peerCovers(tenantId, scopeId, consumer, keys);
|
|
2298
|
+
return coverage.filter((c) => !c.held).map((c) => c.permission);
|
|
2299
|
+
}
|
|
2300
|
+
/** #1705: this scope's watermark per producer, oldest source first. */
|
|
2301
|
+
importCursors(db) {
|
|
2302
|
+
return db.prepare(IMPORT_CURSORS_SQL).all().map((r) => ({
|
|
2303
|
+
source: r.source_scope_id,
|
|
2304
|
+
vertical: r.source_vertical,
|
|
2305
|
+
cursor: r.cursor,
|
|
2306
|
+
updatedAt: r.updated_at,
|
|
2307
|
+
}));
|
|
2308
|
+
}
|
|
2309
|
+
async deliverToPeer(tenantId, scopeId, raw) {
|
|
2310
|
+
// Parsed at the door, like an operation's input: HostAdmin callers are trusted to be the
|
|
2311
|
+
// platform, not to have built a well-formed batch.
|
|
2312
|
+
const batch = importBatch.parse(raw);
|
|
2313
|
+
const source = batch.source;
|
|
2314
|
+
const rt = await this.openActiveScope(tenantId, scopeId);
|
|
2315
|
+
return rt.actor.enqueue(async () => {
|
|
2316
|
+
const result = {
|
|
2317
|
+
delivered: 0,
|
|
2318
|
+
deadLettered: 0,
|
|
2319
|
+
duplicates: 0,
|
|
2320
|
+
withheld: 0,
|
|
2321
|
+
cursor: batch.after,
|
|
2322
|
+
stale: false,
|
|
2323
|
+
paused: null,
|
|
2324
|
+
};
|
|
2325
|
+
// #1706's door, for a delivery (`operation: null`): the producer must be a declared peer
|
|
2326
|
+
// of this vertical with its switch on. Admitted inside the actor task, before anything
|
|
2327
|
+
// runs, so a switch pulled between two passes refuses the second. A refusal is a PAUSE:
|
|
2328
|
+
// nothing ran, the watermark stays, and the producer's outbox keeps the backlog.
|
|
2329
|
+
let subject;
|
|
2330
|
+
try {
|
|
2331
|
+
subject = admitPeer(switchSqlOf(rt.db), this.peerDeclarations(), { vertical: source.vertical, scope: source.scopeId }, null);
|
|
2332
|
+
}
|
|
2333
|
+
catch (err) {
|
|
2334
|
+
return { ...result, paused: { reason: err instanceof Error ? err.message : String(err) } };
|
|
2335
|
+
}
|
|
2336
|
+
// The compare-and-set. Inside the actor, so nothing moves the watermark between this
|
|
2337
|
+
// read and the advance at the end.
|
|
2338
|
+
const current = rt.db.prepare(IMPORT_CURSOR_OF_SQL).get(source.scopeId)?.cursor ?? null;
|
|
2339
|
+
if (current !== batch.after)
|
|
2340
|
+
return { ...result, cursor: current, stale: true };
|
|
2341
|
+
const at = new Date().toISOString();
|
|
2342
|
+
const record = rt.db.prepare(IMPORT_RECORD_SQL);
|
|
2343
|
+
const journaled = rt.db.prepare('SELECT 1 FROM _substrat_deliveries WHERE event_id = ? AND consumer_module = ?');
|
|
2344
|
+
const deadLetter = rt.db.prepare(`INSERT OR IGNORE INTO _substrat_deliveries
|
|
2345
|
+
(event_id, consumer_module, delivered_at, error, invocation_id)
|
|
2346
|
+
VALUES (?, ?, ?, ?, NULL)`);
|
|
2347
|
+
// Named and not carried: a dead letter per importing module, so the operator who reads
|
|
2348
|
+
// this scope's dead letters sees what the producer sent and why it did not arrive.
|
|
2349
|
+
for (const w of batch.withheld) {
|
|
2350
|
+
record.run(w.id, source.scopeId, source.vertical, w.type, w.schemaVersion, w.occurredAt, w.entity.entityType, w.entity.entityId, 0, w.reason, at);
|
|
2351
|
+
for (const moduleId of this.crossVertical.modulesImporting(source.vertical, w.type)) {
|
|
2352
|
+
deadLetter.run(w.id, moduleId, at, withheldNote(w.reason, source.vertical));
|
|
2353
|
+
}
|
|
2354
|
+
result.withheld += 1;
|
|
2355
|
+
}
|
|
2356
|
+
for (const e of batch.events) {
|
|
2357
|
+
record.run(e.id, source.scopeId, source.vertical, e.type, e.schemaVersion, e.occurredAt, e.entity.entityType, e.entity.entityId, e.hops, null, at);
|
|
2358
|
+
let ran = false;
|
|
2359
|
+
for (const imp of this.crossVertical.handlersFor(source.vertical, e.type)) {
|
|
2360
|
+
if (journaled.get(e.id, imp.moduleId))
|
|
2361
|
+
continue;
|
|
2362
|
+
ran = true;
|
|
2363
|
+
if (imp.schemaVersion !== e.schemaVersion) {
|
|
2364
|
+
// The producer released what the consumer's state asked for. A mismatch here means
|
|
2365
|
+
// this scope's code changed between the two, so it is refused by version (K-39)
|
|
2366
|
+
// exactly as the producer would have refused it.
|
|
2367
|
+
deadLetter.run(e.id, imp.moduleId, at, withheldNote('version', source.vertical));
|
|
2368
|
+
result.deadLettered += 1;
|
|
2369
|
+
continue;
|
|
2370
|
+
}
|
|
2371
|
+
const { hops: _hops, ...fact } = e;
|
|
2372
|
+
const event = structuredClone({ ...fact, source });
|
|
2373
|
+
// What the handler emits was emitted BECAUSE of the producer's event (#1237). The id
|
|
2374
|
+
// resolves through `_substrat_imports`, which the row above has just written.
|
|
2375
|
+
rt.causedBy = e.id;
|
|
2376
|
+
rt.db.exec('BEGIN IMMEDIATE');
|
|
2377
|
+
try {
|
|
2378
|
+
// As the producer's principal, admitted above: the handler's checks are real ones,
|
|
2379
|
+
// against the grants this vertical's `peers` gave it, and its emits carry the actor
|
|
2380
|
+
// `{ vertical, scope }` and the authorization they passed (K-34).
|
|
2381
|
+
await imp.handler(this.operationContext(rt, subject), event);
|
|
2382
|
+
rt.db
|
|
2383
|
+
.prepare(`INSERT INTO _substrat_deliveries (event_id, consumer_module, delivered_at, invocation_id)
|
|
2384
|
+
VALUES (?, ?, ?, NULL)`)
|
|
2385
|
+
.run(e.id, imp.moduleId, new Date().toISOString());
|
|
2386
|
+
rt.db.exec('COMMIT');
|
|
2387
|
+
result.delivered += 1;
|
|
2388
|
+
}
|
|
2389
|
+
catch (err) {
|
|
2390
|
+
rt.db.exec('ROLLBACK');
|
|
2391
|
+
// Dead-letter (v0), the consumer's rule: one failure is terminal, and the events
|
|
2392
|
+
// behind it still arrive.
|
|
2393
|
+
deadLetter.run(e.id, imp.moduleId, new Date().toISOString(), String(err));
|
|
2394
|
+
result.deadLettered += 1;
|
|
2395
|
+
}
|
|
2396
|
+
finally {
|
|
2397
|
+
rt.causedBy = null;
|
|
2398
|
+
}
|
|
2399
|
+
}
|
|
2400
|
+
if (!ran)
|
|
2401
|
+
result.duplicates += 1;
|
|
2402
|
+
}
|
|
2403
|
+
// LAST, in this scope's store: a crash before here redelivers the batch, and the journal
|
|
2404
|
+
// rows above absorb it.
|
|
2405
|
+
rt.db.prepare(IMPORT_CURSOR_ADVANCE_SQL).run(source.scopeId, source.vertical, batch.next, at);
|
|
2406
|
+
result.cursor = batch.next;
|
|
2407
|
+
// What the handlers emitted reaches this scope's own consumers and executors in the same
|
|
2408
|
+
// tail, under the ordinary cascade cap.
|
|
2409
|
+
await this.dispatch(rt, null);
|
|
2410
|
+
await this.dispatchExecutors(rt, null);
|
|
2411
|
+
return result;
|
|
2412
|
+
});
|
|
2413
|
+
}
|
|
2204
2414
|
/**
|
|
2205
2415
|
* The lifecycle gate the capability doors share (#1672): the scope must exist in this
|
|
2206
2416
|
* tenant, the tenant must be active, the scope must be active — K-3's fail-closed pair
|
|
@@ -2212,7 +2422,11 @@ export class SqliteScopeHost {
|
|
|
2212
2422
|
.prepare('SELECT tenant_id, status FROM scopes WHERE scope_id = ?')
|
|
2213
2423
|
.get(scopeId);
|
|
2214
2424
|
if (!scope || scope.tenant_id !== tenantId) {
|
|
2215
|
-
|
|
2425
|
+
// TYPED (#1714 review): K-3's pair check is the confinement the capability and peer
|
|
2426
|
+
// doors rest on, so its refusal carries a code rather than being one more untyped
|
|
2427
|
+
// throw a test could pass on by accident. `not_found`, in the same words either way:
|
|
2428
|
+
// a scope of another tenant reads exactly as one that does not exist.
|
|
2429
|
+
throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
2216
2430
|
}
|
|
2217
2431
|
const tenant = this.directory
|
|
2218
2432
|
.prepare('SELECT status FROM tenants WHERE tenant_id = ?')
|
|
@@ -2277,6 +2491,55 @@ export class SqliteScopeHost {
|
|
|
2277
2491
|
const hash = await capabilityTokenHash(sessionToken);
|
|
2278
2492
|
return this.buildStub(tenantId, scopeId, rt, { kind: 'capability-session', hash }, options);
|
|
2279
2493
|
}
|
|
2494
|
+
/**
|
|
2495
|
+
* The capability's attachment door (#1686) — `getCapabilityScope`'s gate, then the
|
|
2496
|
+
* attachment surface with the session's hash as its authority. `buildAttachments`
|
|
2497
|
+
* re-resolves that hash on every call, reads are the checker's as `{ capability }`, and
|
|
2498
|
+
* writes are refused.
|
|
2499
|
+
*/
|
|
2500
|
+
async getCapabilityAttachments(sessionToken, tenantId, scopeId) {
|
|
2501
|
+
if (!plausibleSessionToken(sessionToken)) {
|
|
2502
|
+
throw substratError('unauthenticated', 'not a capability session token');
|
|
2503
|
+
}
|
|
2504
|
+
const rt = await this.openActiveScope(tenantId, scopeId);
|
|
2505
|
+
const hash = await capabilityTokenHash(sessionToken);
|
|
2506
|
+
const scopeRow = this.directory
|
|
2507
|
+
.prepare('SELECT vertical FROM scopes WHERE scope_id = ?')
|
|
2508
|
+
.get(scopeId);
|
|
2509
|
+
const store = this.attachmentStore(tenantId, scopeRow?.vertical ?? null);
|
|
2510
|
+
return this.buildAttachments(rt, { kind: 'capability-session', hash }, store);
|
|
2511
|
+
}
|
|
2512
|
+
/** Every registered module's `peers`, as one declaration per peer vertical (#1706). */
|
|
2513
|
+
peerDeclarations() {
|
|
2514
|
+
return collectPeers(this.modules.values());
|
|
2515
|
+
}
|
|
2516
|
+
/**
|
|
2517
|
+
* The peer door (#1706) — mirror of the connection, system and capability doors. The caller is
|
|
2518
|
+
* the local broker's word (the platform's, on the pure host); the pair and lifecycle gate run
|
|
2519
|
+
* here, and `admitPeer` runs inside the scope's actor task on every invoke (`buildStub`).
|
|
2520
|
+
*/
|
|
2521
|
+
async getVerticalScope(caller, tenantId, scopeId, options) {
|
|
2522
|
+
const parsed = verticalCaller.parse(caller);
|
|
2523
|
+
const rt = await this.openActiveScope(tenantId, scopeId);
|
|
2524
|
+
return this.buildStub(tenantId, scopeId, rt, { kind: 'vertical-peer', caller: parsed }, options);
|
|
2525
|
+
}
|
|
2526
|
+
/**
|
|
2527
|
+
* Does peer `vertical` hold each key at this scope's node right now (#1706) — the checker's own
|
|
2528
|
+
* `covers`, inside the scope's actor task so no invoke's open transaction is read half-done.
|
|
2529
|
+
* The subject's `scope` is the target scope only to satisfy the type: `covers` records nothing,
|
|
2530
|
+
* and the tuple ref it walks is the slug alone.
|
|
2531
|
+
*/
|
|
2532
|
+
async peerCovers(tenantId, scopeId, vertical, permissions) {
|
|
2533
|
+
const rt = await this.openActiveScope(tenantId, scopeId);
|
|
2534
|
+
const subject = { kind: 'vertical', id: verticalSlugOf.parse(vertical), scope: scopeId };
|
|
2535
|
+
const coverage = await rt.actor.enqueue(() => this.checker.covers(subject, [...permissions], { tenantId, scopeId }));
|
|
2536
|
+
const missing = new Set(coverage.covered ? [] : coverage.missing);
|
|
2537
|
+
return permissions.map((permission) => ({ permission, held: !missing.has(permission) }));
|
|
2538
|
+
}
|
|
2539
|
+
/** #1705: what this deployment imports — the sweep's reason to call no scope when it is empty. */
|
|
2540
|
+
registeredImports() {
|
|
2541
|
+
return this.crossVertical.consumes();
|
|
2542
|
+
}
|
|
2280
2543
|
registeredSchedules() {
|
|
2281
2544
|
const out = [];
|
|
2282
2545
|
for (const mod of this.modules.values()) {
|
|
@@ -2581,12 +2844,17 @@ export class SqliteScopeHost {
|
|
|
2581
2844
|
// actor task — so nothing can revoke between this read and the transaction below.
|
|
2582
2845
|
// Refuses a stale session, a revoked or expired capability and an operation off its
|
|
2583
2846
|
// allowlist before anything opens; none of those is a K-35 denial (no key checked).
|
|
2847
|
+
// #1706: the peer door's admission, on the same terms — every invoke, inside the
|
|
2848
|
+
// actor task. An undeclared peer, a switched-off one, or an operation off its
|
|
2849
|
+
// allowlist is refused `forbidden` before anything opens; no key was checked.
|
|
2584
2850
|
const subject = authority.kind === 'capability-session'
|
|
2585
2851
|
? {
|
|
2586
2852
|
kind: 'capability',
|
|
2587
2853
|
id: resolveCapabilitySession(spineSql(rt.db), authority.hash, this.clock(), operation),
|
|
2588
2854
|
}
|
|
2589
|
-
: authority
|
|
2855
|
+
: authority.kind === 'vertical-peer'
|
|
2856
|
+
? admitPeer(switchSqlOf(rt.db), this.peerDeclarations(), authority.caller, operation)
|
|
2857
|
+
: authority;
|
|
2590
2858
|
// #1672: the secrets this invocation mints — withheld from its idempotency recording,
|
|
2591
2859
|
// and what the tripwire on its writes looks for.
|
|
2592
2860
|
const minted = [];
|
|
@@ -3661,6 +3929,7 @@ export class SqliteScopeHost {
|
|
|
3661
3929
|
admissionNote: r.admission_note,
|
|
3662
3930
|
origin: r.origin_json ? JSON.parse(r.origin_json) : null,
|
|
3663
3931
|
outbound: outboundOfManifestJson(r.manifest_json),
|
|
3932
|
+
calls: callsOfManifestJson(r.manifest_json),
|
|
3664
3933
|
createdAt: r.created_at,
|
|
3665
3934
|
});
|
|
3666
3935
|
const readVersion = (id) => {
|
|
@@ -3872,6 +4141,169 @@ export class SqliteScopeHost {
|
|
|
3872
4141
|
permissions: outcome.permissions,
|
|
3873
4142
|
};
|
|
3874
4143
|
};
|
|
4144
|
+
/**
|
|
4145
|
+
* #1706: move one PEER's kill switch on one scope — see `HostAdmin.revokeFromPeer`. The
|
|
4146
|
+
* schedule switch's body with the subject swapped (`switchPeer` → `switchSubjectGrants`):
|
|
4147
|
+
* audit first, one turn on the scope actor, outcome after, `not_found` when nothing was held.
|
|
4148
|
+
*/
|
|
4149
|
+
const switchPeerAt = async (actor, raw, to) => {
|
|
4150
|
+
const input = peerSwitch.parse(raw);
|
|
4151
|
+
const { tenantId, scopeId } = input.node;
|
|
4152
|
+
const scope = this.directory
|
|
4153
|
+
.prepare('SELECT tenant_id FROM scopes WHERE scope_id = ?')
|
|
4154
|
+
.get(scopeId);
|
|
4155
|
+
if (!scope || scope.tenant_id !== tenantId) {
|
|
4156
|
+
throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
4157
|
+
}
|
|
4158
|
+
const operationId = ulid();
|
|
4159
|
+
const action = to === 'off' ? 'revokeFromPeer' : 'restoreToPeer';
|
|
4160
|
+
const target = { tenantId, scopeId };
|
|
4161
|
+
const base = { operationId, vertical: input.vertical, calls: to };
|
|
4162
|
+
this.recordAdmin(actor, action, target, null, { ...base, phase: 'intent', reason: input.reason });
|
|
4163
|
+
let outcome;
|
|
4164
|
+
try {
|
|
4165
|
+
const rt = this.runtime(tenantId, scopeId);
|
|
4166
|
+
outcome = await rt.actor.turn(() => rt.db.transaction(() => switchPeer(switchSqlOf(rt.db), {
|
|
4167
|
+
vertical: input.vertical,
|
|
4168
|
+
scopeId,
|
|
4169
|
+
to,
|
|
4170
|
+
at: new Date().toISOString(),
|
|
4171
|
+
}))());
|
|
4172
|
+
}
|
|
4173
|
+
catch (err) {
|
|
4174
|
+
try {
|
|
4175
|
+
this.recordAdmin(actor, action, target, null, {
|
|
4176
|
+
...base,
|
|
4177
|
+
phase: 'failed',
|
|
4178
|
+
error: err instanceof Error ? err.message : String(err),
|
|
4179
|
+
});
|
|
4180
|
+
}
|
|
4181
|
+
catch {
|
|
4182
|
+
// Best effort: the original error is what the caller must see.
|
|
4183
|
+
}
|
|
4184
|
+
throw err;
|
|
4185
|
+
}
|
|
4186
|
+
this.recordAdmin(actor, action, target, null, {
|
|
4187
|
+
...base,
|
|
4188
|
+
phase: outcome.held ? 'applied' : 'refused',
|
|
4189
|
+
changed: outcome.changed,
|
|
4190
|
+
permissions: outcome.permissions,
|
|
4191
|
+
});
|
|
4192
|
+
if (!outcome.held) {
|
|
4193
|
+
throw substratError('not_found', `scope ${scopeId} holds no grant for peer '${input.vertical}' — nothing to switch ${to} ` +
|
|
4194
|
+
`(check the slug: it is the calling vertical's registry id, as the target's \`peers\` names it)`);
|
|
4195
|
+
}
|
|
4196
|
+
return {
|
|
4197
|
+
operationId,
|
|
4198
|
+
vertical: input.vertical,
|
|
4199
|
+
calls: to,
|
|
4200
|
+
changed: outcome.changed,
|
|
4201
|
+
permissions: outcome.permissions,
|
|
4202
|
+
};
|
|
4203
|
+
};
|
|
4204
|
+
/**
|
|
4205
|
+
* The status read's admin-log join (#1674): the `intent` row of the `revokeFromSystem`
|
|
4206
|
+
* still in force for each OFF module — actor, reason, when. Paired to its `applied` row
|
|
4207
|
+
* by `operationId`, so a refused or failed attempt (only possible on a module this
|
|
4208
|
+
* scope has never switched off before, per `switchSystemSchedules`'s `held` check, so
|
|
4209
|
+
* never the explanation for a module that is currently off) is never read as one.
|
|
4210
|
+
*/
|
|
4211
|
+
const lastSwitchedOffBy = (tenantId, scopeId,
|
|
4212
|
+
/** The revoking action, and the payload field naming its subject. */
|
|
4213
|
+
action, key, subjects) => {
|
|
4214
|
+
const moduleIds = subjects;
|
|
4215
|
+
const rows = this.directory
|
|
4216
|
+
.prepare(`SELECT actor, after, at FROM _substrat_admin_log
|
|
4217
|
+
WHERE tenant_id = ? AND scope_id = ? AND action = ?
|
|
4218
|
+
ORDER BY id DESC`)
|
|
4219
|
+
.all(tenantId, scopeId, action);
|
|
4220
|
+
const appliedOps = new Set();
|
|
4221
|
+
for (const row of rows) {
|
|
4222
|
+
if (!row.after)
|
|
4223
|
+
continue;
|
|
4224
|
+
const payload = JSON.parse(row.after);
|
|
4225
|
+
if (payload.phase === 'applied' && payload.operationId)
|
|
4226
|
+
appliedOps.add(payload.operationId);
|
|
4227
|
+
}
|
|
4228
|
+
const result = new Map();
|
|
4229
|
+
for (const row of rows) {
|
|
4230
|
+
if (result.size === moduleIds.size)
|
|
4231
|
+
break;
|
|
4232
|
+
if (!row.after)
|
|
4233
|
+
continue;
|
|
4234
|
+
const payload = JSON.parse(row.after);
|
|
4235
|
+
const subject = payload[key];
|
|
4236
|
+
if (payload.phase !== 'intent' || !payload.operationId || typeof subject !== 'string')
|
|
4237
|
+
continue;
|
|
4238
|
+
if (!moduleIds.has(subject) || result.has(subject))
|
|
4239
|
+
continue;
|
|
4240
|
+
if (!appliedOps.has(String(payload.operationId)) || typeof payload.reason !== 'string')
|
|
4241
|
+
continue;
|
|
4242
|
+
result.set(subject, {
|
|
4243
|
+
actor: row.actor,
|
|
4244
|
+
reason: payload.reason,
|
|
4245
|
+
at: row.at,
|
|
4246
|
+
});
|
|
4247
|
+
}
|
|
4248
|
+
return result;
|
|
4249
|
+
};
|
|
4250
|
+
/** #1674's join, bound to the schedule switch — the shape every caller here used. */
|
|
4251
|
+
const lastSwitchedOff = (tenantId, scopeId, moduleIds) => lastSwitchedOffBy(tenantId, scopeId, 'revokeFromSystem', 'moduleId', moduleIds);
|
|
4252
|
+
/**
|
|
4253
|
+
* The status read (#1674): every module this scope holds or has held system authority
|
|
4254
|
+
* for, and where each stands — the SAME predicate `switchSystem` gates its writes on
|
|
4255
|
+
* (`systemGrantsStatus`, over the scope's own storage), so the read and the runner
|
|
4256
|
+
* cannot disagree.
|
|
4257
|
+
*/
|
|
4258
|
+
const systemGrantsStatusOf = async (actor, node) => {
|
|
4259
|
+
const { tenantId, scopeId } = node;
|
|
4260
|
+
const scope = this.directory
|
|
4261
|
+
.prepare('SELECT tenant_id FROM scopes WHERE scope_id = ?')
|
|
4262
|
+
.get(scopeId);
|
|
4263
|
+
if (!scope || scope.tenant_id !== tenantId) {
|
|
4264
|
+
throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
4265
|
+
}
|
|
4266
|
+
const rt = this.runtime(tenantId, scopeId);
|
|
4267
|
+
const states = await rt.actor.turn(() => systemGrantsStatus(switchSqlOf(rt.db), new Date().toISOString()));
|
|
4268
|
+
const offModules = new Set(states.filter((s) => s.schedules === 'off').map((s) => s.moduleId));
|
|
4269
|
+
const explanations = offModules.size > 0 ? lastSwitchedOff(tenantId, scopeId, offModules) : new Map();
|
|
4270
|
+
const result = states.map((s) => ({
|
|
4271
|
+
moduleId: s.moduleId,
|
|
4272
|
+
schedules: s.schedules,
|
|
4273
|
+
switchedOff: explanations.get(s.moduleId) ?? null,
|
|
4274
|
+
}));
|
|
4275
|
+
// K-24: reading the switch's position and any live incident reason is itself
|
|
4276
|
+
// access-logged, the same as every other HostAdmin read.
|
|
4277
|
+
this.recordAccess(actor, 'systemGrantsStatus', { tenantId, scopeId }, null, result.length);
|
|
4278
|
+
return result;
|
|
4279
|
+
};
|
|
4280
|
+
/**
|
|
4281
|
+
* The peer switch's status read (#1706) — `systemGrantsStatusOf` with the subject
|
|
4282
|
+
* swapped: the kernel's `peerGrantsStatus` over the scope's own storage, which is the
|
|
4283
|
+
* SAME predicate `admitPeer` refuses a call on, then the admin log's explanation for
|
|
4284
|
+
* each peer that is off. The join belongs on this side because the admin log is the
|
|
4285
|
+
* control plane's store; the position alone is all a vertical's own deployment can say.
|
|
4286
|
+
*/
|
|
4287
|
+
const peerGrantsStatusOf = async (actor, node) => {
|
|
4288
|
+
const { tenantId, scopeId } = node;
|
|
4289
|
+
const scope = this.directory
|
|
4290
|
+
.prepare('SELECT tenant_id FROM scopes WHERE scope_id = ?')
|
|
4291
|
+
.get(scopeId);
|
|
4292
|
+
if (!scope || scope.tenant_id !== tenantId) {
|
|
4293
|
+
throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
|
|
4294
|
+
}
|
|
4295
|
+
const rt = this.runtime(tenantId, scopeId);
|
|
4296
|
+
const states = await rt.actor.turn(() => peerGrantsStatus(switchSqlOf(rt.db), new Date().toISOString()));
|
|
4297
|
+
const offPeers = new Set(states.filter((p) => p.calls === 'off').map((p) => p.vertical));
|
|
4298
|
+
const explanations = offPeers.size > 0 ? lastSwitchedOffBy(tenantId, scopeId, 'revokeFromPeer', 'vertical', offPeers) : new Map();
|
|
4299
|
+
const result = states.map((p) => ({
|
|
4300
|
+
vertical: verticalSlugOf.parse(p.vertical),
|
|
4301
|
+
calls: p.calls,
|
|
4302
|
+
switchedOff: explanations.get(p.vertical) ?? null,
|
|
4303
|
+
}));
|
|
4304
|
+
this.recordAccess(actor, 'peerGrantsStatus', { tenantId, scopeId }, null, result.length);
|
|
4305
|
+
return result;
|
|
4306
|
+
};
|
|
3875
4307
|
return {
|
|
3876
4308
|
// #603: fixed at construction — a host built without a box can never store a
|
|
3877
4309
|
// credential, and saying so is what lets a transport answer 503 instead of 500.
|
|
@@ -4062,8 +4494,14 @@ export class SqliteScopeHost {
|
|
|
4062
4494
|
// #1666: the schedule kill switch and its lever back — `system-switch.ts` is the
|
|
4063
4495
|
// whole rule, shared with the Cloudflare adapter; this is the directory check, the
|
|
4064
4496
|
// transaction and the audit row around it.
|
|
4497
|
+
revokeFromPeer: async (actor, raw) => switchPeerAt(actor, raw, 'off'),
|
|
4498
|
+
restoreToPeer: async (actor, raw) => switchPeerAt(actor, raw, 'on'),
|
|
4065
4499
|
revokeFromSystem: async (actor, raw) => switchSystem(actor, raw, 'off'),
|
|
4066
4500
|
restoreToSystem: async (actor, raw) => switchSystem(actor, raw, 'on'),
|
|
4501
|
+
// #1674: the switch's status read — same gate, the admin log to explain an `off`
|
|
4502
|
+
// entry. Nothing to delegate here: the pure adapter's scope storage IS the store.
|
|
4503
|
+
systemGrantsStatus: systemGrantsStatusOf,
|
|
4504
|
+
peerGrantsStatus: peerGrantsStatusOf,
|
|
4067
4505
|
// #1672 — the platform's two capability verbs, both a turn on the scope actor
|
|
4068
4506
|
// (#1678: every scope-level admin write takes one, so none lands mid-invoke inside a
|
|
4069
4507
|
// stranger's transaction) and both in the admin log without the secret or its hash.
|
|
@@ -4234,6 +4672,21 @@ export class SqliteScopeHost {
|
|
|
4234
4672
|
this.recordAccess(actor, 'listHostnames', { tenantId: filter?.tenantId ?? null, scopeId: filter?.scopeId ?? null }, filter, rows.length);
|
|
4235
4673
|
return rows.map(mapHostname);
|
|
4236
4674
|
},
|
|
4675
|
+
// #1706: the directory's answer to "vertical Y in tenant T", by the kernel's one rule.
|
|
4676
|
+
resolveVerticalInstance: async (tenantId, vertical) => {
|
|
4677
|
+
const rows = this.directory
|
|
4678
|
+
.prepare(`SELECT scope_id, tenant_id, vertical, status, kind, forked_from FROM scopes
|
|
4679
|
+
WHERE tenant_id = ? AND vertical = ?`)
|
|
4680
|
+
.all(tenantId, vertical);
|
|
4681
|
+
return resolveVerticalInstanceFrom(rows.map((r) => ({
|
|
4682
|
+
id: r.scope_id,
|
|
4683
|
+
tenantId: r.tenant_id,
|
|
4684
|
+
vertical: r.vertical,
|
|
4685
|
+
status: r.status,
|
|
4686
|
+
kind: r.kind ?? '',
|
|
4687
|
+
forkedFrom: r.forked_from ?? null,
|
|
4688
|
+
})), tenantId, vertical);
|
|
4689
|
+
},
|
|
4237
4690
|
resolveHostname: async (raw) => {
|
|
4238
4691
|
// The router's per-request read. No actor, not logged — same carve-out as
|
|
4239
4692
|
// resolveIdentity (K-24): this is a machine path, not a staff read.
|
|
@@ -4822,6 +5275,26 @@ export class SqliteScopeHost {
|
|
|
4822
5275
|
}
|
|
4823
5276
|
return drained;
|
|
4824
5277
|
},
|
|
5278
|
+
readExportedEvents: async (actor, tenantId, scopeId, raw) => {
|
|
5279
|
+
// #1705: the producer's half. Parsed here, since HostAdmin is public and the control
|
|
5280
|
+
// plane is not the only caller: a limit or cursor nobody validated must not reach SQL.
|
|
5281
|
+
const input = exportReadInput.parse(raw);
|
|
5282
|
+
this.assertScopeReachable(tenantId, scopeId);
|
|
5283
|
+
const batch = await this.readExports(tenantId, scopeId, input);
|
|
5284
|
+
this.recordAccess(actor, 'readExportedEvents', { tenantId, scopeId }, { consumer: input.consumer, after: input.after, limit: input.limit }, batch.events.length);
|
|
5285
|
+
return batch;
|
|
5286
|
+
},
|
|
5287
|
+
importState: async (actor, tenantId, scopeId) => {
|
|
5288
|
+
this.assertScopeReachable(tenantId, scopeId);
|
|
5289
|
+
const consumes = this.crossVertical.consumes();
|
|
5290
|
+
// A deployment that imports nothing answers without opening the scope. The sweep
|
|
5291
|
+
// asks this of every active scope on every pass that has the phase on.
|
|
5292
|
+
if (consumes.length === 0)
|
|
5293
|
+
return { consumes: [], cursors: [] };
|
|
5294
|
+
const cursors = this.importCursors(this.scopeReadDbFor(tenantId, scopeId));
|
|
5295
|
+
this.recordAccess(actor, 'importState', { tenantId, scopeId }, null, cursors.length);
|
|
5296
|
+
return { consumes, cursors };
|
|
5297
|
+
},
|
|
4825
5298
|
redrainEvents: async (actor, tenantId, scopeId, input) => {
|
|
4826
5299
|
// Directory check first, on mark's reasoning: "nothing to reopen" and "you may not
|
|
4827
5300
|
// address this scope" are different answers.
|
|
@@ -5995,14 +6468,26 @@ export class SqliteScopeHost {
|
|
|
5995
6468
|
status = CASE WHEN status = 'error' THEN 'active' ELSE status END
|
|
5996
6469
|
WHERE id = ?`)
|
|
5997
6470
|
.run(now, id);
|
|
6471
|
+
}
|
|
6472
|
+
else {
|
|
6473
|
+
this.directory
|
|
6474
|
+
.prepare(`UPDATE _substrat_connections
|
|
6475
|
+
SET last_error = ?, last_error_at = ?,
|
|
6476
|
+
status = CASE WHEN status = 'revoked' THEN status ELSE 'error' END
|
|
6477
|
+
WHERE id = ?`)
|
|
6478
|
+
.run(outcome.error.slice(0, 2000), now, id);
|
|
6479
|
+
}
|
|
6480
|
+
// #1691: the line is settled — now the data point, off the row's own identity
|
|
6481
|
+
// (never the caller's input), fire-and-forget. With the no-op default there is
|
|
6482
|
+
// nothing to hand it, so the self-host path skips the read entirely.
|
|
6483
|
+
if (this.connectorCalls === noopConnectorCallRecorder)
|
|
5998
6484
|
return;
|
|
6485
|
+
const row = this.directory
|
|
6486
|
+
.prepare('SELECT tenant_id, vertical, provider FROM _substrat_connections WHERE id = ?')
|
|
6487
|
+
.get(id);
|
|
6488
|
+
if (row) {
|
|
6489
|
+
recordConnectorCall(this.connectorCalls, connectorCallRecord({ tenantId: row.tenant_id, vertical: row.vertical, provider: row.provider }, outcome));
|
|
5999
6490
|
}
|
|
6000
|
-
this.directory
|
|
6001
|
-
.prepare(`UPDATE _substrat_connections
|
|
6002
|
-
SET last_error = ?, last_error_at = ?,
|
|
6003
|
-
status = CASE WHEN status = 'revoked' THEN status ELSE 'error' END
|
|
6004
|
-
WHERE id = ?`)
|
|
6005
|
-
.run(outcome.error.slice(0, 2000), now, id);
|
|
6006
6491
|
},
|
|
6007
6492
|
putConnectorState: async (id, key, value) => {
|
|
6008
6493
|
this.directory
|
|
@@ -6784,8 +7269,8 @@ export class SqliteScopeHost {
|
|
|
6784
7269
|
* row's `last_used_at` are the same value — a second clock read could disagree.
|
|
6785
7270
|
*/
|
|
6786
7271
|
at = this.clock()) {
|
|
6787
|
-
// For a connection, system or
|
|
6788
|
-
// holds — it is not a person, and the event actor below says what it is instead.
|
|
7272
|
+
// For a connection, system, capability or peer-vertical subject this carries THAT id so
|
|
7273
|
+
// the type holds — it is not a person, and the event actor below says what it is instead.
|
|
6789
7274
|
const principal = subject.id;
|
|
6790
7275
|
const checker = this.checker;
|
|
6791
7276
|
const relations = this.relations;
|