@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.
- package/dist/control-plane-do.d.ts +119 -15
- package/dist/control-plane-do.d.ts.map +1 -1
- package/dist/control-plane-do.js +292 -41
- package/dist/control-plane-do.js.map +1 -1
- package/dist/host.d.ts +103 -16
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +329 -163
- package/dist/host.js.map +1 -1
- package/dist/scope-do.d.ts +63 -0
- package/dist/scope-do.d.ts.map +1 -1
- package/dist/scope-do.js +307 -124
- package/dist/scope-do.js.map +1 -1
- package/package.json +9 -9
package/dist/control-plane-do.js
CHANGED
|
@@ -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
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
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
|
-
|
|
1945
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
2155
|
-
|
|
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
|
-
|
|
2167
|
-
|
|
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
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
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) {
|