@substrat-run/adapter-cloudflare 0.39.0 → 0.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/control-plane-do.d.ts +52 -15
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +182 -35
- package/dist/control-plane-do.js.map +1 -1
- package/dist/host.d.ts +30 -3
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +214 -31
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/r2.d.ts +49 -0
- package/dist/r2.d.ts.map +1 -0
- package/dist/r2.js +121 -0
- package/dist/r2.js.map +1 -0
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +188 -5
- package/dist/scope-do.js.map +1 -1
- package/dist/scope-sweeper-do.d.ts +139 -0
- package/dist/scope-sweeper-do.d.ts.map +1 -0
- package/dist/scope-sweeper-do.js +193 -0
- package/dist/scope-sweeper-do.js.map +1 -0
- package/package.json +4 -4
package/dist/host.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { accessLogEntry, adminLogEntry, createTenantInput, identityLink, identityPool, createOrgInput, promotionAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, registerVerticalInput, vertical as verticalSchema, verticalServingState, verticalChannel, verticalVersion, connection, connectionGrant, connectionSecret, systemGrant, entitlementGrant, entitlementGrantInput, subjectRef, createConnectionInput, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, platformRequest, } from '@substrat-run/contracts';
|
|
1
|
+
import { accessLogEntry, adminLogEntry, attachmentRecord, createTenantInput, identityLink, identityPool, createOrgInput, promotionAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, registerVerticalInput, vertical as verticalSchema, verticalServingState, verticalChannel, verticalVersion, connection, connectionGrant, connectionSecret, systemGrant, entitlementGrant, entitlementGrantInput, subjectRef, createConnectionInput, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, platformRequest, } from '@substrat-run/contracts';
|
|
2
2
|
import { normalizeHostname, toRouteTarget } from './route-resolver.js';
|
|
3
|
-
import { resolveScopeRecord, ulid, backoffAt, resolveRetryPolicy, unconfiguredSecretBox, } from '@substrat-run/kernel';
|
|
3
|
+
import { attachmentBlobKey, resolveScopeRecord, ulid, backoffAt, resolveRetryPolicy, unconfiguredSecretBox, } from '@substrat-run/kernel';
|
|
4
4
|
import { tenantStoreDatabaseName } from './d1.js';
|
|
5
|
+
import { blobStoreBucketName, r2TenantBlobStore } from './r2.js';
|
|
5
6
|
/** DO row → contract shape. Never reads the secrets table — that is the split. */
|
|
6
7
|
const toConnection = (r) => connection.parse({
|
|
7
8
|
id: r.id,
|
|
@@ -69,6 +70,8 @@ export class CloudflareScopeHost {
|
|
|
69
70
|
admin;
|
|
70
71
|
scopeNs;
|
|
71
72
|
cp;
|
|
73
|
+
/** No control plane bound — `this.cp` is the throwing null object (Phase 3, CP-less). */
|
|
74
|
+
cpLess;
|
|
72
75
|
/** Project + evaluate permissions scope-locally (scope-local-permissions.md). */
|
|
73
76
|
scopeLocalPermissions;
|
|
74
77
|
// Registration-mechanics bookkeeping (validation only — the DO executes).
|
|
@@ -85,6 +88,10 @@ export class CloudflareScopeHost {
|
|
|
85
88
|
fetchImpl;
|
|
86
89
|
/** The live D1 client for per-tenant stores (#301); undefined ⇒ refuse loudly. */
|
|
87
90
|
tenantStores;
|
|
91
|
+
/** The live R2 client for per-tenant blob stores (#473); undefined ⇒ refuse loudly. */
|
|
92
|
+
blobStores;
|
|
93
|
+
/** Worker-side attachment-bucket resolver (#473); undefined ⇒ attachments() refuses. */
|
|
94
|
+
attachmentBuckets;
|
|
88
95
|
executors = new Map();
|
|
89
96
|
/**
|
|
90
97
|
* The event currently being effected, stamped onto admin rows the executor writes.
|
|
@@ -109,9 +116,12 @@ export class CloudflareScopeHost {
|
|
|
109
116
|
constructor(options) {
|
|
110
117
|
this.secretBox = options.secretBox ?? unconfiguredSecretBox;
|
|
111
118
|
this.tenantStores = options.tenantStores;
|
|
119
|
+
this.blobStores = options.blobStores;
|
|
120
|
+
this.attachmentBuckets = options.attachmentBuckets;
|
|
112
121
|
this.fetchImpl = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
|
|
113
122
|
this.scopeLocalPermissions = options.scopeLocalPermissions ?? false;
|
|
114
123
|
this.scopeNs = options.scope;
|
|
124
|
+
this.cpLess = !options.controlPlane;
|
|
115
125
|
this.cp = options.controlPlane
|
|
116
126
|
? options.controlPlane.get(options.controlPlane.idFromName('control-plane'))
|
|
117
127
|
: nullControlPlane();
|
|
@@ -576,6 +586,119 @@ export class CloudflareScopeHost {
|
|
|
576
586
|
}
|
|
577
587
|
return this.tenantStores;
|
|
578
588
|
}
|
|
589
|
+
async provisionBlobStore(actor, input) {
|
|
590
|
+
// Mirror of provisionTenantStore (#301) with R2 in place of D1: fail-closed tenant
|
|
591
|
+
// gate, ledger idempotency, deterministic name so a crashed retry converges, DO
|
|
592
|
+
// first-writer-wins with loser teardown.
|
|
593
|
+
const r2 = this.requireBlobStores(`provisionBlobStore(tenant=${input.tenantId} vertical=${input.vertical} binding=${input.binding})`);
|
|
594
|
+
const tenant = await this.cp.getTenant(input.tenantId);
|
|
595
|
+
if (!tenant) {
|
|
596
|
+
throw new Error(`cannot provision blob store under unknown tenant: ${input.tenantId}`);
|
|
597
|
+
}
|
|
598
|
+
if (tenant.status !== 'active') {
|
|
599
|
+
throw new Error(`cannot provision blob store under non-active tenant (status: ${tenant.status}): ${input.tenantId}`);
|
|
600
|
+
}
|
|
601
|
+
const existing = await this.cp.getBlobStore(input.tenantId, input.vertical, input.binding);
|
|
602
|
+
if (existing)
|
|
603
|
+
return { binding: input.binding, kind: 'blob', ref: existing.ref };
|
|
604
|
+
const name = await blobStoreBucketName(input.tenantId, input.vertical, input.binding);
|
|
605
|
+
const ref = await r2.create(name);
|
|
606
|
+
const stored = await this.cp.putBlobStore({
|
|
607
|
+
tenantId: input.tenantId,
|
|
608
|
+
vertical: input.vertical,
|
|
609
|
+
binding: input.binding,
|
|
610
|
+
kind: 'blob',
|
|
611
|
+
ref,
|
|
612
|
+
createdAt: new Date().toISOString(),
|
|
613
|
+
});
|
|
614
|
+
if (stored.ref !== ref) {
|
|
615
|
+
await r2.remove(ref).catch(() => undefined);
|
|
616
|
+
return { binding: input.binding, kind: 'blob', ref: stored.ref };
|
|
617
|
+
}
|
|
618
|
+
await this.recordAdmin(actor, 'provisionBlobStore', { tenantId: input.tenantId, vertical: input.vertical }, null, { binding: input.binding, kind: 'blob', ref });
|
|
619
|
+
return { binding: input.binding, kind: 'blob', ref };
|
|
620
|
+
}
|
|
621
|
+
/** The injected R2 client, or a loud refusal naming what to configure. */
|
|
622
|
+
requireBlobStores(what) {
|
|
623
|
+
if (!this.blobStores) {
|
|
624
|
+
throw new Error(`per-tenant blob stores are not configured on this host (#473): pass ` +
|
|
625
|
+
`CloudflareScopeHostOptions.blobStores (createR2BlobStores with the platform's ` +
|
|
626
|
+
`Cloudflare credential) — refused ${what}`);
|
|
627
|
+
}
|
|
628
|
+
return this.blobStores;
|
|
629
|
+
}
|
|
630
|
+
async attachments(principal, tenantId, scopeId) {
|
|
631
|
+
// Same fail-closed lifecycle gate + lazy-migration as getScope (#473). The permission
|
|
632
|
+
// gates and metadata facts live in the ScopeDO (per-scope serialization, spine event
|
|
633
|
+
// in the same transaction); bytes go straight to the per-tenant R2 bucket through the
|
|
634
|
+
// binding the vertical's worker resolved — never through the DO.
|
|
635
|
+
await this.cp.validateScopeAccess(tenantId, scopeId);
|
|
636
|
+
await this.migrateAndRecord(scopeId);
|
|
637
|
+
if (!this.attachmentBuckets) {
|
|
638
|
+
throw new Error(`attachments are not configured on this host (#473): pass ` +
|
|
639
|
+
`CloudflareScopeHostOptions.attachmentBuckets — (tenantId) => ` +
|
|
640
|
+
`env[blobStoreBindingName('<BINDING>', tenantId)] for the vertical's declared blob store`);
|
|
641
|
+
}
|
|
642
|
+
const bucket = await this.attachmentBuckets(tenantId);
|
|
643
|
+
if (!bucket) {
|
|
644
|
+
throw new Error(`no attachment bucket resolved for tenant ${tenantId} (#473) — is the per-tenant blob ` +
|
|
645
|
+
`store provisioned and its r2_bucket binding attached to the serving script?`);
|
|
646
|
+
}
|
|
647
|
+
const store = r2TenantBlobStore(bucket);
|
|
648
|
+
const stub = this.scopeStub(scopeId);
|
|
649
|
+
const sha256Hex = async (body) => {
|
|
650
|
+
const digest = await crypto.subtle.digest('SHA-256', body);
|
|
651
|
+
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
652
|
+
};
|
|
653
|
+
return {
|
|
654
|
+
upload: async (input) => {
|
|
655
|
+
const id = ulid();
|
|
656
|
+
const key = attachmentBlobKey(scopeId, id);
|
|
657
|
+
const record = attachmentRecord.parse({
|
|
658
|
+
id,
|
|
659
|
+
entity: input.entity,
|
|
660
|
+
filename: input.filename,
|
|
661
|
+
contentType: input.contentType,
|
|
662
|
+
size: input.body.byteLength,
|
|
663
|
+
sha256: await sha256Hex(input.body),
|
|
664
|
+
visibility: input.visibility,
|
|
665
|
+
createdBy: principal,
|
|
666
|
+
createdAt: new Date().toISOString(),
|
|
667
|
+
});
|
|
668
|
+
// Bytes first, row second — a crash between the two leaves an orphaned object
|
|
669
|
+
// (harmless, GC-able), never a row without bytes; a refused gate compensates.
|
|
670
|
+
await store.put(key, input.body, { contentType: input.contentType });
|
|
671
|
+
try {
|
|
672
|
+
return await stub.attachmentAdd(record, principal, tenantId, scopeId);
|
|
673
|
+
}
|
|
674
|
+
catch (err) {
|
|
675
|
+
await store.delete(key).catch(() => { });
|
|
676
|
+
throw err;
|
|
677
|
+
}
|
|
678
|
+
},
|
|
679
|
+
list: (entity) => stub.attachmentList(entity, principal, tenantId, scopeId),
|
|
680
|
+
open: async (attachmentId) => {
|
|
681
|
+
const record = await stub.attachmentAuthorize(attachmentId, 'read', principal, tenantId, scopeId);
|
|
682
|
+
if (!record)
|
|
683
|
+
return null;
|
|
684
|
+
const obj = await store.get(attachmentBlobKey(scopeId, record.id));
|
|
685
|
+
if (!obj) {
|
|
686
|
+
throw new Error(`attachment ${record.id}: bytes missing from the blob store — the metadata row ` +
|
|
687
|
+
`survived something the object did not (rewind/reap); see the #473 integrity notes`);
|
|
688
|
+
}
|
|
689
|
+
if ((await sha256Hex(obj.body)) !== record.sha256) {
|
|
690
|
+
throw new Error(`attachment ${record.id}: bytes do not match the recorded sha256`);
|
|
691
|
+
}
|
|
692
|
+
return { record, body: obj.body, contentType: obj.contentType ?? record.contentType };
|
|
693
|
+
},
|
|
694
|
+
remove: async (attachmentId) => {
|
|
695
|
+
const removed = await stub.attachmentRemove(attachmentId, principal, tenantId, scopeId);
|
|
696
|
+
if (removed)
|
|
697
|
+
await store.delete(attachmentBlobKey(scopeId, removed.id)).catch(() => { });
|
|
698
|
+
return removed;
|
|
699
|
+
},
|
|
700
|
+
};
|
|
701
|
+
}
|
|
579
702
|
async importScope(actor, input, dump) {
|
|
580
703
|
// Create the destination scope (directory row + DO + lazy migrate); the DO then
|
|
581
704
|
// replaces its provisioned schema with the dump wholesale (drop-then-replay), so
|
|
@@ -678,12 +801,12 @@ export class CloudflareScopeHost {
|
|
|
678
801
|
throw err;
|
|
679
802
|
}
|
|
680
803
|
}
|
|
681
|
-
async getScope(principal, tenantId, scopeId) {
|
|
804
|
+
async getScope(principal, tenantId, scopeId, options) {
|
|
682
805
|
// Lifecycle gates (control-plane.md §4.1/§4.2), the K-3 fail-closed path,
|
|
683
806
|
// evaluated durably in the ControlPlaneDO. A throw propagates here.
|
|
684
807
|
await this.cp.validateScopeAccess(tenantId, scopeId);
|
|
685
808
|
await this.migrateAndRecord(scopeId);
|
|
686
|
-
return this.buildStub(tenantId, scopeId, principal);
|
|
809
|
+
return this.buildStub(tenantId, scopeId, principal, undefined, undefined, options);
|
|
687
810
|
}
|
|
688
811
|
/**
|
|
689
812
|
* A scope stub whose authority is a CONNECTION (#97).
|
|
@@ -741,10 +864,15 @@ export class CloudflareScopeHost {
|
|
|
741
864
|
if (!schedules || schedules.length === 0)
|
|
742
865
|
return report;
|
|
743
866
|
// Only run on a live scope of this tenant; a scope archived between the sweep's
|
|
744
|
-
// enumeration and here simply has nothing due.
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
867
|
+
// enumeration and here simply has nothing due. A CP-less host has no directory
|
|
868
|
+
// to ask (#461) — it already trusts the router-asserted (tenant, scope) for the
|
|
869
|
+
// whole request path, and lifecycle for its scopes lives wherever provisioning
|
|
870
|
+
// does, so the grant check below is the only gate it can and does enforce.
|
|
871
|
+
if (!this.cpLess) {
|
|
872
|
+
const rec = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
873
|
+
if (!rec || rec.status !== 'active')
|
|
874
|
+
return report;
|
|
875
|
+
}
|
|
748
876
|
const stub = this.scopeStub(scopeId);
|
|
749
877
|
// The grant IS the switch (#383): run only where the scope holds a live
|
|
750
878
|
// `system:<moduleId>` grant. Skips a foreign-vertical scope and a per-tenant
|
|
@@ -779,7 +907,7 @@ export class CloudflareScopeHost {
|
|
|
779
907
|
return report;
|
|
780
908
|
}
|
|
781
909
|
/** The stub body, shared by the principal, connection, and system doors. */
|
|
782
|
-
buildStub(tenantId, scopeId, principal, connectionId, systemModuleId) {
|
|
910
|
+
buildStub(tenantId, scopeId, principal, connectionId, systemModuleId, options) {
|
|
783
911
|
const stub = this.scopeStub(scopeId);
|
|
784
912
|
const cp = this.cp;
|
|
785
913
|
const operationEntitlement = this.operationEntitlement;
|
|
@@ -802,9 +930,13 @@ export class CloudflareScopeHost {
|
|
|
802
930
|
if (requiredKey && !(await cp.tenantHoldsEntitlement(tenantId, requiredKey))) {
|
|
803
931
|
return Promise.reject(new Error(`operation not entitled: ${operation} — tenant does not hold '${requiredKey}'`));
|
|
804
932
|
}
|
|
805
|
-
const
|
|
933
|
+
const envelope = await stub.invoke(operation, input, asPrincipalId, tenantId, scopeId, connectionId, requiredKey, systemModuleId);
|
|
806
934
|
await this.drainExecutors(tenantId, scopeId);
|
|
807
|
-
|
|
935
|
+
// #458: the operation committed having enqueued platform intents — tell the
|
|
936
|
+
// caller's harness so it can flag the response for the router kick (#381).
|
|
937
|
+
if (envelope.platformRequests > 0)
|
|
938
|
+
options?.onPlatformRequests?.(envelope.platformRequests);
|
|
939
|
+
return envelope.result;
|
|
808
940
|
},
|
|
809
941
|
};
|
|
810
942
|
}
|
|
@@ -933,7 +1065,13 @@ export class CloudflareScopeHost {
|
|
|
933
1065
|
await this.fanOut(tenantId); // role definitions are projected into the tenant's scopes
|
|
934
1066
|
},
|
|
935
1067
|
listRoles: async (actor, filter) => {
|
|
936
|
-
const rows = await this.cp.listRoles({
|
|
1068
|
+
const rows = await this.cp.listRoles({
|
|
1069
|
+
tenantId: filter?.tenantId,
|
|
1070
|
+
source: filter?.source,
|
|
1071
|
+
limit: filter?.limit,
|
|
1072
|
+
cursor: filter?.cursor,
|
|
1073
|
+
order: filter?.order,
|
|
1074
|
+
});
|
|
937
1075
|
await this.recordAccess(actor, 'listRoles', { tenantId: filter?.tenantId ?? null }, filter, rows.length);
|
|
938
1076
|
// Parsed, not cast — the same parse the pure adapter does, which is what
|
|
939
1077
|
// makes the shared contract suite mean anything.
|
|
@@ -1110,6 +1248,9 @@ export class CloudflareScopeHost {
|
|
|
1110
1248
|
tenantId: filter?.tenantId,
|
|
1111
1249
|
scopeId: filter?.scopeId,
|
|
1112
1250
|
status: filter?.status,
|
|
1251
|
+
limit: filter?.limit,
|
|
1252
|
+
cursor: filter?.cursor,
|
|
1253
|
+
order: filter?.order,
|
|
1113
1254
|
});
|
|
1114
1255
|
await this.recordAccess(actor, 'listHostnames', { tenantId: filter?.tenantId ?? null, scopeId: filter?.scopeId ?? null }, filter, rows.length);
|
|
1115
1256
|
return rows.map(mapHostname);
|
|
@@ -1161,9 +1302,9 @@ export class CloudflareScopeHost {
|
|
|
1161
1302
|
await this.cp.insertVertical(parsed.slug, parsed.name, parsed.source, parsed.ownerTenant, envSpecJson, installSpecJson, parsed.listed ? 1 : 0, new Date().toISOString());
|
|
1162
1303
|
await this.recordAdmin(actor, 'registerVertical', { tenantId: null }, null, parsed);
|
|
1163
1304
|
},
|
|
1164
|
-
listVerticals: async (actor) => {
|
|
1165
|
-
const rows = await this.cp.listVerticals();
|
|
1166
|
-
await this.recordAccess(actor, 'listVerticals', {}, null, rows.length);
|
|
1305
|
+
listVerticals: async (actor, page) => {
|
|
1306
|
+
const rows = await this.cp.listVerticals(page);
|
|
1307
|
+
await this.recordAccess(actor, 'listVerticals', {}, page ?? null, rows.length);
|
|
1167
1308
|
return rows.map(mapVertical);
|
|
1168
1309
|
},
|
|
1169
1310
|
publishVersion: async (actor, input) => {
|
|
@@ -1192,8 +1333,8 @@ export class CloudflareScopeHost {
|
|
|
1192
1333
|
admission: selfAdmits ? 'admitted' : 'pending',
|
|
1193
1334
|
});
|
|
1194
1335
|
},
|
|
1195
|
-
listVersions: async (actor, verticalSlug) => {
|
|
1196
|
-
const rows = await this.cp.listVersions(verticalSlug);
|
|
1336
|
+
listVersions: async (actor, verticalSlug, page) => {
|
|
1337
|
+
const rows = await this.cp.listVersions(verticalSlug, page);
|
|
1197
1338
|
await this.recordAccess(actor, 'listVersions', {}, { verticalSlug }, rows.length);
|
|
1198
1339
|
return rows.map(mapVersion);
|
|
1199
1340
|
},
|
|
@@ -1240,12 +1381,18 @@ export class CloudflareScopeHost {
|
|
|
1240
1381
|
const existing = await this.cp.readVertical(slug);
|
|
1241
1382
|
if (!existing)
|
|
1242
1383
|
throw new Error(`unknown vertical '${slug}'`);
|
|
1243
|
-
// Refuse while any scope is bound: a deleted registry row would strand
|
|
1244
|
-
// scopes' version pins and routing.
|
|
1245
|
-
//
|
|
1384
|
+
// Refuse while any restorable scope is bound: a deleted registry row would strand
|
|
1385
|
+
// those scopes' version pins and routing. An `archived` scope (a deleted app) still
|
|
1386
|
+
// blocks — unarchive can bring it back — but the refusal names reap/restore, not
|
|
1387
|
+
// "delete", because the app itself is already gone. `reaped` is terminal history and
|
|
1388
|
+
// never blocks. Deployed dispatch scripts are NOT reaped here — they become orphans
|
|
1389
|
+
// for the cleanup script (#248).
|
|
1246
1390
|
const bound = await this.cp.countScopesForVertical(slug);
|
|
1247
|
-
if (bound > 0) {
|
|
1248
|
-
throw new Error(`vertical '${slug}' still backs ${bound} scope(s) — delete or rebind them first`);
|
|
1391
|
+
if (bound.live > 0) {
|
|
1392
|
+
throw new Error(`vertical '${slug}' still backs ${bound.live} scope(s) — delete or rebind them first`);
|
|
1393
|
+
}
|
|
1394
|
+
if (bound.archived > 0) {
|
|
1395
|
+
throw new Error(`vertical '${slug}' still backs ${bound.archived} archived scope(s) — reap or restore them first`);
|
|
1249
1396
|
}
|
|
1250
1397
|
await this.cp.deleteVertical(slug);
|
|
1251
1398
|
await this.recordAdmin(actor, 'deleteVertical', { tenantId: null }, { slug, source: existing.source, ownerTenant: existing.owner_tenant }, null);
|
|
@@ -1355,8 +1502,8 @@ export class CloudflareScopeHost {
|
|
|
1355
1502
|
}
|
|
1356
1503
|
await this.recordAdmin(actor, 'promoteVersion', { tenantId: null, vertical: verticalSlug }, outgoing ? { versionId: outgoing.id, version: outgoing.version } : null, { channel, versionId, version: incoming.version, acknowledged: ack });
|
|
1357
1504
|
},
|
|
1358
|
-
listChannels: async (actor, verticalSlug) => {
|
|
1359
|
-
const rows = await this.cp.listChannels(verticalSlug);
|
|
1505
|
+
listChannels: async (actor, verticalSlug, page) => {
|
|
1506
|
+
const rows = await this.cp.listChannels(verticalSlug, page);
|
|
1360
1507
|
// The serving script runs ONE version (#286); surface it on the prod row so a
|
|
1361
1508
|
// failed in-place serve (channel moved, serve did not) reads honestly instead of
|
|
1362
1509
|
// claiming the new version is live (#321).
|
|
@@ -1370,8 +1517,8 @@ export class CloudflareScopeHost {
|
|
|
1370
1517
|
servingVersionId: r.channel === 'prod' ? serving : null,
|
|
1371
1518
|
}));
|
|
1372
1519
|
},
|
|
1373
|
-
listChannelHistory: async (actor, verticalSlug, channel) => {
|
|
1374
|
-
const rows = await this.cp.listChannelHistory(verticalSlug, channel);
|
|
1520
|
+
listChannelHistory: async (actor, verticalSlug, channel, page) => {
|
|
1521
|
+
const rows = await this.cp.listChannelHistory(verticalSlug, channel, page);
|
|
1375
1522
|
await this.recordAccess(actor, 'listChannelHistory', {}, { verticalSlug, channel }, rows.length);
|
|
1376
1523
|
return rows.map((r) => channelHistoryEntry.parse({
|
|
1377
1524
|
id: r.id,
|
|
@@ -1547,10 +1694,10 @@ export class CloudflareScopeHost {
|
|
|
1547
1694
|
const before = await this.cp.reapTenant(tenantId);
|
|
1548
1695
|
await this.recordAdmin(actor, 'reapTenant', { tenantId }, { status: before }, { status: 'reaped' });
|
|
1549
1696
|
},
|
|
1550
|
-
listTenants: async (actor) => {
|
|
1551
|
-
const tenants = (await this.cp.listTenants()).map((t) => tenantSchema.parse(t));
|
|
1697
|
+
listTenants: async (actor, page) => {
|
|
1698
|
+
const tenants = (await this.cp.listTenants(page)).map((t) => tenantSchema.parse(t));
|
|
1552
1699
|
// Enumerating every tenant on the platform is the read this log exists for.
|
|
1553
|
-
await this.recordAccess(actor, 'listTenants', {}, null, tenants.length);
|
|
1700
|
+
await this.recordAccess(actor, 'listTenants', {}, page ?? null, tenants.length);
|
|
1554
1701
|
return tenants;
|
|
1555
1702
|
},
|
|
1556
1703
|
getTenant: async (actor, tenantId) => {
|
|
@@ -1567,6 +1714,9 @@ export class CloudflareScopeHost {
|
|
|
1567
1714
|
: [filter.status]
|
|
1568
1715
|
: undefined,
|
|
1569
1716
|
vertical: filter?.vertical,
|
|
1717
|
+
limit: filter?.limit,
|
|
1718
|
+
cursor: filter?.cursor,
|
|
1719
|
+
order: filter?.order,
|
|
1570
1720
|
});
|
|
1571
1721
|
await this.recordAccess(actor, 'listScopes', { tenantId: filter?.tenantId ?? null }, filter, rows.length);
|
|
1572
1722
|
return rows.map(mapScope);
|
|
@@ -1591,6 +1741,21 @@ export class CloudflareScopeHost {
|
|
|
1591
1741
|
createdAt: r.created_at,
|
|
1592
1742
|
}));
|
|
1593
1743
|
},
|
|
1744
|
+
listBlobStores: async (actor, filter) => {
|
|
1745
|
+
const rows = await this.cp.listBlobStores({
|
|
1746
|
+
tenantId: filter?.tenantId,
|
|
1747
|
+
vertical: filter?.vertical,
|
|
1748
|
+
});
|
|
1749
|
+
await this.recordAccess(actor, 'listBlobStores', { tenantId: filter?.tenantId ?? null }, filter ?? null, rows.length);
|
|
1750
|
+
return rows.map((r) => ({
|
|
1751
|
+
tenantId: r.tenant_id,
|
|
1752
|
+
vertical: r.vertical,
|
|
1753
|
+
binding: r.binding,
|
|
1754
|
+
kind: 'blob',
|
|
1755
|
+
ref: r.ref,
|
|
1756
|
+
createdAt: r.created_at,
|
|
1757
|
+
}));
|
|
1758
|
+
},
|
|
1594
1759
|
listScopeTables: async (actor, tenantId, scopeId) => {
|
|
1595
1760
|
// K-3 cross-check on the shared directory BEFORE reaching the scope DO: a pair
|
|
1596
1761
|
// that does not resolve is unreachable, never another tenant's database.
|
|
@@ -1812,8 +1977,8 @@ export class CloudflareScopeHost {
|
|
|
1812
1977
|
return; // idempotent, and a no-op is not audited
|
|
1813
1978
|
await this.recordAdmin(actor, 'revokeConnection', { tenantId: row.tenant_id, vertical: row.vertical }, { status: row.status }, { id, provider: row.provider, status: 'revoked', revokedAt: now });
|
|
1814
1979
|
},
|
|
1815
|
-
openConnection: async (tenantId, vertical, provider) => {
|
|
1816
|
-
const row = await this.cp.readLiveConnection(tenantId, vertical, provider);
|
|
1980
|
+
openConnection: async (tenantId, vertical, provider, externalAccountRef) => {
|
|
1981
|
+
const row = await this.cp.readLiveConnection(tenantId, vertical, provider, externalAccountRef);
|
|
1817
1982
|
if (!row)
|
|
1818
1983
|
return undefined;
|
|
1819
1984
|
const secret = connectionSecret.parse(JSON.parse(await this.secretBox.open({ keyId: row.key_id, ciphertext: row.ciphertext })));
|
|
@@ -1889,6 +2054,8 @@ export class CloudflareScopeHost {
|
|
|
1889
2054
|
tenantId: filter?.tenantId,
|
|
1890
2055
|
method: filter?.method,
|
|
1891
2056
|
limit: filter?.limit,
|
|
2057
|
+
cursor: filter?.cursor,
|
|
2058
|
+
order: filter?.order,
|
|
1892
2059
|
});
|
|
1893
2060
|
// Reading the access log is itself a read. Recorded before returning, so
|
|
1894
2061
|
// the row describing this call is not in its own result.
|
|
@@ -2069,6 +2236,22 @@ export class CloudflareScopeHost {
|
|
|
2069
2236
|
object: `scope:${input.scopeId}`,
|
|
2070
2237
|
expires_at: null,
|
|
2071
2238
|
},
|
|
2239
|
+
// #461: each registered module's SCHEDULE grants (#383) ride the same unit —
|
|
2240
|
+
// the CP-less mirror of `provisionScope`'s writeTuple loop. Without them the
|
|
2241
|
+
// grant-is-the-switch check makes every schedule a silent no-op (`fired: 0`,
|
|
2242
|
+
// no error — the #49 unfalsifiable zero).
|
|
2243
|
+
...[...this.moduleSchedules].flatMap(([modId, schedules]) => {
|
|
2244
|
+
const perms = new Set();
|
|
2245
|
+
for (const s of schedules)
|
|
2246
|
+
for (const p of s.permissions)
|
|
2247
|
+
perms.add(p);
|
|
2248
|
+
return [...perms].map((perm) => ({
|
|
2249
|
+
subject: `system:${modId}`,
|
|
2250
|
+
relation: `granted:${perm}`,
|
|
2251
|
+
object: `scope:${input.scopeId}`,
|
|
2252
|
+
expires_at: null,
|
|
2253
|
+
}));
|
|
2254
|
+
}),
|
|
2072
2255
|
],
|
|
2073
2256
|
// #406: identity links, delivered by the platform with provisioning exactly as
|
|
2074
2257
|
// entitlements are (#310). Preserve-on-undefined, so an absent field never wipes
|