@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.
package/dist/host.js CHANGED
@@ -79,6 +79,20 @@ const connectionGrantsSkippedLine = (tenantId, scopeId, skipped) => ({
79
79
  function projectedRoleRows(roles) {
80
80
  return roles.map((r) => ({ role_key: r.key, permissions: JSON.stringify(r.permissions), source: r.source, holder: r.holder === 'system' ? 'system' : 'person' }));
81
81
  }
82
+ /** #2555: a control-plane-full load whose role set was refused in its unit (`loadProjected`), told apart by identity. */
83
+ const loadRoleRefusals = new WeakSet();
84
+ /**
85
+ * #2555: what a refused second read tells staff. The load landed and judged the older set, and the
86
+ * next fan-out, which is not judged (K-55), would write the refused definition over the seats.
87
+ */
88
+ const SECOND_READ_REMEDY = "The load landed with the roles it judged, and holds only until the next fan-out: take the dump's seats in that role back, or define the role person-held again (K-55)";
89
+ /**
90
+ * #2555: a load projection's directory state as one comparable text; its judge is not state. Each
91
+ * list is compared as a set: the directory reads some of them in no fixed order.
92
+ */
93
+ function projectionDigest({ judge: _judge, ...state }) {
94
+ return JSON.stringify(Object.entries(state).map(([k, v]) => [k, Array.isArray(v) ? v.map((x) => JSON.stringify(x)).sort() : v]));
95
+ }
82
96
  function assertCopyLineage(lineage) {
83
97
  if (isPrimaryScope(lineage)) {
84
98
  throw substratError('conflict', 'mark-copy refused: the directory classifies this scope as primary, not as a copy');
@@ -1506,6 +1520,18 @@ export class CloudflareScopeHost {
1506
1520
  async redrainCountLocal(scopeId, drainedBefore) {
1507
1521
  return heldOrRefused(scopeId, await this.scopeStub(scopeId).redrainCountIfHeld(drainedBefore));
1508
1522
  }
1523
+ /**
1524
+ * RFC wire-signing §4: spend a signed call's nonce in this scope's own object, the one the
1525
+ * call's handler reaches through the same stub. The platform's entry calls it, through the host
1526
+ * the vertical registered, only after every other check of the envelope passed, and only for
1527
+ * the scope the verified envelope names. `true` once per nonce, `false` for a nonce already spent
1528
+ * or a claim after `until`, and a throw when the store is at its cap. The tenant is the one the
1529
+ * envelope names beside the scope; the object is keyed by the scope alone, so it is not read here.
1530
+ * No provisioned check: the call that provisions a scope spends its nonce there too.
1531
+ */
1532
+ async claimWireNonceLocal(_tenantId, scopeId, nonce, until) {
1533
+ return this.scopeStub(scopeId).claimWireNonce(nonce, until);
1534
+ }
1509
1535
  /**
1510
1536
  * The PITR bookmarks one scope recorded before its migration passes (#286) — the
1511
1537
  * rewind points a backout UI offers. Behind the vertical's platform-gated
@@ -1735,6 +1761,10 @@ export class CloudflareScopeHost {
1735
1761
  }
1736
1762
  // -- scope lifecycle ------------------------------------------------------
1737
1763
  async provisionScope(actor, input) {
1764
+ await this.provisionScopeCreating(actor, input);
1765
+ }
1766
+ /** `provisionScope`, answering whether this call created the directory row (#2555: an import's teardown). */
1767
+ async provisionScopeCreating(actor, input) {
1738
1768
  // Shared with the pure adapter so the defaults cannot drift between them.
1739
1769
  const record = resolveScopeRecord(input);
1740
1770
  // Fail-closed tenant gate throws out of the awaited cp call BEFORE migrate
@@ -1790,6 +1820,7 @@ export class CloudflareScopeHost {
1790
1820
  if (created) {
1791
1821
  await this.recordAdmin(actor, 'provisionScope', { tenantId: input.tenantId, scopeId: input.scopeId, vertical: record.vertical }, null, record);
1792
1822
  }
1823
+ return created;
1793
1824
  }
1794
1825
  // Per-tenant relational stores (#301, PR-2 — the live D1 path). The coordinator holds
1795
1826
  // the platform's D1 credential (the same split secretBox uses: the DO records, it never
@@ -2175,19 +2206,53 @@ export class CloudflareScopeHost {
2175
2206
  // replaces its provisioned schema with the dump wholesale (drop-then-replay), so
2176
2207
  // the end state is the dump, at the source's frontier. Provenance is stamped from
2177
2208
  // the dump unless the caller set it (§3: a fork always records its origin).
2178
- await this.provisionScope(actor, {
2209
+ const created = await this.provisionScopeCreating(actor, {
2179
2210
  ...input,
2180
2211
  forkedFrom: input.forkedFrom ?? dump.scopeId,
2181
2212
  forkedAt: input.forkedAt ?? dump.capturedAt,
2182
2213
  });
2183
- await this.scopeStub(input.scopeId).importDump(dump.tables, input.scopeId, {
2184
- sourceScopeId: dump.scopeId,
2185
- // A fork: its callers (snapshotScope) hand it a dump the platform exported.
2186
- exact: true,
2187
- });
2214
+ // A fork: its callers (snapshotScope) hand it a dump the platform exported.
2215
+ const load = { sourceScopeId: dump.scopeId, exact: true };
2216
+ // #2555: an import lands with the directory's current permission state, as a restore does. A
2217
+ // snapshot is left as it was: a same-instant copy of a live scope the fan-out keeps current.
2218
+ const projection = judge ? await this.loadProjectionFor(input.tenantId, input.vertical) : undefined;
2219
+ let reprojected;
2220
+ if (projection === undefined)
2221
+ await this.scopeStub(input.scopeId).importDump(dump.tables, input.scopeId, load);
2222
+ else {
2223
+ try {
2224
+ await this.loadProjected(input.scopeId, dump.tables, load, projection, 'import');
2225
+ }
2226
+ catch (e) {
2227
+ if (loadRoleRefusals.has(e))
2228
+ await this.dropRefusedImport(actor, input.tenantId, input.scopeId, created);
2229
+ throw e;
2230
+ }
2231
+ }
2232
+ // The second read comes before the stores, so a scope taken down here has none of the dump's
2233
+ // store rows either.
2234
+ if (projection) {
2235
+ const converged = await this.convergeLoadedProjection(input.tenantId, input.scopeId, input.vertical, projection, 'import');
2236
+ reprojected = converged.reprojected;
2237
+ if ('error' in converged) {
2238
+ // A scope this call created is not activated, and goes as a refused import does. One that
2239
+ // existed has had its rows replaced by the committed load, so its stores follow the load
2240
+ // (as a restore's do) and the import is recorded first.
2241
+ if (created)
2242
+ await this.dropRefusedImport(actor, input.tenantId, input.scopeId, created);
2243
+ else {
2244
+ await this.importScopeStores(input.tenantId, input.scopeId, dump.stores);
2245
+ await this.recordImport(actor, input, dump, reprojected);
2246
+ }
2247
+ throw converged.error;
2248
+ }
2249
+ }
2188
2250
  await this.importScopeStores(input.tenantId, input.scopeId, dump.stores);
2189
2251
  await this.admin.activateScope(actor, input.tenantId, input.scopeId);
2190
- await this.recordAdmin(actor, 'importScope', { tenantId: input.tenantId, scopeId: input.scopeId }, null, { sourceScopeId: dump.scopeId, tables: dump.tables.length, capturedAt: dump.capturedAt });
2252
+ await this.recordImport(actor, input, dump, reprojected);
2253
+ }
2254
+ async recordImport(actor, input, dump, reprojected) {
2255
+ await this.recordAdmin(actor, 'importScope', { tenantId: input.tenantId, scopeId: input.scopeId }, null, { sourceScopeId: dump.scopeId, tables: dump.tables.length, capturedAt: dump.capturedAt, ...(reprojected ? { reprojected } : {}) });
2191
2256
  }
2192
2257
  async restoreScope(actor, tenantId, scopeId, dump, opts) {
2193
2258
  // Restore never creates a scope (that is importScope) — an unknown target fails closed.
@@ -2197,6 +2262,8 @@ export class CloudflareScopeHost {
2197
2262
  this.refuseUnusableScopeStores(dump.stores, existing.vertical);
2198
2263
  // #2555: judged before the fence, so a refused restore moves nothing.
2199
2264
  await this.refuseDumpRoles(tenantId, scopeId, dump);
2265
+ // #2555: the directory's state the load projects, judged again in its unit against the seats it loads.
2266
+ const projection = await this.loadProjectionFor(tenantId, existing.vertical);
2200
2267
  // #2314: the load replaces the scope's rows, so the orphan sweep is fenced first.
2201
2268
  await this.fenceOrphanSweep(scopeId, tenantId);
2202
2269
  // #1742: a dump from before a switch was pulled brings the module's grants back live, so
@@ -2206,21 +2273,25 @@ export class CloudflareScopeHost {
2206
2273
  const ownStore = this.cpLess || existing.vertical === null;
2207
2274
  const replayAt = new Date().toISOString();
2208
2275
  const carry = ownStore ? await this.recordedOffCarry(tenantId, scopeId, replayAt) : undefined;
2209
- const switchedOff = await this.scopeStub(scopeId).importDump(dump.tables, scopeId, {
2210
- switchOff: carry ? { at: replayAt, ...carry } : undefined,
2211
- sourceScopeId: opts?.sourceScopeId ?? dump.scopeId,
2212
- });
2276
+ const switchedOff = await this.loadProjected(scopeId, dump.tables, { switchOff: carry ? { at: replayAt, ...carry } : undefined, sourceScopeId: opts?.sourceScopeId ?? dump.scopeId }, projection, 'restore');
2213
2277
  await this.importScopeStores(tenantId, scopeId, dump.stores);
2214
2278
  const replay = await this.replayShreddedStoreErasures(actor, tenantId, scopeId, (opts?.sourceScopeId ?? dump.scopeId));
2279
+ // The load has committed, so a re-projection refused or failed is recorded with the restore and
2280
+ // the switches still go back off below; it is thrown last, loud.
2281
+ const converged = projection ? await this.convergeLoadedProjection(tenantId, scopeId, existing.vertical, projection, 'restore') : {};
2282
+ const reprojected = converged.reprojected;
2215
2283
  await this.recordAdmin(actor, 'restoreScope', { tenantId, scopeId }, null, {
2216
2284
  sourceScopeId: dump.scopeId,
2217
2285
  tables: dump.tables.length,
2218
2286
  capturedAt: dump.capturedAt,
2219
2287
  ...replay,
2288
+ ...(reprojected ? { reprojected } : {}),
2220
2289
  });
2221
2290
  if (carry) {
2222
2291
  await this.admin.reassertSystemSwitches(actor, { tenantId, scopeId }, { appliedInUnit: switchedOff });
2223
2292
  }
2293
+ if ('error' in converged)
2294
+ throw converged.error;
2224
2295
  return replay;
2225
2296
  }
2226
2297
  /**
@@ -4636,79 +4707,39 @@ export class CloudflareScopeHost {
4636
4707
  // -- the hostname map (K-26) -------------------------------------------
4637
4708
  bindHostname: async (actor, input) => {
4638
4709
  const parsed = bindHostnameInput.parse(input);
4639
- const scope = await this.cp.getScopeRecord(parsed.tenantId, parsed.scopeId);
4640
- if (!scope) {
4641
- throw substratError('not_found', `unknown scope ${parsed.scopeId} in tenant ${parsed.tenantId}`);
4642
- }
4643
- const existing = await this.cp.readHostname(parsed.hostname);
4644
- // The holder's own status decides whether the name is reclaimable. Read it from
4645
- // the scope record rather than joining it onto the hostname read: the router
4646
- // shares that read's shape, and a scope status it can see is an invitation to
4647
- // re-check suspension there (route-resolver's `readRoute` deliberately has none).
4648
- const holder = existing && existing.scope_id !== parsed.scopeId
4649
- ? await this.cp.getScopeRecord(existing.tenant_id, existing.scope_id)
4650
- : undefined;
4651
- const holderReleased = holder?.status === 'archived' || holder?.status === 'reaped';
4652
- if (existing && existing.scope_id !== parsed.scopeId && !holderReleased) {
4653
- // A hostname routes to exactly one place; silently rebinding would move
4654
- // another tenant's traffic. Exception: the holder is ARCHIVED or REAPED (a
4655
- // deleted app, storage since wiped) — it has released the name, so the rebind
4656
- // reclaims it.
4657
- throw substratError('conflict', `hostname '${parsed.hostname}' is already bound to another scope`);
4658
- }
4659
- // Exactly one canonical per (scope, surface).
4660
- if (parsed.canonical)
4661
- await this.cp.demoteCanonical(parsed.scopeId, parsed.surface);
4662
- await this.cp.upsertHostname({
4710
+ // #2526: the scope, the holder whose status decides whether the name is reclaimable
4711
+ // (archived or reaped released it), the canonical's demotion, the bind and its row are one
4712
+ // ControlPlaneDO unit; the row's vertical is the scope's as that unit read it.
4713
+ await this.directory('bindHostnameInDirectory', {
4663
4714
  hostname: parsed.hostname,
4664
4715
  tenantId: parsed.tenantId,
4665
4716
  scopeId: parsed.scopeId,
4666
- verticalSlug: scope.vertical,
4667
4717
  surface: parsed.surface,
4668
4718
  region: parsed.region,
4669
4719
  canonical: parsed.canonical,
4670
4720
  createdAt: new Date().toISOString(),
4671
- });
4672
- await this.recordAdmin(actor, 'bindHostname', { tenantId: parsed.tenantId, scopeId: parsed.scopeId, vertical: scope.vertical }, null, parsed);
4721
+ }, this.adminEntry(actor, 'bindHostname', { tenantId: parsed.tenantId, scopeId: parsed.scopeId }, null, parsed));
4673
4722
  },
4723
+ // #2526: the three below read their row in the unit that writes it (refusing an unbound name
4724
+ // `not_found`, judging the no-op, filling the row's target and `before`), so the template
4725
+ // carries no tenant or scope of its own.
4674
4726
  setHostnameStatus: async (actor, raw, status, note) => {
4675
4727
  const hostname = raw.toLowerCase(); // DNS is case-insensitive; the map is normalized
4676
- const row = await this.cp.readHostname(hostname);
4677
- if (!row)
4678
- throw substratError('not_found', `unknown hostname '${hostname}'`);
4679
- if (row.status === status)
4680
- return; // idempotent, unaudited
4681
- await this.cp.setHostnameStatus(hostname, status, note ?? null);
4682
- await this.recordAdmin(actor, 'setHostnameStatus', { tenantId: row.tenant_id, scopeId: row.scope_id }, { status: row.status }, { status, note: note ?? null });
4728
+ await this.directory('setHostnameStatus', hostname, status, note ?? null, this.adminEntry(actor, 'setHostnameStatus', { tenantId: null }, null, { status, note: note ?? null }));
4683
4729
  },
4684
4730
  setHostnameIssuance: async (actor, raw, fields) => {
4685
4731
  const hostname = raw.toLowerCase(); // DNS is case-insensitive; the map is normalized
4686
- const row = await this.cp.readHostname(hostname);
4687
- if (!row)
4688
- throw substratError('not_found', `unknown hostname '${hostname}'`);
4689
- // A poll that finds nothing changed (same status, same records, id already set)
4690
- // is not an event — skip the write and the audit entry, so the reconcile sweep
4691
- // does not flood the admin log with no-op rows every interval.
4692
- const recordsJson = JSON.stringify(fields.validationRecords);
4693
- const idUnchanged = fields.customHostnameId === undefined || fields.customHostnameId === row.custom_hostname_id;
4694
- if (row.status === fields.status && (row.validation_records ?? '[]') === recordsJson && idUnchanged) {
4695
- return;
4696
- }
4697
- await this.cp.setHostnameIssuance(hostname, {
4732
+ await this.directory('setHostnameIssuance', hostname, {
4698
4733
  status: fields.status,
4699
4734
  note: fields.note ?? null,
4700
4735
  customHostnameId: fields.customHostnameId,
4701
- validationRecords: fields.validationRecords.length ? recordsJson : null,
4702
- });
4703
- await this.recordAdmin(actor, 'setHostnameIssuance', { tenantId: row.tenant_id, scopeId: row.scope_id }, { status: row.status }, { status: fields.status, note: fields.note ?? null });
4736
+ validationRecords: fields.validationRecords.length ? JSON.stringify(fields.validationRecords) : null,
4737
+ }, this.adminEntry(actor, 'setHostnameIssuance', { tenantId: null }, null, { status: fields.status, note: fields.note ?? null }));
4704
4738
  },
4705
4739
  unbindHostname: async (actor, raw, owner) => {
4706
4740
  const hostname = raw.toLowerCase(); // DNS is case-insensitive; the map is normalized
4707
4741
  // The row the delete removed, so an owner is judged by the delete itself (#2285).
4708
- const row = await this.cp.deleteHostname(hostname, owner);
4709
- if (!row)
4710
- return; // idempotent, and a no-op is not audited
4711
- await this.recordAdmin(actor, 'unbindHostname', { tenantId: row.tenant_id, scopeId: row.scope_id }, { hostname, status: row.status }, null);
4742
+ await this.directory('deleteHostname', hostname, owner, this.adminEntry(actor, 'unbindHostname', { tenantId: null }, null, null));
4712
4743
  },
4713
4744
  listHostnames: async (actor, filter) => {
4714
4745
  const rows = await this.cp.listHostnames({
@@ -4912,7 +4943,7 @@ export class CloudflareScopeHost {
4912
4943
  // Judged in the DO's unit (#2528): an admitted version is refused, a rejected one is a no-op.
4913
4944
  await this.directory('rejectVersion', versionId, note, this.adminEntry(actor, 'rejectVersion', { tenantId: null }, null, { admission: 'rejected', note }));
4914
4945
  },
4915
- promoteVersion: async (actor, verticalSlug, channel, versionId, acknowledge) => {
4946
+ promoteVersion: async (actor, verticalSlug, channel, versionId, acknowledge, options) => {
4916
4947
  const incoming = await this.cp.readVersion(versionId);
4917
4948
  if (!incoming)
4918
4949
  throw substratError('not_found', `unknown version ${versionId}`);
@@ -4986,7 +5017,8 @@ export class CloudflareScopeHost {
4986
5017
  // only if the pointer still names `outgoing`; the promote that lost the race is refused as
4987
5018
  // stale and writes nothing. Its admin row commits with the move, ahead of the rebinds below,
4988
5019
  // which record their own — and the acknowledgement is recorded, not just enforced.
4989
- const moved = await this.cp.moveChannel(outgoing?.id ?? null, {
5020
+ // #2565: the listing's rule and the `unlistedOnly` door are judged in that unit too.
5021
+ const moved = await this.directory('promoteChannel', outgoing?.id ?? null, {
4990
5022
  id: ulid(),
4991
5023
  vertical_slug: verticalSlug,
4992
5024
  channel,
@@ -4994,7 +5026,7 @@ export class CloudflareScopeHost {
4994
5026
  from_version_id: outgoing?.id ?? null,
4995
5027
  actor,
4996
5028
  at: promotedAt,
4997
- }, this.adminEntry(actor, 'promoteVersion', { tenantId: null, vertical: verticalSlug }, outgoing ? { versionId: outgoing.id, version: outgoing.version } : null, { channel, versionId, version: incoming.version, acknowledged: ack }));
5029
+ }, options?.unlistedOnly === true, this.adminEntry(actor, 'promoteVersion', { tenantId: null, vertical: verticalSlug }, outgoing ? { versionId: outgoing.id, version: outgoing.version } : null, { channel, versionId, version: incoming.version, acknowledged: ack }));
4998
5030
  if (!moved)
4999
5031
  throw promoteRaceRefusal(channel);
5000
5032
  // For a PRIVATE vertical, prod IS what the owner's apps run: re-point the
@@ -6060,32 +6092,22 @@ export class CloudflareScopeHost {
6060
6092
  },
6061
6093
  grantEntitlement: async (actor, tenantId, entitlementKey, plan) => {
6062
6094
  const input = entitlementGrantInput.parse(plan ?? {});
6063
- const result = await this.cp.grantEntitlement(tenantId, entitlementKey, input, actor);
6095
+ // #2526: the held grant (the row's `before`, and the no-op judgement), the upsert and the
6096
+ // row are one ControlPlaneDO unit, which fills `before` and `after`.
6097
+ const result = await this.directory('grantEntitlement', tenantId, entitlementKey, input, actor, this.adminEntry(actor, 'grantEntitlement', { tenantId }, null, null));
6064
6098
  if (!result.changed)
6065
6099
  return; // idempotent — an unchanged grant is not audited
6066
- await this.recordAdmin(actor, 'grantEntitlement', { tenantId }, result.before
6067
- ? {
6068
- entitlementKey,
6069
- expiresAt: result.before.expires_at,
6070
- quota: result.before.quota,
6071
- plan: result.before.plan,
6072
- }
6073
- : null, {
6074
- entitlementKey,
6075
- expiresAt: result.after.expires_at,
6076
- quota: result.after.quota,
6077
- plan: result.after.plan,
6078
- });
6079
6100
  // #304: an entitlement change is a tenant-level write, so it fans out into the
6080
6101
  // tenant's projected scopes — the invalidation half of the OQ5 answer. A no-op
6081
- // unless scope-local projection is on; the reconcile sweep repairs any drop.
6102
+ // unless scope-local projection is on; the reconcile sweep repairs any drop. It runs
6103
+ // after the grant and its row have committed.
6082
6104
  await this.fanOut(tenantId);
6083
6105
  },
6084
6106
  revokeEntitlement: async (actor, tenantId, entitlementKey) => {
6085
- const removed = await this.cp.revokeEntitlement(tenantId, entitlementKey);
6107
+ // #2526: the delete and its row, `before` the grant it removed, are one unit.
6108
+ const removed = await this.directory('revokeEntitlement', tenantId, entitlementKey, this.adminEntry(actor, 'revokeEntitlement', { tenantId }, null, null));
6086
6109
  if (!removed)
6087
6110
  return; // nothing held, nothing changed
6088
- await this.recordAdmin(actor, 'revokeEntitlement', { tenantId }, { entitlementKey, expiresAt: removed.expires_at, quota: removed.quota, plan: removed.plan }, null);
6089
6111
  // The revoke must reach the projected scopes so a running vertical stops honouring
6090
6112
  // the entitlement — a dropped fan-out here would leave it enforcing a stale grant.
6091
6113
  await this.fanOut(tenantId);
@@ -7915,6 +7937,95 @@ export class CloudflareScopeHost {
7915
7937
  if (refusal)
7916
7938
  throw substratError('forbidden', refusal);
7917
7939
  }
7940
+ /**
7941
+ * #2555: what a control-plane-full restore or import projects into a scope of `vertical` in the
7942
+ * load's own transaction: the directory's current state for `tenantId`, exactly what a fan-out
7943
+ * sends (`tenantProjection` and the vertical's connection keys), with this host's reservations to
7944
+ * judge its roles by. Null on a host that does not project permissions into its scopes: there the
7945
+ * load only keeps the destination's permission flags.
7946
+ */
7947
+ async loadProjectionFor(tenantId, vertical) {
7948
+ if (!this.scopeLocalPermissions)
7949
+ return null;
7950
+ const [state, connectionKeys] = await Promise.all([this.tenantProjection(tenantId), this.connectionKeyRows(tenantId, vertical)]);
7951
+ return { tenantId, ...state, connectionKeys, judge: this.roleJudge() };
7952
+ }
7953
+ /**
7954
+ * #2555: `importDumpProjected`, a failure rethrown with its code. The role set refused in the
7955
+ * load's unit is `forbidden`, worded for the verb (`restore refused: `, `import refused: `), and
7956
+ * told apart by identity (`loadRoleRefusals`); the scope keeps its rows.
7957
+ */
7958
+ async loadProjected(scopeId, tables, opts, projection, verb) {
7959
+ const loaded = unwrapReply(await this.scopeStub(scopeId).importDumpProjected(tables, scopeId, opts, projection));
7960
+ if ('switchedOff' in loaded)
7961
+ return loaded.switchedOff;
7962
+ const refused = substratError('forbidden', `${verb} refused: ${loaded.refusedRoles}`);
7963
+ loadRoleRefusals.add(refused);
7964
+ throw refused;
7965
+ }
7966
+ /**
7967
+ * #2555: an import that cannot finish (its load refused its role set, or its re-projection
7968
+ * refused or failed) takes down the scope it created for itself: only one this call created
7969
+ * (`created`) and still `provisioning`, never activated, so an import pointed at an existing
7970
+ * scope never touches it. Its directory row goes, as the pure host takes such a scope down, so a
7971
+ * retry with the same id imports. The storage object is left for that retry's load to replace;
7972
+ * until then it holds what the provision projected into it (the tenant's roles, tuples,
7973
+ * entitlements, identity links and connection keys) and, after a load, the dump, and no
7974
+ * directory row leads a later tenant deletion to it. Best effort: the refusal is the answer
7975
+ * whatever this does. A teardown that fails is a staff-only ops row (no tenant on it), with the
7976
+ * scope left in `provisioning` for staff to reap. No admin row: the import never happened, and
7977
+ * its `provisionScope` row stays as the record of the attempt.
7978
+ */
7979
+ async dropRefusedImport(actor, tenantId, scopeId, created) {
7980
+ if (!created)
7981
+ return;
7982
+ try {
7983
+ // Judged again in the directory's own unit, so a concurrent call that activated it keeps it; the
7984
+ // read before it is what an older directory, which ignores the condition, is held to.
7985
+ if ((await this.cp.getScopeRecord(tenantId, scopeId))?.status !== 'provisioning')
7986
+ return;
7987
+ await this.cp.deleteScopeDirectory(scopeId, 'provisioning');
7988
+ }
7989
+ catch (teardown) {
7990
+ await this.admin
7991
+ .recordOpsFailure({
7992
+ actor,
7993
+ operation: 'importScope',
7994
+ stage: 'refused-import-teardown',
7995
+ scopeId,
7996
+ message: `a refused import's scope could not be taken down: ${teardown instanceof Error ? teardown.message : String(teardown)}`,
7997
+ })
7998
+ .catch(() => {
7999
+ // Nothing more to do here: the refusal is still the answer.
8000
+ });
8001
+ }
8002
+ }
8003
+ /**
8004
+ * #2555: the directory read again once the load has committed. A tenant-level write made between
8005
+ * the first read and the load fanned out before the load replaced it, so when the directory has
8006
+ * moved, the scope is projected once more, judged in its own unit against the seats the load
8007
+ * brought (`applyJudgedProjectionReply`): a role the directory redefined in the window, which its
8008
+ * own door judged without those seats, is judged with them here. Once at most: a write after this
8009
+ * read fans out to the scope itself. Answers `reprojected` (`true` when it re-projected), and the
8010
+ * error with `'refused'` or `'failed'` when the re-projection could not land, which leaves the
8011
+ * scope on the set the load judged until the next fan-out, which is not judged (K-55); the
8012
+ * refusal says what staff do about it. Ordering projections by a per-tenant revision (#2595)
8013
+ * would make the second read unnecessary.
8014
+ */
8015
+ async convergeLoadedProjection(tenantId, scopeId, vertical, loaded, verb) {
8016
+ try {
8017
+ const now = await this.loadProjectionFor(tenantId, vertical);
8018
+ if (!now || projectionDigest(now) === projectionDigest(loaded))
8019
+ return {};
8020
+ unwrapReply(await this.scopeStub(scopeId).applyJudgedProjectionReply(this.roleJudge(), tenantId, now.roles, now.tuples, now.entitlements, undefined, now.identities, now.connectionKeys));
8021
+ return { reprojected: true };
8022
+ }
8023
+ catch (error) {
8024
+ if (errorCodeOf(error) !== 'forbidden')
8025
+ return { reprojected: 'failed', error };
8026
+ return { reprojected: 'refused', error: substratError('forbidden', `${verb} refused: ${error.message}. ${SECOND_READ_REMEDY}`) };
8027
+ }
8028
+ }
7918
8029
  /**
7919
8030
  * #2446: the roles `scopeId` stores for `tenantId` and the role keys anyone but a system principal
7920
8031
  * holds a live seat in there: what `refuseReservedRoleSet` judges a reconcile's role set against