@substrat-run/control-plane-api 0.48.1 → 0.50.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/api.d.ts +27 -0
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +464 -21
- package/dist/api.js.map +1 -1
- package/dist/backups.d.ts +93 -0
- package/dist/backups.d.ts.map +1 -0
- package/dist/backups.js +30 -0
- package/dist/backups.js.map +1 -0
- package/dist/cf-observability.d.ts.map +1 -1
- package/dist/cf-observability.js +67 -52
- package/dist/cf-observability.js.map +1 -1
- package/dist/deploy.d.ts +23 -1
- package/dist/deploy.d.ts.map +1 -1
- package/dist/deploy.js.map +1 -1
- package/dist/directory-backup.d.ts +57 -0
- package/dist/directory-backup.d.ts.map +1 -0
- package/dist/directory-backup.js +68 -0
- package/dist/directory-backup.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/mask.d.ts +15 -0
- package/dist/mask.d.ts.map +1 -1
- package/dist/mask.js +25 -1
- package/dist/mask.js.map +1 -1
- package/dist/observability.d.ts +7 -2
- package/dist/observability.d.ts.map +1 -1
- package/dist/r2-backups.d.ts +24 -0
- package/dist/r2-backups.d.ts.map +1 -0
- package/dist/r2-backups.js +175 -0
- package/dist/r2-backups.js.map +1 -0
- package/dist/wfp.d.ts.map +1 -1
- package/dist/wfp.js +107 -0
- package/dist/wfp.js.map +1 -1
- package/package.json +6 -6
package/dist/api.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { Hono } from 'hono';
|
|
2
|
-
import { adminAction, channelName, createTenantInput, entitlementGrantInput, hostname as hostnameSchema, hostnameRegion, hostnameStatus, identityLink, listPageQuery, pageOf, principalId as principalIdSchema, promotionAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, registerVerticalInput, scopeDump, scopeId as scopeIdSchema, scopeStatus, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, z, } from '@substrat-run/contracts';
|
|
2
|
+
import { adminAction, ASSET_PART_PREFIX, assetHash, channelName, createTenantInput, entitlementGrantInput, hostname as hostnameSchema, hostnameRegion, hostnameStatus, identityLink, listPageQuery, pageOf, principalId as principalIdSchema, promotionAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, registerVerticalInput, scopeDump, scopeId as scopeIdSchema, scopeStatus, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, z, } from '@substrat-run/contracts';
|
|
3
3
|
import { migrationProgress, ulid } from '@substrat-run/kernel';
|
|
4
4
|
import { TENANT_HEADER } from './auth.js';
|
|
5
5
|
import { ControlPlaneError } from './client.js';
|
|
6
6
|
import { provisionSiblingScope } from './platform-drain.js';
|
|
7
7
|
import { mapError } from './errors.js';
|
|
8
|
-
import { maskDump } from './mask.js';
|
|
8
|
+
import { maskDump, maskRecords } from './mask.js';
|
|
9
9
|
import { assertSandboxContract, deployManifest, storedDeployManifest, deploymentRefFor, stableDeploymentRefFor, nextMigrationTag, upstreamStatusOf, } from './deploy.js';
|
|
10
10
|
import { blobStoreBindings, collectBlobStoreHandles, collectTenantStoreHandles, tenantStoreBindings, } from './tenant-stores.js';
|
|
11
11
|
import { mintPushToken, pushActorFor } from './push-token.js';
|
|
12
|
+
import { backupDirectoryIfDue } from './directory-backup.js';
|
|
12
13
|
import { isCustomHostname, validateBindableHostname, } from './custom-hostnames.js';
|
|
13
14
|
// -- request schemas ---------------------------------------------------------
|
|
14
15
|
// Parse, don't trust: every input crosses Zod at the boundary. The ids stay
|
|
@@ -142,6 +143,31 @@ const snapshotScopeBody = z.object({
|
|
|
142
143
|
kind: z.string().min(1).optional(),
|
|
143
144
|
expiresAt: z.string().datetime({ offset: true }).optional(),
|
|
144
145
|
});
|
|
146
|
+
// A reap request (#493). `backup` is deliberately TRI-STATE, not a defaulted boolean:
|
|
147
|
+
// true — back up or refuse (the console always sends this, so a control plane
|
|
148
|
+
// deployed without a backup store fails loudly instead of quietly wiping)
|
|
149
|
+
// false — the explicit "I accept an unrecoverable wipe"
|
|
150
|
+
// undefined — back up if a store is configured, proceed without one if not, which is
|
|
151
|
+
// what keeps every pre-#493 caller (and self-host) working unchanged
|
|
152
|
+
const reapScopeBody = z.object({
|
|
153
|
+
backup: z.boolean().optional(),
|
|
154
|
+
});
|
|
155
|
+
// A directory-restore request (#40). `capturedAt` addresses the copy — there is one
|
|
156
|
+
// directory, so that is its whole address. `overwrite` is the guard against the
|
|
157
|
+
// dangerous case: replaying a restore onto a control plane that already recovered.
|
|
158
|
+
const restoreDirectoryBody = z.object({
|
|
159
|
+
capturedAt: z.string().min(1),
|
|
160
|
+
overwrite: z.boolean().optional(),
|
|
161
|
+
});
|
|
162
|
+
/**
|
|
163
|
+
* How a stored backup is named in the admin log and to callers: the route that fetches
|
|
164
|
+
* it. Store-neutral by construction — the R2 key scheme stays private to the store — and
|
|
165
|
+
* an operator reading a reap entry gets an address they can actually GET, rather than a
|
|
166
|
+
* bucket path they would have to know the platform's internals to use.
|
|
167
|
+
*/
|
|
168
|
+
function backupRefOf(b) {
|
|
169
|
+
return `/tenants/${b.tenantId}/scopes/${b.scopeId}/backups/${b.capturedAt}`;
|
|
170
|
+
}
|
|
145
171
|
// A per-PR preview request (preview-and-snapshots.md §2/§9 — the "run a new version
|
|
146
172
|
// against a fork of prod" slice). `tag` is a short DNS-safe label (`pr-123`): it names
|
|
147
173
|
// the preview both in its scope slug (`<vertical>--<tag>`) and in its hostname
|
|
@@ -391,6 +417,7 @@ export function createControlPlaneApi(options) {
|
|
|
391
417
|
app.post('/tenants/:tenantId/reap', async (c) => {
|
|
392
418
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
393
419
|
const actor = c.get('actor');
|
|
420
|
+
const { backup: wantsBackup } = reapScopeBody.parse(await c.req.json().catch(() => ({})));
|
|
394
421
|
const tenant = await admin.getTenant(actor, tenantId);
|
|
395
422
|
if (!tenant)
|
|
396
423
|
return c.json({ error: `unknown tenant: ${tenantId}` }, 404);
|
|
@@ -405,12 +432,22 @@ export function createControlPlaneApi(options) {
|
|
|
405
432
|
continue;
|
|
406
433
|
if (scope.status !== 'archived')
|
|
407
434
|
await admin.archiveScope(actor, tenantId, scope.id);
|
|
435
|
+
// A backup here is OPT-IN, the inverse of the per-scope reap's default (#493).
|
|
436
|
+
// A scope reap is operational cleanup, so leaving a copy is the safe default; a
|
|
437
|
+
// TENANT reap is the deletion of a customer, and §4.8 exists partly to serve an
|
|
438
|
+
// Art. 17 erasure — silently writing that customer's data to a bucket the reap
|
|
439
|
+
// does not clear would defeat the request it was made to satisfy. Staff who are
|
|
440
|
+
// retiring (not erasing) a tenant pass `backup: true` deliberately.
|
|
441
|
+
const backup = wantsBackup === true ? await backupScope(c, tenantId, scope) : null;
|
|
408
442
|
const vertical = await verticalForScope(c, scope);
|
|
409
443
|
if (vertical)
|
|
410
444
|
await vertical.deleteScope({ scopeId: scope.id });
|
|
411
445
|
// Tenant teardown reaps every scope and releases every name by design — force past
|
|
412
446
|
// the bound-hostname guard (which fences the interactive per-scope reap route below).
|
|
413
|
-
await admin.reapScope(actor, tenantId, scope.id, {
|
|
447
|
+
await admin.reapScope(actor, tenantId, scope.id, {
|
|
448
|
+
force: true,
|
|
449
|
+
...(backup ? { backupRef: backupRefOf(backup) } : {}),
|
|
450
|
+
});
|
|
414
451
|
}
|
|
415
452
|
await admin.reapTenant(actor, tenantId);
|
|
416
453
|
return c.json(await admin.getTenant(actor, tenantId));
|
|
@@ -1051,6 +1088,154 @@ export function createControlPlaneApi(options) {
|
|
|
1051
1088
|
throw e;
|
|
1052
1089
|
}
|
|
1053
1090
|
});
|
|
1091
|
+
// -- backups (#493) --------------------------------------------------------
|
|
1092
|
+
// The recoverable copy a reap leaves behind. Delegation mirrors the export route
|
|
1093
|
+
// exactly: `admin.exportScope` is the canonical call — it writes the K-24 access-log
|
|
1094
|
+
// entry, and IS the bytes when the host is co-located — and a vertical-held scope's
|
|
1095
|
+
// real tables overlay it. FULL fidelity, never masked: a masked dump cannot restore,
|
|
1096
|
+
// and a backup that cannot restore is a false promise (see `backups.ts`).
|
|
1097
|
+
const backupScope = async (c, tenantId, scope) => {
|
|
1098
|
+
const store = options.scopeBackups;
|
|
1099
|
+
if (!store) {
|
|
1100
|
+
throw new ControlPlaneError(501, 'no backup target configured — this control plane cannot store a scope backup ' +
|
|
1101
|
+
'(bind one, or reap with backup=false to accept an unrecoverable wipe)');
|
|
1102
|
+
}
|
|
1103
|
+
// Residency (K-7/K-32): the platform bucket is global, so writing a jurisdiction-
|
|
1104
|
+
// pinned scope's bytes into it would move them out of the region the scope was
|
|
1105
|
+
// promised. Refuse rather than back up to the wrong place — and, because the reap
|
|
1106
|
+
// aborts with it, rather than wipe a scope we cannot legally copy.
|
|
1107
|
+
if (scope.jurisdiction !== 'global') {
|
|
1108
|
+
throw new ControlPlaneError(409, `scope ${scope.id} is pinned to '${scope.jurisdiction}' — the platform backup ` +
|
|
1109
|
+
`store is global, so a backup would move its data out of that jurisdiction ` +
|
|
1110
|
+
`(K-32); refused until a per-jurisdiction store exists`);
|
|
1111
|
+
}
|
|
1112
|
+
const dump = await admin.exportScope(c.get('actor'), tenantId, scope.id);
|
|
1113
|
+
const vertical = await verticalForScope(c, scope);
|
|
1114
|
+
const tables = vertical ? await vertical.exportScope(scope.id) : dump.tables;
|
|
1115
|
+
return store.put({ vertical: scope.vertical, dump: { ...dump, tables } });
|
|
1116
|
+
};
|
|
1117
|
+
// The copies held for one scope — metadata only, so listing a reaped scope's backups
|
|
1118
|
+
// is cheap and hands out no bytes. Readable AFTER the reap (that is the point): the
|
|
1119
|
+
// directory row survives as a tombstone, and this is what tells the operator a
|
|
1120
|
+
// recoverable copy exists and when it was taken.
|
|
1121
|
+
app.get('/tenants/:tenantId/scopes/:scopeId/backups', async (c) => {
|
|
1122
|
+
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
1123
|
+
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
1124
|
+
if (!options.scopeBackups)
|
|
1125
|
+
return c.json({ error: 'no backup target configured' }, 501);
|
|
1126
|
+
// Reads the directory, not the store, for the tenant cross-check: the store is keyed
|
|
1127
|
+
// by (tenant, scope) but nothing there proves the caller's tenant owns the scope.
|
|
1128
|
+
const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
1129
|
+
if (!scope)
|
|
1130
|
+
return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
|
|
1131
|
+
return c.json(await options.scopeBackups.list({ tenantId, scopeId }));
|
|
1132
|
+
});
|
|
1133
|
+
// One backup's DUMP — the restore source. Staff-only like the export it came from
|
|
1134
|
+
// (not in BUILDER_ROUTES), and full-fidelity, so it is the same governed-pull posture:
|
|
1135
|
+
// K-3 cross-checked above, and `POST …/restore` is where it goes back.
|
|
1136
|
+
app.get('/tenants/:tenantId/scopes/:scopeId/backups/:capturedAt', async (c) => {
|
|
1137
|
+
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
1138
|
+
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
1139
|
+
const capturedAt = c.req.param('capturedAt');
|
|
1140
|
+
if (!options.scopeBackups)
|
|
1141
|
+
return c.json({ error: 'no backup target configured' }, 501);
|
|
1142
|
+
const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
1143
|
+
if (!scope)
|
|
1144
|
+
return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
|
|
1145
|
+
const dump = await options.scopeBackups.get({ tenantId, scopeId, capturedAt });
|
|
1146
|
+
if (!dump)
|
|
1147
|
+
return c.json({ error: `no backup for scope ${scopeId} at ${capturedAt}` }, 404);
|
|
1148
|
+
return c.json(dump);
|
|
1149
|
+
});
|
|
1150
|
+
// Take a backup WITHOUT reaping — the standalone copy (a pre-migration checkpoint, an
|
|
1151
|
+
// export-to-keep). The reap route below takes its own; this is the same act made
|
|
1152
|
+
// available on its own, so "back up" is not something only a destructive path can do.
|
|
1153
|
+
app.post('/tenants/:tenantId/scopes/:scopeId/backups', async (c) => {
|
|
1154
|
+
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
1155
|
+
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
1156
|
+
const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
1157
|
+
if (!scope)
|
|
1158
|
+
return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
|
|
1159
|
+
try {
|
|
1160
|
+
return c.json(await backupScope(c, tenantId, scope), 201);
|
|
1161
|
+
}
|
|
1162
|
+
catch (e) {
|
|
1163
|
+
if (e instanceof ControlPlaneError) {
|
|
1164
|
+
return c.json({ error: e.message }, e.status);
|
|
1165
|
+
}
|
|
1166
|
+
throw e;
|
|
1167
|
+
}
|
|
1168
|
+
});
|
|
1169
|
+
// -- directory backups (#40) -----------------------------------------------
|
|
1170
|
+
// The platform's own disaster recovery, on the same store posture as the scope
|
|
1171
|
+
// backups above and deliberately on a different axis from them: a scope has ~30-day
|
|
1172
|
+
// point-in-time recovery, the directory has one Durable Object and no second copy of
|
|
1173
|
+
// the mapping that makes every scope addressable. These routes are the manual arms of
|
|
1174
|
+
// the cron phase (`backupDirectoryIfDue`) — take one now, see what is held, and the
|
|
1175
|
+
// break-glass restore. Staff-only: none is in BUILDER_ROUTES, and none is per-tenant,
|
|
1176
|
+
// because the subject of all three is every tenant at once.
|
|
1177
|
+
// What copies exist, newest first — metadata only, so this is cheap and hands out no
|
|
1178
|
+
// bytes. This is also the answer to "is the backup actually running?", which is the
|
|
1179
|
+
// question an unrehearsed backup story never has a way to ask.
|
|
1180
|
+
app.get('/directory/backups', async (c) => {
|
|
1181
|
+
if (!options.directoryBackups)
|
|
1182
|
+
return c.json({ error: 'no directory backup target configured' }, 501);
|
|
1183
|
+
return c.json(await options.directoryBackups.list());
|
|
1184
|
+
});
|
|
1185
|
+
// Take one NOW, cadence ignored — the pre-migration checkpoint an operator takes by
|
|
1186
|
+
// hand before touching the directory, and the way a fresh deployment gets its first
|
|
1187
|
+
// copy without waiting a day for the cron.
|
|
1188
|
+
app.post('/directory/backups', async (c) => {
|
|
1189
|
+
if (!options.directoryBackups)
|
|
1190
|
+
return c.json({ error: 'no directory backup target configured' }, 501);
|
|
1191
|
+
const result = await backupDirectoryIfDue({
|
|
1192
|
+
admin,
|
|
1193
|
+
store: options.directoryBackups,
|
|
1194
|
+
actor: c.get('actor'),
|
|
1195
|
+
force: true,
|
|
1196
|
+
});
|
|
1197
|
+
return c.json(result.taken, 201);
|
|
1198
|
+
});
|
|
1199
|
+
// One copy's DUMP — the restore source, and the off-platform escape hatch: an operator
|
|
1200
|
+
// who wants the directory on their own disk GETs this. The most privileged read the
|
|
1201
|
+
// control plane offers (every tenant, every hostname, every identity), so staff-only
|
|
1202
|
+
// and audited by `exportDirectory` underneath.
|
|
1203
|
+
app.get('/directory/backups/:capturedAt', async (c) => {
|
|
1204
|
+
if (!options.directoryBackups)
|
|
1205
|
+
return c.json({ error: 'no directory backup target configured' }, 501);
|
|
1206
|
+
const capturedAt = c.req.param('capturedAt');
|
|
1207
|
+
const dump = await options.directoryBackups.get({ capturedAt });
|
|
1208
|
+
if (!dump)
|
|
1209
|
+
return c.json({ error: `no directory backup at ${capturedAt}` }, 404);
|
|
1210
|
+
return c.json(dump);
|
|
1211
|
+
});
|
|
1212
|
+
// Break-glass: REPLACE the directory with a stored copy.
|
|
1213
|
+
//
|
|
1214
|
+
// Guarded by an explicit `overwrite` rather than a confirmation string, because the
|
|
1215
|
+
// dangerous case is not a slip of the fingers — it is a well-formed retry against a
|
|
1216
|
+
// control plane that has already recovered, which would silently roll the platform
|
|
1217
|
+
// back to the copy's moment and lose every tenant created since. So a directory that
|
|
1218
|
+
// still holds tenants refuses (409) unless the caller says, in the body, that
|
|
1219
|
+
// replacing them is the intent. An EMPTY directory — the actual disaster, a fresh DO
|
|
1220
|
+
// with nothing in it — needs no such ceremony.
|
|
1221
|
+
app.post('/directory/restore', async (c) => {
|
|
1222
|
+
if (!options.directoryBackups)
|
|
1223
|
+
return c.json({ error: 'no directory backup target configured' }, 501);
|
|
1224
|
+
const body = restoreDirectoryBody.parse(await c.req.json().catch(() => ({})));
|
|
1225
|
+
const dump = await options.directoryBackups.get({ capturedAt: body.capturedAt });
|
|
1226
|
+
if (!dump)
|
|
1227
|
+
return c.json({ error: `no directory backup at ${body.capturedAt}` }, 404);
|
|
1228
|
+
const actor = c.get('actor');
|
|
1229
|
+
const live = await admin.listTenants(actor, { limit: 1 });
|
|
1230
|
+
if (live.length > 0 && !body.overwrite) {
|
|
1231
|
+
return c.json({
|
|
1232
|
+
error: 'the directory is not empty — a restore REPLACES it, so anything created ' +
|
|
1233
|
+
'since this copy was taken would be lost; pass overwrite=true to confirm',
|
|
1234
|
+
}, 409);
|
|
1235
|
+
}
|
|
1236
|
+
await admin.restoreDirectory(actor, dump);
|
|
1237
|
+
return c.json({ capturedAt: dump.capturedAt, tables: dump.tables.length });
|
|
1238
|
+
});
|
|
1054
1239
|
// Reap an ARCHIVED primary scope (control-plane.md §4.4): free its DO storage —
|
|
1055
1240
|
// Cloudflare never garbage-collects a Durable Object, so a deleted app's bytes persist
|
|
1056
1241
|
// forever otherwise — while keeping the directory row as a tombstone. A POST verb, not
|
|
@@ -1064,6 +1249,9 @@ export function createControlPlaneApi(options) {
|
|
|
1064
1249
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
1065
1250
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
1066
1251
|
const actor = c.get('actor');
|
|
1252
|
+
// Body is optional so the bare `POST …/reap` every existing caller sends still
|
|
1253
|
+
// parses; `backup` tri-states on purpose (see the ordering comment below).
|
|
1254
|
+
const { backup: wantsBackup } = reapScopeBody.parse(await c.req.json().catch(() => ({})));
|
|
1067
1255
|
const scope = await admin.getScopeRecord(actor, tenantId, scopeId);
|
|
1068
1256
|
if (!scope)
|
|
1069
1257
|
return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
|
|
@@ -1082,12 +1270,41 @@ export function createControlPlaneApi(options) {
|
|
|
1082
1270
|
`unbind it before reaping (reap wipes storage and cannot be undone)`,
|
|
1083
1271
|
}, 409);
|
|
1084
1272
|
}
|
|
1273
|
+
// The recoverable copy, BEFORE any byte is wiped (#493). Ordering is the whole
|
|
1274
|
+
// guarantee: `backupScope` has to have resolved — durably stored — before
|
|
1275
|
+
// `deleteScope` runs, so a store that throws (or is missing when one was asked for)
|
|
1276
|
+
// aborts the reap with the scope intact. `backup: false` is the explicit "I accept an
|
|
1277
|
+
// unrecoverable wipe"; omitting it backs up when a store is configured and proceeds
|
|
1278
|
+
// without one when the platform has none.
|
|
1279
|
+
//
|
|
1280
|
+
// Its own try/catch, OUTSIDE the reap's: a backup failure and a reap failure are
|
|
1281
|
+
// different facts to an operator, and collapsing a dead bucket into the generic 500
|
|
1282
|
+
// would read as "the reap broke" when the scope is in fact untouched (#321's lesson).
|
|
1283
|
+
let backup = null;
|
|
1284
|
+
if (!(wantsBackup === false || (wantsBackup === undefined && !options.scopeBackups))) {
|
|
1285
|
+
try {
|
|
1286
|
+
backup = await backupScope(c, tenantId, scope);
|
|
1287
|
+
}
|
|
1288
|
+
catch (e) {
|
|
1289
|
+
if (e instanceof ControlPlaneError) {
|
|
1290
|
+
return c.json({ error: e.message }, e.status);
|
|
1291
|
+
}
|
|
1292
|
+
return c.json({
|
|
1293
|
+
error: 'backup failed — the scope was NOT reaped and its data is intact; ' +
|
|
1294
|
+
'retry, or reap with backup=false to accept an unrecoverable wipe',
|
|
1295
|
+
detail: e instanceof Error ? e.message : String(e),
|
|
1296
|
+
}, 502);
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1085
1299
|
try {
|
|
1086
1300
|
const vertical = await verticalForScope(c, scope);
|
|
1087
1301
|
if (vertical)
|
|
1088
1302
|
await vertical.deleteScope({ scopeId });
|
|
1089
|
-
await admin.reapScope(actor, tenantId, scopeId
|
|
1090
|
-
|
|
1303
|
+
await admin.reapScope(actor, tenantId, scopeId, {
|
|
1304
|
+
...(backup ? { backupRef: backupRefOf(backup) } : {}),
|
|
1305
|
+
});
|
|
1306
|
+
const reaped = await admin.getScopeRecord(actor, tenantId, scopeId);
|
|
1307
|
+
return c.json({ ...reaped, backup });
|
|
1091
1308
|
}
|
|
1092
1309
|
catch (e) {
|
|
1093
1310
|
if (e instanceof ControlPlaneError) {
|
|
@@ -1136,6 +1353,117 @@ export function createControlPlaneApi(options) {
|
|
|
1136
1353
|
throw e;
|
|
1137
1354
|
}
|
|
1138
1355
|
});
|
|
1356
|
+
// -- tenant export (#36) ----------------------------------------------------
|
|
1357
|
+
//
|
|
1358
|
+
// GDPR Art. 20 portability, and the escrow handover: one tenant, whole, in one file.
|
|
1359
|
+
//
|
|
1360
|
+
// Composed ENTIRELY from the sanctioned reads above — `listScopes`, `listOrgs`,
|
|
1361
|
+
// `listMembers`, `listRoles`, `listEntitlements`, `listIdentityLinks`,
|
|
1362
|
+
// `listHostnames`, the store ledgers, `listConnections`, `exportScope`. That is the
|
|
1363
|
+
// constraint the design puts on this route, not an implementation preference:
|
|
1364
|
+
// control-plane.md §7 says the control plane must not acquire a back door into scope
|
|
1365
|
+
// databases, and the only sanctioned path is the audited admin surface. An export
|
|
1366
|
+
// that reached past it would BE the back door — and every read here is already
|
|
1367
|
+
// K-24 access-logged, so the trail is a property of the parts.
|
|
1368
|
+
//
|
|
1369
|
+
// It is deliberately NOT the same shape as a directory dump (#40): that one is raw
|
|
1370
|
+
// tables for recovery, this one is the platform's documented vocabulary for a reader
|
|
1371
|
+
// who does not know the schema. Only the per-scope `data` is raw, because that is the
|
|
1372
|
+
// half that has to be reloadable.
|
|
1373
|
+
app.get('/tenants/:tenantId/export', async (c) => {
|
|
1374
|
+
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
1375
|
+
const actor = c.get('actor');
|
|
1376
|
+
const tenant = await admin.getTenant(actor, tenantId);
|
|
1377
|
+
if (!tenant)
|
|
1378
|
+
return c.json({ error: `unknown tenant: ${tenantId}` }, 404);
|
|
1379
|
+
// Every scope, tombstones included: an archived or reaped scope is part of the
|
|
1380
|
+
// tenant's history, and an export that quietly dropped them would misrepresent what
|
|
1381
|
+
// the tenant was. Their DATA is a different question, handled below.
|
|
1382
|
+
const scopes = await admin.listScopes(actor, { tenantId });
|
|
1383
|
+
// Residency (K-7/K-32), checked across the WHOLE tenant before anything is read: an
|
|
1384
|
+
// export lands on a machine outside the platform's control, so one pinned scope
|
|
1385
|
+
// taints the file. Refused as a unit rather than silently exporting the global
|
|
1386
|
+
// scopes and omitting the pinned ones — a partial export that does not say it is
|
|
1387
|
+
// partial is the failure mode worth avoiding.
|
|
1388
|
+
const pinned = scopes.filter((s) => s.jurisdiction !== 'global');
|
|
1389
|
+
if (pinned.length > 0) {
|
|
1390
|
+
return c.json({
|
|
1391
|
+
error: `tenant ${tenantId} has ${pinned.length} scope(s) pinned to a jurisdiction ` +
|
|
1392
|
+
`(${[...new Set(pinned.map((s) => s.jurisdiction))].join(', ')}) — an export would ` +
|
|
1393
|
+
`move that data out of the region it was promised; refused (K-32). Export the ` +
|
|
1394
|
+
`global scopes individually, or wait for a per-jurisdiction path.`,
|
|
1395
|
+
}, 403);
|
|
1396
|
+
}
|
|
1397
|
+
const full = c.req.query('full') === 'true';
|
|
1398
|
+
try {
|
|
1399
|
+
const orgs = await admin.listOrgs(actor, tenantId);
|
|
1400
|
+
// Revoked memberships included: K-21 makes a removal a tombstone precisely because
|
|
1401
|
+
// "was a member until March" is the fact an audit asks for.
|
|
1402
|
+
const members = (await Promise.all(orgs.map((o) => admin.listMembers(actor, tenantId, o.id, { includeRevoked: true })))).flat();
|
|
1403
|
+
const [roles, entitlements, identityLinks, hostnames, stores, blobStores, connections] = await Promise.all([
|
|
1404
|
+
admin.listRoles(actor, { tenantId }),
|
|
1405
|
+
admin.listEntitlements(actor, tenantId),
|
|
1406
|
+
admin.listIdentityLinks(actor, tenantId),
|
|
1407
|
+
admin.listHostnames(actor, { tenantId }),
|
|
1408
|
+
admin.listTenantStores(actor, { tenantId }),
|
|
1409
|
+
admin.listBlobStores(actor, { tenantId }),
|
|
1410
|
+
admin.listConnections(actor, { tenantId }),
|
|
1411
|
+
]);
|
|
1412
|
+
// Scope DATA, from the same delegation the per-scope export route uses: the
|
|
1413
|
+
// canonical `exportScope` writes the audit entry and is the bytes when co-located;
|
|
1414
|
+
// a vertical-held scope's real tables overlay it. A reaped scope has no storage
|
|
1415
|
+
// left to read, so it is skipped here while its RECORD stays above — the tombstone
|
|
1416
|
+
// is honest, an error would not be.
|
|
1417
|
+
const live = scopes.filter((s) => s.status !== 'reaped');
|
|
1418
|
+
const data = [];
|
|
1419
|
+
for (const scope of live) {
|
|
1420
|
+
const dump = await admin.exportScope(actor, tenantId, scope.id);
|
|
1421
|
+
const vertical = await verticalForScope(c, scope);
|
|
1422
|
+
const tables = vertical ? await vertical.exportScope(scope.id) : dump.tables;
|
|
1423
|
+
data.push({ ...dump, tables: full ? tables : maskDump(tables) });
|
|
1424
|
+
}
|
|
1425
|
+
// The admin log is FULL-only (#36): it records what STAFF did, so it is not the
|
|
1426
|
+
// customer's Art. 20 data, and it carries staff actor ids and internal action
|
|
1427
|
+
// names. An escrow or a dispute needs it, which is why break-glass reaches it
|
|
1428
|
+
// rather than nothing reaching it.
|
|
1429
|
+
const adminLog = full ? await admin.auditLog(actor, { tenantId }) : null;
|
|
1430
|
+
const body = {
|
|
1431
|
+
tenantId,
|
|
1432
|
+
capturedAt: new Date().toISOString(),
|
|
1433
|
+
masked: !full,
|
|
1434
|
+
tenant: full ? tenant : maskRecords([tenant])[0],
|
|
1435
|
+
scopes: full ? scopes : maskRecords(scopes),
|
|
1436
|
+
orgs: full ? orgs : maskRecords(orgs),
|
|
1437
|
+
members: full ? members : maskRecords(members),
|
|
1438
|
+
// Roles, entitlements and hostnames are configuration rather than personal data,
|
|
1439
|
+
// so they read the same in both fidelities — but they still go through the sweep,
|
|
1440
|
+
// because deciding per-collection what "cannot contain PII" means is exactly the
|
|
1441
|
+
// assumption that ages badly. One rule, applied everywhere.
|
|
1442
|
+
roles: full ? roles : maskRecords(roles),
|
|
1443
|
+
entitlements: full ? entitlements : maskRecords(entitlements),
|
|
1444
|
+
// Identity links are the sharpest item here: `externalId` is usually an email.
|
|
1445
|
+
identityLinks: full ? identityLinks : maskRecords(identityLinks),
|
|
1446
|
+
hostnames: full ? hostnames : maskRecords(hostnames),
|
|
1447
|
+
stores: [...stores, ...blobStores].map((s) => ({
|
|
1448
|
+
kind: s.kind,
|
|
1449
|
+
vertical: s.vertical,
|
|
1450
|
+
binding: s.binding,
|
|
1451
|
+
ref: s.ref,
|
|
1452
|
+
createdAt: s.createdAt,
|
|
1453
|
+
})),
|
|
1454
|
+
connections: full ? connections : maskRecords(connections),
|
|
1455
|
+
adminLog,
|
|
1456
|
+
data,
|
|
1457
|
+
};
|
|
1458
|
+
return c.json(body);
|
|
1459
|
+
}
|
|
1460
|
+
catch (e) {
|
|
1461
|
+
if (e instanceof ControlPlaneError) {
|
|
1462
|
+
return c.json({ error: e.message }, e.status);
|
|
1463
|
+
}
|
|
1464
|
+
throw e;
|
|
1465
|
+
}
|
|
1466
|
+
});
|
|
1139
1467
|
// The write half of the governed pull (§8) — load a dump INTO an existing scope:
|
|
1140
1468
|
// restore a backup, back out to a snapshot, or land a locally-built world on a
|
|
1141
1469
|
// hosted app. Staff-only like the export (not in BUILDER_ROUTES). No jurisdiction
|
|
@@ -1578,6 +1906,22 @@ export function createControlPlaneApi(options) {
|
|
|
1578
1906
|
const registry = json ? (storedDeployManifest.parse(JSON.parse(json)).registry ?? null) : null;
|
|
1579
1907
|
return c.json({ registry });
|
|
1580
1908
|
});
|
|
1909
|
+
// The static files (#340) one version ships: path, size, content type, content address —
|
|
1910
|
+
// read straight out of the retained manifest, which is where they were persisted for the
|
|
1911
|
+
// promote path anyway. Owner-narrowed exactly like the registry route above. `assets` is
|
|
1912
|
+
// null for a version that retained no manifest (pushed pre-#286) or shipped no static
|
|
1913
|
+
// files; the two are distinguishable by the caller only as "nothing to show", which is
|
|
1914
|
+
// all the dashboard panel needs to render an empty state.
|
|
1915
|
+
app.get('/verticals/:slug/versions/:id/assets', async (c) => {
|
|
1916
|
+
const p = c.get('principal');
|
|
1917
|
+
const slug = await resolveVerticalId(c, c.req.param('slug'));
|
|
1918
|
+
if (p.kind === 'builder' && (await ownerOf(p.actor, slug)) !== p.tenantId) {
|
|
1919
|
+
return c.json({ error: 'not found' }, 404);
|
|
1920
|
+
}
|
|
1921
|
+
const json = await admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
|
|
1922
|
+
const assets = json ? (storedDeployManifest.parse(JSON.parse(json)).assets ?? null) : null;
|
|
1923
|
+
return c.json({ assets });
|
|
1924
|
+
});
|
|
1581
1925
|
app.post('/verticals/:slug/versions/:id/admit', async (c) => {
|
|
1582
1926
|
const slug = c.req.param('slug');
|
|
1583
1927
|
const id = c.req.param('id');
|
|
@@ -1725,6 +2069,13 @@ export function createControlPlaneApi(options) {
|
|
|
1725
2069
|
modules,
|
|
1726
2070
|
doClasses: manifest.doClasses,
|
|
1727
2071
|
bindings: [...manifest.bindings, ...storeBindings],
|
|
2072
|
+
// #340: the version's static files travel with it onto the serving script — from
|
|
2073
|
+
// the RETAINED manifest, with no bytes. An asset upload session is driven by
|
|
2074
|
+
// content addresses, and the runtime's asset store is namespace-wide and deduped,
|
|
2075
|
+
// so re-declaring the same hashes re-attaches the same files. This is why the
|
|
2076
|
+
// manifest is retained rather than the bytes: the archive script gives back the
|
|
2077
|
+
// modules (#286), and the asset store gives back the assets.
|
|
2078
|
+
...(manifest.assets ? { assets: manifest.assets } : {}),
|
|
1728
2079
|
}, serving
|
|
1729
2080
|
? { priorDoClasses: serving.doClasses, priorMigrationTag: serving.migrationTag }
|
|
1730
2081
|
: undefined);
|
|
@@ -1915,21 +2266,72 @@ export function createControlPlaneApi(options) {
|
|
|
1915
2266
|
}, 409);
|
|
1916
2267
|
}
|
|
1917
2268
|
}
|
|
2269
|
+
// Two kinds of part in one body (#340): worker MODULES (unprefixed) and static ASSETS
|
|
2270
|
+
// (`asset:<served path>`). Splitting on the prefix — rather than on content type or on
|
|
2271
|
+
// whether the manifest happens to name it — is what keeps an uploaded file from being
|
|
2272
|
+
// able to enter the wrong pipeline.
|
|
1918
2273
|
const modules = [];
|
|
2274
|
+
const assetParts = new Map();
|
|
1919
2275
|
for (const [name, value] of form.entries()) {
|
|
1920
2276
|
if (name === 'manifest')
|
|
1921
2277
|
continue;
|
|
1922
|
-
if (value instanceof File)
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
});
|
|
2278
|
+
if (!(value instanceof File))
|
|
2279
|
+
continue;
|
|
2280
|
+
if (name.startsWith(ASSET_PART_PREFIX)) {
|
|
2281
|
+
assetParts.set(name.slice(ASSET_PART_PREFIX.length), value);
|
|
2282
|
+
continue;
|
|
1928
2283
|
}
|
|
2284
|
+
modules.push({
|
|
2285
|
+
name,
|
|
2286
|
+
content: new Uint8Array(await value.arrayBuffer()),
|
|
2287
|
+
contentType: value.type || 'application/javascript+module',
|
|
2288
|
+
});
|
|
1929
2289
|
}
|
|
1930
2290
|
if (!modules.some((m) => m.name === manifest.entry)) {
|
|
1931
2291
|
return c.json({ error: `entry module '${manifest.entry}' is not among the uploaded files` }, 400);
|
|
1932
2292
|
}
|
|
2293
|
+
// The static-asset half of the §4 sandbox contract (self-serve-deploy.md §4.1). The
|
|
2294
|
+
// bytes are inert — no code, no authority — so they are ACCEPTED; the content-address
|
|
2295
|
+
// is not, so it is VERIFIED. The runtime's asset store dedups by hash across the whole
|
|
2296
|
+
// dispatch namespace, which means bytes stored under a hash they do not have would let
|
|
2297
|
+
// one push decide what another vertical's identical-hash asset serves. Re-deriving the
|
|
2298
|
+
// hash here (from the received bytes, with the same `assetHash` the CLI used) is what
|
|
2299
|
+
// makes that structurally impossible — and it is the only inspection of the bytes we do.
|
|
2300
|
+
const assets = [];
|
|
2301
|
+
for (const file of manifest.assets?.files ?? []) {
|
|
2302
|
+
const part = assetParts.get(file.path);
|
|
2303
|
+
if (!part) {
|
|
2304
|
+
return c.json({ error: `asset '${file.path}' is named in the manifest but was not uploaded` }, 400);
|
|
2305
|
+
}
|
|
2306
|
+
const content = new Uint8Array(await part.arrayBuffer());
|
|
2307
|
+
if (content.byteLength !== file.size) {
|
|
2308
|
+
return c.json({ error: `asset '${file.path}' declares ${file.size} bytes but ${content.byteLength} arrived` }, 400);
|
|
2309
|
+
}
|
|
2310
|
+
const actual = await assetHash(content, file.path);
|
|
2311
|
+
if (actual !== file.hash) {
|
|
2312
|
+
return c.json({
|
|
2313
|
+
error: `asset '${file.path}' does not match its declared content hash ` +
|
|
2314
|
+
`(declared ${file.hash}, computed ${actual}) — the hash is the runtime's shared dedup key, ` +
|
|
2315
|
+
`so a mismatch is refused rather than stored`,
|
|
2316
|
+
}, 400);
|
|
2317
|
+
}
|
|
2318
|
+
assets.push({
|
|
2319
|
+
path: file.path,
|
|
2320
|
+
hash: file.hash,
|
|
2321
|
+
size: file.size,
|
|
2322
|
+
contentType: file.contentType,
|
|
2323
|
+
content,
|
|
2324
|
+
});
|
|
2325
|
+
assetParts.delete(file.path);
|
|
2326
|
+
}
|
|
2327
|
+
if (assetParts.size > 0) {
|
|
2328
|
+
// An unlisted asset part would be uploaded by nothing and served by nothing; saying so
|
|
2329
|
+
// beats silently dropping it, because the builder's page would 404 with no explanation.
|
|
2330
|
+
const extra = [...assetParts.keys()].slice(0, 5).join(', ');
|
|
2331
|
+
return c.json({
|
|
2332
|
+
error: `${assetParts.size} uploaded asset part(s) are absent from the manifest (${extra}${assetParts.size > 5 ? ', …' : ''})`,
|
|
2333
|
+
}, 400);
|
|
2334
|
+
}
|
|
1933
2335
|
// Mint the version id first: the deploymentRef (the dispatch script name) is keyed
|
|
1934
2336
|
// on it, so it is CF-valid and unique per version.
|
|
1935
2337
|
const id = ulid();
|
|
@@ -1942,6 +2344,10 @@ export function createControlPlaneApi(options) {
|
|
|
1942
2344
|
modules,
|
|
1943
2345
|
doClasses: manifest.doClasses,
|
|
1944
2346
|
bindings: manifest.bindings,
|
|
2347
|
+
// #340: the verified bytes go up with the bundle. The manifest's routing config
|
|
2348
|
+
// rides along untouched — it decides what the RUNTIME does with paths, and carries
|
|
2349
|
+
// no reach, so there is nothing in it for the sandbox contract to refuse.
|
|
2350
|
+
...(manifest.assets ? { assets: { ...manifest.assets, files: assets } } : {}),
|
|
1945
2351
|
});
|
|
1946
2352
|
}
|
|
1947
2353
|
catch (e) {
|
|
@@ -2041,16 +2447,20 @@ export function createControlPlaneApi(options) {
|
|
|
2041
2447
|
if (!options.observability) {
|
|
2042
2448
|
return c.json({ error: 'observability is not configured on this control plane' }, 501);
|
|
2043
2449
|
}
|
|
2450
|
+
// `service` repeats: one deployed unit per param, so a caller can ask for a
|
|
2451
|
+
// vertical's whole set (the dashboard's "all versions") in one query and get one
|
|
2452
|
+
// merged stream back. Capped because each extra service is another backend query.
|
|
2453
|
+
const services = (c.req.queries('service') ?? []).filter((s) => s.length > 0);
|
|
2044
2454
|
const input = z
|
|
2045
2455
|
.object({
|
|
2046
|
-
|
|
2456
|
+
services: z.array(z.string().min(1).max(200)).max(20).optional(),
|
|
2047
2457
|
level: z.enum(['log', 'info', 'warn', 'error', 'debug']).optional(),
|
|
2048
2458
|
search: z.string().min(1).max(200).optional(),
|
|
2049
2459
|
hours: z.coerce.number().int().min(1).max(72).default(1),
|
|
2050
2460
|
limit: z.coerce.number().int().min(1).max(500).default(100),
|
|
2051
2461
|
})
|
|
2052
2462
|
.parse({
|
|
2053
|
-
|
|
2463
|
+
services: services.length ? services : undefined,
|
|
2054
2464
|
level: c.req.query('level') || undefined,
|
|
2055
2465
|
search: c.req.query('search') || undefined,
|
|
2056
2466
|
hours: c.req.query('hours'),
|
|
@@ -2265,6 +2675,16 @@ export function createControlPlaneApi(options) {
|
|
|
2265
2675
|
* label is unique within a tenant, so dropping the prefix stays collision-free. Both
|
|
2266
2676
|
* create (provision + reuse-match) and delete (reap-match) run through here, so they agree. */
|
|
2267
2677
|
const previewSlug = (slug, tag) => `${slug.split('/').at(-1)}--${tag}`;
|
|
2678
|
+
/** Reap one preview: wipe the DO in its own deployment, then drop the directory row and
|
|
2679
|
+
* its hostnames. Storage-before-row, the same ordering the DELETE route uses — a crash
|
|
2680
|
+
* between the two converges on retry. Shared by that route and by the create path, which
|
|
2681
|
+
* reaps a HALF-BUILT leftover before re-forking (see `orchestratedPreview`). */
|
|
2682
|
+
const reapPreview = async (c, preview) => {
|
|
2683
|
+
const vertical = await verticalForScope(c, preview);
|
|
2684
|
+
if (vertical)
|
|
2685
|
+
await vertical.deleteScope({ scopeId: preview.id });
|
|
2686
|
+
await options.host.deleteSnapshot(c.get('actor'), preview.tenantId, preview.id);
|
|
2687
|
+
};
|
|
2268
2688
|
/** Given a base hostname `<label>.<domain>`, mint (or find) the preview's `--<tag>`
|
|
2269
2689
|
* hostname `<label>--<tag>.<domain>` bound to `previewId`. Non-canonical, so it never
|
|
2270
2690
|
* demotes the prod surface. Shared by the fork and clean-room paths. */
|
|
@@ -2302,13 +2722,22 @@ export function createControlPlaneApi(options) {
|
|
|
2302
2722
|
}
|
|
2303
2723
|
return src.hostname;
|
|
2304
2724
|
}
|
|
2305
|
-
|
|
2306
|
-
if (!base) {
|
|
2725
|
+
if (platformBaseDomains.length === 0) {
|
|
2307
2726
|
throw new ControlPlaneError(409, `no platform base domain configured — a clean-room preview has no source URL to derive from`);
|
|
2308
2727
|
}
|
|
2728
|
+
// Mint under `<label>.<jurisdiction>.<baseDomain>`, exactly as provisioning does
|
|
2729
|
+
// (provision.ts `bindDefaultHostname` → `egeryds.global.substrat.run`). A clean-room
|
|
2730
|
+
// preview scope is provisioned `global` by construction (see the caller), and the
|
|
2731
|
+
// wildcard DNS/cert lives on `*.global.substrat.run` — NOT the certless apex
|
|
2732
|
+
// `*.substrat.run`. `platformBaseDomains` lists every platform suffix for custom-hostname
|
|
2733
|
+
// detection (`substrat.run`, `global.substrat.run`, …); the registrable base is the
|
|
2734
|
+
// shortest, the one all jurisdiction domains are subdomains of. Taking `[0]` grabbed the
|
|
2735
|
+
// bare apex and stranded clean-room previews on a hostname that never resolves.
|
|
2736
|
+
const baseDomain = [...platformBaseDomains].sort((a, b) => a.length - b.length)[0];
|
|
2737
|
+
const jurisdiction = 'global';
|
|
2309
2738
|
const tenant = await admin.getTenant(actor, tenantId);
|
|
2310
2739
|
const handle = tenant?.slug ?? tenantId;
|
|
2311
|
-
return `${slug.split('/').at(-1)}-${handle}.${
|
|
2740
|
+
return `${slug.split('/').at(-1)}-${handle}.${jurisdiction}.${baseDomain}`;
|
|
2312
2741
|
};
|
|
2313
2742
|
const orchestratedPreview = async (c, tenantId, slug,
|
|
2314
2743
|
// A FORK copies this scope's data; `null` provisions an empty clean-room scope (#509 (b)).
|
|
@@ -2361,7 +2790,20 @@ export function createControlPlaneApi(options) {
|
|
|
2361
2790
|
// onto the SAME preview — successive pushes roll their migrations forward on one copy
|
|
2362
2791
|
// (§4's rehearsal case) — unless `refresh` asks for a fresh one.
|
|
2363
2792
|
const existing = (await admin.listScopes(actor, { tenantId, vertical: slug })).find((s) => s.kind === 'preview' && s.slug === previewSlug(slug, opts.tag));
|
|
2364
|
-
|
|
2793
|
+
// A preview only HAS data once its two-phase create finished: the directory row lands
|
|
2794
|
+
// first as `provisioning` (K-31), the fork's export→restore runs, and `activateScope`
|
|
2795
|
+
// is the last step. So a row still at `provisioning` is a create that DIED mid-fork —
|
|
2796
|
+
// its DO is empty. Reuse must never adopt one: reuse only rebinds the version and the
|
|
2797
|
+
// hostname, it never copies data, so adopting a half-built row hands back a
|
|
2798
|
+
// permanently EMPTY preview and reports `reused: true` — success for a URL that shows
|
|
2799
|
+
// a reviewer no data at all. That is exactly what a CI retry does (the generated
|
|
2800
|
+
// workflow retries `preview create` on a transient), so the failure mode is the
|
|
2801
|
+
// COMMON one, not a corner: attempt 1 forks and dies, attempt 2 adopts its corpse and
|
|
2802
|
+
// goes green. Instead, reap the leftover and fall through to a fresh fork below —
|
|
2803
|
+
// which is what the retry was asking for. Same for an explicit `refresh`, whose fresh
|
|
2804
|
+
// scope would otherwise collide with the old row's still-bound `--<tag>` hostname.
|
|
2805
|
+
const stale = existing !== undefined && (opts.refresh || existing.status !== 'active');
|
|
2806
|
+
if (existing && !stale) {
|
|
2365
2807
|
// Heal a preview provisioned before #527: clear any inherited serving_ref so routing
|
|
2366
2808
|
// follows the bound version (its per-version script), not the prod serving script.
|
|
2367
2809
|
if (existing.servingRef)
|
|
@@ -2373,6 +2815,10 @@ export function createControlPlaneApi(options) {
|
|
|
2373
2815
|
await assertServesBoundVersion(existing.id);
|
|
2374
2816
|
return { scopeId: existing.id, hostname, url: `https://${hostname}`, versionId: opts.versionId, reused: true };
|
|
2375
2817
|
}
|
|
2818
|
+
// Free the tag: the slug is unique per tenant and the `--<tag>` hostname is still bound
|
|
2819
|
+
// to the old row, so the fresh fork below cannot be provisioned until this one is gone.
|
|
2820
|
+
if (existing && stale)
|
|
2821
|
+
await reapPreview(c, existing);
|
|
2376
2822
|
const previewId = scopeIdSchema.parse(ulid());
|
|
2377
2823
|
if (source) {
|
|
2378
2824
|
// A fresh fork. Export from where the prod data lives TODAY. The canonical
|
|
@@ -2531,10 +2977,7 @@ export function createControlPlaneApi(options) {
|
|
|
2531
2977
|
// Storage-before-row, the same ordering as the fork hard-delete: wipe the DO in the
|
|
2532
2978
|
// PR version's deployment, then deleteSnapshot (fork-only re-check, hostnames + row,
|
|
2533
2979
|
// audit). A crash between the two converges on retry.
|
|
2534
|
-
|
|
2535
|
-
if (vertical)
|
|
2536
|
-
await vertical.deleteScope({ scopeId: preview.id });
|
|
2537
|
-
await options.host.deleteSnapshot(actor, tenantId, preview.id);
|
|
2980
|
+
await reapPreview(c, preview);
|
|
2538
2981
|
return c.json({ deleted: preview.id });
|
|
2539
2982
|
}
|
|
2540
2983
|
catch (e) {
|