@substrat-run/adapter-cloudflare 0.162.0 → 0.163.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.
@@ -4,7 +4,7 @@ import { replyOf } from './do-reply.js';
4
4
  import { switchSqlOver } from './scope-do.js';
5
5
  import { blankSqlComments, boundShapesColumn, executableSqlStatements, installsReconciledOf, PROVISIONED_VERSIONS_SQL, provisionedVersionsParams, } from '@substrat-run/kernel';
6
6
  import { COPY_BACKFILL_SCOPE_SQL, COPY_BACKFILL_SQL, COPY_BACKFILL_SUPERSEDE_SQL, COPY_CLAIM_SQL, COPY_EXPIRED_SQL, COPY_MOVE_RENEW_SQL, copyMoveRenewParams, PENDING_COPY_KEEP_ALIVE_SQL, pendingCopyKeepAliveParams, copyBackfillParams, copyBackfillRefusal, SUBJECT_ERASURE_CLAIM_SQL, subjectErasureClaimParams, COPY_MOVE_CONFIRM_SQL, COPY_MOVE_LIVE_PREDICATE, SCOPE_COPY_LEASE_MS, SERVES_NOW_CONDITION, SERVES_NOW_PREDICATE, servesNowParams, SCOPE_SCRIPT_COPY_COLUMNS, copyMoveConfirmParams, copyMoveLiveParams, } from '@substrat-run/kernel';
7
- import { opsFailureReferenceWhere } from '@substrat-run/kernel';
7
+ import { autoAdmittedProdRefusal, listedPromoteRefusal, opsFailureReferenceWhere } from '@substrat-run/kernel';
8
8
  import { doBuiltColumnsOf, doRedactionSql } from './sql.js';
9
9
  import { assertReplayableDump, AUTO_ADMISSION_NOTE, instant, opsFailureFingerprint, ROUTE_SCOPE_HOSTNAMES_MAX, SCOPE_GATE_REASONS, substratError } from '@substrat-run/contracts';
10
10
  /**
@@ -818,6 +818,9 @@ export const REPLIED_METHODS = [
818
818
  'admitVersion',
819
819
  'rejectVersion',
820
820
  'setVerticalServing',
821
+ // #2565: refuses a listed vertical's prod with forbidden (`unlistedOnly`) or conflict (an
822
+ // auto-admitted version). A DO from before lists it not, so the current coordinator fails closed.
823
+ 'promoteChannel',
821
824
  // #2526: the role, grant and org writes at a tenant node, each with its admin row in its unit. A
822
825
  // DO from before lists none of them, so the current coordinator fails closed against it. That is
823
826
  // why the org write is `insertOrg` and not `createOrg` with a trailing row: an older DO lists
@@ -829,6 +832,16 @@ export const REPLIED_METHODS = [
829
832
  'writeTenantSystemGrant',
830
833
  'grantConnectionInDirectory',
831
834
  'insertOrg',
835
+ // #2526: the hostname and entitlement writes, each reading its row and judging its no-op in the
836
+ // unit that writes it and its admin row. A DO from before lists none of them, so the current
837
+ // coordinator fails closed against it; a coordinator from before calls them directly, without
838
+ // the trailing row, and gets the bare write it always did.
839
+ 'bindHostnameInDirectory',
840
+ 'setHostnameStatus',
841
+ 'setHostnameIssuance',
842
+ 'deleteHostname',
843
+ 'grantEntitlement',
844
+ 'revokeEntitlement',
832
845
  ];
833
846
  export class ControlPlaneDO extends DurableObject {
834
847
  sql;
@@ -1382,6 +1395,8 @@ export class ControlPlaneDO extends DurableObject {
1382
1395
  if (status === 'reaped') {
1383
1396
  throw substratError('validation_failed', `tenant ${tenantId} cannot be set to 'reaped' via setTenantStatus — reap goes through reapTenant (control-plane.md §4.8)`);
1384
1397
  }
1398
+ if (before.status === 'reaped')
1399
+ throw substratError('conflict', `tenant ${tenantId} is reaped and cannot change status`);
1385
1400
  // Stamp/clear deleting_at so the grace-window sweep can age tenants (§4.8):
1386
1401
  // entering `deleting` stamps, leaving it (an un-delete) clears — the same shape
1387
1402
  // transitionScope uses for archived_at.
@@ -2205,7 +2220,11 @@ export class ControlPlaneDO extends DurableObject {
2205
2220
  scope_hostnames: JSON.parse(scope_hostnames_json),
2206
2221
  };
2207
2222
  }
2208
- /** Demote any current canonical for this surface — exactly one may hold it. */
2223
+ /**
2224
+ * Demote any current canonical for this surface — exactly one may hold it. This and
2225
+ * `upsertHostname` are `bindHostnameInDirectory`'s steps; a coordinator from before #2526 still
2226
+ * calls them directly.
2227
+ */
2209
2228
  demoteCanonical(scopeId, surface) {
2210
2229
  this.sql.exec('UPDATE hostnames SET canonical = 0 WHERE scope_id = ? AND surface = ?', scopeId, surface);
2211
2230
  }
@@ -2218,8 +2237,62 @@ export class ControlPlaneDO extends DurableObject {
2218
2237
  status, status_note, canonical, created_at, custom_hostname_id, validation_records)
2219
2238
  VALUES (?, ?, ?, ?, ?, ?, 'pending', NULL, ?, ?, NULL, NULL)`, h.hostname, h.tenantId, h.scopeId, h.verticalSlug, h.surface, h.region, h.canonical ? 1 : 0, h.createdAt);
2220
2239
  }
2221
- setHostnameStatus(hostname, status, note) {
2222
- this.sql.exec('UPDATE hostnames SET status = ?, status_note = ? WHERE hostname = ?', status, note, hostname);
2240
+ /**
2241
+ * #2526: `bindHostname` as one unit — the scope (in the tenant), the name's current holder and
2242
+ * whether it released the name (archived or reaped), the demotion of the surface's canonical,
2243
+ * the bind and its admin row, whose vertical is the scope's as read here. A name another live
2244
+ * scope holds is refused `conflict`, writing nothing; so is the demotion when the bind fails.
2245
+ */
2246
+ bindHostnameInDirectory(h, audit) {
2247
+ this.ctx.storage.transactionSync(() => {
2248
+ const scope = this.getScopeRecord(h.tenantId, h.scopeId);
2249
+ if (!scope)
2250
+ throw substratError('not_found', `unknown scope ${h.scopeId} in tenant ${h.tenantId}`);
2251
+ const existing = this.readHostname(h.hostname);
2252
+ if (existing && existing.scope_id !== h.scopeId) {
2253
+ // A hostname routes to exactly one place; silently rebinding would move another
2254
+ // tenant's traffic. Exception: the holder is ARCHIVED or REAPED (a deleted app, storage
2255
+ // since wiped) — it has released the name, so the rebind reclaims it.
2256
+ const holder = this.getScopeRecord(existing.tenant_id, existing.scope_id);
2257
+ if (holder?.status !== 'archived' && holder?.status !== 'reaped') {
2258
+ throw substratError('conflict', `hostname '${h.hostname}' is already bound to another scope`);
2259
+ }
2260
+ }
2261
+ if (h.canonical)
2262
+ this.demoteCanonical(h.scopeId, h.surface);
2263
+ this.upsertHostname({ ...h, verticalSlug: scope.vertical });
2264
+ this.recordAdmin({ ...audit, vertical: scope.vertical });
2265
+ });
2266
+ }
2267
+ /**
2268
+ * With `audit` (#2526, through `reply`): the row is read here — refused `not_found` when the
2269
+ * name is not bound — and a status it already holds writes nothing and records nothing;
2270
+ * otherwise the update and its admin row, its target and `before` from that read, commit as
2271
+ * one unit. Answers whether it wrote. Without, as a coordinator from before #2526 calls it, the
2272
+ * bare update.
2273
+ */
2274
+ setHostnameStatus(hostname, status, note, audit) {
2275
+ return this.auditedHostnameUpdate(hostname, (row) => row.status === status, () => this.sql.exec('UPDATE hostnames SET status = ?, status_note = ? WHERE hostname = ?', status, note, hostname).rowsWritten > 0, audit);
2276
+ }
2277
+ /**
2278
+ * #2526: a hostname update with its admin row. Without `audit`, the bare `write`. With it, one
2279
+ * unit: the row read (refused `not_found` when the name is not bound), nothing written and
2280
+ * nothing recorded when `unchanged` judges it so, else the write and the row, whose target and
2281
+ * `before` status are the row as read.
2282
+ */
2283
+ auditedHostnameUpdate(hostname, unchanged, write, audit) {
2284
+ if (!audit)
2285
+ return write();
2286
+ return this.ctx.storage.transactionSync(() => {
2287
+ const row = this.readHostname(hostname);
2288
+ if (!row)
2289
+ throw substratError('not_found', `unknown hostname '${hostname}'`);
2290
+ if (unchanged(row))
2291
+ return false; // idempotent, and a no-op is not audited
2292
+ write();
2293
+ this.recordAdmin({ ...audit, tenantId: row.tenant_id, scopeId: row.scope_id, before: { status: row.status } });
2294
+ return true;
2295
+ });
2223
2296
  }
2224
2297
  /**
2225
2298
  * Record the result of a Cloudflare-for-SaaS issuance step (§4.7) — the status the
@@ -2228,25 +2301,42 @@ export class ControlPlaneDO extends DurableObject {
2228
2301
  * `undefined`); the DNS records and status are refreshed each pass. This is the only
2229
2302
  * writer that moves a hostname off `pending` into `verifying`/`active`/`failed` for a
2230
2303
  * custom domain.
2304
+ *
2305
+ * With `audit` (#2526, through `reply`): the row is read here — refused `not_found` when the
2306
+ * name is not bound — and a poll that finds nothing changed (the same status, the same records,
2307
+ * the id already set) writes nothing and records nothing; otherwise the update and its admin
2308
+ * row, its target and `before` from that read, commit as one unit. Answers whether it wrote.
2309
+ * Without, as a coordinator from before #2526 calls it, the bare update.
2231
2310
  */
2232
- setHostnameIssuance(hostname, fields) {
2233
- if (fields.customHostnameId !== undefined) {
2234
- this.sql.exec(`UPDATE hostnames
2311
+ setHostnameIssuance(hostname, fields, audit) {
2312
+ // A poll that changes nothing is not an event, so the reconcile sweep does not flood the
2313
+ // admin log with no-op rows every interval. No records are stored as NULL.
2314
+ const unchanged = (row) => row.status === fields.status &&
2315
+ (row.validation_records ?? null) === fields.validationRecords &&
2316
+ (fields.customHostnameId === undefined || fields.customHostnameId === row.custom_hostname_id);
2317
+ return this.auditedHostnameUpdate(hostname, unchanged, () => (fields.customHostnameId !== undefined
2318
+ ? this.sql.exec(`UPDATE hostnames
2235
2319
  SET status = ?, status_note = ?, custom_hostname_id = ?, validation_records = ?
2236
- WHERE hostname = ?`, fields.status, fields.note, fields.customHostnameId, fields.validationRecords, hostname);
2237
- }
2238
- else {
2239
- this.sql.exec(`UPDATE hostnames SET status = ?, status_note = ?, validation_records = ? WHERE hostname = ?`, fields.status, fields.note, fields.validationRecords, hostname);
2240
- }
2320
+ WHERE hostname = ?`, fields.status, fields.note, fields.customHostnameId, fields.validationRecords, hostname)
2321
+ : this.sql.exec(`UPDATE hostnames SET status = ?, status_note = ?, validation_records = ? WHERE hostname = ?`, fields.status, fields.note, fields.validationRecords, hostname)).rowsWritten > 0, audit);
2241
2322
  }
2242
2323
  /**
2243
2324
  * Delete a binding and answer the row it removed, or null. With `owner`, only while the row
2244
2325
  * still names that tenant and scope (#2285): the delete decides it, not an earlier read.
2326
+ * With `audit` (#2526, through `reply`), the admin row, its target and `before` from the row
2327
+ * removed, commits with the delete; a delete that removed nothing records nothing.
2245
2328
  */
2246
- deleteHostname(hostname, owner) {
2247
- const rows = this.sql.exec(`DELETE FROM hostnames WHERE hostname = ?${owner ? ' AND tenant_id = ? AND scope_id = ?' : ''}
2248
- RETURNING tenant_id, scope_id, status`, hostname, ...(owner ? [owner.tenantId, owner.scopeId] : [])).toArray();
2249
- return rows[0] ?? null;
2329
+ deleteHostname(hostname, owner, audit) {
2330
+ let removed = null;
2331
+ this.writeAndAudit(() => {
2332
+ const rows = this.sql.exec(`DELETE FROM hostnames WHERE hostname = ?${owner ? ' AND tenant_id = ? AND scope_id = ?' : ''}
2333
+ RETURNING tenant_id, scope_id, status`, hostname, ...(owner ? [owner.tenantId, owner.scopeId] : [])).toArray();
2334
+ removed = rows[0] ?? null;
2335
+ return removed !== null;
2336
+ }, audit && (() => ({
2337
+ ...audit, tenantId: removed.tenant_id, scopeId: removed.scope_id, before: { hostname, status: removed.status },
2338
+ })));
2339
+ return removed;
2250
2340
  }
2251
2341
  listHostnames(filter) {
2252
2342
  const where = [];
@@ -2310,10 +2400,8 @@ export class ControlPlaneDO extends DurableObject {
2310
2400
  .exec(`SELECT v.id, v.admission_note FROM vertical_channels c JOIN vertical_versions v ON v.id = c.version_id
2311
2401
  WHERE c.vertical_slug = ? AND c.channel = 'prod'`, slug)
2312
2402
  .toArray()[0];
2313
- if (prod?.admission_note === AUTO_ADMISSION_NOTE) {
2314
- throw substratError('conflict', `vertical '${slug}' prod version ${prod.id} is auto-admitted (private self-serve) — ` +
2315
- `a staff admit must vouch for it before listing`);
2316
- }
2403
+ if (prod?.admission_note === AUTO_ADMISSION_NOTE)
2404
+ throw autoAdmittedProdRefusal(slug, prod.id);
2317
2405
  }
2318
2406
  return { listed: !!r.listed };
2319
2407
  }, audit);
@@ -2781,15 +2869,23 @@ export class ControlPlaneDO extends DurableObject {
2781
2869
  * Remove a reaped fork's directory presence: hostname bindings + the scope row
2782
2870
  * (preview-and-snapshots.md §9). The coordinator's deleteSnapshot calls this AFTER
2783
2871
  * wiping the scope DO's storage — the fork-only refusal lives there, not here.
2872
+ * #2555: with `onlyWhile`, only a scope still in that status is removed, judged in the same unit,
2873
+ * so a scope another call activated in between keeps its row; answers whether it was removed.
2784
2874
  */
2785
- deleteScopeDirectory(scopeId) {
2786
- this.ctx.storage.transactionSync(() => {
2875
+ deleteScopeDirectory(scopeId, onlyWhile) {
2876
+ return this.ctx.storage.transactionSync(() => {
2877
+ if (onlyWhile) {
2878
+ const row = this.sql.exec('SELECT status FROM scopes WHERE scope_id = ?', scopeId).toArray()[0];
2879
+ if (row?.status !== onlyWhile)
2880
+ return false;
2881
+ }
2787
2882
  this.sql.exec('UPDATE peer_bindings SET invalidated = 1 WHERE target_scope_id = ?', scopeId);
2788
2883
  this.sql.exec('DELETE FROM hostnames WHERE scope_id = ?', scopeId);
2789
2884
  forgetSwitchesOf(this.kernelSql, scopeId);
2790
2885
  this.forgetLifecycleDeliveries('scope_id = ?', scopeId);
2791
2886
  forgetScopeStorage(doRedactionSql(this.sql), scopeId);
2792
2887
  this.sql.exec('DELETE FROM scopes WHERE scope_id = ?', scopeId);
2888
+ return true;
2793
2889
  });
2794
2890
  }
2795
2891
  /**
@@ -2814,12 +2910,28 @@ export class ControlPlaneDO extends DurableObject {
2814
2910
  * channel has none yet). A compare-and-set in one synchronous transaction, so no other promote
2815
2911
  * runs between the read and the write: a promote judged its gates against `from` across awaits,
2816
2912
  * and the one that lost the race is told so (`false`) and writes nothing, `audit` included.
2913
+ *
2914
+ * #2565: the vertical's `listed` is read in the same unit — refusing a listed vertical when
2915
+ * `unlistedOnly`, and an auto-admitted version as a listed vertical's new prod. It replaces
2916
+ * `moveChannel`, which judged neither: a new name, so a DO from before refuses the call and a
2917
+ * coordinator from before finds no `moveChannel` to call. Both fail closed.
2817
2918
  */
2818
- moveChannel(from, history, audit) {
2919
+ promoteChannel(from, history, unlistedOnly, audit) {
2819
2920
  return this.writeAndAudit(() => {
2820
2921
  const now = this.readChannel(history.vertical_slug, history.channel);
2821
2922
  if ((now?.version_id ?? null) !== from)
2822
2923
  return false;
2924
+ const vertical = this.readVertical(history.vertical_slug);
2925
+ const listed = vertical?.listed;
2926
+ if (listed && unlistedOnly)
2927
+ throw listedPromoteRefusal();
2928
+ // A re-promote of what prod already names, and what serves (if anything does), moves
2929
+ // nothing, so it is not judged again. One whose serve would change what runs is.
2930
+ const serving = vertical?.serving_ref && vertical.serving_version_id ? vertical.serving_version_id : history.version_id;
2931
+ if (listed && history.channel === 'prod' && (from !== history.version_id || serving !== history.version_id) &&
2932
+ this.versionAdmission(history.version_id).admission_note === AUTO_ADMISSION_NOTE) {
2933
+ throw autoAdmittedProdRefusal(history.vertical_slug, history.version_id);
2934
+ }
2823
2935
  this.sql.exec(`INSERT INTO vertical_channels (vertical_slug, channel, version_id, updated_at)
2824
2936
  VALUES (?, ?, ?, ?)
2825
2937
  ON CONFLICT (vertical_slug, channel) DO UPDATE SET version_id = ?, updated_at = ?`, history.vertical_slug, history.channel, history.version_id, history.at, history.version_id, history.at);
@@ -3031,8 +3143,24 @@ export class ControlPlaneDO extends DurableObject {
3031
3143
  /**
3032
3144
  * Upsert; returns before/after for the host's audit row, `changed: false` when
3033
3145
  * the merged result equals what the row already carried (not audited upstream).
3146
+ * With `audit` (#2526, through `reply`), an effective change commits with its admin row,
3147
+ * `before` and `after` the grant as read and written here; an unchanged grant records none.
3034
3148
  */
3035
- grantEntitlement(tenantId, key, input, actor) {
3149
+ grantEntitlement(tenantId, key, input, actor, audit) {
3150
+ let result;
3151
+ this.writeAndAudit(() => {
3152
+ result = this.grantEntitlementIn(tenantId, key, input, actor);
3153
+ return result.changed;
3154
+ }, audit && (() => ({
3155
+ ...audit,
3156
+ before: result.before
3157
+ ? { entitlementKey: key, expiresAt: result.before.expires_at, quota: result.before.quota, plan: result.before.plan }
3158
+ : null,
3159
+ after: { entitlementKey: key, expiresAt: result.after.expires_at, quota: result.after.quota, plan: result.after.plan },
3160
+ })));
3161
+ return result;
3162
+ }
3163
+ grantEntitlementIn(tenantId, key, input, actor) {
3036
3164
  const existing = this.readEntitlement(tenantId, key);
3037
3165
  const next = {
3038
3166
  expiresAt: input.expiresAt !== undefined ? input.expiresAt : (existing?.expires_at ?? null),
@@ -3070,12 +3198,23 @@ export class ControlPlaneDO extends DurableObject {
3070
3198
  },
3071
3199
  };
3072
3200
  }
3073
- /** DELETE; returns the removed row (the host's audit `before`), null if none. */
3074
- revokeEntitlement(tenantId, key) {
3075
- const existing = this.readEntitlement(tenantId, key);
3076
- if (!existing)
3077
- return null;
3078
- this.sql.exec('DELETE FROM _substrat_entitlements WHERE tenant_id = ? AND entitlement_key = ?', tenantId, key);
3201
+ /**
3202
+ * DELETE; returns the removed row (the host's audit `before`), null if none. With `audit`
3203
+ * (#2526, through `reply`), the admin row, `before` the row removed, commits with the delete;
3204
+ * a revoke of nothing held records nothing.
3205
+ */
3206
+ revokeEntitlement(tenantId, key, audit) {
3207
+ let existing = null;
3208
+ this.writeAndAudit(() => {
3209
+ existing = this.readEntitlement(tenantId, key);
3210
+ if (!existing)
3211
+ return false;
3212
+ this.sql.exec('DELETE FROM _substrat_entitlements WHERE tenant_id = ? AND entitlement_key = ?', tenantId, key);
3213
+ return true;
3214
+ }, audit && (() => ({
3215
+ ...audit,
3216
+ before: { entitlementKey: key, expiresAt: existing.expires_at, quota: existing.quota, plan: existing.plan },
3217
+ })));
3079
3218
  return existing;
3080
3219
  }
3081
3220
  tenantHoldsEntitlement(tenantId, key) {