@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/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 those
1265
- // scopes' version pins and routing. Deployed dispatch scripts are NOT reaped
1266
- // here — they become orphans for the cleanup script (#248).
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.