@synoi/gateway-lite 0.1.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/LICENSE +195 -0
- package/README.md +185 -0
- package/bin/synoi-gateway-lite.js +22 -0
- package/dist/cdro-mirror.js +280 -0
- package/dist/daemon.js +194 -0
- package/dist/gap/cited-oracle-inputs.js +283 -0
- package/dist/gap/lite-dashboard.html +502 -0
- package/dist/gap/lite-keystore.js +351 -0
- package/dist/gap/lite-mode.js +31 -0
- package/dist/gap/lite-signing-key.js +362 -0
- package/dist/gap/local-ingest-router.js +1642 -0
- package/dist/gap/operator-enrollment.js +466 -0
- package/dist/gap/store.js +1063 -0
- package/dist/gap/types.js +15 -0
- package/dist/key-provider.js +275 -0
- package/dist/keys.js +250 -0
- package/dist/native-mldsa.js +82 -0
- package/dist/receipt-store.js +801 -0
- package/dist/shadow-mode.js +85 -0
- package/dist/state-drift/quantize.js +107 -0
- package/dist/tenant-store.js +366 -0
- package/dist/verify-router.js +1755 -0
- package/npm-shrinkwrap.json +1400 -0
- package/package.json +46 -0
|
@@ -0,0 +1,1063 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* gap/store.ts - SQLite persistence for GAP objects.
|
|
4
|
+
*
|
|
5
|
+
* Shares the synoi.db file with the rest of the gateway. Indexed for
|
|
6
|
+
* sub-ms lookup on hot paths (grant evaluation, workflow runner).
|
|
7
|
+
*
|
|
8
|
+
* Tables:
|
|
9
|
+
* gap_declarations — capability declarations (long-lived)
|
|
10
|
+
* gap_grants — capability grants (long-lived, revocable)
|
|
11
|
+
* gap_invocations — capability invocations (short-lived audit)
|
|
12
|
+
* gap_workflow_definitions — workflow templates
|
|
13
|
+
* gap_workflow_instances — workflow runs
|
|
14
|
+
* gap_stage_transitions — workflow state transitions
|
|
15
|
+
* gap_channel_events — channel adapter events
|
|
16
|
+
* gap_receipts — Decision Receipts (also persisted to evidence journal)
|
|
17
|
+
* gap_revocations — revocation events
|
|
18
|
+
*
|
|
19
|
+
* V1 scope: L1 revocation only. L2/L3 are tracked in the schema but not
|
|
20
|
+
* enforced yet (separate ticket).
|
|
21
|
+
*/
|
|
22
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
23
|
+
if (k2 === undefined) k2 = k;
|
|
24
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
25
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
26
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
27
|
+
}
|
|
28
|
+
Object.defineProperty(o, k2, desc);
|
|
29
|
+
}) : (function(o, m, k, k2) {
|
|
30
|
+
if (k2 === undefined) k2 = k;
|
|
31
|
+
o[k2] = m[k];
|
|
32
|
+
}));
|
|
33
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
34
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
35
|
+
}) : function(o, v) {
|
|
36
|
+
o["default"] = v;
|
|
37
|
+
});
|
|
38
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
39
|
+
var ownKeys = function(o) {
|
|
40
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
41
|
+
var ar = [];
|
|
42
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
43
|
+
return ar;
|
|
44
|
+
};
|
|
45
|
+
return ownKeys(o);
|
|
46
|
+
};
|
|
47
|
+
return function (mod) {
|
|
48
|
+
if (mod && mod.__esModule) return mod;
|
|
49
|
+
var result = {};
|
|
50
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
51
|
+
__setModuleDefault(result, mod);
|
|
52
|
+
return result;
|
|
53
|
+
};
|
|
54
|
+
})();
|
|
55
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
56
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
57
|
+
};
|
|
58
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
59
|
+
exports.CrossTenantRevocationError = void 0;
|
|
60
|
+
exports.saveDeclaration = saveDeclaration;
|
|
61
|
+
exports.getDeclaration = getDeclaration;
|
|
62
|
+
exports.findActiveDeclarationByActor = findActiveDeclarationByActor;
|
|
63
|
+
exports.listActiveDeclarationsMatching = listActiveDeclarationsMatching;
|
|
64
|
+
exports.saveGrant = saveGrant;
|
|
65
|
+
exports.findActiveGrantsForCaller = findActiveGrantsForCaller;
|
|
66
|
+
exports.capabilityMatches = capabilityMatches;
|
|
67
|
+
exports.getGrant = getGrant;
|
|
68
|
+
exports.listActiveGrantsForTenant = listActiveGrantsForTenant;
|
|
69
|
+
exports.revokeGrant = revokeGrant;
|
|
70
|
+
exports.incrementGrantUse = incrementGrantUse;
|
|
71
|
+
exports.saveInvocation = saveInvocation;
|
|
72
|
+
exports.findInvocationByIdempotency = findInvocationByIdempotency;
|
|
73
|
+
exports.findInvocationByKeyAnyCapability = findInvocationByKeyAnyCapability;
|
|
74
|
+
exports.countGrantInvocationsInWindow = countGrantInvocationsInWindow;
|
|
75
|
+
exports.getInvocationReceiptOid = getInvocationReceiptOid;
|
|
76
|
+
exports.saveWorkflowDefinition = saveWorkflowDefinition;
|
|
77
|
+
exports.getWorkflowDefinition = getWorkflowDefinition;
|
|
78
|
+
exports.findActiveWorkflowDefinitions = findActiveWorkflowDefinitions;
|
|
79
|
+
exports.saveWorkflowInstance = saveWorkflowInstance;
|
|
80
|
+
exports.getWorkflowInstance = getWorkflowInstance;
|
|
81
|
+
exports.listWorkflowDefinitions = listWorkflowDefinitions;
|
|
82
|
+
exports.listWorkflowInstances = listWorkflowInstances;
|
|
83
|
+
exports.saveStageTransition = saveStageTransition;
|
|
84
|
+
exports.listStageTransitions = listStageTransitions;
|
|
85
|
+
exports.saveChannelEvent = saveChannelEvent;
|
|
86
|
+
exports.saveReceipt = saveReceipt;
|
|
87
|
+
exports.getReceipt = getReceipt;
|
|
88
|
+
exports.listReceipts = listReceipts;
|
|
89
|
+
exports.queryLedgerReceipts = queryLedgerReceipts;
|
|
90
|
+
exports.getReceiptsByOids = getReceiptsByOids;
|
|
91
|
+
exports.findDenialReceiptForPrincipal = findDenialReceiptForPrincipal;
|
|
92
|
+
exports.saveRevocation = saveRevocation;
|
|
93
|
+
exports.ownerTenantOf = ownerTenantOf;
|
|
94
|
+
exports.assertTenantOwnsTarget = assertTenantOwnsTarget;
|
|
95
|
+
exports.isOidRevoked = isOidRevoked;
|
|
96
|
+
exports.listRevocations = listRevocations;
|
|
97
|
+
exports.listAllTenants = listAllTenants;
|
|
98
|
+
exports.saveBinding = saveBinding;
|
|
99
|
+
exports.getBinding = getBinding;
|
|
100
|
+
exports.findActiveBindingsForCapability = findActiveBindingsForCapability;
|
|
101
|
+
exports.listBindingsForTenant = listBindingsForTenant;
|
|
102
|
+
exports.deactivateBinding = deactivateBinding;
|
|
103
|
+
exports._resetForTesting = _resetForTesting;
|
|
104
|
+
exports._testRawUpdateReceiptBody = _testRawUpdateReceiptBody;
|
|
105
|
+
exports._setQueryOnlyForTesting = _setQueryOnlyForTesting;
|
|
106
|
+
exports.deleteDemoRowsByTenantPrefix = deleteDemoRowsByTenantPrefix;
|
|
107
|
+
exports.saveDemoNdaAcceptance = saveDemoNdaAcceptance;
|
|
108
|
+
exports.getDemoNdaAcceptance = getDemoNdaAcceptance;
|
|
109
|
+
exports.migrateAgpTablesToGap = migrateAgpTablesToGap;
|
|
110
|
+
const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
|
|
111
|
+
const fs = __importStar(require("node:fs"));
|
|
112
|
+
const os = __importStar(require("node:os"));
|
|
113
|
+
const path = __importStar(require("node:path"));
|
|
114
|
+
function dataDir() {
|
|
115
|
+
return process.env['SYNOI_DATA_DIR'] ?? path.join(os.homedir(), '.synoi');
|
|
116
|
+
}
|
|
117
|
+
let _db = null;
|
|
118
|
+
function db() {
|
|
119
|
+
if (_db)
|
|
120
|
+
return _db;
|
|
121
|
+
const dir = dataDir();
|
|
122
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
123
|
+
_db = new better_sqlite3_1.default(path.join(dir, 'synoi.db'));
|
|
124
|
+
_db.pragma('journal_mode = WAL');
|
|
125
|
+
_db.exec(`
|
|
126
|
+
CREATE TABLE IF NOT EXISTS gap_declarations (
|
|
127
|
+
oid TEXT PRIMARY KEY,
|
|
128
|
+
tenant_id TEXT NOT NULL,
|
|
129
|
+
actor_type TEXT NOT NULL,
|
|
130
|
+
actor_id TEXT NOT NULL,
|
|
131
|
+
actor_version TEXT NOT NULL,
|
|
132
|
+
capabilities TEXT NOT NULL, -- JSON array
|
|
133
|
+
body TEXT NOT NULL, -- full body as JSON
|
|
134
|
+
created_at_ms INTEGER NOT NULL,
|
|
135
|
+
supersedes TEXT,
|
|
136
|
+
revoked_at_ms INTEGER
|
|
137
|
+
);
|
|
138
|
+
CREATE INDEX IF NOT EXISTS idx_gap_decl_actor
|
|
139
|
+
ON gap_declarations(tenant_id, actor_id, revoked_at_ms);
|
|
140
|
+
|
|
141
|
+
CREATE TABLE IF NOT EXISTS gap_grants (
|
|
142
|
+
oid TEXT PRIMARY KEY,
|
|
143
|
+
tenant_id TEXT NOT NULL,
|
|
144
|
+
grantee_actor_oid TEXT NOT NULL,
|
|
145
|
+
grantee_actor_type TEXT NOT NULL,
|
|
146
|
+
capability_pattern TEXT NOT NULL, -- JSON array of capability strings
|
|
147
|
+
body TEXT NOT NULL,
|
|
148
|
+
granted_at_ms INTEGER NOT NULL,
|
|
149
|
+
expires_at_ms INTEGER,
|
|
150
|
+
revoked_at_ms INTEGER,
|
|
151
|
+
uses_count INTEGER NOT NULL DEFAULT 0,
|
|
152
|
+
last_used_at_ms INTEGER
|
|
153
|
+
);
|
|
154
|
+
CREATE INDEX IF NOT EXISTS idx_gap_grants_lookup
|
|
155
|
+
ON gap_grants(tenant_id, grantee_actor_oid, revoked_at_ms, expires_at_ms);
|
|
156
|
+
|
|
157
|
+
CREATE TABLE IF NOT EXISTS gap_invocations (
|
|
158
|
+
oid TEXT PRIMARY KEY,
|
|
159
|
+
tenant_id TEXT NOT NULL,
|
|
160
|
+
caller_actor_oid TEXT NOT NULL,
|
|
161
|
+
capability TEXT NOT NULL,
|
|
162
|
+
grant_oid TEXT NOT NULL,
|
|
163
|
+
workflow_instance_oid TEXT,
|
|
164
|
+
workflow_stage_id TEXT,
|
|
165
|
+
idempotency_key TEXT,
|
|
166
|
+
body TEXT NOT NULL,
|
|
167
|
+
invoked_at_ms INTEGER NOT NULL,
|
|
168
|
+
status TEXT NOT NULL, -- ok | denied | failed | pending_workflow
|
|
169
|
+
receipt_oid TEXT
|
|
170
|
+
);
|
|
171
|
+
CREATE INDEX IF NOT EXISTS idx_gap_inv_tenant
|
|
172
|
+
ON gap_invocations(tenant_id, invoked_at_ms DESC);
|
|
173
|
+
CREATE INDEX IF NOT EXISTS idx_gap_inv_workflow
|
|
174
|
+
ON gap_invocations(workflow_instance_oid);
|
|
175
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_gap_inv_idempotency
|
|
176
|
+
ON gap_invocations(tenant_id, caller_actor_oid, capability, idempotency_key)
|
|
177
|
+
WHERE idempotency_key IS NOT NULL;
|
|
178
|
+
|
|
179
|
+
CREATE TABLE IF NOT EXISTS gap_workflow_definitions (
|
|
180
|
+
oid TEXT PRIMARY KEY,
|
|
181
|
+
tenant_id TEXT NOT NULL,
|
|
182
|
+
workflow_id TEXT NOT NULL,
|
|
183
|
+
workflow_version TEXT NOT NULL,
|
|
184
|
+
trigger_kind TEXT NOT NULL,
|
|
185
|
+
capability_pattern TEXT,
|
|
186
|
+
action_type_pattern TEXT,
|
|
187
|
+
body TEXT NOT NULL,
|
|
188
|
+
created_at_ms INTEGER NOT NULL,
|
|
189
|
+
supersedes TEXT,
|
|
190
|
+
revoked_at_ms INTEGER
|
|
191
|
+
);
|
|
192
|
+
CREATE INDEX IF NOT EXISTS idx_gap_wfdef_lookup
|
|
193
|
+
ON gap_workflow_definitions(tenant_id, workflow_id, revoked_at_ms);
|
|
194
|
+
|
|
195
|
+
CREATE TABLE IF NOT EXISTS gap_workflow_instances (
|
|
196
|
+
oid TEXT PRIMARY KEY,
|
|
197
|
+
tenant_id TEXT NOT NULL,
|
|
198
|
+
workflow_definition_oid TEXT NOT NULL,
|
|
199
|
+
workflow_id TEXT NOT NULL,
|
|
200
|
+
current_stage_id TEXT NOT NULL,
|
|
201
|
+
scope_variables TEXT NOT NULL,
|
|
202
|
+
started_at_ms INTEGER NOT NULL,
|
|
203
|
+
last_transition_at_ms INTEGER NOT NULL,
|
|
204
|
+
terminated_at_ms INTEGER,
|
|
205
|
+
terminal_outcome TEXT,
|
|
206
|
+
body TEXT NOT NULL
|
|
207
|
+
);
|
|
208
|
+
CREATE INDEX IF NOT EXISTS idx_gap_wfinst_tenant
|
|
209
|
+
ON gap_workflow_instances(tenant_id, started_at_ms DESC);
|
|
210
|
+
CREATE INDEX IF NOT EXISTS idx_gap_wfinst_active
|
|
211
|
+
ON gap_workflow_instances(tenant_id, terminated_at_ms);
|
|
212
|
+
|
|
213
|
+
CREATE TABLE IF NOT EXISTS gap_stage_transitions (
|
|
214
|
+
oid TEXT PRIMARY KEY,
|
|
215
|
+
tenant_id TEXT NOT NULL,
|
|
216
|
+
workflow_instance_oid TEXT NOT NULL,
|
|
217
|
+
previous_transition_oid TEXT,
|
|
218
|
+
from_stage_id TEXT NOT NULL,
|
|
219
|
+
to_stage_id TEXT NOT NULL,
|
|
220
|
+
trigger_reason TEXT NOT NULL,
|
|
221
|
+
body TEXT NOT NULL,
|
|
222
|
+
transitioned_at_ms INTEGER NOT NULL
|
|
223
|
+
);
|
|
224
|
+
CREATE INDEX IF NOT EXISTS idx_gap_trans_workflow
|
|
225
|
+
ON gap_stage_transitions(workflow_instance_oid, transitioned_at_ms);
|
|
226
|
+
|
|
227
|
+
CREATE TABLE IF NOT EXISTS gap_channel_events (
|
|
228
|
+
oid TEXT PRIMARY KEY,
|
|
229
|
+
tenant_id TEXT NOT NULL,
|
|
230
|
+
channel TEXT NOT NULL,
|
|
231
|
+
event_kind TEXT NOT NULL,
|
|
232
|
+
workflow_instance_oid TEXT,
|
|
233
|
+
stage_id TEXT,
|
|
234
|
+
body TEXT NOT NULL,
|
|
235
|
+
observed_at_ms INTEGER NOT NULL
|
|
236
|
+
);
|
|
237
|
+
CREATE INDEX IF NOT EXISTS idx_gap_chan_events
|
|
238
|
+
ON gap_channel_events(workflow_instance_oid, observed_at_ms);
|
|
239
|
+
|
|
240
|
+
CREATE TABLE IF NOT EXISTS gap_receipts (
|
|
241
|
+
oid TEXT PRIMARY KEY,
|
|
242
|
+
tenant_id TEXT NOT NULL,
|
|
243
|
+
subject_kind TEXT NOT NULL,
|
|
244
|
+
subject_oid TEXT NOT NULL,
|
|
245
|
+
status TEXT NOT NULL,
|
|
246
|
+
initiator_actor_oid TEXT NOT NULL,
|
|
247
|
+
workflow_instance_oid TEXT,
|
|
248
|
+
workflow_stage_id TEXT,
|
|
249
|
+
body TEXT NOT NULL,
|
|
250
|
+
initiated_at_ms INTEGER NOT NULL,
|
|
251
|
+
resolved_at_ms INTEGER NOT NULL
|
|
252
|
+
);
|
|
253
|
+
CREATE INDEX IF NOT EXISTS idx_gap_rcpt_tenant
|
|
254
|
+
ON gap_receipts(tenant_id, resolved_at_ms DESC);
|
|
255
|
+
CREATE INDEX IF NOT EXISTS idx_gap_rcpt_subject
|
|
256
|
+
ON gap_receipts(subject_oid);
|
|
257
|
+
CREATE INDEX IF NOT EXISTS idx_gap_rcpt_workflow
|
|
258
|
+
ON gap_receipts(workflow_instance_oid);
|
|
259
|
+
|
|
260
|
+
CREATE TABLE IF NOT EXISTS gap_revocations (
|
|
261
|
+
oid TEXT PRIMARY KEY,
|
|
262
|
+
tenant_id TEXT NOT NULL,
|
|
263
|
+
target_kind TEXT NOT NULL,
|
|
264
|
+
target_oid TEXT NOT NULL,
|
|
265
|
+
required_level INTEGER NOT NULL,
|
|
266
|
+
provisional INTEGER NOT NULL, -- 0/1
|
|
267
|
+
effective_at_ms INTEGER,
|
|
268
|
+
lifted_at_ms INTEGER,
|
|
269
|
+
body TEXT NOT NULL
|
|
270
|
+
);
|
|
271
|
+
CREATE INDEX IF NOT EXISTS idx_gap_revoc_target
|
|
272
|
+
ON gap_revocations(target_oid, effective_at_ms);
|
|
273
|
+
|
|
274
|
+
-- Optional Capabilities sharing policy (Phase 2 of SYNOI_OPTIONAL_CAPABILITIES_SPEC.md).
|
|
275
|
+
-- Per-tenant rules controlling which capability classes a given digital
|
|
276
|
+
-- experience (game / app / SDK identified by actor OID) may invoke via
|
|
277
|
+
-- optional_effects. Phase 1 is unchanged when no row exists for an actor.
|
|
278
|
+
CREATE TABLE IF NOT EXISTS gap_optional_sharing_policies (
|
|
279
|
+
tenant_id TEXT NOT NULL,
|
|
280
|
+
experience_actor_oid TEXT NOT NULL,
|
|
281
|
+
allowed_classes_json TEXT NOT NULL DEFAULT '[]',
|
|
282
|
+
denied_classes_json TEXT NOT NULL DEFAULT '[]',
|
|
283
|
+
expires_at INTEGER,
|
|
284
|
+
session_only INTEGER NOT NULL DEFAULT 0,
|
|
285
|
+
notification_on_use TEXT NOT NULL DEFAULT 'never',
|
|
286
|
+
created_at INTEGER NOT NULL,
|
|
287
|
+
updated_at INTEGER NOT NULL,
|
|
288
|
+
PRIMARY KEY (tenant_id, experience_actor_oid)
|
|
289
|
+
);
|
|
290
|
+
CREATE INDEX IF NOT EXISTS idx_gap_sharing_tenant
|
|
291
|
+
ON gap_optional_sharing_policies(tenant_id);
|
|
292
|
+
|
|
293
|
+
-- Instagration I1: capability -> provider binding records (signed CDROs).
|
|
294
|
+
-- Per-tenant. Multiple rows may exist for the same capability (fungible
|
|
295
|
+
-- brokering); resolution logic lives in capability-resolver.ts.
|
|
296
|
+
CREATE TABLE IF NOT EXISTS gap_capability_bindings (
|
|
297
|
+
oid TEXT PRIMARY KEY,
|
|
298
|
+
tenant_id TEXT NOT NULL,
|
|
299
|
+
capability TEXT NOT NULL,
|
|
300
|
+
provider TEXT NOT NULL,
|
|
301
|
+
fungible INTEGER NOT NULL, -- 0/1
|
|
302
|
+
active INTEGER NOT NULL, -- 0/1
|
|
303
|
+
body TEXT NOT NULL, -- full body as JSON
|
|
304
|
+
created_at_ms INTEGER NOT NULL
|
|
305
|
+
);
|
|
306
|
+
CREATE INDEX IF NOT EXISTS idx_gap_binding_lookup
|
|
307
|
+
ON gap_capability_bindings(tenant_id, capability, active);
|
|
308
|
+
|
|
309
|
+
-- Durable NDA acceptance record for the public demo's entry gate. Keyed by
|
|
310
|
+
-- operator_oid so a returning visitor is recognized across sessions and
|
|
311
|
+
-- process restarts (the session object itself is ephemeral/in-memory and
|
|
312
|
+
-- must never be the source of truth for this). One row per operator; a
|
|
313
|
+
-- re-accept (e.g. a new nda_version) overwrites the prior row.
|
|
314
|
+
CREATE TABLE IF NOT EXISTS gap_demo_nda_acceptances (
|
|
315
|
+
operator_oid TEXT PRIMARY KEY,
|
|
316
|
+
tenant_id TEXT NOT NULL,
|
|
317
|
+
nda_version TEXT NOT NULL,
|
|
318
|
+
accepted_at_ms INTEGER NOT NULL
|
|
319
|
+
);
|
|
320
|
+
CREATE INDEX IF NOT EXISTS idx_gap_demo_nda_tenant
|
|
321
|
+
ON gap_demo_nda_acceptances(tenant_id);
|
|
322
|
+
`);
|
|
323
|
+
// Lazy-wire the sharing-policy module to this DB on first open. The
|
|
324
|
+
// import is dynamic so we avoid a hard module cycle (sharing-policy
|
|
325
|
+
// imports capabilityMatches from this file).
|
|
326
|
+
try {
|
|
327
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
328
|
+
const sp = require('./safety/sharing-policy');
|
|
329
|
+
sp._setDbProvider?.(() => db());
|
|
330
|
+
}
|
|
331
|
+
catch { /* sharing-policy module unavailable — non-fatal */ }
|
|
332
|
+
return _db;
|
|
333
|
+
}
|
|
334
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
335
|
+
function exec() { return db(); }
|
|
336
|
+
// ── Declarations ─────────────────────────────────────────────────────────────
|
|
337
|
+
function saveDeclaration(d) {
|
|
338
|
+
exec().prepare(`
|
|
339
|
+
INSERT OR REPLACE INTO gap_declarations
|
|
340
|
+
(oid, tenant_id, actor_type, actor_id, actor_version, capabilities, body, created_at_ms, supersedes, revoked_at_ms)
|
|
341
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
|
|
342
|
+
`).run(d.oid, d.tenant_id, d.body.actor_type, d.body.actor_id, d.body.actor_version, JSON.stringify(d.body.capabilities.map((c) => c.capability)), JSON.stringify(d), d.created_at_ms, d.supersedes ?? null);
|
|
343
|
+
}
|
|
344
|
+
function getDeclaration(oid) {
|
|
345
|
+
const row = exec().prepare(`SELECT body FROM gap_declarations WHERE oid = ?`).get(oid);
|
|
346
|
+
return row ? JSON.parse(row.body) : null;
|
|
347
|
+
}
|
|
348
|
+
function findActiveDeclarationByActor(tenantId, actorId) {
|
|
349
|
+
const row = exec().prepare(`
|
|
350
|
+
SELECT body, revoked_at_ms FROM gap_declarations
|
|
351
|
+
WHERE tenant_id = ? AND actor_id = ? AND revoked_at_ms IS NULL
|
|
352
|
+
ORDER BY created_at_ms DESC LIMIT 1
|
|
353
|
+
`).get(tenantId, actorId);
|
|
354
|
+
return row ? JSON.parse(row.body) : null;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Enumerate all active (non-revoked) declarations for a tenant whose
|
|
358
|
+
* declared capabilities match the given pattern. Used by the optional-
|
|
359
|
+
* effects matcher in the engine: a workflow declares
|
|
360
|
+
* `requires_capability: 'home.lighting.*'` and this returns every smart-
|
|
361
|
+
* home device that declared a `home.lighting.*` capability.
|
|
362
|
+
*
|
|
363
|
+
* Pattern matching uses capabilityMatches() — exact OR prefix wildcard.
|
|
364
|
+
*/
|
|
365
|
+
function listActiveDeclarationsMatching(tenantId, capability_pattern) {
|
|
366
|
+
const rows = exec().prepare(`
|
|
367
|
+
SELECT body FROM gap_declarations
|
|
368
|
+
WHERE tenant_id = ? AND revoked_at_ms IS NULL
|
|
369
|
+
ORDER BY created_at_ms DESC
|
|
370
|
+
`).all(tenantId);
|
|
371
|
+
const out = [];
|
|
372
|
+
for (const r of rows) {
|
|
373
|
+
const decl = JSON.parse(r.body);
|
|
374
|
+
if (decl.body.capabilities.some(c => capabilityMatches(capability_pattern, c.capability))) {
|
|
375
|
+
out.push(decl);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return out;
|
|
379
|
+
}
|
|
380
|
+
// ── Grants ───────────────────────────────────────────────────────────────────
|
|
381
|
+
function saveGrant(g) {
|
|
382
|
+
exec().prepare(`
|
|
383
|
+
INSERT OR REPLACE INTO gap_grants
|
|
384
|
+
(oid, tenant_id, grantee_actor_oid, grantee_actor_type, capability_pattern, body, granted_at_ms, expires_at_ms, revoked_at_ms, uses_count, last_used_at_ms)
|
|
385
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, 0, NULL)
|
|
386
|
+
`).run(g.oid, g.tenant_id, g.body.grantee.actor_oid, g.body.grantee.actor_type, JSON.stringify(g.body.capability_scopes.map((s) => s.capability)), JSON.stringify(g), g.body.granted_at_ms, g.body.expires_at_ms ?? null);
|
|
387
|
+
}
|
|
388
|
+
function findActiveGrantsForCaller(tenantId, callerActorOid, capability) {
|
|
389
|
+
const now = Date.now();
|
|
390
|
+
const rows = exec().prepare(`
|
|
391
|
+
SELECT body FROM gap_grants
|
|
392
|
+
WHERE tenant_id = ?
|
|
393
|
+
AND grantee_actor_oid = ?
|
|
394
|
+
AND revoked_at_ms IS NULL
|
|
395
|
+
AND (expires_at_ms IS NULL OR expires_at_ms > ?)
|
|
396
|
+
ORDER BY granted_at_ms DESC
|
|
397
|
+
`).all(tenantId, callerActorOid, now);
|
|
398
|
+
const all = rows.map((r) => JSON.parse(r.body));
|
|
399
|
+
// Filter to grants that include this capability.
|
|
400
|
+
const matching = all.filter((g) => g.body.capability_scopes.some((s) => capabilityMatches(s.capability, capability)));
|
|
401
|
+
// Exclude superseded grants. A grant-predicate update (updateGrantPredicate)
|
|
402
|
+
// mints a NEW grant carrying supersedes=<prior oid> and leaves the prior in
|
|
403
|
+
// the table (it is not revoked). The prior is no longer the effective grant,
|
|
404
|
+
// so it must not be returned as active -- otherwise a stale predicate (e.g. an
|
|
405
|
+
// old threshold) would be evaluated even though the operator changed it. Keep
|
|
406
|
+
// only the leaves of the supersession chain; ORDER BY granted_at_ms DESC makes
|
|
407
|
+
// the newest leaf first so callers that take [0] get the current grant.
|
|
408
|
+
const supersededOids = new Set(matching.map((g) => g.supersedes).filter((x) => typeof x === 'string'));
|
|
409
|
+
return matching.filter((g) => !supersededOids.has(g.oid));
|
|
410
|
+
}
|
|
411
|
+
/** Match: exact, '*' match-all, or a SEGMENT-BOUNDARY wildcard ('skill.*').
|
|
412
|
+
* A non-boundary wildcard like 'admin.us*' must NOT prefix-match
|
|
413
|
+
* 'admin.users.delete' (privilege-escalation footgun) — the '*' must follow '.'. */
|
|
414
|
+
function capabilityMatches(pattern, target) {
|
|
415
|
+
if (pattern === target)
|
|
416
|
+
return true;
|
|
417
|
+
if (pattern === '*')
|
|
418
|
+
return true;
|
|
419
|
+
if (pattern.endsWith('.*')) {
|
|
420
|
+
const prefix = pattern.slice(0, -1); // keep trailing '.', e.g. 'skill.'
|
|
421
|
+
return target.startsWith(prefix);
|
|
422
|
+
}
|
|
423
|
+
return false;
|
|
424
|
+
}
|
|
425
|
+
function getGrant(oid) {
|
|
426
|
+
const row = exec().prepare(`SELECT body FROM gap_grants WHERE oid = ?`).get(oid);
|
|
427
|
+
return row ? JSON.parse(row.body) : null;
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Enumerate active (non-revoked, non-expired) grants for a tenant.
|
|
431
|
+
* Optional grantee filter narrows to one actor. Used by the operator CLI
|
|
432
|
+
* and the portal grants list view.
|
|
433
|
+
*/
|
|
434
|
+
function listActiveGrantsForTenant(tenantId, granteeOid) {
|
|
435
|
+
const now = Date.now();
|
|
436
|
+
const rows = granteeOid !== undefined
|
|
437
|
+
? exec().prepare(`
|
|
438
|
+
SELECT body FROM gap_grants
|
|
439
|
+
WHERE tenant_id = ? AND grantee_actor_oid = ?
|
|
440
|
+
AND revoked_at_ms IS NULL
|
|
441
|
+
AND (expires_at_ms IS NULL OR expires_at_ms > ?)
|
|
442
|
+
ORDER BY granted_at_ms DESC
|
|
443
|
+
`).all(tenantId, granteeOid, now)
|
|
444
|
+
: exec().prepare(`
|
|
445
|
+
SELECT body FROM gap_grants
|
|
446
|
+
WHERE tenant_id = ?
|
|
447
|
+
AND revoked_at_ms IS NULL
|
|
448
|
+
AND (expires_at_ms IS NULL OR expires_at_ms > ?)
|
|
449
|
+
ORDER BY granted_at_ms DESC
|
|
450
|
+
`).all(tenantId, now);
|
|
451
|
+
return rows.map(r => JSON.parse(r.body));
|
|
452
|
+
}
|
|
453
|
+
function revokeGrant(oid) {
|
|
454
|
+
const r = exec().prepare(`
|
|
455
|
+
UPDATE gap_grants SET revoked_at_ms = ?
|
|
456
|
+
WHERE oid = ? AND revoked_at_ms IS NULL
|
|
457
|
+
`).run(Date.now(), oid);
|
|
458
|
+
return r.changes > 0;
|
|
459
|
+
}
|
|
460
|
+
function incrementGrantUse(oid) {
|
|
461
|
+
exec().prepare(`
|
|
462
|
+
UPDATE gap_grants
|
|
463
|
+
SET uses_count = uses_count + 1, last_used_at_ms = ?
|
|
464
|
+
WHERE oid = ?
|
|
465
|
+
`).run(Date.now(), oid);
|
|
466
|
+
}
|
|
467
|
+
// ── Invocations ──────────────────────────────────────────────────────────────
|
|
468
|
+
function saveInvocation(inv, status, receiptOid) {
|
|
469
|
+
exec().prepare(`
|
|
470
|
+
INSERT OR REPLACE INTO gap_invocations
|
|
471
|
+
(oid, tenant_id, caller_actor_oid, capability, grant_oid, workflow_instance_oid, workflow_stage_id, idempotency_key, body, invoked_at_ms, status, receipt_oid)
|
|
472
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
473
|
+
`).run(inv.oid, inv.tenant_id, inv.body.caller.actor_oid, inv.body.capability, inv.body.caller.grant_oid, inv.body.workflow_context?.workflow_instance_oid ?? null, inv.body.workflow_context?.stage_id ?? null, inv.body.idempotency_key ?? null, JSON.stringify(inv), inv.body.invoked_at_ms, status, receiptOid);
|
|
474
|
+
}
|
|
475
|
+
function findInvocationByIdempotency(tenantId, callerActorOid, capability, idempotencyKey) {
|
|
476
|
+
const row = exec().prepare(`
|
|
477
|
+
SELECT body FROM gap_invocations
|
|
478
|
+
WHERE tenant_id = ? AND caller_actor_oid = ? AND capability = ? AND idempotency_key = ?
|
|
479
|
+
`).get(tenantId, callerActorOid, capability, idempotencyKey);
|
|
480
|
+
return row ? JSON.parse(row.body) : null;
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* PC-22: find any invocation by (tenant, caller, idempotency_key) regardless of capability.
|
|
484
|
+
* Used to detect cross-capability idempotency key reuse.
|
|
485
|
+
*/
|
|
486
|
+
function findInvocationByKeyAnyCapability(tenantId, callerActorOid, idempotencyKey) {
|
|
487
|
+
const row = exec().prepare(`
|
|
488
|
+
SELECT body FROM gap_invocations
|
|
489
|
+
WHERE tenant_id = ? AND caller_actor_oid = ? AND idempotency_key = ?
|
|
490
|
+
LIMIT 1
|
|
491
|
+
`).get(tenantId, callerActorOid, idempotencyKey);
|
|
492
|
+
return row ? JSON.parse(row.body) : null;
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* PC-06: get the receipt OID for an invocation.
|
|
496
|
+
*/
|
|
497
|
+
/**
|
|
498
|
+
* PC-24: count successful invocations for a grant within a rolling window.
|
|
499
|
+
* Used by aggregate_limits enforcement in the invoke gate.
|
|
500
|
+
*/
|
|
501
|
+
function countGrantInvocationsInWindow(tenantId, grantOid, callerActorOid, windowStartMs) {
|
|
502
|
+
const row = exec().prepare(`
|
|
503
|
+
SELECT COUNT(*) as cnt FROM gap_invocations
|
|
504
|
+
WHERE tenant_id = ?
|
|
505
|
+
AND grant_oid = ?
|
|
506
|
+
AND caller_actor_oid = ?
|
|
507
|
+
AND status = 'ok'
|
|
508
|
+
AND invoked_at_ms >= ?
|
|
509
|
+
`).get(tenantId, grantOid, callerActorOid, windowStartMs);
|
|
510
|
+
return row.cnt;
|
|
511
|
+
}
|
|
512
|
+
function getInvocationReceiptOid(invocationOid) {
|
|
513
|
+
const row = exec().prepare(`
|
|
514
|
+
SELECT receipt_oid FROM gap_invocations WHERE oid = ?
|
|
515
|
+
`).get(invocationOid);
|
|
516
|
+
return row?.receipt_oid ?? null;
|
|
517
|
+
}
|
|
518
|
+
// ── Workflow definitions + instances + transitions ───────────────────────────
|
|
519
|
+
function saveWorkflowDefinition(def) {
|
|
520
|
+
exec().prepare(`
|
|
521
|
+
INSERT OR REPLACE INTO gap_workflow_definitions
|
|
522
|
+
(oid, tenant_id, workflow_id, workflow_version, trigger_kind, capability_pattern, action_type_pattern, body, created_at_ms, supersedes, revoked_at_ms)
|
|
523
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
|
|
524
|
+
`).run(def.oid, def.tenant_id, def.body.workflow_id, def.body.workflow_version, def.body.trigger.kind, def.body.trigger.capability_pattern ?? null, def.body.trigger.action_type_pattern ?? null, JSON.stringify(def), def.created_at_ms, def.supersedes ?? null);
|
|
525
|
+
}
|
|
526
|
+
function getWorkflowDefinition(oid) {
|
|
527
|
+
const row = exec().prepare(`SELECT body FROM gap_workflow_definitions WHERE oid = ?`).get(oid);
|
|
528
|
+
return row ? JSON.parse(row.body) : null;
|
|
529
|
+
}
|
|
530
|
+
function findActiveWorkflowDefinitions(tenantId, workflowId) {
|
|
531
|
+
const row = exec().prepare(`
|
|
532
|
+
SELECT body FROM gap_workflow_definitions
|
|
533
|
+
WHERE tenant_id = ? AND workflow_id = ? AND revoked_at_ms IS NULL
|
|
534
|
+
ORDER BY created_at_ms DESC LIMIT 1
|
|
535
|
+
`).get(tenantId, workflowId);
|
|
536
|
+
return row ? JSON.parse(row.body) : null;
|
|
537
|
+
}
|
|
538
|
+
function saveWorkflowInstance(inst) {
|
|
539
|
+
exec().prepare(`
|
|
540
|
+
INSERT OR REPLACE INTO gap_workflow_instances
|
|
541
|
+
(oid, tenant_id, workflow_definition_oid, workflow_id, current_stage_id, scope_variables, started_at_ms, last_transition_at_ms, terminated_at_ms, terminal_outcome, body)
|
|
542
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
543
|
+
`).run(inst.oid, inst.tenant_id, inst.body.workflow_definition_oid, inst.body.workflow_id, inst.body.current_stage_id, JSON.stringify(inst.body.scope_variables), inst.body.started_at_ms, inst.body.last_transition_at_ms, inst.body.terminated_at_ms, inst.body.terminal_outcome, JSON.stringify(inst));
|
|
544
|
+
}
|
|
545
|
+
function getWorkflowInstance(oid) {
|
|
546
|
+
const row = exec().prepare(`SELECT body FROM gap_workflow_instances WHERE oid = ?`).get(oid);
|
|
547
|
+
return row ? JSON.parse(row.body) : null;
|
|
548
|
+
}
|
|
549
|
+
/** List a tenant's workflow definitions (newest first). Pure read. */
|
|
550
|
+
function listWorkflowDefinitions(tenantId, opts = {}) {
|
|
551
|
+
const revokedClause = opts.includeRevoked ? '' : 'AND revoked_at_ms IS NULL';
|
|
552
|
+
const rows = exec().prepare(`
|
|
553
|
+
SELECT body FROM gap_workflow_definitions
|
|
554
|
+
WHERE tenant_id = ? ${revokedClause}
|
|
555
|
+
ORDER BY created_at_ms DESC
|
|
556
|
+
`).all(tenantId);
|
|
557
|
+
return rows.map(r => JSON.parse(r.body));
|
|
558
|
+
}
|
|
559
|
+
/** List a tenant's workflow instances (most-recently-active first). Pure read. */
|
|
560
|
+
function listWorkflowInstances(tenantId, opts = {}) {
|
|
561
|
+
const activeClause = opts.activeOnly ? 'AND terminated_at_ms IS NULL' : '';
|
|
562
|
+
const limit = Math.min(Math.max(opts.limit ?? 200, 1), 500);
|
|
563
|
+
const rows = exec().prepare(`
|
|
564
|
+
SELECT body FROM gap_workflow_instances
|
|
565
|
+
WHERE tenant_id = ? ${activeClause}
|
|
566
|
+
ORDER BY last_transition_at_ms DESC
|
|
567
|
+
LIMIT ?
|
|
568
|
+
`).all(tenantId, limit);
|
|
569
|
+
return rows.map(r => JSON.parse(r.body));
|
|
570
|
+
}
|
|
571
|
+
function saveStageTransition(t) {
|
|
572
|
+
exec().prepare(`
|
|
573
|
+
INSERT OR REPLACE INTO gap_stage_transitions
|
|
574
|
+
(oid, tenant_id, workflow_instance_oid, previous_transition_oid, from_stage_id, to_stage_id, trigger_reason, body, transitioned_at_ms)
|
|
575
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
576
|
+
`).run(t.oid, t.tenant_id, t.body.workflow_instance_oid, t.body.previous_transition_oid, t.body.from_stage_id, t.body.to_stage_id, t.body.trigger_reason, JSON.stringify(t), t.body.transitioned_at_ms);
|
|
577
|
+
}
|
|
578
|
+
function listStageTransitions(workflowInstanceOid) {
|
|
579
|
+
const rows = exec().prepare(`
|
|
580
|
+
SELECT body FROM gap_stage_transitions
|
|
581
|
+
WHERE workflow_instance_oid = ?
|
|
582
|
+
ORDER BY transitioned_at_ms ASC
|
|
583
|
+
`).all(workflowInstanceOid);
|
|
584
|
+
return rows.map((r) => JSON.parse(r.body));
|
|
585
|
+
}
|
|
586
|
+
// ── Channel events ───────────────────────────────────────────────────────────
|
|
587
|
+
function saveChannelEvent(e) {
|
|
588
|
+
exec().prepare(`
|
|
589
|
+
INSERT OR REPLACE INTO gap_channel_events
|
|
590
|
+
(oid, tenant_id, channel, event_kind, workflow_instance_oid, stage_id, body, observed_at_ms)
|
|
591
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
592
|
+
`).run(e.oid, e.tenant_id, e.body.channel, e.body.event_kind, e.body.workflow_instance_oid ?? null, e.body.stage_id ?? null, JSON.stringify(e), e.body.observed_at_ms);
|
|
593
|
+
}
|
|
594
|
+
// ── Receipts ─────────────────────────────────────────────────────────────────
|
|
595
|
+
function saveReceipt(r) {
|
|
596
|
+
exec().prepare(`
|
|
597
|
+
INSERT OR REPLACE INTO gap_receipts
|
|
598
|
+
(oid, tenant_id, subject_kind, subject_oid, status, initiator_actor_oid, workflow_instance_oid, workflow_stage_id, body, initiated_at_ms, resolved_at_ms)
|
|
599
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
600
|
+
`).run(r.oid, r.tenant_id, r.body.subject_kind, r.body.subject_oid, r.body.status, r.body.initiator.actor_oid, r.body.workflow_instance_oid ?? null, r.body.workflow_stage_id ?? null, JSON.stringify(r), r.body.initiated_at_ms, r.body.resolved_at_ms);
|
|
601
|
+
}
|
|
602
|
+
function getReceipt(oid) {
|
|
603
|
+
const row = exec().prepare(`SELECT body FROM gap_receipts WHERE oid = ?`).get(oid);
|
|
604
|
+
return row ? JSON.parse(row.body) : null;
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* PC-17/PC-18: list receipts for a tenant with optional filters.
|
|
608
|
+
* When capability is provided, only returns receipts for capability_invocation
|
|
609
|
+
* subjects where the linked invocation has that capability string.
|
|
610
|
+
* Tenant isolation is enforced by WHERE tenant_id = tenantId.
|
|
611
|
+
* Returns newest first (by resolved_at_ms DESC).
|
|
612
|
+
*/
|
|
613
|
+
function listReceipts(tenantId, opts) {
|
|
614
|
+
const limit = opts?.limit ?? 50;
|
|
615
|
+
let sql;
|
|
616
|
+
const params = [tenantId];
|
|
617
|
+
if (opts?.capability !== undefined) {
|
|
618
|
+
// Join with invocations to filter by capability.
|
|
619
|
+
sql = `
|
|
620
|
+
SELECT r.body FROM gap_receipts r
|
|
621
|
+
JOIN gap_invocations i ON i.oid = r.subject_oid
|
|
622
|
+
AND r.subject_kind = 'capability_invocation'
|
|
623
|
+
WHERE r.tenant_id = ?
|
|
624
|
+
AND i.capability = ?
|
|
625
|
+
`;
|
|
626
|
+
params.push(opts.capability);
|
|
627
|
+
if (opts.status !== undefined) {
|
|
628
|
+
sql += ` AND r.status = ?`;
|
|
629
|
+
params.push(opts.status);
|
|
630
|
+
}
|
|
631
|
+
sql += ` ORDER BY r.resolved_at_ms DESC LIMIT ?`;
|
|
632
|
+
}
|
|
633
|
+
else {
|
|
634
|
+
sql = `SELECT body FROM gap_receipts WHERE tenant_id = ?`;
|
|
635
|
+
if (opts?.status !== undefined) {
|
|
636
|
+
sql += ` AND status = ?`;
|
|
637
|
+
params.push(opts.status);
|
|
638
|
+
}
|
|
639
|
+
if (opts?.subject_kind !== undefined) {
|
|
640
|
+
sql += ` AND subject_kind = ?`;
|
|
641
|
+
params.push(opts.subject_kind);
|
|
642
|
+
}
|
|
643
|
+
sql += ` ORDER BY resolved_at_ms DESC LIMIT ?`;
|
|
644
|
+
}
|
|
645
|
+
params.push(limit);
|
|
646
|
+
const rows = exec().prepare(sql).all(...params);
|
|
647
|
+
return rows.map((r) => JSON.parse(r.body));
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Cycle 7 — Receipt Ledger query. A tenant-scoped, time-windowed, paginated read
|
|
651
|
+
* over the receipt store for the admin ledger surface (GET /admin/ledger). The
|
|
652
|
+
* column-backed filters (status, subject_kind, resolved_at_ms window) run in SQL;
|
|
653
|
+
* the JSON-body filters (capability, provider_ran) are applied by the caller on
|
|
654
|
+
* the projection layer, since they live inside the serialized body.
|
|
655
|
+
*
|
|
656
|
+
* TRUNCATION HONESTY (Cycle-6 [14] lesson): the ledger NEVER implies completeness
|
|
657
|
+
* it cannot back. `total` is a COUNT(*) over the SAME column-backed predicate, so
|
|
658
|
+
* a caller can compare `total` against `offset + rows.length` and report exactly
|
|
659
|
+
* whether the page is the tail of the result set or a truncated prefix. The COUNT
|
|
660
|
+
* covers only the column-backed predicate; any additional body-level filter the
|
|
661
|
+
* caller applies further reduces the returned rows below `total`, which the caller
|
|
662
|
+
* MUST surface (it does: see ledger.ts `filtered_by_body`).
|
|
663
|
+
*
|
|
664
|
+
* Tenant isolation is enforced by WHERE tenant_id = tenantId. Newest first.
|
|
665
|
+
*/
|
|
666
|
+
function queryLedgerReceipts(tenantId, opts) {
|
|
667
|
+
const limit = Math.max(1, Math.min(opts?.limit ?? 50, 1000));
|
|
668
|
+
const offset = Math.max(0, opts?.offset ?? 0);
|
|
669
|
+
const where = ['tenant_id = ?'];
|
|
670
|
+
const whereParams = [tenantId];
|
|
671
|
+
if (opts?.status !== undefined) {
|
|
672
|
+
where.push('status = ?');
|
|
673
|
+
whereParams.push(opts.status);
|
|
674
|
+
}
|
|
675
|
+
if (opts?.subject_kind !== undefined) {
|
|
676
|
+
where.push('subject_kind = ?');
|
|
677
|
+
whereParams.push(opts.subject_kind);
|
|
678
|
+
}
|
|
679
|
+
if (opts?.since_ms !== undefined) {
|
|
680
|
+
where.push('resolved_at_ms >= ?');
|
|
681
|
+
whereParams.push(opts.since_ms);
|
|
682
|
+
}
|
|
683
|
+
if (opts?.until_ms !== undefined) {
|
|
684
|
+
where.push('resolved_at_ms <= ?');
|
|
685
|
+
whereParams.push(opts.until_ms);
|
|
686
|
+
}
|
|
687
|
+
const whereSql = where.join(' AND ');
|
|
688
|
+
const totalRow = exec()
|
|
689
|
+
.prepare(`SELECT COUNT(*) AS n FROM gap_receipts WHERE ${whereSql}`)
|
|
690
|
+
.get(...whereParams);
|
|
691
|
+
const rows = exec()
|
|
692
|
+
.prepare(`SELECT body FROM gap_receipts WHERE ${whereSql} ` +
|
|
693
|
+
`ORDER BY resolved_at_ms DESC LIMIT ? OFFSET ?`)
|
|
694
|
+
.all(...whereParams, limit, offset);
|
|
695
|
+
return {
|
|
696
|
+
rows: rows.map((r) => JSON.parse(r.body)),
|
|
697
|
+
total: totalRow.n,
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Cycle 7 — resolve a set of receipt OIDs to full receipts for a tenant, in the
|
|
702
|
+
* order requested. Used by the evidence-bundle export to gather the exact signed
|
|
703
|
+
* receipt objects a filter selected. Tenant-scoped: a receipt whose tenant_id
|
|
704
|
+
* does not match is skipped (never leaked across tenants). Missing OIDs are
|
|
705
|
+
* skipped; the caller reconciles requested-vs-returned counts.
|
|
706
|
+
*/
|
|
707
|
+
function getReceiptsByOids(tenantId, oids) {
|
|
708
|
+
const out = [];
|
|
709
|
+
const stmt = exec().prepare(`SELECT body FROM gap_receipts WHERE oid = ? AND tenant_id = ?`);
|
|
710
|
+
for (const oid of oids) {
|
|
711
|
+
const row = stmt.get(oid, tenantId);
|
|
712
|
+
if (row)
|
|
713
|
+
out.push(JSON.parse(row.body));
|
|
714
|
+
}
|
|
715
|
+
return out;
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* S2.3 — find the most-recent denial receipt for a (tenant, principal, subject_oid)
|
|
719
|
+
* triple within a look-back window. Used by the 2-receipt chain gate to locate D1
|
|
720
|
+
* before emitting E1 with `replayed_after = D1.oid`.
|
|
721
|
+
*
|
|
722
|
+
* The look-back window guards the provisional-block condition: we only allow
|
|
723
|
+
* replay if D1 is still within the provisional-block window (caller enforces this).
|
|
724
|
+
*
|
|
725
|
+
* Returns the most recent matching denial receipt, or null if none.
|
|
726
|
+
* Does NOT check whether the denial's auto-lift fired — caller must verify
|
|
727
|
+
* provisional state before acting on this result.
|
|
728
|
+
*/
|
|
729
|
+
function findDenialReceiptForPrincipal(tenantId, principalOid, subjectOid, windowMs) {
|
|
730
|
+
const since = Date.now() - windowMs;
|
|
731
|
+
const row = exec().prepare(`
|
|
732
|
+
SELECT body FROM gap_receipts
|
|
733
|
+
WHERE tenant_id = ?
|
|
734
|
+
AND initiator_actor_oid = ?
|
|
735
|
+
AND subject_oid = ?
|
|
736
|
+
AND status = 'denied'
|
|
737
|
+
AND resolved_at_ms >= ?
|
|
738
|
+
ORDER BY resolved_at_ms DESC
|
|
739
|
+
LIMIT 1
|
|
740
|
+
`).get(tenantId, principalOid, subjectOid, since);
|
|
741
|
+
return row ? JSON.parse(row.body) : null;
|
|
742
|
+
}
|
|
743
|
+
// ── Revocation ───────────────────────────────────────────────────────────────
|
|
744
|
+
function saveRevocation(r) {
|
|
745
|
+
exec().prepare(`
|
|
746
|
+
INSERT OR REPLACE INTO gap_revocations
|
|
747
|
+
(oid, tenant_id, target_kind, target_oid, required_level, provisional, effective_at_ms, lifted_at_ms, body)
|
|
748
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
749
|
+
`).run(r.oid, r.tenant_id, r.body.target_kind, r.body.target_oid, r.body.required_level, r.body.provisional ? 1 : 0, r.body.effective_at_ms, r.body.lifted_at_ms ?? null, JSON.stringify(r));
|
|
750
|
+
// If effective immediately, mark the target as revoked.
|
|
751
|
+
if (r.body.effective_at_ms !== null && r.body.effective_at_ms <= Date.now()) {
|
|
752
|
+
applyRevocationToTarget(r.body.target_kind, r.body.target_oid, r.tenant_id);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* Resolve the owning tenant of a revocation target, or null when ownership
|
|
757
|
+
* is not locally knowable. The tenant-owned GAP-native kinds carry a
|
|
758
|
+
* tenant_id column; 'skill' is Vault-homed + content-addressed (the same
|
|
759
|
+
* skill OID is shared across tenants) so it has no single local owner and
|
|
760
|
+
* returns null. Callers use null to mean "cannot verify ownership here".
|
|
761
|
+
*
|
|
762
|
+
* The table name is selected from a fixed internal allow-list, never from
|
|
763
|
+
* caller input, so the interpolation is not an injection surface.
|
|
764
|
+
*/
|
|
765
|
+
function ownerTenantOf(kind, oid) {
|
|
766
|
+
const table = kind === 'capability_grant' ? 'gap_grants' :
|
|
767
|
+
kind === 'capability_declaration' ? 'gap_declarations' :
|
|
768
|
+
kind === 'workflow_definition' ? 'gap_workflow_definitions' :
|
|
769
|
+
kind === 'workflow_instance' ? 'gap_workflow_instances' :
|
|
770
|
+
null; // 'skill' has no local ownership row
|
|
771
|
+
if (table === null)
|
|
772
|
+
return null;
|
|
773
|
+
const row = exec()
|
|
774
|
+
.prepare(`SELECT tenant_id FROM ${table} WHERE oid = ?`)
|
|
775
|
+
.get(oid);
|
|
776
|
+
return row?.tenant_id ?? null;
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* Thrown when a tenant attempts to revoke a target owned by a different
|
|
780
|
+
* tenant. Mapped to HTTP 403 by the router. Closes the cross-tenant
|
|
781
|
+
* DoS-by-revocation hole (H1) across every revocation entry point: L1
|
|
782
|
+
* (revokeGapObject), L2/L3 initiation, and the emergency provisional block —
|
|
783
|
+
* each fed the gap_revocations table / provisional-block set with no
|
|
784
|
+
* per-target-tenant ownership check.
|
|
785
|
+
*/
|
|
786
|
+
class CrossTenantRevocationError extends Error {
|
|
787
|
+
target_oid;
|
|
788
|
+
owner_tenant;
|
|
789
|
+
caller_tenant;
|
|
790
|
+
constructor(target_oid, owner_tenant, caller_tenant) {
|
|
791
|
+
super(`tenant '${caller_tenant}' may not revoke ${target_oid} owned by tenant '${owner_tenant}'`);
|
|
792
|
+
this.target_oid = target_oid;
|
|
793
|
+
this.owner_tenant = owner_tenant;
|
|
794
|
+
this.caller_tenant = caller_tenant;
|
|
795
|
+
this.name = 'CrossTenantRevocationError';
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
exports.CrossTenantRevocationError = CrossTenantRevocationError;
|
|
799
|
+
/**
|
|
800
|
+
* Ownership guard shared by every revocation entry point. For tenant-owned
|
|
801
|
+
* kinds (grant/declaration/workflow) the caller's tenant must own the target;
|
|
802
|
+
* 'skill' is content-addressed + Vault-homed (ownerTenantOf returns null), so
|
|
803
|
+
* it is not guarded here — a network-wide skill kill goes through the
|
|
804
|
+
* multi-party L2/L3 path. See CAPABILITY_NETWORK_HARDENING.md H1.
|
|
805
|
+
*/
|
|
806
|
+
function assertTenantOwnsTarget(tenant_id, target_kind, target_oid) {
|
|
807
|
+
const owner = ownerTenantOf(target_kind, target_oid);
|
|
808
|
+
if (owner !== null && owner !== tenant_id) {
|
|
809
|
+
throw new CrossTenantRevocationError(target_oid, owner, tenant_id);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
function applyRevocationToTarget(kind, targetOid, tenantId) {
|
|
813
|
+
const now = Date.now();
|
|
814
|
+
// Defense in depth: the UPDATE is scoped to the revocation's own tenant, so
|
|
815
|
+
// a revocation row can only flip a target row that the same tenant owns even
|
|
816
|
+
// if the upstream ownership guard were ever bypassed.
|
|
817
|
+
if (kind === 'capability_grant') {
|
|
818
|
+
exec().prepare(`UPDATE gap_grants SET revoked_at_ms = ? WHERE oid = ? AND tenant_id = ? AND revoked_at_ms IS NULL`).run(now, targetOid, tenantId);
|
|
819
|
+
}
|
|
820
|
+
else if (kind === 'capability_declaration') {
|
|
821
|
+
exec().prepare(`UPDATE gap_declarations SET revoked_at_ms = ? WHERE oid = ? AND tenant_id = ? AND revoked_at_ms IS NULL`).run(now, targetOid, tenantId);
|
|
822
|
+
}
|
|
823
|
+
else if (kind === 'workflow_definition') {
|
|
824
|
+
exec().prepare(`UPDATE gap_workflow_definitions SET revoked_at_ms = ? WHERE oid = ? AND tenant_id = ? AND revoked_at_ms IS NULL`).run(now, targetOid, tenantId);
|
|
825
|
+
}
|
|
826
|
+
// workflow_instance + skill revocation are handled elsewhere (engine in-flight cancellation; skill registry).
|
|
827
|
+
}
|
|
828
|
+
function isOidRevoked(oid) {
|
|
829
|
+
// Check grants, declarations, and workflow defs (per-table revoked_at_ms columns).
|
|
830
|
+
const r1 = exec().prepare(`SELECT 1 FROM gap_grants WHERE oid = ? AND revoked_at_ms IS NOT NULL`).get(oid);
|
|
831
|
+
if (r1 !== undefined)
|
|
832
|
+
return true;
|
|
833
|
+
const r2 = exec().prepare(`SELECT 1 FROM gap_declarations WHERE oid = ? AND revoked_at_ms IS NOT NULL`).get(oid);
|
|
834
|
+
if (r2 !== undefined)
|
|
835
|
+
return true;
|
|
836
|
+
const r3 = exec().prepare(`SELECT 1 FROM gap_workflow_definitions WHERE oid = ? AND revoked_at_ms IS NOT NULL`).get(oid);
|
|
837
|
+
if (r3 !== undefined)
|
|
838
|
+
return true;
|
|
839
|
+
// Skills don't have a per-row revoked_at_ms because they live outside GAP's
|
|
840
|
+
// own tables (their canonical home is the Vault). Check gap_revocations
|
|
841
|
+
// directly for an effective, non-lifted revocation targeting this OID
|
|
842
|
+
// regardless of target_kind. Same check covers any future target_kind that
|
|
843
|
+
// gets added without needing a new per-table column.
|
|
844
|
+
const now = Date.now();
|
|
845
|
+
const r4 = exec().prepare(`
|
|
846
|
+
SELECT 1 FROM gap_revocations
|
|
847
|
+
WHERE target_oid = ?
|
|
848
|
+
AND effective_at_ms IS NOT NULL
|
|
849
|
+
AND effective_at_ms <= ?
|
|
850
|
+
AND (lifted_at_ms IS NULL OR lifted_at_ms > ?)
|
|
851
|
+
LIMIT 1
|
|
852
|
+
`).get(oid, now, now);
|
|
853
|
+
return r4 !== undefined;
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* List recent revocation events for the open OID Resolver / observability
|
|
857
|
+
* surface. Defaults to descending effective_at_ms (most recent first), with
|
|
858
|
+
* optional filters on target_kind + tenant.
|
|
859
|
+
*/
|
|
860
|
+
function listRevocations(opts = {}) {
|
|
861
|
+
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 500);
|
|
862
|
+
const where = [];
|
|
863
|
+
const params = [];
|
|
864
|
+
if (opts.before_ms !== undefined) {
|
|
865
|
+
where.push(`effective_at_ms IS NOT NULL AND effective_at_ms < ?`);
|
|
866
|
+
params.push(opts.before_ms);
|
|
867
|
+
}
|
|
868
|
+
if (opts.target_kind !== undefined) {
|
|
869
|
+
where.push(`target_kind = ?`);
|
|
870
|
+
params.push(opts.target_kind);
|
|
871
|
+
}
|
|
872
|
+
if (opts.tenant_id !== undefined) {
|
|
873
|
+
where.push(`tenant_id = ?`);
|
|
874
|
+
params.push(opts.tenant_id);
|
|
875
|
+
}
|
|
876
|
+
const whereClause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
|
|
877
|
+
// ORDER BY effective_at_ms DESC NULLS LAST — SQLite sorts NULLs first in
|
|
878
|
+
// DESC order by default, so we explicitly push them to the end.
|
|
879
|
+
const rows = exec().prepare(`
|
|
880
|
+
SELECT oid, tenant_id, target_kind, target_oid, required_level,
|
|
881
|
+
provisional, effective_at_ms, lifted_at_ms, body
|
|
882
|
+
FROM gap_revocations
|
|
883
|
+
${whereClause}
|
|
884
|
+
ORDER BY (effective_at_ms IS NULL), effective_at_ms DESC
|
|
885
|
+
LIMIT ?
|
|
886
|
+
`).all(...params, limit);
|
|
887
|
+
return rows.map(r => ({
|
|
888
|
+
oid: r.oid,
|
|
889
|
+
tenant_id: r.tenant_id,
|
|
890
|
+
target_kind: r.target_kind,
|
|
891
|
+
target_oid: r.target_oid,
|
|
892
|
+
required_level: r.required_level,
|
|
893
|
+
provisional: r.provisional === 1,
|
|
894
|
+
effective_at_ms: r.effective_at_ms,
|
|
895
|
+
lifted_at_ms: r.lifted_at_ms,
|
|
896
|
+
body: JSON.parse(r.body),
|
|
897
|
+
}));
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Return one summary row per distinct tenant_id known to the grants table,
|
|
901
|
+
* including tenants whose grants have all been revoked (historical presence).
|
|
902
|
+
* Ordered by most-recent grant descending.
|
|
903
|
+
*/
|
|
904
|
+
function listAllTenants() {
|
|
905
|
+
const rows = exec().prepare(`
|
|
906
|
+
SELECT
|
|
907
|
+
tenant_id,
|
|
908
|
+
COUNT(*) AS grant_count,
|
|
909
|
+
MIN(granted_at_ms) AS first_ms,
|
|
910
|
+
MAX(granted_at_ms) AS last_ms
|
|
911
|
+
FROM gap_grants
|
|
912
|
+
GROUP BY tenant_id
|
|
913
|
+
ORDER BY last_ms DESC
|
|
914
|
+
`).all();
|
|
915
|
+
return rows.map(r => ({
|
|
916
|
+
tenant_id: r.tenant_id,
|
|
917
|
+
grant_count: r.grant_count,
|
|
918
|
+
first_grant_at: new Date(r.first_ms).toISOString(),
|
|
919
|
+
last_grant_at: new Date(r.last_ms).toISOString(),
|
|
920
|
+
}));
|
|
921
|
+
}
|
|
922
|
+
// ── Capability bindings (Instagration I1) ───────────────────────────────────
|
|
923
|
+
function saveBinding(b) {
|
|
924
|
+
exec().prepare(`
|
|
925
|
+
INSERT OR REPLACE INTO gap_capability_bindings
|
|
926
|
+
(oid, tenant_id, capability, provider, fungible, active, body, created_at_ms)
|
|
927
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
928
|
+
`).run(b.oid, b.tenant_id, b.body.capability, b.body.provider, b.body.fungible ? 1 : 0, b.body.active ? 1 : 0, JSON.stringify(b), b.created_at_ms);
|
|
929
|
+
}
|
|
930
|
+
function getBinding(oid) {
|
|
931
|
+
const row = exec().prepare(`SELECT body FROM gap_capability_bindings WHERE oid = ?`).get(oid);
|
|
932
|
+
return row ? JSON.parse(row.body) : null;
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Active bindings for a capability, ordered by created_at_ms ascending
|
|
936
|
+
* (oldest first -- the resolver's "pick the first" seam, doc I1, relies on
|
|
937
|
+
* stable insertion order for the >1-fungible placeholder path).
|
|
938
|
+
*/
|
|
939
|
+
function findActiveBindingsForCapability(tenantId, capability) {
|
|
940
|
+
const rows = exec().prepare(`
|
|
941
|
+
SELECT body FROM gap_capability_bindings
|
|
942
|
+
WHERE tenant_id = ? AND capability = ? AND active = 1
|
|
943
|
+
ORDER BY created_at_ms ASC
|
|
944
|
+
`).all(tenantId, capability);
|
|
945
|
+
return rows.map((r) => JSON.parse(r.body));
|
|
946
|
+
}
|
|
947
|
+
function listBindingsForTenant(tenantId) {
|
|
948
|
+
const rows = exec().prepare(`
|
|
949
|
+
SELECT body FROM gap_capability_bindings
|
|
950
|
+
WHERE tenant_id = ?
|
|
951
|
+
ORDER BY created_at_ms DESC
|
|
952
|
+
`).all(tenantId);
|
|
953
|
+
return rows.map((r) => JSON.parse(r.body));
|
|
954
|
+
}
|
|
955
|
+
/** Deactivate a binding (soft-disable; preserves the signed CDRO history). */
|
|
956
|
+
function deactivateBinding(oid) {
|
|
957
|
+
const r = exec().prepare(`
|
|
958
|
+
UPDATE gap_capability_bindings SET active = 0
|
|
959
|
+
WHERE oid = ? AND active = 1
|
|
960
|
+
`).run(oid);
|
|
961
|
+
return r.changes > 0;
|
|
962
|
+
}
|
|
963
|
+
// ── Test helpers ─────────────────────────────────────────────────────────────
|
|
964
|
+
function _resetForTesting() {
|
|
965
|
+
if (_db !== null) {
|
|
966
|
+
_db.close();
|
|
967
|
+
_db = null;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* Test-only: overwrite the persisted `body` column of a receipt row directly,
|
|
972
|
+
* WITHOUT re-signing. Models a DB-level tamper / corrupt row. Used to prove the
|
|
973
|
+
* authorized-axis read-time re-verify (D3 [5]): a mutated body no longer binds
|
|
974
|
+
* the signed attestation, so the receipt authorizes nothing. Not exported into
|
|
975
|
+
* any runtime path.
|
|
976
|
+
*/
|
|
977
|
+
function _testRawUpdateReceiptBody(oid, body) {
|
|
978
|
+
exec().prepare(`UPDATE gap_receipts SET body = ? WHERE oid = ?`).run(body, oid);
|
|
979
|
+
}
|
|
980
|
+
/**
|
|
981
|
+
* Test-only: force the shared connection into (or out of) SQLite `query_only`
|
|
982
|
+
* mode. When ON, every write (INSERT/UPDATE/DELETE) throws SQLITE_READONLY
|
|
983
|
+
* while reads still succeed. Used to prove the bindings rebind fail-safe:
|
|
984
|
+
* with writes failing, the new-binding INSERT throws BEFORE any deactivation
|
|
985
|
+
* runs, so the old binding must remain active (no zero-active-binding window).
|
|
986
|
+
*/
|
|
987
|
+
function _setQueryOnlyForTesting(on) {
|
|
988
|
+
exec().pragma(`query_only = ${on ? 'ON' : 'OFF'}`);
|
|
989
|
+
}
|
|
990
|
+
// ── Demo data bulk-delete (admin reset) ─────────────────────────────────────
|
|
991
|
+
/**
|
|
992
|
+
* Delete all rows from gap_receipts and gap_grants whose tenant_id starts
|
|
993
|
+
* with the given prefix (e.g. 'tenant:demo-'). Covers both the gate tenant
|
|
994
|
+
* (tenant:demo-<oid>) and the memory tenant (tenant:demo-mem-<oid>).
|
|
995
|
+
* Returns the number of rows deleted across both tables.
|
|
996
|
+
*
|
|
997
|
+
* Scoped strictly to the prefix: the LIKE pattern uses the prefix followed by
|
|
998
|
+
* '%'. No rows outside that prefix are touched.
|
|
999
|
+
*/
|
|
1000
|
+
function deleteDemoRowsByTenantPrefix(prefix) {
|
|
1001
|
+
const pattern = prefix + '%';
|
|
1002
|
+
const r1 = exec().prepare(`DELETE FROM gap_receipts WHERE tenant_id LIKE ?`).run(pattern);
|
|
1003
|
+
const r2 = exec().prepare(`DELETE FROM gap_grants WHERE tenant_id LIKE ?`).run(pattern);
|
|
1004
|
+
const r3 = exec().prepare(`DELETE FROM gap_demo_nda_acceptances WHERE tenant_id LIKE ?`).run(pattern);
|
|
1005
|
+
return { receipts: r1.changes, grants: r2.changes, nda: r3.changes };
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* Record (or overwrite) the durable NDA acceptance for an operator. Called
|
|
1009
|
+
* only after POST /v1/demo/nda/accept has verified the operator's signature.
|
|
1010
|
+
* Survives process restart and is independent of any in-memory session.
|
|
1011
|
+
*/
|
|
1012
|
+
function saveDemoNdaAcceptance(operatorOid, tenantId, ndaVersion, acceptedAtMs) {
|
|
1013
|
+
exec().prepare(`
|
|
1014
|
+
INSERT INTO gap_demo_nda_acceptances (operator_oid, tenant_id, nda_version, accepted_at_ms)
|
|
1015
|
+
VALUES (?, ?, ?, ?)
|
|
1016
|
+
ON CONFLICT(operator_oid) DO UPDATE SET
|
|
1017
|
+
tenant_id = excluded.tenant_id,
|
|
1018
|
+
nda_version = excluded.nda_version,
|
|
1019
|
+
accepted_at_ms = excluded.accepted_at_ms
|
|
1020
|
+
`).run(operatorOid, tenantId, ndaVersion, acceptedAtMs);
|
|
1021
|
+
}
|
|
1022
|
+
/** Look up the durable NDA acceptance for an operator. Null when never accepted. */
|
|
1023
|
+
function getDemoNdaAcceptance(operatorOid) {
|
|
1024
|
+
const row = exec().prepare(`
|
|
1025
|
+
SELECT operator_oid, nda_version, accepted_at_ms
|
|
1026
|
+
FROM gap_demo_nda_acceptances
|
|
1027
|
+
WHERE operator_oid = ?
|
|
1028
|
+
`).get(operatorOid);
|
|
1029
|
+
return row ?? null;
|
|
1030
|
+
}
|
|
1031
|
+
// ── ADR_007 migration: rename agp_* tables to gap_* for existing databases ──
|
|
1032
|
+
/**
|
|
1033
|
+
* Rename existing agp_* tables to gap_* for any database created before the
|
|
1034
|
+
* ADR_007 signed-byte migration. Safe to call multiple times: each ALTER is
|
|
1035
|
+
* wrapped in an existence check so it is a no-op when the gap_* table already
|
|
1036
|
+
* exists (i.e. fresh post-migration databases).
|
|
1037
|
+
*
|
|
1038
|
+
* Call this once at gateway startup before any other store operations.
|
|
1039
|
+
* The old agp_* table names no longer appear in any CREATE TABLE or SQL
|
|
1040
|
+
* query in this codebase after ADR_007.
|
|
1041
|
+
*/
|
|
1042
|
+
function migrateAgpTablesToGap() {
|
|
1043
|
+
const d = exec();
|
|
1044
|
+
const tableRenames = [
|
|
1045
|
+
['agp_declarations', 'gap_declarations'],
|
|
1046
|
+
['agp_grants', 'gap_grants'],
|
|
1047
|
+
['agp_invocations', 'gap_invocations'],
|
|
1048
|
+
['agp_workflow_definitions', 'gap_workflow_definitions'],
|
|
1049
|
+
['agp_workflow_instances', 'gap_workflow_instances'],
|
|
1050
|
+
['agp_stage_transitions', 'gap_stage_transitions'],
|
|
1051
|
+
['agp_channel_events', 'gap_channel_events'],
|
|
1052
|
+
['agp_receipts', 'gap_receipts'],
|
|
1053
|
+
['agp_revocations', 'gap_revocations'],
|
|
1054
|
+
['agp_optional_sharing_policies', 'gap_optional_sharing_policies'],
|
|
1055
|
+
['agp_provisional_blocks', 'gap_provisional_blocks'],
|
|
1056
|
+
];
|
|
1057
|
+
for (const [oldName, newName] of tableRenames) {
|
|
1058
|
+
const exists = d.prepare(`SELECT 1 FROM sqlite_master WHERE type='table' AND name=?`).get(oldName);
|
|
1059
|
+
if (exists) {
|
|
1060
|
+
d.exec(`ALTER TABLE ${oldName} RENAME TO ${newName}`);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
}
|