@substrat-run/adapter-cloudflare 0.31.0 → 0.33.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 +14 -0
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +14 -0
- package/dist/control-plane-do.js.map +1 -1
- package/dist/host.d.ts +33 -6
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +160 -16
- package/dist/host.js.map +1 -1
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +123 -11
- package/dist/scope-do.js.map +1 -1
- package/package.json +4 -4
package/dist/host.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
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, 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, 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
3
|
import { resolveScopeRecord, ulid, backoffAt, resolveRetryPolicy, unconfiguredSecretBox, } from '@substrat-run/kernel';
|
|
4
4
|
/** DO row → contract shape. Never reads the secrets table — that is the split. */
|
|
@@ -73,6 +73,8 @@ export class CloudflareScopeHost {
|
|
|
73
73
|
// Registration-mechanics bookkeeping (validation only — the DO executes).
|
|
74
74
|
// Code-time, derived from the bundled modules, NOT durable directory state.
|
|
75
75
|
moduleIds = new Set();
|
|
76
|
+
/** Module id → its declared recurring schedules (#383), for `registeredSchedules`/`runDueSchedules`. */
|
|
77
|
+
moduleSchedules = new Map();
|
|
76
78
|
/** Registered (module, version) pairs — the frontier `schemaVersion` counts toward (§5.3, #49). */
|
|
77
79
|
migrationTotal = 0;
|
|
78
80
|
operations = new Set();
|
|
@@ -436,6 +438,9 @@ export class CloudflareScopeHost {
|
|
|
436
438
|
this.predicateNames.set(name, manifest.id);
|
|
437
439
|
}
|
|
438
440
|
this.moduleIds.add(manifest.id);
|
|
441
|
+
if (manifest.schedules && manifest.schedules.length > 0) {
|
|
442
|
+
this.moduleSchedules.set(manifest.id, manifest.schedules);
|
|
443
|
+
}
|
|
439
444
|
this.migrationTotal += migrations.length;
|
|
440
445
|
const ownOperations = new Set(Object.keys(registration.operations ?? {}));
|
|
441
446
|
for (const name of manifest.withdraws ?? []) {
|
|
@@ -474,6 +479,20 @@ export class CloudflareScopeHost {
|
|
|
474
479
|
// storage, so project the tenant's current roles/tuples into it (no-op when off,
|
|
475
480
|
// or if migration threw above — a failed scope stays closed, never projected).
|
|
476
481
|
await this.projectScope(input.tenantId, input.scopeId);
|
|
482
|
+
// Project each registered module's SCHEDULE grants (#383): the system principal
|
|
483
|
+
// holds exactly the permissions its schedules declared, on this scope, so
|
|
484
|
+
// `ctx.check` resolves for scheduled work (the gate stays the check). Written to
|
|
485
|
+
// the scope's own tuples, where the checker reads them — the same place the owner
|
|
486
|
+
// grant and connection grants land. Idempotent, so a re-provision re-asserts them.
|
|
487
|
+
for (const [moduleId, schedules] of this.moduleSchedules) {
|
|
488
|
+
const perms = new Set();
|
|
489
|
+
for (const s of schedules)
|
|
490
|
+
for (const p of s.permissions)
|
|
491
|
+
perms.add(p);
|
|
492
|
+
for (const perm of perms) {
|
|
493
|
+
await this.scopeStub(input.scopeId).writeTuple(`system:${moduleId}`, `granted:${perm}`, `scope:${input.scopeId}`, null);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
477
496
|
// Audit a real provision only; an idempotent re-provision changed nothing.
|
|
478
497
|
if (created) {
|
|
479
498
|
await this.recordAdmin(actor, 'provisionScope', { tenantId: input.tenantId, scopeId: input.scopeId, vertical: record.vertical }, null, record);
|
|
@@ -628,15 +647,82 @@ export class CloudflareScopeHost {
|
|
|
628
647
|
await this.migrateAndRecord(scopeId);
|
|
629
648
|
return this.buildStub(conn.tenant_id, scopeId, undefined, connectionId);
|
|
630
649
|
}
|
|
631
|
-
/**
|
|
632
|
-
|
|
650
|
+
/**
|
|
651
|
+
* A scope stub whose authority is a MODULE on a timer (#383) — the scheduler's
|
|
652
|
+
* door, mirror of `getConnectorScope`. The module must be registered on this host
|
|
653
|
+
* and the scope must pass the ordinary lifecycle gate; authority is then a check
|
|
654
|
+
* against `system:<moduleId>` grants inside the stub.
|
|
655
|
+
*/
|
|
656
|
+
async getSystemScope(moduleId, tenantId, scopeId) {
|
|
657
|
+
if (!this.moduleIds.has(moduleId)) {
|
|
658
|
+
throw new Error(`module not registered on this host: ${moduleId}`);
|
|
659
|
+
}
|
|
660
|
+
await this.cp.validateScopeAccess(tenantId, scopeId);
|
|
661
|
+
await this.migrateAndRecord(scopeId);
|
|
662
|
+
return this.buildStub(tenantId, scopeId, undefined, undefined, moduleId);
|
|
663
|
+
}
|
|
664
|
+
registeredSchedules() {
|
|
665
|
+
const out = [];
|
|
666
|
+
for (const [moduleId, schedules] of this.moduleSchedules) {
|
|
667
|
+
if (schedules.length > 0)
|
|
668
|
+
out.push({ moduleId: moduleId, schedules });
|
|
669
|
+
}
|
|
670
|
+
return out;
|
|
671
|
+
}
|
|
672
|
+
async runDueSchedules(moduleId, tenantId, scopeId) {
|
|
673
|
+
const report = { fired: 0, skipped: 0, failed: 0, errors: [] };
|
|
674
|
+
const schedules = this.moduleSchedules.get(moduleId);
|
|
675
|
+
if (!schedules || schedules.length === 0)
|
|
676
|
+
return report;
|
|
677
|
+
// Only run on a live scope of this tenant; a scope archived between the sweep's
|
|
678
|
+
// enumeration and here simply has nothing due.
|
|
679
|
+
const rec = await this.cp.getScopeRecord(tenantId, scopeId);
|
|
680
|
+
if (!rec || rec.status !== 'active')
|
|
681
|
+
return report;
|
|
682
|
+
const stub = this.scopeStub(scopeId);
|
|
683
|
+
// The grant IS the switch (#383): run only where the scope holds a live
|
|
684
|
+
// `system:<moduleId>` grant. Skips a foreign-vertical scope and a per-tenant
|
|
685
|
+
// revoke quietly — no run, no error.
|
|
686
|
+
if (!(await stub.hasSystemGrant(moduleId)))
|
|
687
|
+
return report;
|
|
688
|
+
const now = Date.now();
|
|
689
|
+
for (const schedule of schedules) {
|
|
690
|
+
const last = await stub.scheduleLastRun(schedule.operation);
|
|
691
|
+
const lastRun = last ? Date.parse(last) : null;
|
|
692
|
+
const dueAt = lastRun === null ? -Infinity : lastRun + schedule.cadence.everyMinutes * 60_000;
|
|
693
|
+
if (now < dueAt) {
|
|
694
|
+
report.skipped += 1;
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
let status = 'ok';
|
|
698
|
+
try {
|
|
699
|
+
const scope = await this.getSystemScope(moduleId, tenantId, scopeId);
|
|
700
|
+
await scope.invoke(schedule.operation, schedule.input);
|
|
701
|
+
report.fired += 1;
|
|
702
|
+
}
|
|
703
|
+
catch (err) {
|
|
704
|
+
status = 'failed';
|
|
705
|
+
report.failed += 1;
|
|
706
|
+
report.errors.push({
|
|
707
|
+
operation: schedule.operation,
|
|
708
|
+
error: err instanceof Error ? err.message : String(err),
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
await stub.recordScheduleRun(schedule.operation, new Date(now).toISOString(), status);
|
|
712
|
+
}
|
|
713
|
+
return report;
|
|
714
|
+
}
|
|
715
|
+
/** The stub body, shared by the principal, connection, and system doors. */
|
|
716
|
+
buildStub(tenantId, scopeId, principal, connectionId, systemModuleId) {
|
|
633
717
|
const stub = this.scopeStub(scopeId);
|
|
634
718
|
const cp = this.cp;
|
|
635
719
|
const operationEntitlement = this.operationEntitlement;
|
|
636
720
|
// The DO needs SOME principal-shaped value for `ctx.principal`; for a
|
|
637
|
-
// connection it is the connection id,
|
|
638
|
-
// the event actor instead.
|
|
639
|
-
const asPrincipalId = (principal ??
|
|
721
|
+
// connection it is the connection id, for a schedule the module id, and the
|
|
722
|
+
// honest attribution rides on the event actor instead.
|
|
723
|
+
const asPrincipalId = (principal ??
|
|
724
|
+
connectionId ??
|
|
725
|
+
systemModuleId);
|
|
640
726
|
return {
|
|
641
727
|
tenantId,
|
|
642
728
|
scopeId,
|
|
@@ -650,7 +736,7 @@ export class CloudflareScopeHost {
|
|
|
650
736
|
if (requiredKey && !(await cp.tenantHoldsEntitlement(tenantId, requiredKey))) {
|
|
651
737
|
return Promise.reject(new Error(`operation not entitled: ${operation} — tenant does not hold '${requiredKey}'`));
|
|
652
738
|
}
|
|
653
|
-
const result = (await stub.invoke(operation, input, asPrincipalId, tenantId, scopeId, connectionId, requiredKey));
|
|
739
|
+
const result = (await stub.invoke(operation, input, asPrincipalId, tenantId, scopeId, connectionId, requiredKey, systemModuleId));
|
|
654
740
|
await this.drainExecutors(tenantId, scopeId);
|
|
655
741
|
return result;
|
|
656
742
|
},
|
|
@@ -862,6 +948,15 @@ export class CloudflareScopeHost {
|
|
|
862
948
|
node: grant.node,
|
|
863
949
|
});
|
|
864
950
|
},
|
|
951
|
+
grantToSystem: async (actor, raw) => {
|
|
952
|
+
// The scheduler's grant (#383) — mirror of grantToConnection. Narrow: one
|
|
953
|
+
// module, one permission; tombstones on revoke; shows in the permission diff.
|
|
954
|
+
const grant = systemGrant.parse(raw);
|
|
955
|
+
await writeGrant(subjectRef({ kind: 'system', id: grant.moduleId }), grant.permission, grant.node, undefined, grant.expiresAt);
|
|
956
|
+
await this.recordAdmin(actor, 'grantToSystem', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId }, null, { moduleId: grant.moduleId, permission: grant.permission, node: grant.node });
|
|
957
|
+
if (!grant.node.scopeId)
|
|
958
|
+
await this.fanOut(grant.node.tenantId);
|
|
959
|
+
},
|
|
865
960
|
grantToOrg: async (actor, orgId, permission, node, entity) => {
|
|
866
961
|
// The org must exist in the node's tenant. A grant to a phantom org looks
|
|
867
962
|
// applied, resolves for nobody, and still shows up in the permission diff.
|
|
@@ -1553,6 +1648,18 @@ export class CloudflareScopeHost {
|
|
|
1553
1648
|
await this.recordAccess(actor, 'listIdentityTenants', {}, { provider }, tenants.length);
|
|
1554
1649
|
return tenants;
|
|
1555
1650
|
},
|
|
1651
|
+
listIdentityLinks: async (actor, tenantId) => {
|
|
1652
|
+
const rows = await this.cp.dumpTenantIdentities(tenantId);
|
|
1653
|
+
const links = rows.map((r) => identityLink.parse({
|
|
1654
|
+
provider: r.provider,
|
|
1655
|
+
externalId: r.external_id,
|
|
1656
|
+
principal: r.principal_id,
|
|
1657
|
+
tenantId,
|
|
1658
|
+
scopeId: r.scope_id ?? undefined,
|
|
1659
|
+
}));
|
|
1660
|
+
await this.recordAccess(actor, 'listIdentityLinks', { tenantId }, null, links.length);
|
|
1661
|
+
return links;
|
|
1662
|
+
},
|
|
1556
1663
|
// -- the integrations hub (#101) ---------------------------------------
|
|
1557
1664
|
createConnection: async (actor, raw) => {
|
|
1558
1665
|
const input = createConnectionInput.parse(raw);
|
|
@@ -1660,6 +1767,10 @@ export class CloudflareScopeHost {
|
|
|
1660
1767
|
if (!changed)
|
|
1661
1768
|
return;
|
|
1662
1769
|
await this.recordAdmin(actor, 'linkIdentity', { tenantId: parsed.tenantId, scopeId: parsed.scopeId }, null, { provider: parsed.provider, externalId: parsed.externalId, principal: parsed.principal });
|
|
1770
|
+
// #406: an identity link is a tenant-level write, so it fans out into the tenant's
|
|
1771
|
+
// projected scopes exactly as entitlements do (#304) — a no-op unless scope-local
|
|
1772
|
+
// projection is on; the reconcile sweep repairs any drop.
|
|
1773
|
+
await this.fanOut(parsed.tenantId);
|
|
1663
1774
|
},
|
|
1664
1775
|
unlinkIdentity: async (actor, tenantId, principal) => {
|
|
1665
1776
|
// DELETE by principal (audit is the log), so the caller who removed a member
|
|
@@ -1668,6 +1779,10 @@ export class CloudflareScopeHost {
|
|
|
1668
1779
|
if (!changed)
|
|
1669
1780
|
return;
|
|
1670
1781
|
await this.recordAdmin(actor, 'unlinkIdentity', { tenantId, scopeId: null }, { principal }, null);
|
|
1782
|
+
// The unlink must reach the projected scopes so the severed login stops resolving
|
|
1783
|
+
// there — this is what makes revocation durable at request time (#406); a dropped
|
|
1784
|
+
// fan-out is repaired by the reconcile sweep.
|
|
1785
|
+
await this.fanOut(tenantId);
|
|
1671
1786
|
},
|
|
1672
1787
|
resolveIdentity: async (tenantId, provider, externalId) => {
|
|
1673
1788
|
const row = await this.cp.resolveIdentity(tenantId, provider, externalId);
|
|
@@ -1771,14 +1886,16 @@ export class CloudflareScopeHost {
|
|
|
1771
1886
|
// the coordinator PROJECTS the tenant's current roles + tenant-level tuples into
|
|
1772
1887
|
// its scopes, which then evaluate permissions from their own storage. Cost moves
|
|
1773
1888
|
// from the request hot path (every check) to the admin write path (rare).
|
|
1774
|
-
/** The tenant's current roles + tenant-level tuples + entitlements, in the
|
|
1775
|
-
* stores. Entitlements (#304) ride the same projection so a hosted scope
|
|
1776
|
-
* expiry locally; expiry is applied at READ (in the scope), so the full list
|
|
1889
|
+
/** The tenant's current roles + tenant-level tuples + entitlements + identity links, in the
|
|
1890
|
+
* shape the ScopeDO stores. Entitlements (#304) ride the same projection so a hosted scope
|
|
1891
|
+
* reads plan/quota/expiry locally; expiry is applied at READ (in the scope), so the full list
|
|
1892
|
+
* is carried. Identity links (#406) ride it too, so a scope resolves logins locally. */
|
|
1777
1893
|
async tenantProjection(tenantId) {
|
|
1778
|
-
const [roleRows, tuples, entitlementRows] = await Promise.all([
|
|
1894
|
+
const [roleRows, tuples, entitlementRows, identities] = await Promise.all([
|
|
1779
1895
|
this.cp.listRoles({ tenantId }),
|
|
1780
1896
|
this.cp.dumpTenantTuples(tenantId),
|
|
1781
1897
|
this.cp.listEntitlements(tenantId),
|
|
1898
|
+
this.cp.dumpTenantIdentities(tenantId),
|
|
1782
1899
|
]);
|
|
1783
1900
|
return {
|
|
1784
1901
|
roles: roleRows.map((r) => ({ role_key: r.role_key, permissions: r.permissions, source: r.source })),
|
|
@@ -1789,14 +1906,15 @@ export class CloudflareScopeHost {
|
|
|
1789
1906
|
quota: e.quota,
|
|
1790
1907
|
plan: e.plan,
|
|
1791
1908
|
})),
|
|
1909
|
+
identities,
|
|
1792
1910
|
};
|
|
1793
1911
|
}
|
|
1794
1912
|
/** Project the tenant's current state into ONE scope + flip it to local. */
|
|
1795
1913
|
async projectScope(tenantId, scopeId) {
|
|
1796
1914
|
if (!this.scopeLocalPermissions)
|
|
1797
1915
|
return;
|
|
1798
|
-
const { roles, tuples, entitlements } = await this.tenantProjection(tenantId);
|
|
1799
|
-
await this.scopeStub(scopeId).applyProjection(tenantId, roles, tuples, entitlements);
|
|
1916
|
+
const { roles, tuples, entitlements, identities } = await this.tenantProjection(tenantId);
|
|
1917
|
+
await this.scopeStub(scopeId).applyProjection(tenantId, roles, tuples, entitlements, undefined, identities);
|
|
1800
1918
|
}
|
|
1801
1919
|
/**
|
|
1802
1920
|
* Fan the tenant's current state out into ALL its scopes — called after any
|
|
@@ -1806,9 +1924,9 @@ export class CloudflareScopeHost {
|
|
|
1806
1924
|
async fanOut(tenantId) {
|
|
1807
1925
|
if (!this.scopeLocalPermissions)
|
|
1808
1926
|
return;
|
|
1809
|
-
const { roles, tuples, entitlements } = await this.tenantProjection(tenantId);
|
|
1927
|
+
const { roles, tuples, entitlements, identities } = await this.tenantProjection(tenantId);
|
|
1810
1928
|
const scopes = await this.cp.listScopes({ tenantId });
|
|
1811
|
-
await Promise.all(scopes.map((s) => this.scopeStub(s.scope_id).applyProjection(tenantId, roles, tuples, entitlements)));
|
|
1929
|
+
await Promise.all(scopes.map((s) => this.scopeStub(s.scope_id).applyProjection(tenantId, roles, tuples, entitlements, undefined, identities)));
|
|
1812
1930
|
}
|
|
1813
1931
|
/**
|
|
1814
1932
|
* Re-project a tenant's full state into every one of its scopes — the
|
|
@@ -1858,7 +1976,33 @@ export class CloudflareScopeHost {
|
|
|
1858
1976
|
object: `scope:${input.scopeId}`,
|
|
1859
1977
|
expires_at: null,
|
|
1860
1978
|
},
|
|
1861
|
-
]
|
|
1979
|
+
],
|
|
1980
|
+
// #406: identity links, delivered by the platform with provisioning exactly as
|
|
1981
|
+
// entitlements are (#310). Preserve-on-undefined, so an absent field never wipes
|
|
1982
|
+
// links a prior fan-out or reconcile projected.
|
|
1983
|
+
input.identityLinks
|
|
1984
|
+
? input.identityLinks.map((l) => ({
|
|
1985
|
+
provider: l.provider,
|
|
1986
|
+
external_id: l.externalId,
|
|
1987
|
+
principal_id: l.principal,
|
|
1988
|
+
scope_id: l.scopeId ?? null,
|
|
1989
|
+
}))
|
|
1990
|
+
: undefined);
|
|
1991
|
+
}
|
|
1992
|
+
/**
|
|
1993
|
+
* Resolve an external identity from a scope's PROJECTED links (#406) — the CP-less
|
|
1994
|
+
* auth adapter's read path, the local equivalent of `HostAdmin.resolveIdentity` (same
|
|
1995
|
+
* exemption: a machine read on the request path, not audited). Reads only what the
|
|
1996
|
+
* platform projected into the scope (provision / reconcile / fan-out), so a miss means
|
|
1997
|
+
* "unknown login — deny": an un-projected scope can only refuse a legitimate login,
|
|
1998
|
+
* never admit a revoked one. A CP-full deployment keeps using `admin.resolveIdentity`;
|
|
1999
|
+
* this exists precisely for deployments where that surface is unavailable.
|
|
2000
|
+
*/
|
|
2001
|
+
async resolveIdentityLocal(tenantId, scopeId, provider, externalId) {
|
|
2002
|
+
const row = await this.scopeStub(scopeId).resolveProjectedIdentity(tenantId, provider, externalId);
|
|
2003
|
+
if (!row)
|
|
2004
|
+
return undefined;
|
|
2005
|
+
return resolvedIdentity.parse({ principal: row.principal, scopeId: row.scopeId });
|
|
1862
2006
|
}
|
|
1863
2007
|
/**
|
|
1864
2008
|
* Grant a principal a role at SCOPE level in a CP-less vertical — the MEMBER half of
|