@substrat-run/adapter-cloudflare 0.160.0 → 0.162.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.
@@ -6,7 +6,7 @@ import { blankSqlComments, boundShapesColumn, executableSqlStatements, installsR
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
7
  import { opsFailureReferenceWhere } from '@substrat-run/kernel';
8
8
  import { doBuiltColumnsOf, doRedactionSql } from './sql.js';
9
- import { assertReplayableDump, instant, opsFailureFingerprint, ROUTE_SCOPE_HOSTNAMES_MAX, SCOPE_GATE_REASONS, substratError } from '@substrat-run/contracts';
9
+ import { assertReplayableDump, AUTO_ADMISSION_NOTE, instant, opsFailureFingerprint, ROUTE_SCOPE_HOSTNAMES_MAX, SCOPE_GATE_REASONS, substratError } from '@substrat-run/contracts';
10
10
  /**
11
11
  * The router's per-request directory read (`readRoute`), lifted out so its plan can be asked for
12
12
  * (#2005): the scope's hostname set is a correlated subquery on the hot path every request takes,
@@ -807,6 +807,28 @@ export const REPLIED_METHODS = [
807
807
  // #2309: a fleet rebuild's lease write refuses with conflict or validation_failed.
808
808
  'claimStoreDriverRebuild',
809
809
  'advanceStoreDriverRebuild',
810
+ // #2528: the vertical writes that read their row's `before` and their guards in the unit that
811
+ // writes, refusing an unknown vertical or version with not_found and a failed guard with conflict.
812
+ 'updateVerticalListed',
813
+ 'updateVerticalPublishRequest',
814
+ 'updateVerticalInstallsBlocked',
815
+ 'updateVerticalTenantProvisioner',
816
+ 'updateVerticalEmailSender',
817
+ 'deleteVertical',
818
+ 'admitVersion',
819
+ 'rejectVersion',
820
+ 'setVerticalServing',
821
+ // #2526: the role, grant and org writes at a tenant node, each with its admin row in its unit. A
822
+ // DO from before lists none of them, so the current coordinator fails closed against it. That is
823
+ // why the org write is `insertOrg` and not `createOrg` with a trailing row: an older DO lists
824
+ // `createOrg` and would drop the row and create the org.
825
+ 'defineRoleOrRefusal',
826
+ 'assignTenantRole',
827
+ 'writeTenantTuple',
828
+ 'writeOrgTenantTuple',
829
+ 'writeTenantSystemGrant',
830
+ 'grantConnectionInDirectory',
831
+ 'insertOrg',
810
832
  ];
811
833
  export class ControlPlaneDO extends DurableObject {
812
834
  sql;
@@ -1340,8 +1362,10 @@ export class ControlPlaneDO extends DurableObject {
1340
1362
  const slugOwner = this.sql
1341
1363
  .exec('SELECT tenant_id FROM tenants WHERE slug = ?', slug)
1342
1364
  .toArray()[0];
1365
+ // #2556: the refusal never names the owner. Another tenant holds it, and the text reaches
1366
+ // whoever asked (a manager vertical's intent journal among them).
1343
1367
  if (slugOwner) {
1344
- throw substratError('conflict', `tenant slug '${slug}' already taken by ${slugOwner.tenant_id} (slugs are unique)`);
1368
+ throw substratError('conflict', `tenant slug '${slug}' is already taken (slugs are unique)`);
1345
1369
  }
1346
1370
  this.writeAndAudit(() => this.sql.exec(`INSERT INTO tenants (tenant_id, slug, name, status, created_at, provisioned_by_tenant)
1347
1371
  VALUES (?, ?, ?, 'active', ?, ?)`, id, slug, name, createdAt, provisionedByTenant).rowsWritten > 0, audit && (() => ({ ...audit, after: this.readTenant(id) })));
@@ -1851,9 +1875,19 @@ export class ControlPlaneDO extends DurableObject {
1851
1875
  * 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
1852
1876
  * `seatedInScopes` carries what the coordinator read off the tenant's scopes. A refusal is answered
1853
1877
  * as data, so its text survives the hop, and writes nothing. The current coordinator calls this one
1854
- * (the same split as `transitionScope` and `transitionScopeOrRefusal`).
1878
+ * (the same split as `transitionScope` and `transitionScopeOrRefusal`), through `reply` (#2526),
1879
+ * with `audit`: the row, its `before` the role read here, commits with the definition. A
1880
+ * coordinator from before #2526 passes none and records its own.
1855
1881
  */
1856
- defineRoleOrRefusal(tenantId, role, reservation = { reserved: [], declared: [] }, seatedInScopes = false) {
1882
+ defineRoleOrRefusal(tenantId, role, reservation = { reserved: [], declared: [] }, seatedInScopes = false, audit) {
1883
+ return this.ctx.storage.transactionSync(() => {
1884
+ const outcome = this.defineRoleIn(tenantId, role, reservation, seatedInScopes);
1885
+ if (audit && 'before' in outcome)
1886
+ this.recordAdmin({ ...audit, before: outcome.before });
1887
+ return outcome;
1888
+ });
1889
+ }
1890
+ defineRoleIn(tenantId, role, reservation, seatedInScopes) {
1857
1891
  const tenantRoles = this.listRoles({ tenantId }).map((r) => ({
1858
1892
  key: r.role_key,
1859
1893
  permissions: JSON.parse(r.permissions),
@@ -1917,21 +1951,67 @@ export class ControlPlaneDO extends DurableObject {
1917
1951
  };
1918
1952
  }
1919
1953
  // -- tenant-level tuples (checker rules 1, 2, 4) ----------------------------
1920
- writeTenantTuple(tenantId, subject, relation, object, expiresAt) {
1921
- this.sql.exec(`INSERT OR REPLACE INTO _substrat_tenant_tuples
1922
- (tenant_id, subject, relation, object, expires_at)
1923
- VALUES (?, ?, ?, ?, ?)`, tenantId, subject, relation, object, expiresAt);
1954
+ /**
1955
+ * A tenant tuple, upserted. With `audit` (#2526, `grant` at the tenant node, called through
1956
+ * `reply`), its admin row commits in the same transaction; without, as a coordinator from before
1957
+ * #2526 calls it, and as this object's own units do, it is the bare write.
1958
+ */
1959
+ writeTenantTuple(tenantId, subject, relation, object, expiresAt, audit) {
1960
+ const write = () => {
1961
+ this.sql.exec(`INSERT OR REPLACE INTO _substrat_tenant_tuples
1962
+ (tenant_id, subject, relation, object, expires_at)
1963
+ VALUES (?, ?, ?, ?, ?)`, tenantId, subject, relation, object, expiresAt);
1964
+ return true;
1965
+ };
1966
+ if (audit)
1967
+ this.writeAndAudit(write, audit);
1968
+ else
1969
+ write();
1970
+ }
1971
+ /**
1972
+ * #2526: `assignRole` at the tenant node — the role's reservation judged (`reserved`, what the
1973
+ * coordinator's modules reserve to a system principal: a role mixing them with other keys is
1974
+ * refused, #2430), the tuple and its admin row, in one unit. A refusal is answered as data and
1975
+ * writes nothing. A role not defined here is not judged, as before.
1976
+ */
1977
+ assignTenantRole(tenantId, principal, roleKey, reserved, audit) {
1978
+ return this.ctx.storage.transactionSync(() => {
1979
+ const role = reserved.length > 0 ? this.getRole(tenantId, roleKey) : undefined;
1980
+ const refused = role ? reservedRoleRefusal(role, new Set(reserved), 'platform') : null;
1981
+ if (refused)
1982
+ return { refused };
1983
+ this.writeTenantTuple(tenantId, `principal:${principal}`, `role:${roleKey}`, `tenant:${tenantId}`, null);
1984
+ this.recordAdmin(audit);
1985
+ return {};
1986
+ });
1987
+ }
1988
+ /**
1989
+ * #2526: a tenant tuple that names an org — `grantToOrg` at the tenant node, and `addMember` —
1990
+ * with its admin row, in one unit, refused `not_found` when the org is not in the tenant.
1991
+ */
1992
+ writeOrgTenantTuple(tenantId, orgId, subject, relation, object, expiresAt, audit) {
1993
+ this.writeAndAudit(() => {
1994
+ if (!this.readOrg(tenantId, orgId))
1995
+ throw substratError('not_found', `unknown org ${orgId} in tenant ${tenantId}`);
1996
+ this.writeTenantTuple(tenantId, subject, relation, object, expiresAt);
1997
+ return true;
1998
+ }, audit);
1924
1999
  }
1925
2000
  /**
1926
2001
  * #1743: a tenant-level `system:<module>` grant, refused while the record holds the module
1927
2002
  * off on any scope (`scopesSwitchedOffFor`). Synchronous, so the read and the write are one
1928
- * DO unit. Answers the switched-off scopes; non-empty means nothing was written.
2003
+ * DO unit. Answers the switched-off scopes; non-empty means nothing was written. With `audit`
2004
+ * (#2526, through `reply`), the row commits with the tuple, and a refusal records none.
1929
2005
  */
1930
- writeTenantSystemGrant(tenantId, moduleId, relation, expiresAt) {
1931
- const off = scopesSwitchedOffFor(this.kernelSql, tenantId, moduleId);
1932
- if (off.length === 0) {
2006
+ writeTenantSystemGrant(tenantId, moduleId, relation, expiresAt, audit) {
2007
+ let off = [];
2008
+ this.writeAndAudit(() => {
2009
+ off = scopesSwitchedOffFor(this.kernelSql, tenantId, moduleId);
2010
+ if (off.length > 0)
2011
+ return false;
1933
2012
  this.writeTenantTuple(tenantId, `system:${moduleId}`, relation, `tenant:${tenantId}`, expiresAt);
1934
- }
2013
+ return true;
2014
+ }, audit);
1935
2015
  return off;
1936
2016
  }
1937
2017
  // -- the hostname map (K-26) ------------------------------------------------
@@ -2218,30 +2298,55 @@ export class ControlPlaneDO extends DurableObject {
2218
2298
  this.sql.exec('UPDATE verticals SET listed = ? WHERE slug = ?', listed, slug);
2219
2299
  }
2220
2300
  }
2221
- /** Publish/unpublish a vertical to the public marketplace + resolve any pending request. */
2301
+ /**
2302
+ * Publish/unpublish a vertical to the public marketplace + resolve any pending request. Listing
2303
+ * refuses a prod version that is auto-admitted (only its author has read it), judged on the
2304
+ * channel as this unit reads it (#2528).
2305
+ */
2222
2306
  updateVerticalListed(slug, listed, audit) {
2223
- this.writeAndAudit(() => this.sql.exec('UPDATE verticals SET listed = ?, publish_requested_at = NULL WHERE slug = ?', listed, slug)
2224
- .rowsWritten > 0, audit);
2307
+ this.updateVertical(slug, 'listed = ?, publish_requested_at = NULL', [listed], (r) => {
2308
+ if (listed) {
2309
+ const prod = this.sql
2310
+ .exec(`SELECT v.id, v.admission_note FROM vertical_channels c JOIN vertical_versions v ON v.id = c.version_id
2311
+ WHERE c.vertical_slug = ? AND c.channel = 'prod'`, slug)
2312
+ .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
+ }
2317
+ }
2318
+ return { listed: !!r.listed };
2319
+ }, audit);
2225
2320
  }
2226
2321
  /** Record a builder's pending publish request (marketplace-publish.md §5). */
2227
2322
  updateVerticalPublishRequest(slug, requestedAt, audit) {
2228
- this.writeAndAudit(() => this.sql.exec('UPDATE verticals SET publish_requested_at = ? WHERE slug = ?', requestedAt, slug)
2229
- .rowsWritten > 0, audit);
2323
+ this.updateVertical(slug, 'publish_requested_at = ?', [requestedAt], () => null, audit);
2230
2324
  }
2231
2325
  /** Block/unblock new installs of a vertical (staff kill-switch). */
2232
2326
  updateVerticalInstallsBlocked(slug, blocked, audit) {
2233
- this.writeAndAudit(() => this.sql.exec('UPDATE verticals SET installs_blocked = ? WHERE slug = ?', blocked, slug)
2234
- .rowsWritten > 0, audit);
2327
+ this.updateVertical(slug, 'installs_blocked = ?', [blocked], (r) => ({ installsBlocked: !!r.installs_blocked }), audit);
2235
2328
  }
2236
2329
  /** Grant/revoke the tenant-provisioner capability (#412, staff grant). */
2237
2330
  updateVerticalTenantProvisioner(slug, granted, audit) {
2238
- this.writeAndAudit(() => this.sql.exec('UPDATE verticals SET tenant_provisioner = ? WHERE slug = ?', granted, slug)
2239
- .rowsWritten > 0, audit);
2331
+ this.updateVertical(slug, 'tenant_provisioner = ?', [granted], (r) => ({ tenantProvisioner: !!r.tenant_provisioner }), audit);
2240
2332
  }
2241
2333
  /** Grant/revoke the email-sender capability (#303, staff grant). */
2242
2334
  updateVerticalEmailSender(slug, granted, audit) {
2243
- this.writeAndAudit(() => this.sql.exec('UPDATE verticals SET email_sender = ? WHERE slug = ?', granted, slug)
2244
- .rowsWritten > 0, audit);
2335
+ this.updateVertical(slug, 'email_sender = ?', [granted], (r) => ({ emailSender: !!r.email_sender }), audit);
2336
+ }
2337
+ /**
2338
+ * #2528: one vertical row update whose row reads its `before` (and runs its guard, by throwing)
2339
+ * from the vertical as this unit reads it, so neither can be stale against the write.
2340
+ */
2341
+ updateVertical(slug, set, args, judge, audit) {
2342
+ let before = null;
2343
+ this.writeAndAudit(() => {
2344
+ const r = this.readVertical(slug);
2345
+ if (!r)
2346
+ throw substratError('not_found', `unknown vertical '${slug}'`);
2347
+ before = judge(r);
2348
+ return this.sql.exec(`UPDATE verticals SET ${set} WHERE slug = ?`, ...args, slug).rowsWritten > 0;
2349
+ }, audit && (() => ({ ...audit, before })));
2245
2350
  }
2246
2351
  /**
2247
2352
  * How many scopes a vertical still backs, split by liveness — deleteVertical's refusal
@@ -2260,8 +2365,10 @@ export class ControlPlaneDO extends DurableObject {
2260
2365
  return { live: r?.live ?? 0, archived: r?.archived ?? 0 };
2261
2366
  }
2262
2367
  /**
2263
- * Remove a vertical's registry presence: channels, versions, the row. The bound-scope
2264
- * refusal lives host-side (the audited seam), not here — this is the storage verb.
2368
+ * Remove a vertical's registry presence: channels, versions, the row. Refused while any
2369
+ * restorable scope is bound (`countScopesForVertical`): a deleted registry row would strand
2370
+ * those scopes' version pins and routing. That refusal and `audit`'s `before` are read in the
2371
+ * unit that deletes (#2528).
2265
2372
  */
2266
2373
  deleteVertical(slug, audit, until) {
2267
2374
  // #2501: one transaction, so the row and its ownership period close together. `until` is the
@@ -2269,7 +2376,21 @@ export class ControlPlaneDO extends DurableObject {
2269
2376
  // optional and trailing so a caller from before it, sending (slug, audit), still closes the
2270
2377
  // period, on this DO's own reading of that clock; anything but an instant is refused.
2271
2378
  const closedAt = until === undefined ? verticalOwnerNow() : instant.parse(until);
2379
+ let before = null;
2272
2380
  this.writeAndAudit(() => {
2381
+ const existing = this.readVertical(slug);
2382
+ if (!existing)
2383
+ throw substratError('not_found', `unknown vertical '${slug}'`);
2384
+ // An `archived` scope (a deleted app) still blocks — unarchive can bring it back — but the
2385
+ // refusal names reap/restore, because the app itself is already gone.
2386
+ const bound = this.countScopesForVertical(slug);
2387
+ if (bound.live > 0) {
2388
+ throw substratError('conflict', `vertical '${slug}' still backs ${bound.live} scope(s) — delete or rebind them first`);
2389
+ }
2390
+ if (bound.archived > 0) {
2391
+ throw substratError('conflict', `vertical '${slug}' still backs ${bound.archived} archived scope(s) — reap or restore them first`);
2392
+ }
2393
+ before = { slug, source: existing.source, ownerTenant: existing.owner_tenant };
2273
2394
  this.sql.exec('DELETE FROM vertical_channels WHERE vertical_slug = ?', slug);
2274
2395
  this.sql.exec('DELETE FROM vertical_channel_history WHERE vertical_slug = ?', slug);
2275
2396
  this.sql.exec(`DELETE FROM vertical_version_migrations
@@ -2281,7 +2402,7 @@ export class ControlPlaneDO extends DurableObject {
2281
2402
  if (changed)
2282
2403
  closeVerticalOwnerRow(doRedactionSql(this.sql), slug, closedAt);
2283
2404
  return changed;
2284
- }, audit);
2405
+ }, audit && (() => ({ ...audit, before })));
2285
2406
  }
2286
2407
  /** #2501: every period of a slug's ownership, oldest first — kernel `vertical-owners.ts`. */
2287
2408
  listVerticalOwners(slug) {
@@ -2312,17 +2433,26 @@ export class ControlPlaneDO extends DurableObject {
2312
2433
  * that carries none to show.
2313
2434
  */
2314
2435
  insertVersion(v, audit) {
2436
+ // #2528: whether it self-admits is judged on the vertical as this unit reads it — a PRIVATE
2437
+ // vertical (tenant-owned, not listed) self-admits, noted so the publish seam can tell a staff
2438
+ // vouch from this shortcut — whatever the caller judged from its own earlier read.
2439
+ let admission = v.admission;
2315
2440
  this.writeAndAudit(() => {
2441
+ const owning = this.readVertical(v.verticalSlug);
2442
+ if (!owning)
2443
+ throw substratError('not_found', `unknown vertical '${v.verticalSlug}'`);
2444
+ const selfAdmits = owning.owner_tenant !== null && !owning.listed;
2445
+ admission = selfAdmits ? 'admitted' : 'pending';
2316
2446
  this.sql.exec(`INSERT INTO vertical_versions
2317
2447
  (id, vertical_slug, version, manifest_digest, permission_digest,
2318
2448
  migration_digest, deployment_ref, admission, admission_note, manifest_json,
2319
2449
  origin_json, migration_count, migrations_split, migration_digest_derived, created_at)
2320
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?)`, v.id, v.verticalSlug, v.version, v.manifestDigest, v.permissionDigest, v.migrationDigest, v.deploymentRef, v.admission, v.admissionNote, v.manifestJson, v.originJson, v.migrations?.length ?? null, v.createdAt);
2450
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?)`, v.id, v.verticalSlug, v.version, v.manifestDigest, v.permissionDigest, v.migrationDigest, v.deploymentRef, admission, selfAdmits ? AUTO_ADMISSION_NOTE : null, v.manifestJson, v.originJson, v.migrations?.length ?? null, v.createdAt);
2321
2451
  writeVersionMigrations(this.kernelSql, v.id, v.migrations);
2322
2452
  // #2286: the version's scope-store bindings, refusing one moved to another driver, in the insert's transaction.
2323
2453
  indexVersionScopeStores(this.kernelSql, v.verticalSlug, v.id, v.manifestJson);
2324
2454
  return true;
2325
- }, audit);
2455
+ }, audit && (() => ({ ...audit, after: { ...audit.after, admission } })));
2326
2456
  }
2327
2457
  /** Every scope-store binding any version of the vertical declared (#2286); indexes versions not indexed yet. */
2328
2458
  verticalScopeStores(slug) {
@@ -2332,18 +2462,37 @@ export class ControlPlaneDO extends DurableObject {
2332
2462
  readVersionMigrations(id) {
2333
2463
  return versionMigrationsOf(this.kernelSql, id);
2334
2464
  }
2335
- /** Record what the serving script now runs — written only after a successful
2336
- * in-place upload, so a failed serve leaves the trailing state visible. */
2337
- setVerticalServing(slug, s, audit) {
2338
- this.writeAndAudit(() => this.sql.exec(`UPDATE verticals SET serving_ref = ?, serving_version_id = ?,
2339
- serving_do_classes = ?, serving_migration_tag = ? WHERE slug = ?`, s.ref, s.versionId, s.doClassesJson, s.migrationTag, slug).rowsWritten > 0, audit);
2465
+ /**
2466
+ * Record what the serving script now runs — written only after a successful in-place upload, so
2467
+ * a failed serve leaves the trailing state visible. #2528: `expected` is the serving ref and
2468
+ * version the caller's #2178 gate judged the move from (nulls: nothing served); the write lands
2469
+ * only if the row still names both (`SERVES_NOW_CONDITION`), and `audit`'s `before` is the
2470
+ * serving state this unit read. False when either moved. Optional and trailing, so a caller from
2471
+ * before it still writes unconditionally.
2472
+ */
2473
+ setVerticalServing(slug, s, audit, expected) {
2474
+ let before = null;
2475
+ let raced = false;
2476
+ this.writeAndAudit(() => {
2477
+ const r = this.readVertical(slug);
2478
+ if (!r)
2479
+ throw substratError('not_found', `unknown vertical '${slug}'`);
2480
+ before = r.serving_ref ? { ref: r.serving_ref, versionId: r.serving_version_id } : null;
2481
+ const moved = this.sql.exec(`UPDATE verticals SET serving_ref = ?, serving_version_id = ?,
2482
+ serving_do_classes = ?, serving_migration_tag = ?
2483
+ WHERE ${expected === undefined ? 'slug = ?' : SERVES_NOW_CONDITION}`, s.ref, s.versionId, s.doClassesJson, s.migrationTag, ...(expected === undefined ? [slug] : [slug, expected.ref, expected.versionId])).rowsWritten > 0;
2484
+ raced = !moved;
2485
+ return moved;
2486
+ }, audit && (() => ({ ...audit, before })));
2487
+ return !raced;
2340
2488
  }
2341
- /** One upload's capability record (RFC wire-signing §7, Q8), replacing the script's earlier one. */
2489
+ /**
2490
+ * One upload's capability record (RFC wire-signing §7, Q8), replacing the script's earlier one. False,
2491
+ * with nothing written and no admin row, for a vertical the registry does not hold: the host answers
2492
+ * that `not_found` on its side of the RPC, so the code survives.
2493
+ */
2342
2494
  recordWireAuth(row, audit) {
2343
- this.writeAndAudit(() => {
2344
- writeDeploymentWireAuth(this.kernelSql, row);
2345
- return true;
2346
- }, audit);
2495
+ return this.writeAndAudit(() => writeDeploymentWireAuth(this.kernelSql, row), audit);
2347
2496
  }
2348
2497
  /**
2349
2498
  * One page of the stored capability records the filter's reach confines it to (one tenant's
@@ -2391,9 +2540,55 @@ export class ControlPlaneDO extends DurableObject {
2391
2540
  await deriveVersionMigrationDigest(this.kernelSql, r.id);
2392
2541
  return read();
2393
2542
  }
2543
+ /** Kept for a coordinator from before #2528, which judged the admission itself. */
2394
2544
  setAdmission(id, admission, note, audit) {
2395
2545
  this.writeAndAudit(() => this.sql.exec('UPDATE vertical_versions SET admission = ?, admission_note = ? WHERE id = ?', admission, note, id).rowsWritten > 0, audit);
2396
2546
  }
2547
+ /**
2548
+ * Admit a version, judged on its admission as this unit reads it (#2528). Idempotent on an
2549
+ * admitted one — except an AUTO-admitted version, which this upgrades to a manual vouch by
2550
+ * clearing the note (what the publish seam requires). A rejected one is refused.
2551
+ */
2552
+ admitVersion(id, audit) {
2553
+ let row = { before: null, after: null };
2554
+ this.writeAndAudit(() => {
2555
+ const v = this.versionAdmission(id);
2556
+ if (v.admission === 'admitted') {
2557
+ if (v.admission_note !== AUTO_ADMISSION_NOTE)
2558
+ return false;
2559
+ row = { before: { admission: v.admission, note: v.admission_note }, after: { admission: 'admitted', note: null } };
2560
+ }
2561
+ else if (v.admission === 'rejected') {
2562
+ throw substratError('conflict', `version ${id} was rejected — publish a new one`);
2563
+ }
2564
+ else {
2565
+ row = { before: { admission: v.admission }, after: { admission: 'admitted' } };
2566
+ }
2567
+ return this.sql.exec("UPDATE vertical_versions SET admission = 'admitted', admission_note = NULL WHERE id = ?", id)
2568
+ .rowsWritten > 0;
2569
+ }, audit && (() => ({ ...audit, ...row })));
2570
+ }
2571
+ /** Reject a version, judged as `admitVersion` is: an admitted one is refused, a rejected one is a no-op. */
2572
+ rejectVersion(id, note, audit) {
2573
+ let before = null;
2574
+ this.writeAndAudit(() => {
2575
+ const v = this.versionAdmission(id);
2576
+ if (v.admission === 'admitted')
2577
+ throw substratError('conflict', `version ${id} is already admitted — it may be bound`);
2578
+ if (v.admission === 'rejected')
2579
+ return false;
2580
+ before = { admission: v.admission };
2581
+ return this.sql.exec("UPDATE vertical_versions SET admission = 'rejected', admission_note = ? WHERE id = ?", note, id)
2582
+ .rowsWritten > 0;
2583
+ }, audit && (() => ({ ...audit, before, after: { admission: 'rejected', note } })));
2584
+ }
2585
+ versionAdmission(id) {
2586
+ const v = this.sql.exec('SELECT admission, admission_note FROM vertical_versions WHERE id = ?', id)
2587
+ .toArray()[0];
2588
+ if (!v)
2589
+ throw substratError('not_found', `unknown version ${id}`);
2590
+ return v;
2591
+ }
2397
2592
  /**
2398
2593
  * False when a conditional bind lost its compare-and-set (the host throws, as above). #2347:
2399
2594
  * `servingRef` moves the route in the same write, and `expectedServing` holds it to what the
@@ -2677,6 +2872,15 @@ export class ControlPlaneDO extends DurableObject {
2677
2872
  this.sql.exec('INSERT INTO orgs (org_id, tenant_id, slug, name, created_at) VALUES (?, ?, ?, ?, ?)', orgId, tenantId, slug, name, createdAt);
2678
2873
  return true;
2679
2874
  }
2875
+ /**
2876
+ * #2526: `createOrg` with its admin row, in one unit; an org that already exists writes nothing,
2877
+ * the row included. A new name rather than a trailing argument on `createOrg`, which an older
2878
+ * ControlPlaneDO already lists in `REPLIED_METHODS`: there the argument would be dropped and the
2879
+ * org created with no row, where an unknown method fails closed.
2880
+ */
2881
+ insertOrg(orgId, tenantId, slug, name, createdAt, audit) {
2882
+ return this.writeAndAudit(() => this.createOrg(orgId, tenantId, slug, name, createdAt), audit);
2883
+ }
2680
2884
  listOrgs(tenantId) {
2681
2885
  return this.sql
2682
2886
  .exec('SELECT * FROM orgs WHERE tenant_id = ? ORDER BY slug', tenantId)
@@ -3210,6 +3414,42 @@ export class ControlPlaneDO extends DurableObject {
3210
3414
  granted_by, granted_at, revoked_at)
3211
3415
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)`, row.connectionId, row.tenantId, row.vertical, row.permission, row.scopeId, row.expiresAt, row.grantedBy, row.grantedAt);
3212
3416
  }
3417
+ /**
3418
+ * #2526: `grantToConnection`'s directory half as one unit — the connection (live, in the grant's
3419
+ * tenant) and, at a scope node, the scope (in that tenant, running the connection's vertical)
3420
+ * judged as they stand now, the record (`recordConnectionGrant`), the tenant tuple at the tenant
3421
+ * node, and the admin row, whose vertical and provider are the connection's as read here. A
3422
+ * scope node's tuple is delivered by the coordinator after this commits; the record is what a
3423
+ * reconcile re-seats it from when that delivery fails. Answers the connection's vertical.
3424
+ */
3425
+ grantConnectionInDirectory(row, audit) {
3426
+ return this.ctx.storage.transactionSync(() => {
3427
+ const conn = this.readConnection(row.connectionId);
3428
+ if (!conn)
3429
+ throw new Error(`connection not found: ${row.connectionId}`);
3430
+ if (conn.revoked_at)
3431
+ throw new Error(`connection ${row.connectionId} is revoked — grant nothing to it`);
3432
+ if (conn.tenant_id !== row.tenantId) {
3433
+ throw new Error(`connection ${row.connectionId} belongs to tenant ${conn.tenant_id} and cannot be granted anything in ${row.tenantId}`);
3434
+ }
3435
+ if (row.scopeId) {
3436
+ const scope = this.getScopeRecord(row.tenantId, row.scopeId);
3437
+ if (!scope)
3438
+ throw substratError('not_found', `unknown scope ${row.scopeId} in tenant ${row.tenantId}`);
3439
+ if (scope.vertical !== conn.vertical) {
3440
+ throw new Error(`connection ${row.connectionId} is for vertical '${conn.vertical}' and scope ` +
3441
+ `${row.scopeId} runs '${scope.vertical ?? 'none'}'`);
3442
+ }
3443
+ }
3444
+ this.recordConnectionGrant({ ...row, vertical: conn.vertical });
3445
+ if (!row.scopeId) {
3446
+ this.writeTenantTuple(row.tenantId, `connection:${row.connectionId}`, `granted:${row.permission}`, `tenant:${row.tenantId}`, row.expiresAt);
3447
+ }
3448
+ const after = (audit.after ?? {});
3449
+ this.recordAdmin({ ...audit, vertical: conn.vertical, after: { ...after, provider: conn.provider } });
3450
+ return { vertical: conn.vertical };
3451
+ });
3452
+ }
3213
3453
  // -- the kill switches' record (#1674, #2029) — `system-switch-record.ts` is the whole rule ---
3214
3454
  /** OFF's write, before the scope's switch moves (#1823). Answers the row as it was. */
3215
3455
  recordSwitchedOff(row) {