@substrat-run/adapter-cloudflare 0.108.0 → 0.110.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
@@ -497,6 +497,10 @@ export class CloudflareScopeHost {
497
497
  async exportScopeLocal(scopeId) {
498
498
  return this.scopeStub(scopeId).exportDump();
499
499
  }
500
+ /** Facet this host's own scope's outbox (#1239) — the vertical-host read. */
501
+ async facetEventsLocal(scopeId, input) {
502
+ return this.scopeStub(scopeId).facetEvents(input);
503
+ }
500
504
  /** One record's event history (#1235) on this host's own scope — the vertical-host read. */
501
505
  async entityHistoryLocal(scopeId, input) {
502
506
  return this.scopeStub(scopeId).entityHistory(input);
@@ -2238,26 +2242,51 @@ export class CloudflareScopeHost {
2238
2242
  listScopeTables: async (actor, tenantId, scopeId) => {
2239
2243
  // K-3 cross-check on the shared directory BEFORE reaching the scope DO: a pair
2240
2244
  // that does not resolve is unreachable, never another tenant's database.
2241
- const row = await this.cp.getScopeRecord(tenantId, scopeId);
2242
- if (!row)
2243
- throw new Error(`unknown scope for tenant: (${tenantId}, ${scopeId})`);
2245
+ await this.scopeRecordForRead(tenantId, scopeId);
2244
2246
  const tables = await this.scopeStub(scopeId).introspectTables();
2245
2247
  await this.recordAccess(actor, 'listScopeTables', { tenantId, scopeId }, null, tables.length);
2246
2248
  return tables;
2247
2249
  },
2250
+ readUndrainedEvents: async (actor, tenantId, scopeId, limit) => {
2251
+ await this.scopeRecordForRead(tenantId, scopeId);
2252
+ const events = await this.scopeStub(scopeId).undrainedEvents(Math.min(Math.max(limit ?? 200, 1), 1000));
2253
+ await this.recordAccess(actor, 'readUndrainedEvents', { tenantId, scopeId }, { limit }, events.length);
2254
+ return events;
2255
+ },
2256
+ markEventsDrained: async (actor, tenantId, scopeId, eventIds) => {
2257
+ // The same refusal the reads carry: a reaped scope's storage is gone, and
2258
+ // addressing its DO would construct an empty one to stamp nothing in.
2259
+ await this.scopeRecordForRead(tenantId, scopeId);
2260
+ if (eventIds.length === 0)
2261
+ return 0;
2262
+ const drainedAt = new Date().toISOString();
2263
+ const drained = await this.scopeStub(scopeId).markEventsDrained(eventIds, drainedAt);
2264
+ // K-24's rule, one tier down (#1334): declaring domain payloads shipped is an
2265
+ // EGRESS, and the admin log is where "these events left the platform, at this
2266
+ // time, on this actor's say-so" is recorded. `drainAccessLog` audits the same
2267
+ // act for the smaller class of data; this one carries the larger. Only a
2268
+ // NONZERO change is recorded, so a retried pass that re-marks a batch it
2269
+ // already shipped writes no row claiming an egress that never happened.
2270
+ if (drained > 0) {
2271
+ await this.recordAdmin(actor, 'drainEvents', { tenantId, scopeId }, null, { drained, requested: eventIds.length, drainedAt });
2272
+ }
2273
+ return drained;
2274
+ },
2275
+ facetEvents: async (actor, tenantId, scopeId, input) => {
2276
+ await this.scopeRecordForRead(tenantId, scopeId);
2277
+ const result = await this.scopeStub(scopeId).facetEvents(input);
2278
+ await this.recordAccess(actor, 'facetEvents', { tenantId, scopeId }, input, result.buckets.length);
2279
+ return result;
2280
+ },
2248
2281
  entityHistory: async (actor, tenantId, scopeId, input) => {
2249
2282
  // K-3 cross-check on the directory BEFORE the scope DO, like every read here.
2250
- const row = await this.cp.getScopeRecord(tenantId, scopeId);
2251
- if (!row)
2252
- throw new Error(`unknown scope for tenant: (${tenantId}, ${scopeId})`);
2283
+ await this.scopeRecordForRead(tenantId, scopeId);
2253
2284
  const page = await this.scopeStub(scopeId).entityHistory(input);
2254
2285
  await this.recordAccess(actor, 'entityHistory', { tenantId, scopeId }, { entityType: input.entityType, entityId: input.entityId }, page.entries.length);
2255
2286
  return page;
2256
2287
  },
2257
2288
  readScopeTable: async (actor, tenantId, scopeId, input) => {
2258
- const row = await this.cp.getScopeRecord(tenantId, scopeId);
2259
- if (!row)
2260
- throw new Error(`unknown scope for tenant: (${tenantId}, ${scopeId})`);
2289
+ await this.scopeRecordForRead(tenantId, scopeId);
2261
2290
  const page = await this.scopeStub(scopeId).introspectTable(input.table, input.limit, input.offset);
2262
2291
  await this.recordAccess(actor, 'readScopeTable', { tenantId, scopeId }, { table: input.table, limit: page.limit, offset: page.offset }, page.rows.length);
2263
2292
  return page;
@@ -2266,25 +2295,19 @@ export class CloudflareScopeHost {
2266
2295
  // K-3 cross-check on the shared directory BEFORE reaching the scope DO, as
2267
2296
  // every read here does: an unresolved pair is unreachable, never another
2268
2297
  // tenant's log.
2269
- const row = await this.cp.getScopeRecord(tenantId, scopeId);
2270
- if (!row)
2271
- throw new Error(`unknown scope for tenant: (${tenantId}, ${scopeId})`);
2298
+ await this.scopeRecordForRead(tenantId, scopeId);
2272
2299
  const rows = await this.scopeStub(scopeId).listDenials(filter);
2273
2300
  await this.recordAccess(actor, 'listDenials', { tenantId, scopeId }, filter ?? null, rows.length);
2274
2301
  return rows;
2275
2302
  },
2276
2303
  summarizeDenials: async (actor, tenantId, scopeId, filter) => {
2277
- const row = await this.cp.getScopeRecord(tenantId, scopeId);
2278
- if (!row)
2279
- throw new Error(`unknown scope for tenant: (${tenantId}, ${scopeId})`);
2304
+ await this.scopeRecordForRead(tenantId, scopeId);
2280
2305
  const summary = await this.scopeStub(scopeId).summarizeDenials(filter);
2281
2306
  await this.recordAccess(actor, 'summarizeDenials', { tenantId, scopeId }, filter ?? null, summary.buckets.length);
2282
2307
  return summary;
2283
2308
  },
2284
2309
  queryScope: async (actor, tenantId, scopeId, input) => {
2285
- const row = await this.cp.getScopeRecord(tenantId, scopeId);
2286
- if (!row)
2287
- throw new Error(`unknown scope for tenant: (${tenantId}, ${scopeId})`);
2310
+ await this.scopeRecordForRead(tenantId, scopeId);
2288
2311
  const result = await this.scopeStub(scopeId).introspectQuery(input.sql);
2289
2312
  // The statement is the logged argument: the access log is the evidence trail,
2290
2313
  // and for a console read the SQL is the whole story.
@@ -2296,9 +2319,7 @@ export class CloudflareScopeHost {
2296
2319
  // as the introspection reads: an unresolved pair is unreachable, never another
2297
2320
  // tenant's database. The DO returns the tables; the coordinator, which knows the
2298
2321
  // scope's identity, stamps the dump.
2299
- const row = await this.cp.getScopeRecord(tenantId, scopeId);
2300
- if (!row)
2301
- throw new Error(`unknown scope for tenant: (${tenantId}, ${scopeId})`);
2322
+ await this.scopeRecordForRead(tenantId, scopeId);
2302
2323
  const tables = await this.scopeStub(scopeId).exportDump();
2303
2324
  await this.recordAccess(actor, 'exportScope', { tenantId, scopeId }, null, tables.length);
2304
2325
  return { tenantId, scopeId, capturedAt: new Date().toISOString(), tables };
@@ -2944,6 +2965,24 @@ export class CloudflareScopeHost {
2944
2965
  if (!rec)
2945
2966
  throw new Error(`unknown scope for tenant: (${tenantId}, ${scopeId})`);
2946
2967
  }
2968
+ /**
2969
+ * The same K-3 cross-check, for a read that then opens the scope's STORAGE — every
2970
+ * introspection verb below. It adds one refusal the bare existence check cannot make:
2971
+ * a REAPED scope keeps its directory row as a tombstone (§4.4) while its storage is
2972
+ * gone, so the row resolves and the read looks legal. Addressing the DO anyway
2973
+ * CONSTRUCTS an empty one, and the answer comes back as a scope with no tables, no
2974
+ * events and no denials rather than as a scope that no longer exists — the read
2975
+ * quietly contradicting the reap that was meant to be irreversible. `archived` still
2976
+ * reads: its bytes are there, which is the whole distinction between the two states.
2977
+ */
2978
+ async scopeRecordForRead(tenantId, scopeId) {
2979
+ const row = await this.cp.getScopeRecord(tenantId, scopeId);
2980
+ if (!row)
2981
+ throw new Error(`unknown scope for tenant: (${tenantId}, ${scopeId})`);
2982
+ if (row.status === 'reaped') {
2983
+ throw new Error(`scope ${scopeId} is reaped — its storage is gone and cannot be read`);
2984
+ }
2985
+ }
2947
2986
  /**
2948
2987
  * This scope's per-subject keys (#37). The crypto lives in the kernel
2949
2988
  * (`createSubjectKeys`); the adapter supplies only the three row operations, which here
@@ -3165,7 +3204,32 @@ export class CloudflareScopeHost {
3165
3204
  if (!this.scopeLocalPermissions)
3166
3205
  return;
3167
3206
  const { roles, tuples, entitlements, identities } = await this.tenantProjection(tenantId);
3168
- const scopes = await this.cp.listScopes({ tenantId });
3207
+ // Only scopes that can still EVALUATE a permission. Unfiltered, this projected
3208
+ // into `archived` and `reaped` rows too — and a reaped scope's storage was
3209
+ // deliberately `deleteAll()`d ("the bytes are gone, so there is no restore",
3210
+ // tenancy.ts), so writing a projection into its DO recreated storage for a scope
3211
+ // the platform believes dead. Silent, unbounded in the number of apps a tenant
3212
+ // has ever archived, and paid for on every membership change.
3213
+ //
3214
+ // `provisioning` is included: a scope mid-provision pulls the projection itself
3215
+ // at creation, and including it costs one idempotent write while excluding it
3216
+ // could race that pull. `suspended` is included because suspension is reversible
3217
+ // and a suspended scope must not come back with a stale projection.
3218
+ // Only scopes that can still EVALUATE a permission. Unfiltered, this projected
3219
+ // into `archived` and `reaped` rows too — and a reaped scope's storage was
3220
+ // deliberately `deleteAll()`d ("the bytes are gone, so there is no restore",
3221
+ // tenancy.ts), so writing a projection into its DO recreated storage for a scope
3222
+ // the platform believes dead. Silent, unbounded in the number of apps a tenant
3223
+ // has ever archived, and paid for on every membership change.
3224
+ //
3225
+ // `provisioning` is included: a scope mid-provision pulls the projection itself
3226
+ // at creation, and including it costs one idempotent write while excluding it
3227
+ // could race that pull. `suspended` is included because suspension is reversible
3228
+ // and a suspended scope must not come back with a stale projection.
3229
+ const scopes = await this.cp.listScopes({
3230
+ tenantId,
3231
+ status: ['provisioning', 'active', 'suspended'],
3232
+ });
3169
3233
  // #687: connection keys are per (tenant, vertical), so they are resolved per scope
3170
3234
  // rather than once for the tenant — memoized, because a fan-out over twenty scopes
3171
3235
  // of one vertical must not mint or re-read twenty times.