@substrat-run/adapter-cloudflare 0.5.0 → 0.7.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/README.md +1 -1
- package/dist/checker.d.ts +1 -0
- package/dist/checker.d.ts.map +1 -1
- package/dist/checker.js +12 -4
- package/dist/checker.js.map +1 -1
- package/dist/control-plane-do.d.ts +177 -5
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +414 -25
- package/dist/control-plane-do.js.map +1 -1
- package/dist/host.d.ts +41 -1
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +444 -18
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/route-resolver.d.ts +31 -0
- package/dist/route-resolver.d.ts.map +1 -0
- package/dist/route-resolver.js +47 -0
- package/dist/route-resolver.js.map +1 -0
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +58 -0
- package/dist/scope-do.js.map +1 -1
- package/package.json +8 -4
package/dist/host.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type PlatformActorId, type PrincipalId, type ScopeId, type TenantId } from '@substrat-run/contracts';
|
|
2
|
-
import { type HostAdmin, type ModuleRegistration, type OperationHandler, type PermissionChecker, type ProvisionScopeInput, type ScopeHost, type ScopeStub } from '@substrat-run/kernel';
|
|
2
|
+
import { type ExecutorHandler, type HostAdmin, type ModuleRegistration, type OperationHandler, type PermissionChecker, type ProvisionScopeInput, type ScopeHost, type ScopeStub } from '@substrat-run/kernel';
|
|
3
3
|
export interface CloudflareScopeHostOptions {
|
|
4
4
|
scope: DurableObjectNamespace;
|
|
5
5
|
controlPlane: DurableObjectNamespace;
|
|
@@ -17,9 +17,40 @@ export declare class CloudflareScopeHost implements ScopeHost {
|
|
|
17
17
|
private readonly moduleIds;
|
|
18
18
|
private readonly operations;
|
|
19
19
|
private readonly predicateNames;
|
|
20
|
+
/** Executor id → {eventType, handler} (K-22 §4.2). Coordinator-side, not in the DO. */
|
|
21
|
+
private readonly executors;
|
|
22
|
+
/**
|
|
23
|
+
* The event currently being effected, stamped onto admin rows the executor writes.
|
|
24
|
+
* Ambient rather than threaded through every HostAdmin signature: set and cleared
|
|
25
|
+
* around one await, with executors running sequentially, so there is no window
|
|
26
|
+
* where it belongs to a different event.
|
|
27
|
+
*/
|
|
28
|
+
private causedBy;
|
|
20
29
|
private readonly withdrawn;
|
|
21
30
|
private readonly operationEntitlement;
|
|
31
|
+
/**
|
|
32
|
+
* MUST be constructed per request. Never cache an instance across requests.
|
|
33
|
+
*
|
|
34
|
+
* The stub below is a Durable Object stub, which is an I/O object owned by the
|
|
35
|
+
* request that created it — reusing one throws "Cannot perform I/O on behalf of a
|
|
36
|
+
* different request". Every worker in this repo rebuilds the host per request
|
|
37
|
+
* (`hostFor(env)`), which is what makes this safe, and it is the only thing that
|
|
38
|
+
* does. The router learned this the expensive way: it memoised a resolver that
|
|
39
|
+
* closed over a stub, the first request after each cold start succeeded, and every
|
|
40
|
+
* request after that returned 1101 in production.
|
|
41
|
+
*/
|
|
22
42
|
constructor(options: CloudflareScopeHostOptions);
|
|
43
|
+
registerExecutor(id: string, eventType: string, handler: ExecutorHandler): void;
|
|
44
|
+
/**
|
|
45
|
+
* Drain this scope's outbox into the registered executors (K-22 §4.2).
|
|
46
|
+
*
|
|
47
|
+
* Runs on the coordinator because executors act through `HostAdmin`, which the
|
|
48
|
+
* ScopeDO cannot reach. Prompt: called inline after the operation returns, so the
|
|
49
|
+
* common case completes inside the request. A throwing executor leaves the
|
|
50
|
+
* delivery unjournaled and is retried on the next drain — the outbox is the
|
|
51
|
+
* backstop, not a substitute for idempotent handlers.
|
|
52
|
+
*/
|
|
53
|
+
private drainExecutors;
|
|
23
54
|
registerModule(registration: ModuleRegistration): void;
|
|
24
55
|
defineOperation<I, O>(name: string, _handler: OperationHandler<I, O>): void;
|
|
25
56
|
private bindOperation;
|
|
@@ -29,11 +60,20 @@ export declare class CloudflareScopeHost implements ScopeHost {
|
|
|
29
60
|
* (§5.4: fleet questions never fan out). The ScopeDO reports null when nothing
|
|
30
61
|
* was pending, which skips the write — otherwise every stub mint would cost an
|
|
31
62
|
* extra control-plane RPC to store a number that did not change.
|
|
63
|
+
*
|
|
64
|
+
* A failure is recorded and then RETHROWN (#32): the scope still fails closed,
|
|
65
|
+
* but the directory learns which `module@version` broke and how many attempts it
|
|
66
|
+
* has taken, instead of keeping a stale `schema_version` that renders as healthy.
|
|
32
67
|
*/
|
|
33
68
|
private migrateAndRecord;
|
|
34
69
|
getScope(principal: PrincipalId, tenantId: TenantId, scopeId: ScopeId): Promise<ScopeStub>;
|
|
35
70
|
close(): Promise<void>;
|
|
36
71
|
private buildAdmin;
|
|
72
|
+
/**
|
|
73
|
+
* Record a staff read (K-24). `params` is a bounded summary, capped so one query
|
|
74
|
+
* cannot write an unbounded row.
|
|
75
|
+
*/
|
|
76
|
+
private recordAccess;
|
|
37
77
|
private recordAdmin;
|
|
38
78
|
private writeScopeTuple;
|
|
39
79
|
private scopeStub;
|
package/dist/host.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"host.d.ts","sourceRoot":"","sources":["../src/host.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"host.d.ts","sourceRoot":"","sources":["../src/host.ts"],"names":[],"mappings":"AAAA,OAAO,EAqCL,KAAK,eAAe,EACpB,KAAK,WAAW,EAYhB,KAAK,OAAO,EAGZ,KAAK,QAAQ,EAGd,MAAM,yBAAyB,CAAC;AAEjC,OAAO,EAKL,KAAK,eAAe,EACpB,KAAK,SAAS,EACd,KAAK,kBAAkB,EACvB,KAAK,gBAAgB,EACrB,KAAK,iBAAiB,EACtB,KAAK,mBAAmB,EAGxB,KAAK,SAAS,EACd,KAAK,SAAS,EACf,MAAM,sBAAsB,CAAC;AA+M9B,MAAM,WAAW,0BAA0B;IACzC,KAAK,EAAE,sBAAsB,CAAC;IAC9B,YAAY,EAAE,sBAAsB,CAAC;IACrC;;;;OAIG;IACH,OAAO,CAAC,EAAE,iBAAiB,CAAC;CAC7B;AAED,qBAAa,mBAAoB,YAAW,SAAS;IACnD,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC;IAE1B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAyB;IACjD,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAmB;IAItC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAqB;IAC/C,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAqB;IAChD,OAAO,CAAC,QAAQ,CAAC,cAAc,CAA6B;IAC5D,uFAAuF;IACvF,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAsE;IAChG;;;;;OAKG;IACH,OAAO,CAAC,QAAQ,CAAuB;IACvC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA6B;IACvD,OAAO,CAAC,QAAQ,CAAC,oBAAoB,CAA6B;IAElE;;;;;;;;;;OAUG;gBACS,OAAO,EAAE,0BAA0B;IAU/C,gBAAgB,CAAC,EAAE,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,eAAe,GAAG,IAAI;IAK/E;;;;;;;;OAQG;YACW,cAAc;IAkB5B,cAAc,CAAC,YAAY,EAAE,kBAAkB,GAAG,IAAI;IAgDtD,eAAe,CAAC,CAAC,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,gBAAgB,CAAC,CAAC,EAAE,CAAC,CAAC,GAAG,IAAI;IAI3E,OAAO,CAAC,aAAa;IAQf,cAAc,CAAC,KAAK,EAAE,eAAe,EAAE,KAAK,EAAE,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC;IAyBvF;;;;;;;;;OASG;YACW,gBAAgB;IAwBxB,QAAQ,CACZ,SAAS,EAAE,WAAW,EACtB,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE,OAAO,GACf,OAAO,CAAC,SAAS,CAAC;IAqCf,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAM5B,OAAO,CAAC,UAAU;IAmqBlB;;;OAGG;YACW,YAAY;YAmBZ,WAAW;YAqBX,eAAe;IAU7B,OAAO,CAAC,SAAS;CAKlB"}
|
package/dist/host.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { adminLogEntry, createTenantInput, identityLink, moduleManifest, resolvedIdentity, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, } from '@substrat-run/contracts';
|
|
1
|
+
import { accessLogEntry, adminLogEntry, createTenantInput, identityLink, identityPool, createOrgInput, promotionAcknowledgement, bindHostnameInput, hostnameBinding, publishVersionInput, registerVerticalInput, vertical as verticalSchema, verticalChannel, verticalVersion, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, } from '@substrat-run/contracts';
|
|
2
|
+
import { normalizeHostname, toRouteTarget } from './route-resolver.js';
|
|
2
3
|
import { resolveScopeRecord, ulid, } from '@substrat-run/kernel';
|
|
3
4
|
export class CloudflareScopeHost {
|
|
4
5
|
admin;
|
|
@@ -9,14 +10,67 @@ export class CloudflareScopeHost {
|
|
|
9
10
|
moduleIds = new Set();
|
|
10
11
|
operations = new Set();
|
|
11
12
|
predicateNames = new Map(); // name → module
|
|
13
|
+
/** Executor id → {eventType, handler} (K-22 §4.2). Coordinator-side, not in the DO. */
|
|
14
|
+
executors = new Map();
|
|
15
|
+
/**
|
|
16
|
+
* The event currently being effected, stamped onto admin rows the executor writes.
|
|
17
|
+
* Ambient rather than threaded through every HostAdmin signature: set and cleared
|
|
18
|
+
* around one await, with executors running sequentially, so there is no window
|
|
19
|
+
* where it belongs to a different event.
|
|
20
|
+
*/
|
|
21
|
+
causedBy = null;
|
|
12
22
|
withdrawn = new Map(); // operation → module
|
|
13
23
|
operationEntitlement = new Map();
|
|
24
|
+
/**
|
|
25
|
+
* MUST be constructed per request. Never cache an instance across requests.
|
|
26
|
+
*
|
|
27
|
+
* The stub below is a Durable Object stub, which is an I/O object owned by the
|
|
28
|
+
* request that created it — reusing one throws "Cannot perform I/O on behalf of a
|
|
29
|
+
* different request". Every worker in this repo rebuilds the host per request
|
|
30
|
+
* (`hostFor(env)`), which is what makes this safe, and it is the only thing that
|
|
31
|
+
* does. The router learned this the expensive way: it memoised a resolver that
|
|
32
|
+
* closed over a stub, the first request after each cold start succeeded, and every
|
|
33
|
+
* request after that returned 1101 in production.
|
|
34
|
+
*/
|
|
14
35
|
constructor(options) {
|
|
15
36
|
this.scopeNs = options.scope;
|
|
16
37
|
this.cp = options.controlPlane.get(options.controlPlane.idFromName('control-plane'));
|
|
17
38
|
this.admin = this.buildAdmin();
|
|
18
39
|
}
|
|
19
40
|
// -- registration mechanics (validation only) -----------------------------
|
|
41
|
+
registerExecutor(id, eventType, handler) {
|
|
42
|
+
if (this.executors.has(id))
|
|
43
|
+
throw new Error(`executor '${id}' is already registered`);
|
|
44
|
+
this.executors.set(id, { eventType, handler });
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Drain this scope's outbox into the registered executors (K-22 §4.2).
|
|
48
|
+
*
|
|
49
|
+
* Runs on the coordinator because executors act through `HostAdmin`, which the
|
|
50
|
+
* ScopeDO cannot reach. Prompt: called inline after the operation returns, so the
|
|
51
|
+
* common case completes inside the request. A throwing executor leaves the
|
|
52
|
+
* delivery unjournaled and is retried on the next drain — the outbox is the
|
|
53
|
+
* backstop, not a substitute for idempotent handlers.
|
|
54
|
+
*/
|
|
55
|
+
async drainExecutors(scopeId) {
|
|
56
|
+
if (this.executors.size === 0)
|
|
57
|
+
return;
|
|
58
|
+
const stub = this.scopeStub(scopeId);
|
|
59
|
+
for (const [id, executor] of this.executors) {
|
|
60
|
+
const deliveryId = `executor:${id}`;
|
|
61
|
+
const events = await stub.pendingExecutorEvents(deliveryId, executor.eventType);
|
|
62
|
+
for (const event of events) {
|
|
63
|
+
this.causedBy = event.id;
|
|
64
|
+
try {
|
|
65
|
+
await executor.handler(this.admin, event);
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
this.causedBy = null;
|
|
69
|
+
}
|
|
70
|
+
await stub.markExecutorDelivered(event.id, deliveryId);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
20
74
|
registerModule(registration) {
|
|
21
75
|
const manifest = moduleManifest.parse(registration.manifest);
|
|
22
76
|
if (this.moduleIds.has(manifest.id)) {
|
|
@@ -87,11 +141,36 @@ export class CloudflareScopeHost {
|
|
|
87
141
|
* (§5.4: fleet questions never fan out). The ScopeDO reports null when nothing
|
|
88
142
|
* was pending, which skips the write — otherwise every stub mint would cost an
|
|
89
143
|
* extra control-plane RPC to store a number that did not change.
|
|
144
|
+
*
|
|
145
|
+
* A failure is recorded and then RETHROWN (#32): the scope still fails closed,
|
|
146
|
+
* but the directory learns which `module@version` broke and how many attempts it
|
|
147
|
+
* has taken, instead of keeping a stale `schema_version` that renders as healthy.
|
|
90
148
|
*/
|
|
91
149
|
async migrateAndRecord(scopeId) {
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
await
|
|
150
|
+
const stub = this.scopeStub(scopeId);
|
|
151
|
+
try {
|
|
152
|
+
const applied = await stub.migrate();
|
|
153
|
+
if (applied !== null)
|
|
154
|
+
await this.cp.setMigrationState(scopeId, String(applied), null);
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
// Best-effort: a scope that failed to migrate may also fail to answer, and a
|
|
158
|
+
// broken recorder must not replace the migration error with its own — that
|
|
159
|
+
// trades a diagnosable failure for a confusing one.
|
|
160
|
+
try {
|
|
161
|
+
const failure = await stub.migrationFailure();
|
|
162
|
+
if (failure) {
|
|
163
|
+
await this.cp.setMigrationState(scopeId, String(failure.applied), {
|
|
164
|
+
version: failure.version,
|
|
165
|
+
error: failure.error,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
// deliberately swallowed — the rethrow below is the real signal
|
|
171
|
+
}
|
|
172
|
+
throw err;
|
|
173
|
+
}
|
|
95
174
|
}
|
|
96
175
|
async getScope(principal, tenantId, scopeId) {
|
|
97
176
|
// Lifecycle gates (control-plane.md §4.1/§4.2), the K-3 fail-closed path,
|
|
@@ -111,7 +190,9 @@ export class CloudflareScopeHost {
|
|
|
111
190
|
if (requiredKey && !(await cp.tenantHoldsEntitlement(tenantId, requiredKey))) {
|
|
112
191
|
return Promise.reject(new Error(`operation not entitled: ${operation} — tenant does not hold '${requiredKey}'`));
|
|
113
192
|
}
|
|
114
|
-
|
|
193
|
+
const result = (await stub.invoke(operation, input, principal, tenantId, scopeId));
|
|
194
|
+
await this.drainExecutors(scopeId);
|
|
195
|
+
return result;
|
|
115
196
|
},
|
|
116
197
|
};
|
|
117
198
|
}
|
|
@@ -125,6 +206,48 @@ export class CloudflareScopeHost {
|
|
|
125
206
|
// table) while the contract requires them, so this parse is where that gap is
|
|
126
207
|
// held shut — and it is the same parse the pure adapter does, which is what
|
|
127
208
|
// makes the shared contract suite meaningful.
|
|
209
|
+
const mapHostname = (r) => hostnameBinding.parse({
|
|
210
|
+
hostname: r.hostname,
|
|
211
|
+
tenantId: r.tenant_id,
|
|
212
|
+
scopeId: r.scope_id,
|
|
213
|
+
verticalSlug: r.vertical_slug,
|
|
214
|
+
surface: r.surface,
|
|
215
|
+
region: r.region,
|
|
216
|
+
status: r.status,
|
|
217
|
+
statusNote: r.status_note,
|
|
218
|
+
canonical: r.canonical === 1,
|
|
219
|
+
createdAt: r.created_at,
|
|
220
|
+
});
|
|
221
|
+
const mapVertical = (r) => verticalSchema.parse({ slug: r.slug, name: r.name, source: r.source, createdAt: r.created_at });
|
|
222
|
+
const mapVersion = (r) => verticalVersion.parse({
|
|
223
|
+
id: r.id,
|
|
224
|
+
verticalSlug: r.vertical_slug,
|
|
225
|
+
version: r.version,
|
|
226
|
+
manifestDigest: r.manifest_digest,
|
|
227
|
+
permissionDigest: r.permission_digest,
|
|
228
|
+
migrationDigest: r.migration_digest,
|
|
229
|
+
deploymentRef: r.deployment_ref,
|
|
230
|
+
admission: r.admission,
|
|
231
|
+
admissionNote: r.admission_note,
|
|
232
|
+
createdAt: r.created_at,
|
|
233
|
+
});
|
|
234
|
+
const mapOrg = (r) => orgSchema.parse({
|
|
235
|
+
id: r.org_id,
|
|
236
|
+
tenantId: r.tenant_id,
|
|
237
|
+
slug: r.slug,
|
|
238
|
+
name: r.name,
|
|
239
|
+
createdAt: r.created_at,
|
|
240
|
+
});
|
|
241
|
+
/**
|
|
242
|
+
* Fail closed on an org that does not exist in this tenant. Scoped by tenant, not
|
|
243
|
+
* just by id: an org from another tenant must read as absent, or grantToOrg would
|
|
244
|
+
* reach across the boundary the record exists to make explicit.
|
|
245
|
+
*/
|
|
246
|
+
const requireOrg = async (tenant, id) => {
|
|
247
|
+
if (!(await this.cp.readOrg(tenant, id))) {
|
|
248
|
+
throw new Error(`unknown org ${id} in tenant ${tenant}`);
|
|
249
|
+
}
|
|
250
|
+
};
|
|
128
251
|
const mapScope = (r) => scopeSchema.parse({
|
|
129
252
|
id: r.scope_id,
|
|
130
253
|
tenantId: r.tenant_id,
|
|
@@ -137,6 +260,15 @@ export class CloudflareScopeHost {
|
|
|
137
260
|
jurisdiction: r.jurisdiction,
|
|
138
261
|
vertical: r.vertical,
|
|
139
262
|
schemaVersion: r.schema_version,
|
|
263
|
+
verticalVersionId: r.vertical_version_id,
|
|
264
|
+
migrationFailure: r.migration_failed_version && r.migration_last_attempt_at
|
|
265
|
+
? {
|
|
266
|
+
version: r.migration_failed_version,
|
|
267
|
+
error: r.migration_error ?? '',
|
|
268
|
+
attempts: r.migration_attempts,
|
|
269
|
+
lastAttemptAt: r.migration_last_attempt_at,
|
|
270
|
+
}
|
|
271
|
+
: null,
|
|
140
272
|
createdAt: r.created_at,
|
|
141
273
|
});
|
|
142
274
|
const transitionScope = async (actor, action, tenantId, scopeId, from, to) => {
|
|
@@ -164,8 +296,9 @@ export class CloudflareScopeHost {
|
|
|
164
296
|
const before = await this.cp.defineRole(tenantId, parsed);
|
|
165
297
|
await this.recordAdmin(actor, 'defineRole', { tenantId }, before, parsed);
|
|
166
298
|
},
|
|
167
|
-
listRoles: async (filter) => {
|
|
299
|
+
listRoles: async (actor, filter) => {
|
|
168
300
|
const rows = await this.cp.listRoles({ tenantId: filter?.tenantId, source: filter?.source });
|
|
301
|
+
await this.recordAccess(actor, 'listRoles', { tenantId: filter?.tenantId ?? null }, filter, rows.length);
|
|
169
302
|
// Parsed, not cast — the same parse the pure adapter does, which is what
|
|
170
303
|
// makes the shared contract suite mean anything.
|
|
171
304
|
return rows.map((r) => tenantRole.parse({
|
|
@@ -190,13 +323,213 @@ export class CloudflareScopeHost {
|
|
|
190
323
|
await this.recordAdmin(actor, 'grant', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId }, null, grant);
|
|
191
324
|
},
|
|
192
325
|
grantToOrg: async (actor, orgId, permission, node, entity) => {
|
|
326
|
+
// The org must exist in the node's tenant. A grant to a phantom org looks
|
|
327
|
+
// applied, resolves for nobody, and still shows up in the permission diff.
|
|
328
|
+
await requireOrg(node.tenantId, orgId);
|
|
193
329
|
await writeGrant(`org:${orgId}`, permission, node, entity);
|
|
194
330
|
await this.recordAdmin(actor, 'grantToOrg', { tenantId: node.tenantId, scopeId: node.scopeId }, null, { orgId, permission, node, entity });
|
|
195
331
|
},
|
|
332
|
+
// -- vertical + version registry (#31) ---------------------------------
|
|
333
|
+
// -- the hostname map (K-26) -------------------------------------------
|
|
334
|
+
bindHostname: async (actor, input) => {
|
|
335
|
+
const parsed = bindHostnameInput.parse(input);
|
|
336
|
+
const scope = await this.cp.getScopeRecord(parsed.tenantId, parsed.scopeId);
|
|
337
|
+
if (!scope) {
|
|
338
|
+
throw new Error(`unknown scope ${parsed.scopeId} in tenant ${parsed.tenantId}`);
|
|
339
|
+
}
|
|
340
|
+
const existing = await this.cp.readHostname(parsed.hostname);
|
|
341
|
+
if (existing && existing.scope_id !== parsed.scopeId) {
|
|
342
|
+
// A hostname routes to exactly one place; silently rebinding would move
|
|
343
|
+
// another tenant's traffic.
|
|
344
|
+
throw new Error(`hostname '${parsed.hostname}' is already bound to another scope`);
|
|
345
|
+
}
|
|
346
|
+
// Exactly one canonical per (scope, surface).
|
|
347
|
+
if (parsed.canonical)
|
|
348
|
+
await this.cp.demoteCanonical(parsed.scopeId, parsed.surface);
|
|
349
|
+
await this.cp.upsertHostname({
|
|
350
|
+
hostname: parsed.hostname,
|
|
351
|
+
tenantId: parsed.tenantId,
|
|
352
|
+
scopeId: parsed.scopeId,
|
|
353
|
+
verticalSlug: scope.vertical,
|
|
354
|
+
surface: parsed.surface,
|
|
355
|
+
region: parsed.region,
|
|
356
|
+
canonical: parsed.canonical,
|
|
357
|
+
createdAt: new Date().toISOString(),
|
|
358
|
+
});
|
|
359
|
+
await this.recordAdmin(actor, 'bindHostname', { tenantId: parsed.tenantId, scopeId: parsed.scopeId, vertical: scope.vertical }, null, parsed);
|
|
360
|
+
},
|
|
361
|
+
setHostnameStatus: async (actor, raw, status, note) => {
|
|
362
|
+
const hostname = raw.toLowerCase(); // DNS is case-insensitive; the map is normalized
|
|
363
|
+
const row = await this.cp.readHostname(hostname);
|
|
364
|
+
if (!row)
|
|
365
|
+
throw new Error(`unknown hostname '${hostname}'`);
|
|
366
|
+
if (row.status === status)
|
|
367
|
+
return; // idempotent, unaudited
|
|
368
|
+
await this.cp.setHostnameStatus(hostname, status, note ?? null);
|
|
369
|
+
await this.recordAdmin(actor, 'setHostnameStatus', { tenantId: row.tenant_id, scopeId: row.scope_id }, { status: row.status }, { status, note: note ?? null });
|
|
370
|
+
},
|
|
371
|
+
listHostnames: async (actor, filter) => {
|
|
372
|
+
const rows = await this.cp.listHostnames({
|
|
373
|
+
tenantId: filter?.tenantId,
|
|
374
|
+
scopeId: filter?.scopeId,
|
|
375
|
+
});
|
|
376
|
+
await this.recordAccess(actor, 'listHostnames', { tenantId: filter?.tenantId ?? null, scopeId: filter?.scopeId ?? null }, filter, rows.length);
|
|
377
|
+
return rows.map(mapHostname);
|
|
378
|
+
},
|
|
379
|
+
resolveHostname: async (raw) =>
|
|
380
|
+
// The router's per-request read. No actor, not logged — the same machine-path
|
|
381
|
+
// carve-out resolveIdentity has (K-24). Shares its mapping with the router's
|
|
382
|
+
// own resolver so the two cannot disagree on what resolves.
|
|
383
|
+
toRouteTarget(await this.cp.readHostname(normalizeHostname(raw))),
|
|
384
|
+
registerVertical: async (actor, input) => {
|
|
385
|
+
const parsed = registerVerticalInput.parse(input);
|
|
386
|
+
const existing = await this.cp.readVertical(parsed.slug);
|
|
387
|
+
if (existing) {
|
|
388
|
+
if (existing.source === parsed.source && existing.name === parsed.name)
|
|
389
|
+
return;
|
|
390
|
+
throw new Error(`vertical '${parsed.slug}' is already registered as ${existing.source}`);
|
|
391
|
+
}
|
|
392
|
+
await this.cp.insertVertical(parsed.slug, parsed.name, parsed.source, new Date().toISOString());
|
|
393
|
+
await this.recordAdmin(actor, 'registerVertical', { tenantId: null }, null, parsed);
|
|
394
|
+
},
|
|
395
|
+
listVerticals: async (actor) => {
|
|
396
|
+
const rows = await this.cp.listVerticals();
|
|
397
|
+
await this.recordAccess(actor, 'listVerticals', {}, null, rows.length);
|
|
398
|
+
return rows.map(mapVertical);
|
|
399
|
+
},
|
|
400
|
+
publishVersion: async (actor, input) => {
|
|
401
|
+
const parsed = publishVersionInput.parse(input);
|
|
402
|
+
if (!(await this.cp.readVertical(parsed.verticalSlug))) {
|
|
403
|
+
throw new Error(`unknown vertical '${parsed.verticalSlug}'`);
|
|
404
|
+
}
|
|
405
|
+
// Lands PENDING — a push is not a deploy.
|
|
406
|
+
await this.cp.insertVersion({ ...parsed, createdAt: new Date().toISOString() });
|
|
407
|
+
await this.recordAdmin(actor, 'publishVersion', { tenantId: null }, null, parsed);
|
|
408
|
+
},
|
|
409
|
+
listVersions: async (actor, verticalSlug) => {
|
|
410
|
+
const rows = await this.cp.listVersions(verticalSlug);
|
|
411
|
+
await this.recordAccess(actor, 'listVersions', {}, { verticalSlug }, rows.length);
|
|
412
|
+
return rows.map(mapVersion);
|
|
413
|
+
},
|
|
414
|
+
admitVersion: async (actor, versionId) => {
|
|
415
|
+
const v = await this.cp.readVersion(versionId);
|
|
416
|
+
if (!v)
|
|
417
|
+
throw new Error(`unknown version ${versionId}`);
|
|
418
|
+
if (v.admission === 'admitted')
|
|
419
|
+
return;
|
|
420
|
+
if (v.admission === 'rejected') {
|
|
421
|
+
throw new Error(`version ${versionId} was rejected — publish a new one`);
|
|
422
|
+
}
|
|
423
|
+
await this.cp.setAdmission(versionId, 'admitted', null);
|
|
424
|
+
await this.recordAdmin(actor, 'admitVersion', { tenantId: null }, { admission: v.admission }, { admission: 'admitted' });
|
|
425
|
+
},
|
|
426
|
+
rejectVersion: async (actor, versionId, note) => {
|
|
427
|
+
const v = await this.cp.readVersion(versionId);
|
|
428
|
+
if (!v)
|
|
429
|
+
throw new Error(`unknown version ${versionId}`);
|
|
430
|
+
if (v.admission === 'admitted') {
|
|
431
|
+
throw new Error(`version ${versionId} is already admitted — it may be bound`);
|
|
432
|
+
}
|
|
433
|
+
if (v.admission === 'rejected')
|
|
434
|
+
return;
|
|
435
|
+
await this.cp.setAdmission(versionId, 'rejected', note);
|
|
436
|
+
await this.recordAdmin(actor, 'rejectVersion', { tenantId: null }, { admission: v.admission }, { admission: 'rejected', note });
|
|
437
|
+
},
|
|
438
|
+
promoteVersion: async (actor, verticalSlug, channel, versionId, acknowledge) => {
|
|
439
|
+
const incoming = await this.cp.readVersion(versionId);
|
|
440
|
+
if (!incoming)
|
|
441
|
+
throw new Error(`unknown version ${versionId}`);
|
|
442
|
+
if (incoming.vertical_slug !== verticalSlug) {
|
|
443
|
+
throw new Error(`version ${versionId} belongs to '${incoming.vertical_slug}'`);
|
|
444
|
+
}
|
|
445
|
+
if (incoming.admission !== 'admitted') {
|
|
446
|
+
throw new Error(`version ${versionId} is ${incoming.admission}, not admitted — it cannot be promoted`);
|
|
447
|
+
}
|
|
448
|
+
const current = await this.cp.readChannel(verticalSlug, channel);
|
|
449
|
+
const outgoing = current ? await this.cp.readVersion(current.version_id) : undefined;
|
|
450
|
+
const ack = promotionAcknowledgement.parse(acknowledge ?? {});
|
|
451
|
+
// §4's checkpoints, at the moment of exposure. A first promotion has
|
|
452
|
+
// nothing to diff against — the gate is about change, not existence.
|
|
453
|
+
if (outgoing) {
|
|
454
|
+
if (outgoing.permission_digest !== incoming.permission_digest && !ack.permissionChange) {
|
|
455
|
+
throw new Error(`promotion changes the permission surface (${outgoing.permission_digest} → ` +
|
|
456
|
+
`${incoming.permission_digest}) — acknowledge it explicitly to promote`);
|
|
457
|
+
}
|
|
458
|
+
if (outgoing.migration_digest !== incoming.migration_digest && !ack.migrationChange) {
|
|
459
|
+
throw new Error(`promotion changes migrations (${outgoing.migration_digest} → ` +
|
|
460
|
+
`${incoming.migration_digest}) — acknowledge it explicitly to promote`);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
await this.cp.setChannel(verticalSlug, channel, versionId, new Date().toISOString());
|
|
464
|
+
await this.recordAdmin(actor, 'promoteVersion', { tenantId: null, vertical: verticalSlug }, outgoing ? { versionId: outgoing.id, version: outgoing.version } : null, { channel, versionId, version: incoming.version, acknowledged: ack });
|
|
465
|
+
},
|
|
466
|
+
listChannels: async (actor, verticalSlug) => {
|
|
467
|
+
const rows = await this.cp.listChannels(verticalSlug);
|
|
468
|
+
await this.recordAccess(actor, 'listChannels', {}, { verticalSlug }, rows.length);
|
|
469
|
+
return rows.map((r) => verticalChannel.parse({
|
|
470
|
+
verticalSlug: r.vertical_slug,
|
|
471
|
+
channel: r.channel,
|
|
472
|
+
versionId: r.version_id,
|
|
473
|
+
updatedAt: r.updated_at,
|
|
474
|
+
}));
|
|
475
|
+
},
|
|
476
|
+
bindScopeVersion: async (actor, tenantId, scopeId, versionId) => {
|
|
477
|
+
const v = await this.cp.readVersion(versionId);
|
|
478
|
+
if (!v)
|
|
479
|
+
throw new Error(`unknown version ${versionId}`);
|
|
480
|
+
// The refusal the registry exists for.
|
|
481
|
+
if (v.admission !== 'admitted') {
|
|
482
|
+
throw new Error(`version ${versionId} is ${v.admission}, not admitted — it cannot be bound to a scope`);
|
|
483
|
+
}
|
|
484
|
+
const scope = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
485
|
+
if (!scope)
|
|
486
|
+
throw new Error(`unknown scope ${scopeId} in tenant ${tenantId}`);
|
|
487
|
+
await this.cp.bindScopeVersion(scopeId, versionId, v.vertical_slug);
|
|
488
|
+
await this.recordAdmin(actor, 'bindScopeVersion', { tenantId, scopeId }, null, {
|
|
489
|
+
versionId, vertical: v.vertical_slug, version: v.version,
|
|
490
|
+
});
|
|
491
|
+
},
|
|
492
|
+
createOrg: async (actor, input) => {
|
|
493
|
+
const parsed = createOrgInput.parse(input);
|
|
494
|
+
const created = await this.cp.createOrg(parsed.id, parsed.tenantId, parsed.slug, parsed.name, new Date().toISOString());
|
|
495
|
+
if (!created)
|
|
496
|
+
return; // idempotent, and a no-op is not audited
|
|
497
|
+
await this.recordAdmin(actor, 'createOrg', { tenantId: parsed.tenantId }, null, parsed);
|
|
498
|
+
},
|
|
499
|
+
listOrgs: async (actor, tenantId) => {
|
|
500
|
+
const orgs = (await this.cp.listOrgs(tenantId)).map(mapOrg);
|
|
501
|
+
await this.recordAccess(actor, 'listOrgs', { tenantId }, null, orgs.length);
|
|
502
|
+
return orgs;
|
|
503
|
+
},
|
|
504
|
+
getOrg: async (actor, tenantId, orgId) => {
|
|
505
|
+
const r = await this.cp.readOrg(tenantId, orgId);
|
|
506
|
+
await this.recordAccess(actor, 'getOrg', { tenantId }, { orgId }, r ? 1 : 0);
|
|
507
|
+
return r ? mapOrg(r) : undefined;
|
|
508
|
+
},
|
|
196
509
|
addMember: async (actor, tenantId, principal, orgId) => {
|
|
510
|
+
await requireOrg(tenantId, orgId);
|
|
197
511
|
await this.cp.writeTenantTuple(tenantId, `principal:${principal}`, 'member', `org:${orgId}`, null);
|
|
198
512
|
await this.recordAdmin(actor, 'addMember', { tenantId }, null, { principal, orgId });
|
|
199
513
|
},
|
|
514
|
+
removeMember: async (actor, tenantId, principal, orgId) => {
|
|
515
|
+
await requireOrg(tenantId, orgId);
|
|
516
|
+
// Tombstone (K-21), never DELETE. The DO reports whether anything changed
|
|
517
|
+
// so a repeat revoke stays a silent no-op rather than a second audit row.
|
|
518
|
+
const changed = await this.cp.revokeMember(tenantId, `principal:${principal}`, `org:${orgId}`, new Date().toISOString());
|
|
519
|
+
if (!changed)
|
|
520
|
+
return;
|
|
521
|
+
await this.recordAdmin(actor, 'removeMember', { tenantId }, { principal, orgId }, null);
|
|
522
|
+
},
|
|
523
|
+
listMembers: async (actor, tenantId, orgId, options) => {
|
|
524
|
+
await requireOrg(tenantId, orgId);
|
|
525
|
+
const rows = await this.cp.listMembers(tenantId, `org:${orgId}`, options?.includeRevoked ?? false);
|
|
526
|
+
await this.recordAccess(actor, 'listMembers', { tenantId }, { orgId, ...options }, rows.length);
|
|
527
|
+
return rows.map((r) => orgMembership.parse({
|
|
528
|
+
principal: r.subject.slice('principal:'.length),
|
|
529
|
+
orgId,
|
|
530
|
+
revokedAt: r.revoked_at,
|
|
531
|
+
}));
|
|
532
|
+
},
|
|
200
533
|
createTenant: async (actor, input) => {
|
|
201
534
|
const parsed = createTenantInput.parse(input);
|
|
202
535
|
const created = await this.cp.createTenant(parsed.id, parsed.slug, parsed.name, new Date().toISOString());
|
|
@@ -209,12 +542,18 @@ export class CloudflareScopeHost {
|
|
|
209
542
|
const before = await this.cp.setTenantStatus(tenantId, status);
|
|
210
543
|
await this.recordAdmin(actor, 'setTenantStatus', { tenantId }, { status: before }, { status });
|
|
211
544
|
},
|
|
212
|
-
listTenants: async () =>
|
|
213
|
-
|
|
545
|
+
listTenants: async (actor) => {
|
|
546
|
+
const tenants = (await this.cp.listTenants()).map((t) => tenantSchema.parse(t));
|
|
547
|
+
// Enumerating every tenant on the platform is the read this log exists for.
|
|
548
|
+
await this.recordAccess(actor, 'listTenants', {}, null, tenants.length);
|
|
549
|
+
return tenants;
|
|
550
|
+
},
|
|
551
|
+
getTenant: async (actor, tenantId) => {
|
|
214
552
|
const t = await this.cp.getTenant(tenantId);
|
|
553
|
+
await this.recordAccess(actor, 'getTenant', { tenantId }, null, t ? 1 : 0);
|
|
215
554
|
return t ? tenantSchema.parse(t) : undefined;
|
|
216
555
|
},
|
|
217
|
-
listScopes: async (filter) => {
|
|
556
|
+
listScopes: async (actor, filter) => {
|
|
218
557
|
const rows = await this.cp.listScopes({
|
|
219
558
|
tenantId: filter?.tenantId,
|
|
220
559
|
status: filter?.status
|
|
@@ -224,10 +563,12 @@ export class CloudflareScopeHost {
|
|
|
224
563
|
: undefined,
|
|
225
564
|
vertical: filter?.vertical,
|
|
226
565
|
});
|
|
566
|
+
await this.recordAccess(actor, 'listScopes', { tenantId: filter?.tenantId ?? null }, filter, rows.length);
|
|
227
567
|
return rows.map(mapScope);
|
|
228
568
|
},
|
|
229
|
-
getScopeRecord: async (tenantId, scopeId) => {
|
|
569
|
+
getScopeRecord: async (actor, tenantId, scopeId) => {
|
|
230
570
|
const row = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
571
|
+
await this.recordAccess(actor, 'getScopeRecord', { tenantId, scopeId }, null, row ? 1 : 0);
|
|
231
572
|
return row ? mapScope(row) : undefined;
|
|
232
573
|
},
|
|
233
574
|
suspendScope: async (actor, tenantId, scopeId) => transitionScope(actor, 'suspendScope', tenantId, scopeId, ['active'], 'suspended'),
|
|
@@ -246,26 +587,92 @@ export class CloudflareScopeHost {
|
|
|
246
587
|
return; // nothing held, nothing changed
|
|
247
588
|
await this.recordAdmin(actor, 'revokeEntitlement', { tenantId }, { entitlementKey }, null);
|
|
248
589
|
},
|
|
249
|
-
listEntitlements: async (tenantId) =>
|
|
590
|
+
listEntitlements: async (actor, tenantId) => {
|
|
591
|
+
const keys = await this.cp.listEntitlements(tenantId);
|
|
592
|
+
await this.recordAccess(actor, 'listEntitlements', { tenantId }, null, keys.length);
|
|
593
|
+
return keys;
|
|
594
|
+
},
|
|
595
|
+
registerIdentityPool: async (actor, input) => {
|
|
596
|
+
const parsed = identityPool.parse(input);
|
|
597
|
+
const created = await this.cp.registerIdentityPool(parsed.provider, parsed.topology, parsed.tenantId, new Date().toISOString());
|
|
598
|
+
if (!created)
|
|
599
|
+
return; // identical registration is idempotent, unaudited
|
|
600
|
+
// Null tenant for a central pool: it belongs to no single tenant, which is
|
|
601
|
+
// what made the admin log's tenantId nullable.
|
|
602
|
+
await this.recordAdmin(actor, 'registerIdentityPool', { tenantId: parsed.tenantId }, null, parsed);
|
|
603
|
+
},
|
|
604
|
+
getIdentityPool: async (actor, provider) => {
|
|
605
|
+
const r = await this.cp.readPool(provider);
|
|
606
|
+
await this.recordAccess(actor, 'getIdentityPool', {}, { provider }, r ? 1 : 0);
|
|
607
|
+
return r
|
|
608
|
+
? identityPool.parse({ provider: r.provider, topology: r.topology, tenantId: r.tenant_id })
|
|
609
|
+
: undefined;
|
|
610
|
+
},
|
|
611
|
+
listIdentityTenants: async (actor, provider, externalId) => {
|
|
612
|
+
const r = await this.cp.readPool(provider);
|
|
613
|
+
if (!r)
|
|
614
|
+
throw new Error(`identity pool '${provider}' is not registered`);
|
|
615
|
+
if (r.topology !== 'central') {
|
|
616
|
+
throw new Error(`identity pool '${provider}' is tenant-bound — enumerating tenants is only ` +
|
|
617
|
+
`meaningful on a central pool, where the same externalId is the same person`);
|
|
618
|
+
}
|
|
619
|
+
const tenants = (await this.cp.identityTenants(provider, externalId));
|
|
620
|
+
await this.recordAccess(actor, 'listIdentityTenants', {}, { provider }, tenants.length);
|
|
621
|
+
return tenants;
|
|
622
|
+
},
|
|
250
623
|
linkIdentity: async (actor, input) => {
|
|
251
624
|
const parsed = identityLink.parse(input);
|
|
625
|
+
const pool = await this.cp.readPool(parsed.provider);
|
|
626
|
+
if (!pool) {
|
|
627
|
+
throw new Error(`identity pool '${parsed.provider}' is not registered — a pool must declare ` +
|
|
628
|
+
`its topology before it may link (central vs tenant-bound decides whether ` +
|
|
629
|
+
`the same externalId in two tenants is one person or two)`);
|
|
630
|
+
}
|
|
631
|
+
if (pool.topology === 'tenant-bound' && pool.tenant_id !== parsed.tenantId) {
|
|
632
|
+
throw new Error(`identity pool '${parsed.provider}' is bound to tenant ${pool.tenant_id} and cannot link into ${parsed.tenantId}`);
|
|
633
|
+
}
|
|
252
634
|
const changed = await this.cp.linkIdentity(parsed.provider, parsed.externalId, parsed.principal, parsed.tenantId, parsed.scopeId ?? null, new Date().toISOString());
|
|
253
635
|
// Idempotent: an identity already bound is a no-op, not audited.
|
|
254
636
|
if (!changed)
|
|
255
637
|
return;
|
|
256
638
|
await this.recordAdmin(actor, 'linkIdentity', { tenantId: parsed.tenantId, scopeId: parsed.scopeId }, null, { provider: parsed.provider, externalId: parsed.externalId, principal: parsed.principal });
|
|
257
639
|
},
|
|
258
|
-
resolveIdentity: async (provider, externalId) => {
|
|
259
|
-
const row = await this.cp.resolveIdentity(provider, externalId);
|
|
640
|
+
resolveIdentity: async (tenantId, provider, externalId) => {
|
|
641
|
+
const row = await this.cp.resolveIdentity(tenantId, provider, externalId);
|
|
260
642
|
if (!row)
|
|
261
643
|
return undefined;
|
|
262
|
-
return resolvedIdentity.parse({
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
644
|
+
return resolvedIdentity.parse({ principal: row.principal, scopeId: row.scopeId });
|
|
645
|
+
},
|
|
646
|
+
accessLog: async (actor, filter) => {
|
|
647
|
+
const rows = await this.cp.accessLog({
|
|
648
|
+
actor: filter?.actor,
|
|
649
|
+
tenantId: filter?.tenantId,
|
|
650
|
+
method: filter?.method,
|
|
651
|
+
limit: filter?.limit,
|
|
266
652
|
});
|
|
653
|
+
// Reading the access log is itself a read. Recorded before returning, so
|
|
654
|
+
// the row describing this call is not in its own result.
|
|
655
|
+
await this.recordAccess(actor, 'accessLog', { tenantId: filter?.tenantId ?? null }, filter, rows.length);
|
|
656
|
+
return rows.map((r) => accessLogEntry.parse({
|
|
657
|
+
id: r.id,
|
|
658
|
+
actor: r.actor,
|
|
659
|
+
method: r.method,
|
|
660
|
+
tenantId: r.tenant_id,
|
|
661
|
+
scopeId: r.scope_id,
|
|
662
|
+
params: r.params,
|
|
663
|
+
resultCount: r.result_count,
|
|
664
|
+
drainedAt: r.drained_at,
|
|
665
|
+
at: r.at,
|
|
666
|
+
}));
|
|
667
|
+
},
|
|
668
|
+
pruneAccessLog: async (actor, limit) => {
|
|
669
|
+
const pruned = await this.cp.pruneAccessLog(limit);
|
|
670
|
+
if (pruned > 0) {
|
|
671
|
+
await this.recordAdmin(actor, 'pruneAccessLog', { tenantId: null }, { pruned }, null);
|
|
672
|
+
}
|
|
673
|
+
return pruned;
|
|
267
674
|
},
|
|
268
|
-
auditLog: async (filter) => {
|
|
675
|
+
auditLog: async (actor, filter) => {
|
|
269
676
|
const rows = await this.cp.auditLog({
|
|
270
677
|
tenantId: filter?.tenantId,
|
|
271
678
|
scopeId: filter?.scopeId,
|
|
@@ -282,17 +689,36 @@ export class CloudflareScopeHost {
|
|
|
282
689
|
cursor: filter?.cursor,
|
|
283
690
|
order: filter?.order,
|
|
284
691
|
});
|
|
692
|
+
// Reading the audit trail is itself audited.
|
|
693
|
+
await this.recordAccess(actor, 'auditLog', { tenantId: filter?.tenantId ?? null, scopeId: filter?.scopeId ?? null }, filter, rows.length);
|
|
285
694
|
return rows.map((r) => adminLogEntry.parse(r));
|
|
286
695
|
},
|
|
287
696
|
};
|
|
288
697
|
}
|
|
289
698
|
// -- helpers --------------------------------------------------------------
|
|
699
|
+
/**
|
|
700
|
+
* Record a staff read (K-24). `params` is a bounded summary, capped so one query
|
|
701
|
+
* cannot write an unbounded row.
|
|
702
|
+
*/
|
|
703
|
+
async recordAccess(actor, method, target, params, resultCount) {
|
|
704
|
+
await this.cp.recordAccess({
|
|
705
|
+
id: ulid(),
|
|
706
|
+
actor,
|
|
707
|
+
method,
|
|
708
|
+
tenantId: target.tenantId ?? null,
|
|
709
|
+
scopeId: target.scopeId ?? null,
|
|
710
|
+
params: params == null ? null : JSON.stringify(params).slice(0, 500),
|
|
711
|
+
resultCount,
|
|
712
|
+
at: new Date().toISOString(),
|
|
713
|
+
});
|
|
714
|
+
}
|
|
290
715
|
async recordAdmin(actor, action, target, before, after) {
|
|
291
716
|
await this.cp.recordAdmin({
|
|
292
717
|
id: ulid(),
|
|
293
718
|
actor,
|
|
294
719
|
action,
|
|
295
720
|
tenantId: target.tenantId,
|
|
721
|
+
causedBy: this.causedBy,
|
|
296
722
|
scopeId: target.scopeId ?? null,
|
|
297
723
|
vertical: target.vertical ?? null,
|
|
298
724
|
before: before ?? null,
|