@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/control-plane-do.d.ts +73 -1
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +231 -3
- package/dist/control-plane-do.js.map +1 -1
- package/dist/d1.d.ts +1 -1
- package/dist/d1.d.ts.map +1 -1
- package/dist/d1.js +2 -1
- package/dist/d1.js.map +1 -1
- package/dist/host.d.ts +50 -2
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +174 -4
- package/dist/host.js.map +1 -1
- package/dist/platform-sweeper-do.d.ts +2 -1
- package/dist/platform-sweeper-do.d.ts.map +1 -1
- package/dist/platform-sweeper-do.js +2 -1
- package/dist/platform-sweeper-do.js.map +1 -1
- package/dist/r2.d.ts +1 -1
- package/dist/r2.d.ts.map +1 -1
- package/dist/r2.js +2 -1
- package/dist/r2.js.map +1 -1
- package/dist/scope-do.d.ts +8 -0
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +143 -44
- package/dist/scope-do.js.map +1 -1
- package/dist/scope-sweeper-do.d.ts +20 -3
- package/dist/scope-sweeper-do.d.ts.map +1 -1
- package/dist/scope-sweeper-do.js +53 -0
- package/dist/scope-sweeper-do.js.map +1 -1
- package/package.json +4 -4
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 ??
|
|
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
|