@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.d.ts +24 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +510 -59
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
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
|
|
462
|
-
-- kept as evidence (K-21's tombstone rule) and must not block a
|
|
463
|
-
-- which is why the index is partial rather than a table
|
|
464
|
-
|
|
465
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 +=
|
|
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
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
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(
|
|
2173
|
-
.all(
|
|
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
|
|
2230
|
-
// scopes' version pins and routing.
|
|
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
|
|
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.
|
|
2235
|
-
throw new Error(`vertical '${slug}' still backs ${bound.
|
|
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(
|
|
2375
|
-
.all(
|
|
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
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
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
|
|
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
|
|
2665
|
-
|
|
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
|
|
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
|
-
|
|
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 +=
|
|
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) => {
|