@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/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
- /** The stub body, shared by the principal and connection doors. */
632
- buildStub(tenantId, scopeId, principal, connectionId) {
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, and the honest attribution rides on
638
- // the event actor instead.
639
- const asPrincipalId = (principal ?? connectionId);
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 shape the ScopeDO
1775
- * stores. Entitlements (#304) ride the same projection so a hosted scope reads plan/quota/
1776
- * expiry locally; expiry is applied at READ (in the scope), so the full list is carried. */
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