@substrat-run/adapter-cloudflare 0.40.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 +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 +27 -2
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +148 -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 +176 -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,119 @@ 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
|
+
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
|
+
}
|
|
582
702
|
async importScope(actor, input, dump) {
|
|
583
703
|
// Create the destination scope (directory row + DO + lazy migrate); the DO then
|
|
584
704
|
// replaces its provisioned schema with the dump wholesale (drop-then-replay), so
|
|
@@ -1261,12 +1381,18 @@ export class CloudflareScopeHost {
|
|
|
1261
1381
|
const existing = await this.cp.readVertical(slug);
|
|
1262
1382
|
if (!existing)
|
|
1263
1383
|
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
|
-
//
|
|
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).
|
|
1267
1390
|
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`);
|
|
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`);
|
|
1270
1396
|
}
|
|
1271
1397
|
await this.cp.deleteVertical(slug);
|
|
1272
1398
|
await this.recordAdmin(actor, 'deleteVertical', { tenantId: null }, { slug, source: existing.source, ownerTenant: existing.owner_tenant }, null);
|
|
@@ -1615,6 +1741,21 @@ export class CloudflareScopeHost {
|
|
|
1615
1741
|
createdAt: r.created_at,
|
|
1616
1742
|
}));
|
|
1617
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
|
+
},
|
|
1618
1759
|
listScopeTables: async (actor, tenantId, scopeId) => {
|
|
1619
1760
|
// K-3 cross-check on the shared directory BEFORE reaching the scope DO: a pair
|
|
1620
1761
|
// that does not resolve is unreachable, never another tenant's database.
|