@substrat-run/adapter-sqlite 0.39.0 → 0.41.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.js CHANGED
@@ -1,8 +1,8 @@
1
- import { mkdirSync, rmSync } from 'node:fs';
2
- import { join } from 'node:path';
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
3
  import Database from 'better-sqlite3';
4
- import { accessLogEntry, adminLogEntry, createTenantInput, domainEvent, domainEventInput, entitlementGrant, entitlementGrantInput, eventId, platformRequestInput, platformRequestId, platformRequest, MAX_PENDING_PLATFORM_REQUESTS, identityLink, identityPool, instant, connection, connectionGrant, connectionSecret, systemGrant, subjectRef, createConnectionInput, moduleManifest, createOrgInput, promotionAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, routeTarget, registerVerticalInput, vertical as verticalSchema, verticalChannel, verticalVersion, objectRef, grantRefFromProof, org as orgSchema, orgMembership, principalId, resolvedIdentity, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, SCOPE_TABLE_PAGE_DEFAULT, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, verticalServingState, } from '@substrat-run/contracts';
5
- import { asPrincipal, assertReadOnlyQuery, resolveScopeRecord, ulid, backoffAt, resolveRetryPolicy, unconfiguredSecretBox, PermissionDenied, } from '@substrat-run/kernel';
4
+ import { accessLogEntry, adminLogEntry, ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, createTenantInput, domainEvent, domainEventInput, entitlementGrant, entitlementGrantInput, eventId, platformRequestInput, platformRequestId, platformRequest, MAX_PENDING_PLATFORM_REQUESTS, identityLink, identityPool, instant, connection, connectionGrant, connectionSecret, systemGrant, subjectRef, createConnectionInput, moduleManifest, createOrgInput, promotionAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, routeTarget, registerVerticalInput, vertical as verticalSchema, verticalChannel, verticalVersion, objectRef, grantRefFromProof, org as orgSchema, orgMembership, principalId, resolvedIdentity, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, SCOPE_TABLE_PAGE_DEFAULT, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, verticalServingState, } from '@substrat-run/contracts';
5
+ import { asPrincipal, assertAllowed, assertReadOnlyQuery, attachmentBlobKey, resolveScopeRecord, ulid, backoffAt, resolveRetryPolicy, unconfiguredSecretBox, PermissionDenied, } from '@substrat-run/kernel';
6
6
  import { ScopeActor } from './actor.js';
7
7
  import { createTupleChecker } from './checker.js';
8
8
  const KERNEL_DDL = `
@@ -88,6 +88,24 @@ const KERNEL_DDL = `
88
88
  next_attempt_at TEXT,
89
89
  PRIMARY KEY (event_id, consumer_module)
90
90
  );
91
+ -- Attachment metadata facts (#473): one row per object in the per-tenant blob store,
92
+ -- keyed to the owning entity a module manifest declared as an attachmentTarget. Lives
93
+ -- INSIDE the scope database on purpose — scope pull / restore / PITR carry the rows
94
+ -- like any other scope fact; sha256 is the integrity witness for the bytes outside.
95
+ CREATE TABLE IF NOT EXISTS _substrat_attachments (
96
+ id TEXT PRIMARY KEY,
97
+ entity_type TEXT NOT NULL,
98
+ entity_id TEXT NOT NULL,
99
+ filename TEXT NOT NULL,
100
+ content_type TEXT NOT NULL,
101
+ size INTEGER NOT NULL,
102
+ sha256 TEXT NOT NULL,
103
+ visibility TEXT NOT NULL,
104
+ created_by TEXT NOT NULL,
105
+ created_at TEXT NOT NULL
106
+ );
107
+ CREATE INDEX IF NOT EXISTS _substrat_attachments_entity
108
+ ON _substrat_attachments (entity_type, entity_id);
91
109
  `;
92
110
  /** Row → contract shape. Never reads the secrets table — that is the point of the split. */
93
111
  const toConnection = (r) => connection.parse({
@@ -139,6 +157,26 @@ const mapHostname = (r) => hostnameBinding.parse({
139
157
  customHostnameId: r.custom_hostname_id,
140
158
  validationRecords: r.validation_records ? JSON.parse(r.validation_records) : [],
141
159
  });
160
+ /**
161
+ * Fold keyset page params (contracts pagination.ts) into an in-progress WHERE
162
+ * build and return the `ORDER BY … [LIMIT ?]` tail. The cursor is EXCLUSIVE —
163
+ * strictly after it ascending, strictly before it descending — and an unset
164
+ * limit stays unbounded: internal callers mean "everything", and a silent cap
165
+ * would let them mistake a page for the whole set.
166
+ */
167
+ function keysetTail(where, params, key, page, defaultOrder = 'asc') {
168
+ const order = (page?.order ?? defaultOrder) === 'desc' ? 'DESC' : 'ASC';
169
+ if (page?.cursor) {
170
+ where.push(order === 'DESC' ? `${key} < ?` : `${key} > ?`);
171
+ params.push(page.cursor);
172
+ }
173
+ let tail = ` ORDER BY ${key} ${order}`;
174
+ if (page?.limit !== undefined) {
175
+ tail += ' LIMIT ?';
176
+ params.push(page.limit);
177
+ }
178
+ return tail;
179
+ }
142
180
  /** Map a stored platform-request row to the `PlatformRequest` contract shape (JSON columns parsed). */
143
181
  function rowToPlatformRequest(r) {
144
182
  return platformRequest.parse({
@@ -174,6 +212,8 @@ export class SqliteScopeHost {
174
212
  /** operation names whose default binding some manifest withdrew (K-17). */
175
213
  withdrawn = new Map(); // operation → withdrawing module
176
214
  relations = new Map();
215
+ /** entityType → the declared attachment gate (#473): read key + write key (default: read). */
216
+ attachmentTargets = new Map();
177
217
  /** operation name → its owning module's entitlementKey (§4.3 gate). */
178
218
  operationEntitlement = new Map();
179
219
  roles = new Map(); // 'tenantId/roleKey'
@@ -291,6 +331,18 @@ export class SqliteScopeHost {
291
331
  created_at TEXT NOT NULL,
292
332
  PRIMARY KEY (tenant_id, vertical, binding)
293
333
  );
334
+ -- Per-tenant blob stores (#473) — the tenant_stores twin for attachment bytes.
335
+ -- ref is the opaque handle: a per-tenant directory token here, an R2 bucket name
336
+ -- on Cloudflare. Same idempotency + reap ledger role.
337
+ CREATE TABLE IF NOT EXISTS blob_stores (
338
+ tenant_id TEXT NOT NULL,
339
+ vertical TEXT NOT NULL,
340
+ binding TEXT NOT NULL,
341
+ kind TEXT NOT NULL,
342
+ ref TEXT NOT NULL,
343
+ created_at TEXT NOT NULL,
344
+ PRIMARY KEY (tenant_id, vertical, binding)
345
+ );
294
346
  -- The vertical + version registry (#31). A scope binds to a VERSION, so
295
347
  -- dev/staging/prod are the same vertical pinned differently, and a preview
296
348
  -- deployment is a version nothing has been promoted to yet.
@@ -458,11 +510,15 @@ export class SqliteScopeHost {
458
510
  created_at TEXT NOT NULL,
459
511
  revoked_at TEXT
460
512
  );
461
- -- One LIVE connection per (tenant, vertical, provider). Revoked rows are
462
- -- kept as evidence (K-21's tombstone rule) and must not block a successor,
463
- -- which is why the index is partial rather than a table constraint.
464
- CREATE UNIQUE INDEX IF NOT EXISTS _substrat_connections_live
465
- ON _substrat_connections (tenant_id, vertical, provider)
513
+ -- One LIVE connection per (tenant, vertical, provider, account). Revoked
514
+ -- rows are kept as evidence (K-21's tombstone rule) and must not block a
515
+ -- successor, which is why the index is partial rather than a table
516
+ -- constraint. The account leg is COALESCEd so providers that never set an
517
+ -- external_account_ref keep the original singleton semantics (all their
518
+ -- NULLs collide on ''), while a multi-namespace provider (GitHub) holds
519
+ -- one live connection PER account — the Vercel git-namespace shape.
520
+ CREATE UNIQUE INDEX IF NOT EXISTS _substrat_connections_live_account
521
+ ON _substrat_connections (tenant_id, vertical, provider, COALESCE(external_account_ref, ''))
466
522
  WHERE revoked_at IS NULL;
467
523
  -- Sealed credentials, in their own table so that reading a connection's
468
524
  -- METADATA never touches ciphertext. Nothing above SecretBox sees plaintext.
@@ -693,6 +749,22 @@ export class SqliteScopeHost {
693
749
  parents.add(rel.parentType);
694
750
  this.relations.set(rel.entityType, parents);
695
751
  }
752
+ // Attachment targets (#473): entityType → the permission gate the kernel's attachment
753
+ // surface enforces. Two modules re-declaring the same entityType with the SAME gate is
754
+ // tolerated (idempotent); with different gates it is refused — ambiguous authority
755
+ // over who may read an entity's attachments must not depend on registration order.
756
+ for (const target of manifest.attachmentTargets) {
757
+ const gate = {
758
+ read: target.readPermission,
759
+ write: target.writePermission ?? target.readPermission,
760
+ };
761
+ const existing = this.attachmentTargets.get(target.entityType);
762
+ if (existing && (existing.read !== gate.read || existing.write !== gate.write)) {
763
+ throw new Error(`conflicting attachmentTargets for '${target.entityType}': ` +
764
+ `(${existing.read}/${existing.write}) vs (${gate.read}/${gate.write})`);
765
+ }
766
+ this.attachmentTargets.set(target.entityType, gate);
767
+ }
696
768
  // WITHDRAWAL (K-17): suppress another module's default binding. Order
697
769
  // independent — a manifest may withdraw an operation whose module has not
698
770
  // registered yet (recorded here, skipped at defineOperation) or one already
@@ -864,6 +936,289 @@ export class SqliteScopeHost {
864
936
  }
865
937
  return db;
866
938
  }
939
+ async provisionBlobStore(actor, input) {
940
+ // Same fail-closed tenant gate and (tenant, vertical, binding) idempotency as
941
+ // provisionTenantStore (#301) — the blob store is the fourth store shape, not a
942
+ // new lifecycle (#473).
943
+ const tenantRow = this.directory
944
+ .prepare('SELECT status FROM tenants WHERE tenant_id = ?')
945
+ .get(input.tenantId);
946
+ if (!tenantRow) {
947
+ throw new Error(`cannot provision blob store under unknown tenant: ${input.tenantId}`);
948
+ }
949
+ if (tenantRow.status !== 'active') {
950
+ throw new Error(`cannot provision blob store under non-active tenant (status: ${tenantRow.status}): ${input.tenantId}`);
951
+ }
952
+ const existing = this.directory
953
+ .prepare('SELECT ref FROM blob_stores WHERE tenant_id = ? AND vertical = ? AND binding = ?')
954
+ .get(input.tenantId, input.vertical, input.binding);
955
+ if (existing) {
956
+ return { binding: input.binding, kind: 'blob', ref: existing.ref };
957
+ }
958
+ // The opaque `ref` is a bare directory name under `dir`, prefixed `blob__` so it can
959
+ // never collide with a scope DB or a tenant store file. On Cloudflare the ref is an
960
+ // R2 bucket name; here it is a per-tenant directory — the same file-per-unit grain
961
+ // the scope DBs use, so the whole path runs in dev/CI without Cloudflare.
962
+ const safeVertical = input.vertical.replace(/[^A-Za-z0-9_-]+/g, '-');
963
+ const ref = `blob__${input.tenantId}__${safeVertical}__${input.binding}__${ulid()}`;
964
+ mkdirSync(join(this.dir, ref), { recursive: true });
965
+ this.directory
966
+ .prepare(`INSERT INTO blob_stores (tenant_id, vertical, binding, kind, ref, created_at)
967
+ VALUES (?, ?, ?, 'blob', ?, ?)`)
968
+ .run(input.tenantId, input.vertical, input.binding, ref, new Date().toISOString());
969
+ this.recordAdmin(actor, 'provisionBlobStore', { tenantId: input.tenantId, vertical: input.vertical }, null, { binding: input.binding, kind: 'blob', ref });
970
+ return { binding: input.binding, kind: 'blob', ref };
971
+ }
972
+ /** A `TenantBlobStore` over the per-tenant directory a `ref` names (#473). Keys are
973
+ * platform-derived (`attachmentBlobKey`), but both `ref` and key are still guarded at
974
+ * the open boundary (parse, don't trust) so neither can escape `this.dir`. */
975
+ blobStore(ref) {
976
+ if (ref.includes('/') || ref.includes('\\') || ref.includes('..')) {
977
+ throw new Error(`invalid blob-store ref (must be a bare directory name): ${ref}`);
978
+ }
979
+ const root = join(this.dir, ref);
980
+ const resolveKey = (key) => {
981
+ if (key.startsWith('/') || key.includes('\\') || key.split('/').some((s) => s === '' || s === '.' || s === '..')) {
982
+ throw new Error(`invalid blob key: ${key}`);
983
+ }
984
+ return join(root, key);
985
+ };
986
+ const walk = (d, rel, out) => {
987
+ if (!existsSync(d))
988
+ return;
989
+ for (const e of readdirSync(d, { withFileTypes: true })) {
990
+ const r = rel ? `${rel}/${e.name}` : e.name;
991
+ if (e.isDirectory())
992
+ walk(join(d, e.name), r, out);
993
+ else if (!e.name.endsWith('.meta'))
994
+ out.push(r);
995
+ }
996
+ };
997
+ return {
998
+ put: async (key, body, opts) => {
999
+ const p = resolveKey(key);
1000
+ mkdirSync(dirname(p), { recursive: true });
1001
+ writeFileSync(p, body);
1002
+ writeFileSync(`${p}.meta`, JSON.stringify({ contentType: opts?.contentType ?? null }));
1003
+ },
1004
+ get: async (key) => {
1005
+ const p = resolveKey(key);
1006
+ if (!existsSync(p))
1007
+ return null;
1008
+ const body = new Uint8Array(readFileSync(p));
1009
+ let contentType;
1010
+ try {
1011
+ const meta = JSON.parse(readFileSync(`${p}.meta`, 'utf8'));
1012
+ contentType = meta.contentType ?? undefined;
1013
+ }
1014
+ catch {
1015
+ // No sidecar (or unreadable): the caller falls back to the metadata row.
1016
+ }
1017
+ return contentType !== undefined ? { body, contentType } : { body };
1018
+ },
1019
+ delete: async (key) => {
1020
+ const p = resolveKey(key);
1021
+ rmSync(p, { force: true });
1022
+ rmSync(`${p}.meta`, { force: true });
1023
+ },
1024
+ list: async (prefix) => {
1025
+ const out = [];
1026
+ walk(root, '', out);
1027
+ return out.filter((k) => k.startsWith(prefix)).sort();
1028
+ },
1029
+ };
1030
+ }
1031
+ /** Resolve the blob store that carries a scope's attachments (#473): the platform-minted
1032
+ * store for (tenant, the scope's vertical). Fails loudly when none was provisioned —
1033
+ * the K-31 posture — and refuses ambiguity when several are, unless one is named
1034
+ * ATTACHMENTS (the convention the deploy vocabulary documents). */
1035
+ attachmentStore(tenantId, vertical) {
1036
+ if (!vertical) {
1037
+ throw new Error('scope runs no vertical; attachments need the vertical\'s platform blob store (#473)');
1038
+ }
1039
+ const rows = this.directory
1040
+ .prepare('SELECT binding, ref FROM blob_stores WHERE tenant_id = ? AND vertical = ? ORDER BY binding')
1041
+ .all(tenantId, vertical);
1042
+ if (rows.length === 0) {
1043
+ throw new Error(`no blob store provisioned for (${tenantId}, ${vertical}) — declare runtimeNeeds.blobStores ` +
1044
+ `and provision it in the tenant lifecycle (provisionBlobStore, #473)`);
1045
+ }
1046
+ const chosen = rows.length === 1 ? rows[0] : rows.find((r) => r.binding === 'ATTACHMENTS');
1047
+ if (!chosen) {
1048
+ throw new Error(`multiple blob stores provisioned for (${tenantId}, ${vertical}); name the one that ` +
1049
+ `carries attachments ATTACHMENTS`);
1050
+ }
1051
+ return this.blobStore(chosen.ref);
1052
+ }
1053
+ async attachments(principal, tenantId, scopeId) {
1054
+ // Same fail-closed (tenantId, scopeId) pair + lifecycle gates + lazy-migration
1055
+ // retry as minting a stub — getScope IS that gate, so go through it.
1056
+ await this.getScope(principal, tenantId, scopeId);
1057
+ const rt = this.runtime(tenantId, scopeId);
1058
+ const scopeRow = this.directory
1059
+ .prepare('SELECT vertical FROM scopes WHERE scope_id = ?')
1060
+ .get(scopeId);
1061
+ const store = this.attachmentStore(tenantId, scopeRow?.vertical ?? null);
1062
+ return this.buildAttachments(rt, asPrincipal(principal), store);
1063
+ }
1064
+ /**
1065
+ * The attachment surface (#473) — `attachmentTargets` consumed at last. Metadata facts
1066
+ * live in `_substrat_attachments` inside the scope DB and move under the scope's strict
1067
+ * serialization, permission-checked as the ambient principal with the owning entity as
1068
+ * the per-entity ref, with a spine event in the same transaction. Bytes go straight to
1069
+ * the per-tenant blob store — never through the scope pipe (the issue's point).
1070
+ */
1071
+ buildAttachments(rt, subject, store) {
1072
+ const targetGate = (entityType) => {
1073
+ const gate = this.attachmentTargets.get(entityType);
1074
+ if (!gate) {
1075
+ throw new Error(`no registered module declares '${entityType}' in attachmentTargets — attachments bind ` +
1076
+ `only to declared entity types`);
1077
+ }
1078
+ return gate;
1079
+ };
1080
+ const rowToRecord = (row) => attachmentRecord.parse({
1081
+ id: row.id,
1082
+ entity: { entityType: row.entity_type, entityId: row.entity_id },
1083
+ filename: row.filename,
1084
+ contentType: row.content_type,
1085
+ size: row.size,
1086
+ sha256: row.sha256,
1087
+ visibility: row.visibility,
1088
+ createdBy: row.created_by,
1089
+ createdAt: row.created_at,
1090
+ });
1091
+ const sha256Hex = async (body) => {
1092
+ const digest = await globalThis.crypto.subtle.digest('SHA-256', body);
1093
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
1094
+ };
1095
+ // One attachment mutation/read = one serialized scope task, transactional exactly
1096
+ // like an operation invoke (including K-35 denial recording and prompt dispatch of
1097
+ // the events it emitted).
1098
+ const guarded = (operation, fn) => rt.actor.enqueue(async () => {
1099
+ const ctx = this.operationContext(rt, subject);
1100
+ rt.db.exec('BEGIN IMMEDIATE');
1101
+ let result;
1102
+ try {
1103
+ result = await fn(ctx);
1104
+ rt.db.exec('COMMIT');
1105
+ }
1106
+ catch (err) {
1107
+ rt.db.exec('ROLLBACK');
1108
+ if (err instanceof PermissionDenied)
1109
+ this.recordDenial(rt, subject, operation, err);
1110
+ throw err;
1111
+ }
1112
+ await this.dispatch(rt);
1113
+ await this.dispatchExecutors(rt);
1114
+ return result;
1115
+ });
1116
+ return {
1117
+ upload: async (input) => {
1118
+ const gate = targetGate(input.entity.entityType);
1119
+ const id = ulid();
1120
+ const key = attachmentBlobKey(rt.scopeId, id);
1121
+ const record = attachmentRecord.parse({
1122
+ id,
1123
+ entity: input.entity,
1124
+ filename: input.filename,
1125
+ contentType: input.contentType,
1126
+ size: input.body.byteLength,
1127
+ sha256: await sha256Hex(input.body),
1128
+ visibility: input.visibility,
1129
+ createdBy: subject.id,
1130
+ createdAt: new Date().toISOString(),
1131
+ });
1132
+ // Bytes first, row second: a crash between the two leaves an orphaned object
1133
+ // (harmless, GC-able via list), never a row without bytes. A refused check
1134
+ // compensates the object away below.
1135
+ await store.put(key, input.body, { contentType: input.contentType });
1136
+ try {
1137
+ return await guarded('attachments.upload', async (ctx) => {
1138
+ assertAllowed(await ctx.check(gate.write, input.entity));
1139
+ rt.db
1140
+ .prepare(`INSERT INTO _substrat_attachments
1141
+ (id, entity_type, entity_id, filename, content_type, size, sha256,
1142
+ visibility, created_by, created_at)
1143
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
1144
+ .run(record.id, record.entity.entityType, record.entity.entityId, record.filename, record.contentType, record.size, record.sha256, record.visibility, record.createdBy, record.createdAt);
1145
+ ctx.emit({
1146
+ type: ATTACHMENT_ADDED,
1147
+ schemaVersion: 1,
1148
+ entity: input.entity,
1149
+ piiClass: 'none',
1150
+ payload: { attachment: record },
1151
+ });
1152
+ return record;
1153
+ });
1154
+ }
1155
+ catch (err) {
1156
+ await store.delete(key).catch(() => { });
1157
+ throw err;
1158
+ }
1159
+ },
1160
+ list: async (entity) => {
1161
+ const gate = targetGate(entity.entityType);
1162
+ return guarded('attachments.list', async (ctx) => {
1163
+ assertAllowed(await ctx.check(gate.read, entity));
1164
+ const rows = rt.db
1165
+ .prepare(`SELECT * FROM _substrat_attachments WHERE entity_type = ? AND entity_id = ?
1166
+ ORDER BY id DESC`)
1167
+ .all(entity.entityType, entity.entityId);
1168
+ return rows.map(rowToRecord);
1169
+ });
1170
+ },
1171
+ open: async (attachmentId) => {
1172
+ const record = await guarded('attachments.open', async (ctx) => {
1173
+ const row = rt.db
1174
+ .prepare('SELECT * FROM _substrat_attachments WHERE id = ?')
1175
+ .get(attachmentId);
1176
+ if (!row)
1177
+ return null;
1178
+ const gate = targetGate(row.entity_type);
1179
+ assertAllowed(await ctx.check(gate.read, { entityType: row.entity_type, entityId: row.entity_id }));
1180
+ return rowToRecord(row);
1181
+ });
1182
+ if (!record)
1183
+ return null;
1184
+ const obj = await store.get(attachmentBlobKey(rt.scopeId, record.id));
1185
+ if (!obj) {
1186
+ throw new Error(`attachment ${record.id}: bytes missing from the blob store — the metadata row ` +
1187
+ `survived something the object did not (rewind/reap); see the #473 integrity notes`);
1188
+ }
1189
+ if ((await sha256Hex(obj.body)) !== record.sha256) {
1190
+ throw new Error(`attachment ${record.id}: bytes do not match the recorded sha256`);
1191
+ }
1192
+ return { record, body: obj.body, contentType: obj.contentType ?? record.contentType };
1193
+ },
1194
+ remove: async (attachmentId) => {
1195
+ const removed = await guarded('attachments.remove', async (ctx) => {
1196
+ const row = rt.db
1197
+ .prepare('SELECT * FROM _substrat_attachments WHERE id = ?')
1198
+ .get(attachmentId);
1199
+ if (!row)
1200
+ return null;
1201
+ const gate = targetGate(row.entity_type);
1202
+ assertAllowed(await ctx.check(gate.write, { entityType: row.entity_type, entityId: row.entity_id }));
1203
+ rt.db.prepare('DELETE FROM _substrat_attachments WHERE id = ?').run(attachmentId);
1204
+ const record = rowToRecord(row);
1205
+ ctx.emit({
1206
+ type: ATTACHMENT_REMOVED,
1207
+ schemaVersion: 1,
1208
+ entity: record.entity,
1209
+ piiClass: 'none',
1210
+ payload: { attachment: record },
1211
+ });
1212
+ return record;
1213
+ });
1214
+ // Row (and event) first, bytes second: at worst an orphaned object, never a
1215
+ // dangling row.
1216
+ if (removed)
1217
+ await store.delete(attachmentBlobKey(rt.scopeId, removed.id)).catch(() => { });
1218
+ return removed;
1219
+ },
1220
+ };
1221
+ }
867
1222
  async importScope(actor, input, dump) {
868
1223
  // Create the destination scope (directory row + storage). Its migration step
869
1224
  // creates some tables; the dump then replaces them wholesale, so the end state
@@ -1006,7 +1361,7 @@ export class SqliteScopeHost {
1006
1361
  kind: rec.kind,
1007
1362
  });
1008
1363
  }
1009
- async getScope(principal, tenantId, scopeId) {
1364
+ async getScope(principal, tenantId, scopeId, options) {
1010
1365
  const row = this.directory
1011
1366
  .prepare('SELECT tenant_id, status FROM scopes WHERE scope_id = ?')
1012
1367
  .get(scopeId);
@@ -1045,7 +1400,7 @@ export class SqliteScopeHost {
1045
1400
  // the migration's own message, which is the one an operator needs.
1046
1401
  throw new Error(`scope not active (status: ${row.status}): ${scopeId}`);
1047
1402
  }
1048
- return this.buildStub(tenantId, scopeId, rt, asPrincipal(principal));
1403
+ return this.buildStub(tenantId, scopeId, rt, asPrincipal(principal), options);
1049
1404
  }
1050
1405
  /**
1051
1406
  * A scope stub whose authority is a CONNECTION (#97).
@@ -1190,12 +1545,12 @@ export class SqliteScopeHost {
1190
1545
  return report;
1191
1546
  }
1192
1547
  /** The stub body, shared by the principal and connection doors. */
1193
- buildStub(tenantId, scopeId, rt, subject) {
1548
+ buildStub(tenantId, scopeId, rt, subject, options) {
1194
1549
  const operations = this.operations;
1195
1550
  return {
1196
1551
  tenantId,
1197
1552
  scopeId,
1198
- invoke: (operation, input) => {
1553
+ invoke: async (operation, input) => {
1199
1554
  const handler = operations.get(operation);
1200
1555
  if (!handler)
1201
1556
  return Promise.reject(new Error(`unknown operation: ${operation}`));
@@ -1207,10 +1562,14 @@ export class SqliteScopeHost {
1207
1562
  if (requiredKey && !this.tenantHoldsEntitlement(tenantId, requiredKey)) {
1208
1563
  return Promise.reject(new Error(`operation not entitled: ${operation} — tenant does not hold '${requiredKey}'`));
1209
1564
  }
1210
- return rt.actor.enqueue(async () => {
1565
+ // #458: per-invoke tally of `ctx.requestPlatform` calls. Reported to the
1566
+ // harness only after the enqueue task resolves — i.e. after COMMIT — so a
1567
+ // rolled-back intent never signals.
1568
+ const signals = { platformRequests: 0 };
1569
+ const invoked = await rt.actor.enqueue(async () => {
1211
1570
  // Fresh per operation: the context carries the K-34 authorization accumulator,
1212
1571
  // which must not leak across operations (invokes are serialized per scope).
1213
- const ctx = this.operationContext(rt, subject);
1572
+ const ctx = this.operationContext(rt, subject, undefined, signals);
1214
1573
  const clonedInput = structuredClone(input);
1215
1574
  rt.db.exec('BEGIN IMMEDIATE');
1216
1575
  let result;
@@ -1238,6 +1597,9 @@ export class SqliteScopeHost {
1238
1597
  await this.dispatchExecutors(rt);
1239
1598
  return structuredClone(result);
1240
1599
  });
1600
+ if (signals.platformRequests > 0)
1601
+ options?.onPlatformRequests?.(signals.platformRequests);
1602
+ return invoked;
1241
1603
  },
1242
1604
  };
1243
1605
  }
@@ -1831,9 +2193,24 @@ export class SqliteScopeHost {
1831
2193
  where.push('source = ?');
1832
2194
  params.push(filter.source);
1833
2195
  }
1834
- const sql = 'SELECT tenant_id, role_key, permissions, source FROM _substrat_roles' +
2196
+ const order = (filter?.order ?? 'asc') === 'desc' ? 'DESC' : 'ASC';
2197
+ if (filter?.cursor) {
2198
+ // Composite sort key `${tenantId}|${roleKey}` (pagination.ts) — the
2199
+ // tenant id is a ULID and never contains '|', so split on the FIRST.
2200
+ const split = filter.cursor.indexOf('|');
2201
+ const afterTenant = split === -1 ? filter.cursor : filter.cursor.slice(0, split);
2202
+ const afterKey = split === -1 ? '' : filter.cursor.slice(split + 1);
2203
+ const cmp = order === 'DESC' ? '<' : '>';
2204
+ where.push(`(tenant_id ${cmp} ? OR (tenant_id = ? AND role_key ${cmp} ?))`);
2205
+ params.push(afterTenant, afterTenant, afterKey);
2206
+ }
2207
+ let sql = 'SELECT tenant_id, role_key, permissions, source FROM _substrat_roles' +
1835
2208
  (where.length ? ` WHERE ${where.join(' AND ')}` : '') +
1836
- ' ORDER BY tenant_id, role_key';
2209
+ ` ORDER BY tenant_id ${order}, role_key ${order}`;
2210
+ if (filter?.limit !== undefined) {
2211
+ sql += ' LIMIT ?';
2212
+ params.push(filter.limit);
2213
+ }
1837
2214
  const rows = this.directory.prepare(sql).all(...params);
1838
2215
  // Parsed, not cast: `permissions` is a JSON blob in a TEXT column, so the
1839
2216
  // contract is the only thing standing between a corrupted row and the
@@ -2047,10 +2424,11 @@ export class SqliteScopeHost {
2047
2424
  where.push('status = ?');
2048
2425
  params.push(filter.status);
2049
2426
  }
2427
+ const tail = keysetTail(where, params, 'hostname', filter);
2050
2428
  let sql = 'SELECT * FROM hostnames';
2051
2429
  if (where.length)
2052
2430
  sql += ` WHERE ${where.join(' AND ')}`;
2053
- sql += ' ORDER BY hostname';
2431
+ sql += tail;
2054
2432
  const rows = this.directory.prepare(sql).all(...params);
2055
2433
  this.recordAccess(actor, 'listHostnames', { tenantId: filter?.tenantId ?? null, scopeId: filter?.scopeId ?? null }, filter, rows.length);
2056
2434
  return rows.map(mapHostname);
@@ -2135,11 +2513,13 @@ export class SqliteScopeHost {
2135
2513
  .run(parsed.slug, parsed.name, parsed.source, parsed.ownerTenant, envSpecJson, installSpecJson, parsed.listed ? 1 : 0, new Date().toISOString());
2136
2514
  this.recordAdmin(actor, 'registerVertical', { tenantId: null }, null, parsed);
2137
2515
  },
2138
- listVerticals: async (actor) => {
2139
- const rows = this.directory
2140
- .prepare('SELECT * FROM verticals ORDER BY slug')
2141
- .all();
2142
- this.recordAccess(actor, 'listVerticals', {}, null, rows.length);
2516
+ listVerticals: async (actor, page) => {
2517
+ const where = [];
2518
+ const params = [];
2519
+ const tail = keysetTail(where, params, 'slug', page);
2520
+ const sql = 'SELECT * FROM verticals' + (where.length ? ` WHERE ${where.join(' AND ')}` : '') + tail;
2521
+ const rows = this.directory.prepare(sql).all(...params);
2522
+ this.recordAccess(actor, 'listVerticals', {}, page ?? null, rows.length);
2143
2523
  return rows.map(mapVertical);
2144
2524
  },
2145
2525
  publishVersion: async (actor, input) => {
@@ -2167,10 +2547,13 @@ export class SqliteScopeHost {
2167
2547
  admission: selfAdmits ? 'admitted' : 'pending',
2168
2548
  });
2169
2549
  },
2170
- listVersions: async (actor, verticalSlug) => {
2550
+ listVersions: async (actor, verticalSlug, page) => {
2551
+ const where = ['vertical_slug = ?'];
2552
+ const params = [verticalSlug];
2553
+ const tail = keysetTail(where, params, 'id', page);
2171
2554
  const rows = this.directory
2172
- .prepare('SELECT * FROM vertical_versions WHERE vertical_slug = ? ORDER BY id')
2173
- .all(verticalSlug);
2555
+ .prepare(`SELECT * FROM vertical_versions WHERE ${where.join(' AND ')}${tail}`)
2556
+ .all(...params);
2174
2557
  this.recordAccess(actor, 'listVersions', {}, { verticalSlug }, rows.length);
2175
2558
  return rows.map(mapVersion);
2176
2559
  },
@@ -2226,13 +2609,22 @@ export class SqliteScopeHost {
2226
2609
  const existing = readVertical(slug);
2227
2610
  if (!existing)
2228
2611
  throw new Error(`unknown vertical '${slug}'`);
2229
- // Refuse while any scope is bound: a deleted registry row would strand those
2230
- // scopes' version pins and routing. The count names the blast radius.
2612
+ // Refuse while any restorable scope is bound: a deleted registry row would strand
2613
+ // those scopes' version pins and routing. An `archived` scope (a deleted app) still
2614
+ // blocks — unarchive can bring it back — but the refusal names reap/restore, not
2615
+ // "delete", because the app itself is already gone. `reaped` is terminal history
2616
+ // and never blocks. The count names the blast radius.
2231
2617
  const bound = this.directory
2232
- .prepare('SELECT COUNT(*) AS n FROM scopes WHERE vertical = ?')
2618
+ .prepare('SELECT ' +
2619
+ "COUNT(*) FILTER (WHERE status NOT IN ('archived', 'reaped')) AS live, " +
2620
+ "COUNT(*) FILTER (WHERE status = 'archived') AS archived " +
2621
+ 'FROM scopes WHERE vertical = ?')
2233
2622
  .get(slug);
2234
- if (bound.n > 0) {
2235
- throw new Error(`vertical '${slug}' still backs ${bound.n} scope(s) — delete or rebind them first`);
2623
+ if (bound.live > 0) {
2624
+ throw new Error(`vertical '${slug}' still backs ${bound.live} scope(s) — delete or rebind them first`);
2625
+ }
2626
+ if (bound.archived > 0) {
2627
+ throw new Error(`vertical '${slug}' still backs ${bound.archived} archived scope(s) — reap or restore them first`);
2236
2628
  }
2237
2629
  // Deployed dispatch scripts are NOT reaped here — they become orphans for the
2238
2630
  // cleanup script (#248), never destroyed alongside a registry row.
@@ -2369,10 +2761,13 @@ export class SqliteScopeHost {
2369
2761
  // "someone reviewed the permission change" into evidence.
2370
2762
  this.recordAdmin(actor, 'promoteVersion', { tenantId: null, vertical: verticalSlug }, outgoing ? { versionId: outgoing.id, version: outgoing.version } : null, { channel, versionId, version: incoming.version, acknowledged: ack });
2371
2763
  },
2372
- listChannels: async (actor, verticalSlug) => {
2764
+ listChannels: async (actor, verticalSlug, page) => {
2765
+ const where = ['vertical_slug = ?'];
2766
+ const params = [verticalSlug];
2767
+ const tail = keysetTail(where, params, 'channel', page);
2373
2768
  const rows = this.directory
2374
- .prepare('SELECT * FROM vertical_channels WHERE vertical_slug = ? ORDER BY channel')
2375
- .all(verticalSlug);
2769
+ .prepare(`SELECT * FROM vertical_channels WHERE ${where.join(' AND ')}${tail}`)
2770
+ .all(...params);
2376
2771
  // The serving script runs ONE version (#286); surface it on the prod row so a
2377
2772
  // failed in-place serve reads honestly instead of claiming the new version is
2378
2773
  // live (#321). Parity with the Cloudflare host.
@@ -2388,14 +2783,18 @@ export class SqliteScopeHost {
2388
2783
  servingVersionId: r.channel === 'prod' ? serving : null,
2389
2784
  }));
2390
2785
  },
2391
- listChannelHistory: async (actor, verticalSlug, channel) => {
2392
- const rows = (channel
2393
- ? this.directory
2394
- .prepare('SELECT * FROM vertical_channel_history WHERE vertical_slug = ? AND channel = ? ORDER BY id DESC')
2395
- .all(verticalSlug, channel)
2396
- : this.directory
2397
- .prepare('SELECT * FROM vertical_channel_history WHERE vertical_slug = ? ORDER BY id DESC')
2398
- .all(verticalSlug));
2786
+ listChannelHistory: async (actor, verticalSlug, channel, page) => {
2787
+ const where = ['vertical_slug = ?'];
2788
+ const params = [verticalSlug];
2789
+ if (channel) {
2790
+ where.push('channel = ?');
2791
+ params.push(channel);
2792
+ }
2793
+ // Newest first is the shipped order, so 'desc' is the DEFAULT here.
2794
+ const tail = keysetTail(where, params, 'id', page, 'desc');
2795
+ const rows = this.directory
2796
+ .prepare(`SELECT * FROM vertical_channel_history WHERE ${where.join(' AND ')}${tail}`)
2797
+ .all(...params);
2399
2798
  this.recordAccess(actor, 'listChannelHistory', {}, { verticalSlug, channel }, rows.length);
2400
2799
  return rows.map((r) => channelHistoryEntry.parse({
2401
2800
  id: r.id,
@@ -2630,10 +3029,14 @@ export class SqliteScopeHost {
2630
3029
  this.directory.prepare('UPDATE tenants SET name = ? WHERE tenant_id = ?').run(name, tenantId);
2631
3030
  this.recordAdmin(actor, 'setTenantName', { tenantId }, { name: before.name }, { name });
2632
3031
  },
2633
- listTenants: async (actor) => {
2634
- const rows = this.directory.prepare('SELECT * FROM tenants ORDER BY tenant_id').all().map(mapTenant);
3032
+ listTenants: async (actor, page) => {
3033
+ const where = [];
3034
+ const params = [];
3035
+ const tail = keysetTail(where, params, 'tenant_id', page);
3036
+ const sql = 'SELECT * FROM tenants' + (where.length ? ` WHERE ${where.join(' AND ')}` : '') + tail;
3037
+ const rows = this.directory.prepare(sql).all(...params).map(mapTenant);
2635
3038
  // Enumerating every tenant on the platform is the read this log exists for.
2636
- this.recordAccess(actor, 'listTenants', {}, null, rows.length);
3039
+ this.recordAccess(actor, 'listTenants', {}, page ?? null, rows.length);
2637
3040
  return rows;
2638
3041
  },
2639
3042
  getTenant: async (actor, tenantId) => {
@@ -2661,9 +3064,8 @@ export class SqliteScopeHost {
2661
3064
  where.push('vertical = ?');
2662
3065
  params.push(filter.vertical);
2663
3066
  }
2664
- const sql = 'SELECT * FROM scopes' +
2665
- (where.length ? ` WHERE ${where.join(' AND ')}` : '') +
2666
- ' ORDER BY scope_id';
3067
+ const tail = keysetTail(where, params, 'scope_id', filter);
3068
+ const sql = 'SELECT * FROM scopes' + (where.length ? ` WHERE ${where.join(' AND ')}` : '') + tail;
2667
3069
  const scopes = this.directory.prepare(sql).all(...params).map(mapScope);
2668
3070
  this.recordAccess(actor, 'listScopes', { tenantId: filter?.tenantId ?? null }, filter, scopes.length);
2669
3071
  return scopes;
@@ -2694,6 +3096,32 @@ export class SqliteScopeHost {
2694
3096
  createdAt: r.created_at,
2695
3097
  }));
2696
3098
  },
3099
+ listBlobStores: async (actor, filter) => {
3100
+ const where = [];
3101
+ const params = [];
3102
+ if (filter?.tenantId) {
3103
+ where.push('tenant_id = ?');
3104
+ params.push(filter.tenantId);
3105
+ }
3106
+ if (filter?.vertical) {
3107
+ where.push('vertical = ?');
3108
+ params.push(filter.vertical);
3109
+ }
3110
+ const rows = this.directory
3111
+ .prepare('SELECT * FROM blob_stores' +
3112
+ (where.length ? ` WHERE ${where.join(' AND ')}` : '') +
3113
+ ' ORDER BY tenant_id, vertical, binding')
3114
+ .all(...params);
3115
+ this.recordAccess(actor, 'listBlobStores', { tenantId: filter?.tenantId ?? null }, filter, rows.length);
3116
+ return rows.map((r) => ({
3117
+ tenantId: r.tenant_id,
3118
+ vertical: r.vertical,
3119
+ binding: r.binding,
3120
+ kind: 'blob',
3121
+ ref: r.ref,
3122
+ createdAt: r.created_at,
3123
+ }));
3124
+ },
2697
3125
  getScopeRecord: async (actor, tenantId, scopeId) => {
2698
3126
  const r = this.directory.prepare('SELECT * FROM scopes WHERE scope_id = ?').get(scopeId);
2699
3127
  // Cross-check the pair (K-3): a scope that exists under a DIFFERENT tenant
@@ -3020,8 +3448,11 @@ export class SqliteScopeHost {
3020
3448
  }
3021
3449
  catch (err) {
3022
3450
  if (/UNIQUE constraint failed/i.test(err.message)) {
3451
+ const account = input.externalAccountRef
3452
+ ? ` under account '${input.externalAccountRef}'`
3453
+ : '';
3023
3454
  throw new Error(`tenant ${input.tenantId} already has a live '${input.provider}' connection ` +
3024
- `for vertical '${input.vertical}' — revoke it before connecting another`);
3455
+ `for vertical '${input.vertical}'${account} — revoke it before connecting another`);
3025
3456
  }
3026
3457
  throw err;
3027
3458
  }
@@ -3051,6 +3482,8 @@ export class SqliteScopeHost {
3051
3482
  (where.push('vertical = ?'), params.push(f.vertical));
3052
3483
  if (f.provider)
3053
3484
  (where.push('provider = ?'), params.push(f.provider));
3485
+ if (f.externalAccountRef)
3486
+ (where.push('external_account_ref = ?'), params.push(f.externalAccountRef));
3054
3487
  if (!f.includeRevoked)
3055
3488
  where.push('revoked_at IS NULL');
3056
3489
  const rows = this.directory
@@ -3099,11 +3532,22 @@ export class SqliteScopeHost {
3099
3532
  .run(id);
3100
3533
  this.recordAdmin(actor, 'revokeConnection', { tenantId: row.tenant_id, vertical: row.vertical }, { status: row.status }, { id, provider: row.provider, status: 'revoked', revokedAt: now });
3101
3534
  },
3102
- openConnection: async (tenantId, vertical, provider) => {
3103
- const row = this.directory
3535
+ openConnection: async (tenantId, vertical, provider, externalAccountRef) => {
3536
+ const rows = this.directory
3104
3537
  .prepare(`SELECT * FROM _substrat_connections
3105
- WHERE tenant_id = ? AND vertical = ? AND provider = ? AND revoked_at IS NULL`)
3106
- .get(tenantId, vertical, provider);
3538
+ WHERE tenant_id = ? AND vertical = ? AND provider = ? AND revoked_at IS NULL
3539
+ ${externalAccountRef === undefined ? '' : 'AND external_account_ref = ?'}
3540
+ LIMIT 2`)
3541
+ .all(...(externalAccountRef === undefined
3542
+ ? [tenantId, vertical, provider]
3543
+ : [tenantId, vertical, provider, externalAccountRef]));
3544
+ // Several live accounts and no selector: failing beats acting against
3545
+ // an arbitrary one of the tenant's provider accounts (scope-host.ts).
3546
+ if (rows.length > 1) {
3547
+ throw new Error(`tenant ${tenantId} has multiple live '${provider}' connections for vertical ` +
3548
+ `'${vertical}' — pass externalAccountRef to select one`);
3549
+ }
3550
+ const row = rows[0];
3107
3551
  if (!row)
3108
3552
  return undefined;
3109
3553
  const sealed = this.directory
@@ -3220,14 +3664,12 @@ export class SqliteScopeHost {
3220
3664
  where.push('method = ?');
3221
3665
  params.push(filter.method);
3222
3666
  }
3667
+ // Cursor + order mirror auditLog: the id is a ULID, so it IS the cursor.
3668
+ const tail = keysetTail(where, params, 'id', filter);
3223
3669
  let sql = 'SELECT * FROM _substrat_access_log';
3224
3670
  if (where.length)
3225
3671
  sql += ` WHERE ${where.join(' AND ')}`;
3226
- sql += ' ORDER BY id';
3227
- if (filter?.limit !== undefined) {
3228
- sql += ' LIMIT ?';
3229
- params.push(filter.limit);
3230
- }
3672
+ sql += tail;
3231
3673
  const rows = this.directory.prepare(sql).all(...params);
3232
3674
  // Reading the access log is itself a read. Recorded BEFORE the rows are
3233
3675
  // returned, so the row describing this call is not in its own result.
@@ -3412,6 +3854,11 @@ export class SqliteScopeHost {
3412
3854
  ensureDirectoryColumns() {
3413
3855
  this.ensureIdentityKey();
3414
3856
  this.ensureAdminLogTenantNullable();
3857
+ // The pre-account-aware live-uniqueness index: superseded by
3858
+ // _substrat_connections_live_account (created in the bootstrap DDL), which
3859
+ // adds the external-account leg so one tenant can hold the same provider
3860
+ // under several accounts. Existing data always satisfies the wider key.
3861
+ this.directory.exec('DROP INDEX IF EXISTS _substrat_connections_live');
3415
3862
  this.ensureColumn(this.directory, '_substrat_admin_log', 'caused_by', 'caused_by TEXT');
3416
3863
  // §4.8's grace-window timestamp on tenants (mirrors scopes' archived_at).
3417
3864
  this.ensureColumn(this.directory, 'tenants', 'deleting_at', 'deleting_at TEXT');
@@ -3508,7 +3955,9 @@ export class SqliteScopeHost {
3508
3955
  * can never disagree about who acted (#97). `overrideActor` remains for the
3509
3956
  * system-actor path, where the acting module is the honest answer.
3510
3957
  */
3511
- operationContext(rt, subject, overrideActor) {
3958
+ operationContext(rt, subject, overrideActor,
3959
+ /** #458: per-invoke tally of `ctx.requestPlatform` calls; absent for consumer dispatch. */
3960
+ signals) {
3512
3961
  const principal = subject.id;
3513
3962
  const checker = this.checker;
3514
3963
  const relations = this.relations;
@@ -3567,6 +4016,8 @@ export class SqliteScopeHost {
3567
4016
  (id, kind, payload, requested_by, status, attempts, requested_at)
3568
4017
  VALUES (?, ?, ?, ?, 'pending', 0, ?)`)
3569
4018
  .run(id, input.kind, JSON.stringify(input.payload ?? null), JSON.stringify(requestedBy), instant.parse(new Date().toISOString()));
4019
+ if (signals)
4020
+ signals.platformRequests += 1;
3570
4021
  return id;
3571
4022
  },
3572
4023
  check: async (permission, entity) => {