@substrat-run/adapter-cloudflare 0.98.1 → 0.103.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 { fromWireFailure, accessLogEntry, adminLogEntry, opsFailureEntry, modelUsageEntry, attachmentRecord, createTenantInput, identityLink, identityPool, createOrgInput, promotionAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, registerVerticalInput, vertical as verticalSchema, verticalServingState, verticalChannel, verticalVersion, connection, capabilityGrant, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, entitlementGrant, entitlementGrantInput, instant, meterReading, subjectRef, createConnectionInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, subjectShredReceipt, platformRequest, connectorDispatchKind, outboundOfManifestJson, substratError, } from '@substrat-run/contracts';
1
+ import { fromWireFailure, accessLogEntry, adminLogEntry, opsFailureEntry, opsFailureFingerprint, issueEntry, sweepRunEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunsPayload, modelUsageEntry, attachmentRecord, createTenantInput, identityLink, identityPool, createOrgInput, promotionAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, registerVerticalInput, vertical as verticalSchema, verticalServingState, verticalChannel, verticalVersion, connection, capabilityGrant, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, entitlementGrant, entitlementGrantInput, instant, meterReading, subjectRef, createConnectionInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, subjectShredReceipt, platformRequest, connectorDispatchKind, outboundOfManifestJson, substratError, } from '@substrat-run/contracts';
2
2
  import { normalizeHostname, toRouteTarget } from './route-resolver.js';
3
- import { attachmentBlobKey, entitlementDenial, foldMeterReading, parseValidationRecords, resolveScopeRecord, ulid, foldModelUsage, backoffAt, resolveRetryPolicy, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, openSealed, ImpersonationRefused, assertSessionUsable, impersonationRowValues, mapImpersonationRow, newImpersonationSession, } from '@substrat-run/kernel';
3
+ import { attachmentBlobKey, entitlementDenial, foldMeterReading, parseValidationRecords, resolveScopeRecord, ulid, foldModelUsage, backoffAt, resolveRetryPolicy, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, openSealed, ImpersonationRefused, assertSessionUsable, impersonationRowValues, mapImpersonationRow, newImpersonationSession, globalFetch, } 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. */
@@ -81,6 +81,10 @@ export class CloudflareScopeHost {
81
81
  moduleIds = new Set();
82
82
  /** Module id → its declared recurring schedules (#383), for `registeredSchedules`/`runDueSchedules`. */
83
83
  moduleSchedules = new Map();
84
+ /** #1232: declared freshness per module. Registered UNCONDITIONALLY — the schedule
85
+ * map is only populated for modules WITH schedules, and hanging freshness off it
86
+ * would drop a freshness-only module at registration. */
87
+ moduleFreshness = new Map();
84
88
  /** Registered (module, version) pairs — the frontier `schemaVersion` counts toward (§5.3, #49). */
85
89
  migrationTotal = 0;
86
90
  operations = new Set();
@@ -122,7 +126,7 @@ export class CloudflareScopeHost {
122
126
  this.tenantStores = options.tenantStores;
123
127
  this.blobStores = options.blobStores;
124
128
  this.attachmentBuckets = options.attachmentBuckets;
125
- this.fetchImpl = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
129
+ this.fetchImpl = options.fetch ?? globalFetch;
126
130
  this.scopeLocalPermissions = options.scopeLocalPermissions ?? false;
127
131
  this.scopeNs = options.scope;
128
132
  this.cpLess = !options.controlPlane;
@@ -326,6 +330,14 @@ export class CloudflareScopeHost {
326
330
  await this.migrateAndRecord(scopeId);
327
331
  return this.scopeStub(scopeId).executorDeadLetters();
328
332
  }
333
+ /**
334
+ * #1232: hand one pass's schedule outcomes to the scope's own intent journal —
335
+ * the CP-less deployment's only road to `_substrat_sweep_runs`. Null = the
336
+ * journal is full and the report was deliberately dropped, never queued late.
337
+ */
338
+ async enqueueSweepRuns(scopeId, payload) {
339
+ return this.scopeStub(scopeId).enqueueSweepRuns(JSON.stringify(sweepRunsPayload.parse(payload)), JSON.stringify({ system: 'scope-sweeper' }));
340
+ }
329
341
  async listPlatformRequests(tenantId, scopeId) {
330
342
  await this.cp.validateScopeAccess(tenantId, scopeId);
331
343
  await this.migrateAndRecord(scopeId);
@@ -531,6 +543,9 @@ export class CloudflareScopeHost {
531
543
  if (manifest.schedules && manifest.schedules.length > 0) {
532
544
  this.moduleSchedules.set(manifest.id, manifest.schedules);
533
545
  }
546
+ if (manifest.freshness && manifest.freshness.length > 0) {
547
+ this.moduleFreshness.set(manifest.id, manifest.freshness);
548
+ }
534
549
  this.migrationTotal += migrations.length;
535
550
  const ownOperations = new Set(Object.keys(registration.operations ?? {}));
536
551
  for (const name of manifest.withdraws ?? []) {
@@ -1075,8 +1090,64 @@ export class CloudflareScopeHost {
1075
1090
  }
1076
1091
  return out;
1077
1092
  }
1093
+ registeredFreshness() {
1094
+ const out = [];
1095
+ for (const [moduleId, freshness] of this.moduleFreshness) {
1096
+ if (freshness.length > 0)
1097
+ out.push({ moduleId: moduleId, freshness });
1098
+ }
1099
+ return out;
1100
+ }
1101
+ /**
1102
+ * The freshness evaluator (#1232), CF half — same verdicts and gating as the pure
1103
+ * adapter's, over one `freshnessProbe` round trip to the scope DO. Aggregates
1104
+ * across EVERY registered module (two modules declaring one type share one
1105
+ * gating-state key and one dedupe unit — per-module evaluation would corrupt
1106
+ * both), collapsing duplicates to the tightest window; called once per scope,
1107
+ * any registered module's id as the entry ticket. NOT gated on the system
1108
+ * grant — see the kernel interface doc.
1109
+ */
1110
+ async checkFreshness(moduleId, tenantId, scopeId) {
1111
+ const report = { checks: [] };
1112
+ if (!this.moduleIds.has(moduleId))
1113
+ return report;
1114
+ if (!this.cpLess) {
1115
+ const rec = await this.cp.getScopeRecord(tenantId, scopeId);
1116
+ if (!rec || rec.status !== 'active')
1117
+ return report;
1118
+ }
1119
+ const stub = this.scopeStub(scopeId);
1120
+ const windows = new Map();
1121
+ for (const specs of this.moduleFreshness.values()) {
1122
+ for (const f of specs) {
1123
+ const prev = windows.get(f.eventType);
1124
+ if (prev === undefined || f.within.hours < prev)
1125
+ windows.set(f.eventType, f.within.hours);
1126
+ }
1127
+ }
1128
+ if (windows.size === 0)
1129
+ return report;
1130
+ const probe = await stub.freshnessProbe([...windows.keys()]);
1131
+ const now = Date.now();
1132
+ const nowIso = new Date(now).toISOString();
1133
+ for (const [eventType, withinHours] of windows) {
1134
+ const p = probe[eventType] ?? { observedAt: null, stateAt: null, stateOutcome: null };
1135
+ const outcome = p.observedAt === null
1136
+ ? 'skipped' // never observed — the never-run analogue, not a failure
1137
+ : now - Date.parse(p.observedAt) <= withinHours * 3_600_000
1138
+ ? 'ok'
1139
+ : 'failed';
1140
+ const changed = p.stateOutcome !== outcome;
1141
+ const heartbeatDue = p.stateAt === null || now - Date.parse(p.stateAt) > FRESHNESS_HEARTBEAT_MINUTES * 60_000;
1142
+ if (!changed && !heartbeatDue)
1143
+ continue;
1144
+ await stub.recordScheduleRun(`freshness:${eventType}`, nowIso, outcome);
1145
+ report.checks.push({ eventType, outcome, observedAt: p.observedAt, withinHours });
1146
+ }
1147
+ return report;
1148
+ }
1078
1149
  async runDueSchedules(moduleId, tenantId, scopeId) {
1079
- const report = { fired: 0, skipped: 0, failed: 0, errors: [] };
1150
+ const report = { fired: 0, skipped: 0, failed: 0, errors: [], runs: [] };
1080
1151
  const schedules = this.moduleSchedules.get(moduleId);
1081
1152
  if (!schedules || schedules.length === 0)
1082
1153
  return report;
@@ -1103,6 +1174,7 @@ export class CloudflareScopeHost {
1103
1174
  const dueAt = lastRun === null ? -Infinity : lastRun + schedule.cadence.everyMinutes * 60_000;
1104
1175
  if (now < dueAt) {
1105
1176
  report.skipped += 1;
1177
+ report.runs.push({ operation: schedule.operation, outcome: 'skipped' });
1106
1178
  continue;
1107
1179
  }
1108
1180
  let status = 'ok';
@@ -1120,6 +1192,7 @@ export class CloudflareScopeHost {
1120
1192
  });
1121
1193
  }
1122
1194
  await stub.recordScheduleRun(schedule.operation, new Date(now).toISOString(), status);
1195
+ report.runs.push({ operation: schedule.operation, outcome: status === 'ok' ? 'ok' : 'failed' });
1123
1196
  }
1124
1197
  return report;
1125
1198
  }
@@ -2702,11 +2775,15 @@ export class CloudflareScopeHost {
2702
2775
  tenant_id: entry.tenantId ?? null,
2703
2776
  scope_id: entry.scopeId ?? null,
2704
2777
  vertical: entry.vertical ?? null,
2778
+ version: entry.version ?? null,
2705
2779
  status: entry.status ?? null,
2706
2780
  // Bounded here, not trusted from the catch site: one runaway upstream body
2707
2781
  // must not become a runaway directory row (#559).
2708
2782
  message: entry.message.slice(0, 2000),
2709
2783
  reference: entry.reference ?? null,
2784
+ origin: entry.origin ?? null,
2785
+ code: entry.code ?? null,
2786
+ fingerprint: opsFailureFingerprint(entry),
2710
2787
  at: new Date().toISOString(),
2711
2788
  });
2712
2789
  },
@@ -2715,7 +2792,10 @@ export class CloudflareScopeHost {
2715
2792
  tenantId: filter?.tenantId,
2716
2793
  scopeId: filter?.scopeId,
2717
2794
  vertical: filter?.vertical,
2795
+ version: filter?.version,
2718
2796
  operation: filter?.operation,
2797
+ code: filter?.code,
2798
+ fingerprint: filter?.fingerprint,
2719
2799
  reference: filter?.reference,
2720
2800
  since: filter?.since,
2721
2801
  until: filter?.until,
@@ -2728,6 +2808,67 @@ export class CloudflareScopeHost {
2728
2808
  await this.recordAccess(actor, 'listOpsFailures', { tenantId: filter?.tenantId ?? null, scopeId: filter?.scopeId ?? null }, filter, rows.length);
2729
2809
  return rows.map((r) => opsFailureEntry.parse(r));
2730
2810
  },
2811
+ recordSweepRun: async (entry) => {
2812
+ await this.cp.recordSweepRun({
2813
+ id: ulid(),
2814
+ kind: entry.kind,
2815
+ unit: entry.unit,
2816
+ outcome: entry.outcome,
2817
+ tenant_id: entry.tenantId ?? null,
2818
+ scope_id: entry.scopeId ?? null,
2819
+ vertical: entry.vertical ?? null,
2820
+ version: entry.version ?? null,
2821
+ operation: entry.operation ?? null,
2822
+ connection_id: entry.connectionId ?? null,
2823
+ // Bounded here, not trusted from the sweep (the #559 rule).
2824
+ error: entry.error == null ? null : entry.error.slice(0, 2000),
2825
+ elapsed_ms: entry.elapsedMs ?? null,
2826
+ request_id: entry.requestId ?? null,
2827
+ event_type: entry.eventType ?? null,
2828
+ observed_at: entry.observedAt ?? null,
2829
+ at: entry.at ?? new Date().toISOString(),
2830
+ });
2831
+ },
2832
+ listSweepRuns: async (actor, filter) => {
2833
+ const rows = await this.cp.listSweepRuns({
2834
+ kind: filter?.kind,
2835
+ unit: filter?.unit,
2836
+ outcome: filter?.outcome,
2837
+ tenantId: filter?.tenantId,
2838
+ scopeId: filter?.scopeId,
2839
+ vertical: filter?.vertical,
2840
+ connectionId: filter?.connectionId,
2841
+ since: filter?.since,
2842
+ until: filter?.until,
2843
+ limit: filter?.limit,
2844
+ cursor: filter?.cursor,
2845
+ order: filter?.order,
2846
+ });
2847
+ // Rows can name tenants and scopes, so reading them is recorded (K-24).
2848
+ await this.recordAccess(actor, 'listSweepRuns', { tenantId: filter?.tenantId ?? null, scopeId: filter?.scopeId ?? null }, filter, rows.length);
2849
+ return rows.map((r) => sweepRunEntry.parse(r));
2850
+ },
2851
+ listIssues: async (actor, filter) => {
2852
+ const rows = await this.cp.listIssues({
2853
+ status: filter?.status,
2854
+ operation: filter?.operation,
2855
+ code: filter?.code,
2856
+ limit: filter?.limit,
2857
+ });
2858
+ // Issues aggregate fleet-wide failures; the read is recorded like the rows' own (K-24).
2859
+ await this.recordAccess(actor, 'listIssues', {}, filter, rows.length);
2860
+ return rows.map((r) => issueEntry.parse(r));
2861
+ },
2862
+ setIssueStatus: async (actor, fingerprint, status) => {
2863
+ const change = await this.cp.setIssueStatus(fingerprint, status, new Date().toISOString());
2864
+ if (!change)
2865
+ return undefined;
2866
+ const before = issueEntry.parse(change.before);
2867
+ const after = issueEntry.parse(change.after);
2868
+ // A lifecycle flip is a staff mutation — audited with the diff (K-33).
2869
+ await this.recordAdmin(actor, 'setIssueStatus', { tenantId: null, vertical: after.lastVertical }, { fingerprint, status: before.status }, { fingerprint, status: after.status });
2870
+ return after;
2871
+ },
2731
2872
  recordModelUsage: async (input) => {
2732
2873
  const l = input.line;
2733
2874
  return this.cp.recordModelUsage({
@@ -3135,6 +3276,35 @@ export class CloudflareScopeHost {
3135
3276
  async assignScopeRole(scopeId, principal, roleKey) {
3136
3277
  await this.scopeStub(scopeId).writeTuple(`principal:${principal}`, `role:${roleKey}`, `scope:${scopeId}`, null);
3137
3278
  }
3279
+ /**
3280
+ * Take a scope-level role back — the counterpart `assignScopeRole` went without (#1161).
3281
+ * A tombstone, exactly as `HostAdmin.unassignRole` writes for a scope-level assignment:
3282
+ * the row stays with `revoked_at` set and the local checker's walk skips it, so the
3283
+ * principal loses the role's permissions on the next check. `assignScopeRole` is
3284
+ * `INSERT OR REPLACE`, so a later re-assign clears the tombstone and grants again.
3285
+ *
3286
+ * Returns whether anything changed: a repeat revoke, or a revoke of a role that was never
3287
+ * assigned, is a silent `false` rather than an error, which lets a harness route stay
3288
+ * idempotent. Emits nothing on the scope outbox — the vertical whose flow revoked the
3289
+ * seat is the one that knows what to announce, and it emits from its own operation.
3290
+ * Guarded like `assignScopeRole`: at the harness route, not at this seam.
3291
+ *
3292
+ * Two things a caller has to know, because the tombstone is only as durable as the next
3293
+ * `INSERT OR REPLACE` on the same row:
3294
+ * - Anything that re-projects the scope's tuples clears it — `provisionScopeLocal` on a
3295
+ * reconcile re-seats the owner and the `system:` grants, and a vertical's `onProvision`
3296
+ * hook that re-issues `assignScopeRole` re-seats whatever it names. A revoke of a role
3297
+ * such a path grants is undone on the next reconcile, silently. Revoke the seats your
3298
+ * own flow granted, not the ones provisioning did.
3299
+ * - Revoking the LAST live role tuple in a scope leaves nobody who passes a check, and
3300
+ * the local checker has no way back in (#332 guards the flip to local on the way in
3301
+ * only). Seat the successor before unseating the last holder.
3302
+ * On a CP-less host this records no admin-log row (there is no control plane to hold
3303
+ * one), so the row's `revoked_at` is the only evidence, and a re-assign replaces it.
3304
+ */
3305
+ async revokeScopeRole(scopeId, principal, roleKey) {
3306
+ return this.scopeStub(scopeId).revokeTuple(`principal:${principal}`, `role:${roleKey}`, `scope:${scopeId}`, new Date().toISOString());
3307
+ }
3138
3308
  /**
3139
3309
  * Grant a principal an ENTITY-NARROWED permission in a CP-less vertical — the self-service
3140
3310
  * half of membership, the local equivalent of `HostAdmin.grant` with an `entity`. Where a