@substrat-run/adapter-cloudflare 0.161.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,30 @@ 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',
824
+ // #2526: the role, grant and org writes at a tenant node, each with its admin row in its unit. A
825
+ // DO from before lists none of them, so the current coordinator fails closed against it. That is
826
+ // why the org write is `insertOrg` and not `createOrg` with a trailing row: an older DO lists
827
+ // `createOrg` and would drop the row and create the org.
828
+ 'defineRoleOrRefusal',
829
+ 'assignTenantRole',
830
+ 'writeTenantTuple',
831
+ 'writeOrgTenantTuple',
832
+ 'writeTenantSystemGrant',
833
+ 'grantConnectionInDirectory',
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',
821
845
  ];
822
846
  export class ControlPlaneDO extends DurableObject {
823
847
  sql;
@@ -1371,6 +1395,8 @@ export class ControlPlaneDO extends DurableObject {
1371
1395
  if (status === 'reaped') {
1372
1396
  throw substratError('validation_failed', `tenant ${tenantId} cannot be set to 'reaped' via setTenantStatus — reap goes through reapTenant (control-plane.md §4.8)`);
1373
1397
  }
1398
+ if (before.status === 'reaped')
1399
+ throw substratError('conflict', `tenant ${tenantId} is reaped and cannot change status`);
1374
1400
  // Stamp/clear deleting_at so the grace-window sweep can age tenants (§4.8):
1375
1401
  // entering `deleting` stamps, leaving it (an un-delete) clears — the same shape
1376
1402
  // transitionScope uses for archived_at.
@@ -1864,9 +1890,19 @@ export class ControlPlaneDO extends DurableObject {
1864
1890
  * system-held definition of a key with no system-held definition stored (none at all included): its tenant-level seats are read here, in the unit, and
1865
1891
  * `seatedInScopes` carries what the coordinator read off the tenant's scopes. A refusal is answered
1866
1892
  * as data, so its text survives the hop, and writes nothing. The current coordinator calls this one
1867
- * (the same split as `transitionScope` and `transitionScopeOrRefusal`).
1893
+ * (the same split as `transitionScope` and `transitionScopeOrRefusal`), through `reply` (#2526),
1894
+ * with `audit`: the row, its `before` the role read here, commits with the definition. A
1895
+ * coordinator from before #2526 passes none and records its own.
1868
1896
  */
1869
- defineRoleOrRefusal(tenantId, role, reservation = { reserved: [], declared: [] }, seatedInScopes = false) {
1897
+ defineRoleOrRefusal(tenantId, role, reservation = { reserved: [], declared: [] }, seatedInScopes = false, audit) {
1898
+ return this.ctx.storage.transactionSync(() => {
1899
+ const outcome = this.defineRoleIn(tenantId, role, reservation, seatedInScopes);
1900
+ if (audit && 'before' in outcome)
1901
+ this.recordAdmin({ ...audit, before: outcome.before });
1902
+ return outcome;
1903
+ });
1904
+ }
1905
+ defineRoleIn(tenantId, role, reservation, seatedInScopes) {
1870
1906
  const tenantRoles = this.listRoles({ tenantId }).map((r) => ({
1871
1907
  key: r.role_key,
1872
1908
  permissions: JSON.parse(r.permissions),
@@ -1930,21 +1966,67 @@ export class ControlPlaneDO extends DurableObject {
1930
1966
  };
1931
1967
  }
1932
1968
  // -- tenant-level tuples (checker rules 1, 2, 4) ----------------------------
1933
- writeTenantTuple(tenantId, subject, relation, object, expiresAt) {
1934
- this.sql.exec(`INSERT OR REPLACE INTO _substrat_tenant_tuples
1935
- (tenant_id, subject, relation, object, expires_at)
1936
- VALUES (?, ?, ?, ?, ?)`, tenantId, subject, relation, object, expiresAt);
1969
+ /**
1970
+ * A tenant tuple, upserted. With `audit` (#2526, `grant` at the tenant node, called through
1971
+ * `reply`), its admin row commits in the same transaction; without, as a coordinator from before
1972
+ * #2526 calls it, and as this object's own units do, it is the bare write.
1973
+ */
1974
+ writeTenantTuple(tenantId, subject, relation, object, expiresAt, audit) {
1975
+ const write = () => {
1976
+ this.sql.exec(`INSERT OR REPLACE INTO _substrat_tenant_tuples
1977
+ (tenant_id, subject, relation, object, expires_at)
1978
+ VALUES (?, ?, ?, ?, ?)`, tenantId, subject, relation, object, expiresAt);
1979
+ return true;
1980
+ };
1981
+ if (audit)
1982
+ this.writeAndAudit(write, audit);
1983
+ else
1984
+ write();
1985
+ }
1986
+ /**
1987
+ * #2526: `assignRole` at the tenant node — the role's reservation judged (`reserved`, what the
1988
+ * coordinator's modules reserve to a system principal: a role mixing them with other keys is
1989
+ * refused, #2430), the tuple and its admin row, in one unit. A refusal is answered as data and
1990
+ * writes nothing. A role not defined here is not judged, as before.
1991
+ */
1992
+ assignTenantRole(tenantId, principal, roleKey, reserved, audit) {
1993
+ return this.ctx.storage.transactionSync(() => {
1994
+ const role = reserved.length > 0 ? this.getRole(tenantId, roleKey) : undefined;
1995
+ const refused = role ? reservedRoleRefusal(role, new Set(reserved), 'platform') : null;
1996
+ if (refused)
1997
+ return { refused };
1998
+ this.writeTenantTuple(tenantId, `principal:${principal}`, `role:${roleKey}`, `tenant:${tenantId}`, null);
1999
+ this.recordAdmin(audit);
2000
+ return {};
2001
+ });
2002
+ }
2003
+ /**
2004
+ * #2526: a tenant tuple that names an org — `grantToOrg` at the tenant node, and `addMember` —
2005
+ * with its admin row, in one unit, refused `not_found` when the org is not in the tenant.
2006
+ */
2007
+ writeOrgTenantTuple(tenantId, orgId, subject, relation, object, expiresAt, audit) {
2008
+ this.writeAndAudit(() => {
2009
+ if (!this.readOrg(tenantId, orgId))
2010
+ throw substratError('not_found', `unknown org ${orgId} in tenant ${tenantId}`);
2011
+ this.writeTenantTuple(tenantId, subject, relation, object, expiresAt);
2012
+ return true;
2013
+ }, audit);
1937
2014
  }
1938
2015
  /**
1939
2016
  * #1743: a tenant-level `system:<module>` grant, refused while the record holds the module
1940
2017
  * off on any scope (`scopesSwitchedOffFor`). Synchronous, so the read and the write are one
1941
- * DO unit. Answers the switched-off scopes; non-empty means nothing was written.
2018
+ * DO unit. Answers the switched-off scopes; non-empty means nothing was written. With `audit`
2019
+ * (#2526, through `reply`), the row commits with the tuple, and a refusal records none.
1942
2020
  */
1943
- writeTenantSystemGrant(tenantId, moduleId, relation, expiresAt) {
1944
- const off = scopesSwitchedOffFor(this.kernelSql, tenantId, moduleId);
1945
- if (off.length === 0) {
2021
+ writeTenantSystemGrant(tenantId, moduleId, relation, expiresAt, audit) {
2022
+ let off = [];
2023
+ this.writeAndAudit(() => {
2024
+ off = scopesSwitchedOffFor(this.kernelSql, tenantId, moduleId);
2025
+ if (off.length > 0)
2026
+ return false;
1946
2027
  this.writeTenantTuple(tenantId, `system:${moduleId}`, relation, `tenant:${tenantId}`, expiresAt);
1947
- }
2028
+ return true;
2029
+ }, audit);
1948
2030
  return off;
1949
2031
  }
1950
2032
  // -- the hostname map (K-26) ------------------------------------------------
@@ -2138,7 +2220,11 @@ export class ControlPlaneDO extends DurableObject {
2138
2220
  scope_hostnames: JSON.parse(scope_hostnames_json),
2139
2221
  };
2140
2222
  }
2141
- /** 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
+ */
2142
2228
  demoteCanonical(scopeId, surface) {
2143
2229
  this.sql.exec('UPDATE hostnames SET canonical = 0 WHERE scope_id = ? AND surface = ?', scopeId, surface);
2144
2230
  }
@@ -2151,8 +2237,62 @@ export class ControlPlaneDO extends DurableObject {
2151
2237
  status, status_note, canonical, created_at, custom_hostname_id, validation_records)
2152
2238
  VALUES (?, ?, ?, ?, ?, ?, 'pending', NULL, ?, ?, NULL, NULL)`, h.hostname, h.tenantId, h.scopeId, h.verticalSlug, h.surface, h.region, h.canonical ? 1 : 0, h.createdAt);
2153
2239
  }
2154
- setHostnameStatus(hostname, status, note) {
2155
- 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
+ });
2156
2296
  }
2157
2297
  /**
2158
2298
  * Record the result of a Cloudflare-for-SaaS issuance step (§4.7) — the status the
@@ -2161,25 +2301,42 @@ export class ControlPlaneDO extends DurableObject {
2161
2301
  * `undefined`); the DNS records and status are refreshed each pass. This is the only
2162
2302
  * writer that moves a hostname off `pending` into `verifying`/`active`/`failed` for a
2163
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.
2164
2310
  */
2165
- setHostnameIssuance(hostname, fields) {
2166
- if (fields.customHostnameId !== undefined) {
2167
- 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
2168
2319
  SET status = ?, status_note = ?, custom_hostname_id = ?, validation_records = ?
2169
- WHERE hostname = ?`, fields.status, fields.note, fields.customHostnameId, fields.validationRecords, hostname);
2170
- }
2171
- else {
2172
- this.sql.exec(`UPDATE hostnames SET status = ?, status_note = ?, validation_records = ? WHERE hostname = ?`, fields.status, fields.note, fields.validationRecords, hostname);
2173
- }
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);
2174
2322
  }
2175
2323
  /**
2176
2324
  * Delete a binding and answer the row it removed, or null. With `owner`, only while the row
2177
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.
2178
2328
  */
2179
- deleteHostname(hostname, owner) {
2180
- const rows = this.sql.exec(`DELETE FROM hostnames WHERE hostname = ?${owner ? ' AND tenant_id = ? AND scope_id = ?' : ''}
2181
- RETURNING tenant_id, scope_id, status`, hostname, ...(owner ? [owner.tenantId, owner.scopeId] : [])).toArray();
2182
- 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;
2183
2340
  }
2184
2341
  listHostnames(filter) {
2185
2342
  const where = [];
@@ -2243,10 +2400,8 @@ export class ControlPlaneDO extends DurableObject {
2243
2400
  .exec(`SELECT v.id, v.admission_note FROM vertical_channels c JOIN vertical_versions v ON v.id = c.version_id
2244
2401
  WHERE c.vertical_slug = ? AND c.channel = 'prod'`, slug)
2245
2402
  .toArray()[0];
2246
- if (prod?.admission_note === AUTO_ADMISSION_NOTE) {
2247
- throw substratError('conflict', `vertical '${slug}' prod version ${prod.id} is auto-admitted (private self-serve) — ` +
2248
- `a staff admit must vouch for it before listing`);
2249
- }
2403
+ if (prod?.admission_note === AUTO_ADMISSION_NOTE)
2404
+ throw autoAdmittedProdRefusal(slug, prod.id);
2250
2405
  }
2251
2406
  return { listed: !!r.listed };
2252
2407
  }, audit);
@@ -2714,15 +2869,23 @@ export class ControlPlaneDO extends DurableObject {
2714
2869
  * Remove a reaped fork's directory presence: hostname bindings + the scope row
2715
2870
  * (preview-and-snapshots.md §9). The coordinator's deleteSnapshot calls this AFTER
2716
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.
2717
2874
  */
2718
- deleteScopeDirectory(scopeId) {
2719
- 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
+ }
2720
2882
  this.sql.exec('UPDATE peer_bindings SET invalidated = 1 WHERE target_scope_id = ?', scopeId);
2721
2883
  this.sql.exec('DELETE FROM hostnames WHERE scope_id = ?', scopeId);
2722
2884
  forgetSwitchesOf(this.kernelSql, scopeId);
2723
2885
  this.forgetLifecycleDeliveries('scope_id = ?', scopeId);
2724
2886
  forgetScopeStorage(doRedactionSql(this.sql), scopeId);
2725
2887
  this.sql.exec('DELETE FROM scopes WHERE scope_id = ?', scopeId);
2888
+ return true;
2726
2889
  });
2727
2890
  }
2728
2891
  /**
@@ -2747,12 +2910,28 @@ export class ControlPlaneDO extends DurableObject {
2747
2910
  * channel has none yet). A compare-and-set in one synchronous transaction, so no other promote
2748
2911
  * runs between the read and the write: a promote judged its gates against `from` across awaits,
2749
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.
2750
2918
  */
2751
- moveChannel(from, history, audit) {
2919
+ promoteChannel(from, history, unlistedOnly, audit) {
2752
2920
  return this.writeAndAudit(() => {
2753
2921
  const now = this.readChannel(history.vertical_slug, history.channel);
2754
2922
  if ((now?.version_id ?? null) !== from)
2755
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
+ }
2756
2935
  this.sql.exec(`INSERT INTO vertical_channels (vertical_slug, channel, version_id, updated_at)
2757
2936
  VALUES (?, ?, ?, ?)
2758
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);
@@ -2805,6 +2984,15 @@ export class ControlPlaneDO extends DurableObject {
2805
2984
  this.sql.exec('INSERT INTO orgs (org_id, tenant_id, slug, name, created_at) VALUES (?, ?, ?, ?, ?)', orgId, tenantId, slug, name, createdAt);
2806
2985
  return true;
2807
2986
  }
2987
+ /**
2988
+ * #2526: `createOrg` with its admin row, in one unit; an org that already exists writes nothing,
2989
+ * the row included. A new name rather than a trailing argument on `createOrg`, which an older
2990
+ * ControlPlaneDO already lists in `REPLIED_METHODS`: there the argument would be dropped and the
2991
+ * org created with no row, where an unknown method fails closed.
2992
+ */
2993
+ insertOrg(orgId, tenantId, slug, name, createdAt, audit) {
2994
+ return this.writeAndAudit(() => this.createOrg(orgId, tenantId, slug, name, createdAt), audit);
2995
+ }
2808
2996
  listOrgs(tenantId) {
2809
2997
  return this.sql
2810
2998
  .exec('SELECT * FROM orgs WHERE tenant_id = ? ORDER BY slug', tenantId)
@@ -2955,8 +3143,24 @@ export class ControlPlaneDO extends DurableObject {
2955
3143
  /**
2956
3144
  * Upsert; returns before/after for the host's audit row, `changed: false` when
2957
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.
2958
3148
  */
2959
- 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) {
2960
3164
  const existing = this.readEntitlement(tenantId, key);
2961
3165
  const next = {
2962
3166
  expiresAt: input.expiresAt !== undefined ? input.expiresAt : (existing?.expires_at ?? null),
@@ -2994,12 +3198,23 @@ export class ControlPlaneDO extends DurableObject {
2994
3198
  },
2995
3199
  };
2996
3200
  }
2997
- /** DELETE; returns the removed row (the host's audit `before`), null if none. */
2998
- revokeEntitlement(tenantId, key) {
2999
- const existing = this.readEntitlement(tenantId, key);
3000
- if (!existing)
3001
- return null;
3002
- 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
+ })));
3003
3218
  return existing;
3004
3219
  }
3005
3220
  tenantHoldsEntitlement(tenantId, key) {
@@ -3338,6 +3553,42 @@ export class ControlPlaneDO extends DurableObject {
3338
3553
  granted_by, granted_at, revoked_at)
3339
3554
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)`, row.connectionId, row.tenantId, row.vertical, row.permission, row.scopeId, row.expiresAt, row.grantedBy, row.grantedAt);
3340
3555
  }
3556
+ /**
3557
+ * #2526: `grantToConnection`'s directory half as one unit — the connection (live, in the grant's
3558
+ * tenant) and, at a scope node, the scope (in that tenant, running the connection's vertical)
3559
+ * judged as they stand now, the record (`recordConnectionGrant`), the tenant tuple at the tenant
3560
+ * node, and the admin row, whose vertical and provider are the connection's as read here. A
3561
+ * scope node's tuple is delivered by the coordinator after this commits; the record is what a
3562
+ * reconcile re-seats it from when that delivery fails. Answers the connection's vertical.
3563
+ */
3564
+ grantConnectionInDirectory(row, audit) {
3565
+ return this.ctx.storage.transactionSync(() => {
3566
+ const conn = this.readConnection(row.connectionId);
3567
+ if (!conn)
3568
+ throw new Error(`connection not found: ${row.connectionId}`);
3569
+ if (conn.revoked_at)
3570
+ throw new Error(`connection ${row.connectionId} is revoked — grant nothing to it`);
3571
+ if (conn.tenant_id !== row.tenantId) {
3572
+ throw new Error(`connection ${row.connectionId} belongs to tenant ${conn.tenant_id} and cannot be granted anything in ${row.tenantId}`);
3573
+ }
3574
+ if (row.scopeId) {
3575
+ const scope = this.getScopeRecord(row.tenantId, row.scopeId);
3576
+ if (!scope)
3577
+ throw substratError('not_found', `unknown scope ${row.scopeId} in tenant ${row.tenantId}`);
3578
+ if (scope.vertical !== conn.vertical) {
3579
+ throw new Error(`connection ${row.connectionId} is for vertical '${conn.vertical}' and scope ` +
3580
+ `${row.scopeId} runs '${scope.vertical ?? 'none'}'`);
3581
+ }
3582
+ }
3583
+ this.recordConnectionGrant({ ...row, vertical: conn.vertical });
3584
+ if (!row.scopeId) {
3585
+ this.writeTenantTuple(row.tenantId, `connection:${row.connectionId}`, `granted:${row.permission}`, `tenant:${row.tenantId}`, row.expiresAt);
3586
+ }
3587
+ const after = (audit.after ?? {});
3588
+ this.recordAdmin({ ...audit, vertical: conn.vertical, after: { ...after, provider: conn.provider } });
3589
+ return { vertical: conn.vertical };
3590
+ });
3591
+ }
3341
3592
  // -- the kill switches' record (#1674, #2029) — `system-switch-record.ts` is the whole rule ---
3342
3593
  /** OFF's write, before the scope's switch moves (#1823). Answers the row as it was. */
3343
3594
  recordSwitchedOff(row) {