@substrat-run/adapter-cloudflare 0.70.0 → 0.72.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,6 +1,6 @@
1
- import { accessLogEntry, adminLogEntry, opsFailureEntry, attachmentRecord, createTenantInput, identityLink, identityPool, createOrgInput, promotionAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, registerVerticalInput, vertical as verticalSchema, verticalServingState, verticalChannel, verticalVersion, connection, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, entitlementGrant, entitlementGrantInput, instant, meterReading, subjectRef, createConnectionInput, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, subjectShredReceipt, platformRequest, connectorDispatchKind, outboundOfManifestJson, } from '@substrat-run/contracts';
1
+ import { accessLogEntry, adminLogEntry, opsFailureEntry, attachmentRecord, createTenantInput, identityLink, identityPool, createOrgInput, promotionAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, registerVerticalInput, vertical as verticalSchema, verticalServingState, verticalChannel, verticalVersion, connection, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, entitlementGrant, entitlementGrantInput, instant, meterReading, subjectRef, createConnectionInput, projectedConnectionKey, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, subjectShredReceipt, platformRequest, connectorDispatchKind, outboundOfManifestJson, } from '@substrat-run/contracts';
2
2
  import { normalizeHostname, toRouteTarget } from './route-resolver.js';
3
- import { attachmentBlobKey, entitlementDenial, foldMeterReading, parseValidationRecords, resolveScopeRecord, ulid, backoffAt, resolveRetryPolicy, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, } from '@substrat-run/kernel';
3
+ import { attachmentBlobKey, entitlementDenial, foldMeterReading, parseValidationRecords, resolveScopeRecord, ulid, backoffAt, resolveRetryPolicy, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, openSealed, } from '@substrat-run/kernel';
4
4
  import { tenantStoreDatabaseName } from './d1.js';
5
5
  import { blobStoreBucketName, r2TenantBlobStore } from './r2.js';
6
6
  /** DO row → contract shape. Never reads the secrets table — that is the split. */
@@ -188,6 +188,11 @@ export class CloudflareScopeHost {
188
188
  // the control plane holds the directory and the credential but not the
189
189
  // vertical's R2, so the bytes come back over the seam.
190
190
  openAttachment: (attachmentId) => this.getConnectorAttachments(open.id, scopeId).then((a) => a.open(attachmentId)),
191
+ // #687: open a cell the scope sealed TO this connection. The keyId-indexed
192
+ // map goes in, never out — a connector receives the plaintext of one cell,
193
+ // not a private key it could mislay. Retired keys are in the map too, so a
194
+ // request pending across a rotation still opens.
195
+ unseal: async (sealed) => openSealed(await this.openSealingKeys(open.id), sealed),
191
196
  fetch: async (input, init) => {
192
197
  try {
193
198
  const res = await fetchImpl(input, {
@@ -2273,6 +2278,17 @@ export class CloudflareScopeHost {
2273
2278
  createdBy: input.createdBy ?? actor,
2274
2279
  });
2275
2280
  },
2281
+ connectionSealingKey: (id) => this.ensureSealingKey(id),
2282
+ connectionSealingKeys: async (tenantId, vertical) => {
2283
+ // LIVE connections only. A revoked connection's key is KEPT (its pending
2284
+ // ciphertext must still open) but stops being projected, so a scope can no
2285
+ // longer seal to a credential that has been withdrawn.
2286
+ const rows = await this.cp.listConnections({ tenantId, vertical });
2287
+ const out = [];
2288
+ for (const r of rows)
2289
+ out.push(await this.ensureSealingKey(r.id));
2290
+ return out;
2291
+ },
2276
2292
  listConnections: async (actor, filter) => {
2277
2293
  const f = filter ?? {};
2278
2294
  const rows = await this.cp.listConnections(f);
@@ -2553,6 +2569,70 @@ export class CloudflareScopeHost {
2553
2569
  async writeScopeTuple(scopeId, subject, relation, object, expiresAt) {
2554
2570
  await this.scopeStub(scopeId).writeTuple(subject, relation, object, expiresAt);
2555
2571
  }
2572
+ /**
2573
+ * The connection's CURRENT public sealing key, minted on first ask (#687).
2574
+ *
2575
+ * Mint-on-read rather than mint-only-at-connect, because the fleet already holds
2576
+ * live connections older than this feature — Egeryds' Scrive credential carries
2577
+ * years of real contracts, and "reconnect to acquire a keypair" is not a
2578
+ * migration anyone should have to run against production. Asking IS the
2579
+ * back-fill, and it is idempotent: the DO's insert loses silently against the
2580
+ * partial-unique index and re-reads the winner, so a race yields one key.
2581
+ *
2582
+ * Minted HERE, on the coordinator, for the reason the credential is: the DO has
2583
+ * never held a `SecretBox` and must never see an unsealed private half.
2584
+ */
2585
+ async ensureSealingKey(connectionId) {
2586
+ const conn = await this.cp.readConnection(connectionId);
2587
+ if (!conn)
2588
+ throw new Error(`connection not found: ${connectionId}`);
2589
+ const rows = await this.cp.readConnectionKeys(connectionId);
2590
+ const current = rows.find((r) => r.retired_at === null);
2591
+ if (current) {
2592
+ return projectedConnectionKey.parse({
2593
+ connectionId,
2594
+ provider: conn.provider,
2595
+ keyId: current.key_id,
2596
+ publicKey: current.public_key,
2597
+ });
2598
+ }
2599
+ const pair = await generateSealingKeyPair(`connection:${connectionId}:${ulid()}`);
2600
+ const wrapped = await this.secretBox.seal(pair.privateKey);
2601
+ const won = await this.cp.insertConnectionKey({
2602
+ connectionId,
2603
+ keyId: pair.keyId,
2604
+ publicKey: pair.publicKey,
2605
+ wrappedKeyId: wrapped.keyId,
2606
+ wrappedPrivate: wrapped.ciphertext,
2607
+ createdAt: new Date().toISOString(),
2608
+ });
2609
+ return projectedConnectionKey.parse({
2610
+ connectionId,
2611
+ provider: conn.provider,
2612
+ keyId: won?.key_id ?? pair.keyId,
2613
+ publicKey: won?.public_key ?? pair.publicKey,
2614
+ });
2615
+ }
2616
+ /**
2617
+ * EVERY private half this connection holds, keyed by keyId — retired ones too.
2618
+ *
2619
+ * A ciphertext sealed before a rotation still names the key that sealed it, so
2620
+ * the opener has to hold the whole map or every request pending across a
2621
+ * rotation dead-letters. When rotation eventually DESTROYS a retired key its row
2622
+ * goes and the open fails loudly, which is the intended erasure (D-5), not an
2623
+ * accident.
2624
+ */
2625
+ async openSealingKeys(connectionId) {
2626
+ const rows = await this.cp.readConnectionKeys(connectionId);
2627
+ const out = {};
2628
+ for (const r of rows) {
2629
+ out[r.key_id] = await this.secretBox.open({
2630
+ keyId: r.wrapped_key_id,
2631
+ ciphertext: r.wrapped_private,
2632
+ });
2633
+ }
2634
+ return out;
2635
+ }
2556
2636
  scopeStub(scopeId) {
2557
2637
  // Deterministic DO id in milestone 1. Production mints per-jurisdiction ids
2558
2638
  // via newUniqueId (K-7) and stores the mapping in the directory — deferred.
@@ -2586,12 +2666,35 @@ export class CloudflareScopeHost {
2586
2666
  identities,
2587
2667
  };
2588
2668
  }
2669
+ /**
2670
+ * The public sealing keys a scope of THIS vertical may seal to (#687), in the shape
2671
+ * the ScopeDO stores.
2672
+ *
2673
+ * Per (tenant, vertical) rather than per tenant, unlike everything else in the
2674
+ * projection: a connection is keyed (tenant, vertical, provider) because a vertical
2675
+ * is a blast-radius boundary (D-30), so projecting another vertical's key would hand
2676
+ * this scope a write channel into a credential it may not reach. `[]` for a scope
2677
+ * bound to no vertical — which then seals to nothing, the fail-closed direction.
2678
+ */
2679
+ async connectionKeyRows(tenantId, vertical) {
2680
+ if (!vertical)
2681
+ return [];
2682
+ const keys = await this.admin.connectionSealingKeys(tenantId, vertical);
2683
+ return keys.map((k) => ({
2684
+ connection_id: k.connectionId,
2685
+ provider: k.provider,
2686
+ key_id: k.keyId,
2687
+ public_key: k.publicKey,
2688
+ }));
2689
+ }
2589
2690
  /** Project the tenant's current state into ONE scope + flip it to local. */
2590
2691
  async projectScope(tenantId, scopeId) {
2591
2692
  if (!this.scopeLocalPermissions)
2592
2693
  return;
2593
2694
  const { roles, tuples, entitlements, identities } = await this.tenantProjection(tenantId);
2594
- await this.scopeStub(scopeId).applyProjection(tenantId, roles, tuples, entitlements, undefined, identities);
2695
+ const scope = await this.cp.getScopeRecord(tenantId, scopeId);
2696
+ const connectionKeys = await this.connectionKeyRows(tenantId, scope?.vertical);
2697
+ await this.scopeStub(scopeId).applyProjection(tenantId, roles, tuples, entitlements, undefined, identities, connectionKeys);
2595
2698
  }
2596
2699
  /**
2597
2700
  * Fan the tenant's current state out into ALL its scopes — called after any
@@ -2603,7 +2706,17 @@ export class CloudflareScopeHost {
2603
2706
  return;
2604
2707
  const { roles, tuples, entitlements, identities } = await this.tenantProjection(tenantId);
2605
2708
  const scopes = await this.cp.listScopes({ tenantId });
2606
- await Promise.all(scopes.map((s) => this.scopeStub(s.scope_id).applyProjection(tenantId, roles, tuples, entitlements, undefined, identities)));
2709
+ // #687: connection keys are per (tenant, vertical), so they are resolved per scope
2710
+ // rather than once for the tenant — memoized, because a fan-out over twenty scopes
2711
+ // of one vertical must not mint or re-read twenty times.
2712
+ const keysByVertical = new Map();
2713
+ await Promise.all(scopes.map(async (s) => {
2714
+ const vertical = s.vertical ?? '';
2715
+ if (!keysByVertical.has(vertical)) {
2716
+ keysByVertical.set(vertical, this.connectionKeyRows(tenantId, s.vertical));
2717
+ }
2718
+ await this.scopeStub(s.scope_id).applyProjection(tenantId, roles, tuples, entitlements, undefined, identities, await keysByVertical.get(vertical));
2719
+ }));
2607
2720
  }
2608
2721
  /**
2609
2722
  * Re-project a tenant's full state into every one of its scopes — the
@@ -2689,6 +2802,18 @@ export class CloudflareScopeHost {
2689
2802
  principal_id: l.principal,
2690
2803
  scope_id: l.scopeId ?? null,
2691
2804
  }))
2805
+ : undefined,
2806
+ // #687: the tenant's connection sealing keys, delivered with provisioning exactly as
2807
+ // entitlements and identity links are. Preserve-on-undefined for the same reason: an
2808
+ // absent field must never wipe keys a prior reconcile projected, or a working
2809
+ // signature flow would break on an unrelated re-provision.
2810
+ input.connectionKeys
2811
+ ? input.connectionKeys.map((k) => ({
2812
+ connection_id: k.connectionId,
2813
+ provider: k.provider,
2814
+ key_id: k.keyId,
2815
+ public_key: k.publicKey,
2816
+ }))
2692
2817
  : undefined);
2693
2818
  }
2694
2819
  /**