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