@substrat-run/adapter-cloudflare 0.40.0 → 0.42.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 +39 -2
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +68 -3
- package/dist/control-plane-do.js.map +1 -1
- package/dist/host.d.ts +37 -2
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +190 -7
- 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/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 +184 -2
- package/dist/scope-do.js.map +1 -1
- 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,
|
|
@@ -87,6 +88,10 @@ export class CloudflareScopeHost {
|
|
|
87
88
|
fetchImpl;
|
|
88
89
|
/** The live D1 client for per-tenant stores (#301); undefined ⇒ refuse loudly. */
|
|
89
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;
|
|
90
95
|
executors = new Map();
|
|
91
96
|
/**
|
|
92
97
|
* The event currently being effected, stamped onto admin rows the executor writes.
|
|
@@ -111,6 +116,8 @@ export class CloudflareScopeHost {
|
|
|
111
116
|
constructor(options) {
|
|
112
117
|
this.secretBox = options.secretBox ?? unconfiguredSecretBox;
|
|
113
118
|
this.tenantStores = options.tenantStores;
|
|
119
|
+
this.blobStores = options.blobStores;
|
|
120
|
+
this.attachmentBuckets = options.attachmentBuckets;
|
|
114
121
|
this.fetchImpl = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
|
|
115
122
|
this.scopeLocalPermissions = options.scopeLocalPermissions ?? false;
|
|
116
123
|
this.scopeNs = options.scope;
|
|
@@ -579,6 +586,161 @@ export class CloudflareScopeHost {
|
|
|
579
586
|
}
|
|
580
587
|
return this.tenantStores;
|
|
581
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
|
+
const store = await this.resolveAttachmentStore(tenantId);
|
|
638
|
+
return this.buildAttachmentSurface({ principal }, tenantId, scopeId, store);
|
|
639
|
+
}
|
|
640
|
+
async getConnectorAttachments(connectionId, scopeId) {
|
|
641
|
+
// The exact door `getConnectorScope` opens — same (tenant, vertical) gate — but it
|
|
642
|
+
// returns the attachment surface instead of the invoke stub, gated as the connection
|
|
643
|
+
// rather than a principal (#476).
|
|
644
|
+
const conn = await this.cp.readConnection(connectionId);
|
|
645
|
+
if (!conn)
|
|
646
|
+
throw new Error(`connection not found: ${connectionId}`);
|
|
647
|
+
if (conn.revoked_at)
|
|
648
|
+
throw new Error(`connection ${connectionId} is revoked`);
|
|
649
|
+
const scope = await this.cp.getScopeRecord(conn.tenant_id, scopeId);
|
|
650
|
+
if (!scope)
|
|
651
|
+
throw new Error(`unknown scope for connection: ${scopeId}`);
|
|
652
|
+
if (scope.vertical !== conn.vertical) {
|
|
653
|
+
throw new Error(`connection ${connectionId} is for vertical '${conn.vertical}' and scope ${scopeId} ` +
|
|
654
|
+
`runs '${scope.vertical ?? 'none'}'`);
|
|
655
|
+
}
|
|
656
|
+
await this.cp.validateScopeAccess(conn.tenant_id, scopeId);
|
|
657
|
+
await this.migrateAndRecord(scopeId);
|
|
658
|
+
const store = await this.resolveAttachmentStore(conn.tenant_id);
|
|
659
|
+
return this.buildAttachmentSurface({ connectionId }, conn.tenant_id, scopeId, store);
|
|
660
|
+
}
|
|
661
|
+
/** Resolve the per-tenant R2 blob store, or fail closed exactly as `attachments` did. */
|
|
662
|
+
async resolveAttachmentStore(tenantId) {
|
|
663
|
+
if (!this.attachmentBuckets) {
|
|
664
|
+
throw new Error(`attachments are not configured on this host (#473): pass ` +
|
|
665
|
+
`CloudflareScopeHostOptions.attachmentBuckets — (tenantId) => ` +
|
|
666
|
+
`env[blobStoreBindingName('<BINDING>', tenantId)] for the vertical's declared blob store`);
|
|
667
|
+
}
|
|
668
|
+
const bucket = await this.attachmentBuckets(tenantId);
|
|
669
|
+
if (!bucket) {
|
|
670
|
+
throw new Error(`no attachment bucket resolved for tenant ${tenantId} (#473) — is the per-tenant blob ` +
|
|
671
|
+
`store provisioned and its r2_bucket binding attached to the serving script?`);
|
|
672
|
+
}
|
|
673
|
+
return r2TenantBlobStore(bucket);
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* The `ScopeAttachments` surface, gated as either a principal or a connection (#473/#476).
|
|
677
|
+
* The subject decides two things: `createdBy` on the record, and the `connectionId` threaded
|
|
678
|
+
* to the DO so its permission gate resolves against `connection:<id>` grants (and a denial is
|
|
679
|
+
* attributed to the connection, not a laundered principal). Bytes never cross the DO boundary.
|
|
680
|
+
*/
|
|
681
|
+
buildAttachmentSurface(subject, tenantId, scopeId, store) {
|
|
682
|
+
const connectionId = 'connectionId' in subject ? subject.connectionId : undefined;
|
|
683
|
+
const createdBy = 'principal' in subject ? subject.principal : subject.connectionId;
|
|
684
|
+
// The DO needs SOME principal-shaped value for `ctx.principal`; for a connection it is
|
|
685
|
+
// the connection id, and the honest attribution rides the event actor — the same trick
|
|
686
|
+
// `buildStub` uses for the invoke path.
|
|
687
|
+
const asPrincipalId = ('principal' in subject
|
|
688
|
+
? subject.principal
|
|
689
|
+
: subject.connectionId);
|
|
690
|
+
const stub = this.scopeStub(scopeId);
|
|
691
|
+
const sha256Hex = async (body) => {
|
|
692
|
+
const digest = await crypto.subtle.digest('SHA-256', body);
|
|
693
|
+
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
694
|
+
};
|
|
695
|
+
return {
|
|
696
|
+
upload: async (input) => {
|
|
697
|
+
const id = ulid();
|
|
698
|
+
const key = attachmentBlobKey(scopeId, id);
|
|
699
|
+
const record = attachmentRecord.parse({
|
|
700
|
+
id,
|
|
701
|
+
entity: input.entity,
|
|
702
|
+
filename: input.filename,
|
|
703
|
+
contentType: input.contentType,
|
|
704
|
+
size: input.body.byteLength,
|
|
705
|
+
sha256: await sha256Hex(input.body),
|
|
706
|
+
visibility: input.visibility,
|
|
707
|
+
createdBy,
|
|
708
|
+
createdAt: new Date().toISOString(),
|
|
709
|
+
});
|
|
710
|
+
// Bytes first, row second — a crash between the two leaves an orphaned object
|
|
711
|
+
// (harmless, GC-able), never a row without bytes; a refused gate compensates.
|
|
712
|
+
await store.put(key, input.body, { contentType: input.contentType });
|
|
713
|
+
try {
|
|
714
|
+
return await stub.attachmentAdd(record, asPrincipalId, tenantId, scopeId, connectionId);
|
|
715
|
+
}
|
|
716
|
+
catch (err) {
|
|
717
|
+
await store.delete(key).catch(() => { });
|
|
718
|
+
throw err;
|
|
719
|
+
}
|
|
720
|
+
},
|
|
721
|
+
list: (entity) => stub.attachmentList(entity, asPrincipalId, tenantId, scopeId, connectionId),
|
|
722
|
+
open: async (attachmentId) => {
|
|
723
|
+
const record = await stub.attachmentAuthorize(attachmentId, 'read', asPrincipalId, tenantId, scopeId, connectionId);
|
|
724
|
+
if (!record)
|
|
725
|
+
return null;
|
|
726
|
+
const obj = await store.get(attachmentBlobKey(scopeId, record.id));
|
|
727
|
+
if (!obj) {
|
|
728
|
+
throw new Error(`attachment ${record.id}: bytes missing from the blob store — the metadata row ` +
|
|
729
|
+
`survived something the object did not (rewind/reap); see the #473 integrity notes`);
|
|
730
|
+
}
|
|
731
|
+
if ((await sha256Hex(obj.body)) !== record.sha256) {
|
|
732
|
+
throw new Error(`attachment ${record.id}: bytes do not match the recorded sha256`);
|
|
733
|
+
}
|
|
734
|
+
return { record, body: obj.body, contentType: obj.contentType ?? record.contentType };
|
|
735
|
+
},
|
|
736
|
+
remove: async (attachmentId) => {
|
|
737
|
+
const removed = await stub.attachmentRemove(attachmentId, asPrincipalId, tenantId, scopeId, connectionId);
|
|
738
|
+
if (removed)
|
|
739
|
+
await store.delete(attachmentBlobKey(scopeId, removed.id)).catch(() => { });
|
|
740
|
+
return removed;
|
|
741
|
+
},
|
|
742
|
+
};
|
|
743
|
+
}
|
|
582
744
|
async importScope(actor, input, dump) {
|
|
583
745
|
// Create the destination scope (directory row + DO + lazy migrate); the DO then
|
|
584
746
|
// replaces its provisioned schema with the dump wholesale (drop-then-replay), so
|
|
@@ -1261,12 +1423,18 @@ export class CloudflareScopeHost {
|
|
|
1261
1423
|
const existing = await this.cp.readVertical(slug);
|
|
1262
1424
|
if (!existing)
|
|
1263
1425
|
throw new Error(`unknown vertical '${slug}'`);
|
|
1264
|
-
// Refuse while any scope is bound: a deleted registry row would strand
|
|
1265
|
-
// scopes' version pins and routing.
|
|
1266
|
-
//
|
|
1426
|
+
// Refuse while any restorable scope is bound: a deleted registry row would strand
|
|
1427
|
+
// those scopes' version pins and routing. An `archived` scope (a deleted app) still
|
|
1428
|
+
// blocks — unarchive can bring it back — but the refusal names reap/restore, not
|
|
1429
|
+
// "delete", because the app itself is already gone. `reaped` is terminal history and
|
|
1430
|
+
// never blocks. Deployed dispatch scripts are NOT reaped here — they become orphans
|
|
1431
|
+
// for the cleanup script (#248).
|
|
1267
1432
|
const bound = await this.cp.countScopesForVertical(slug);
|
|
1268
|
-
if (bound > 0) {
|
|
1269
|
-
throw new Error(`vertical '${slug}' still backs ${bound} scope(s) — delete or rebind them first`);
|
|
1433
|
+
if (bound.live > 0) {
|
|
1434
|
+
throw new Error(`vertical '${slug}' still backs ${bound.live} scope(s) — delete or rebind them first`);
|
|
1435
|
+
}
|
|
1436
|
+
if (bound.archived > 0) {
|
|
1437
|
+
throw new Error(`vertical '${slug}' still backs ${bound.archived} archived scope(s) — reap or restore them first`);
|
|
1270
1438
|
}
|
|
1271
1439
|
await this.cp.deleteVertical(slug);
|
|
1272
1440
|
await this.recordAdmin(actor, 'deleteVertical', { tenantId: null }, { slug, source: existing.source, ownerTenant: existing.owner_tenant }, null);
|
|
@@ -1615,6 +1783,21 @@ export class CloudflareScopeHost {
|
|
|
1615
1783
|
createdAt: r.created_at,
|
|
1616
1784
|
}));
|
|
1617
1785
|
},
|
|
1786
|
+
listBlobStores: async (actor, filter) => {
|
|
1787
|
+
const rows = await this.cp.listBlobStores({
|
|
1788
|
+
tenantId: filter?.tenantId,
|
|
1789
|
+
vertical: filter?.vertical,
|
|
1790
|
+
});
|
|
1791
|
+
await this.recordAccess(actor, 'listBlobStores', { tenantId: filter?.tenantId ?? null }, filter ?? null, rows.length);
|
|
1792
|
+
return rows.map((r) => ({
|
|
1793
|
+
tenantId: r.tenant_id,
|
|
1794
|
+
vertical: r.vertical,
|
|
1795
|
+
binding: r.binding,
|
|
1796
|
+
kind: 'blob',
|
|
1797
|
+
ref: r.ref,
|
|
1798
|
+
createdAt: r.created_at,
|
|
1799
|
+
}));
|
|
1800
|
+
},
|
|
1618
1801
|
listScopeTables: async (actor, tenantId, scopeId) => {
|
|
1619
1802
|
// K-3 cross-check on the shared directory BEFORE reaching the scope DO: a pair
|
|
1620
1803
|
// that does not resolve is unreachable, never another tenant's database.
|