@substrat-run/adapter-cloudflare 0.71.0 → 0.73.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 +28 -0
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +47 -0
- package/dist/control-plane-do.js.map +1 -1
- package/dist/host.d.ts +42 -1
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +129 -4
- package/dist/host.js.map +1 -1
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +107 -22
- package/dist/scope-do.js.map +1 -1
- package/package.json +7 -7
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.
|
|
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
|
-
|
|
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
|
/**
|