@substrat-run/control-plane-api 0.49.0 → 0.51.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 +47 -0
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +368 -4
- 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/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/do-namespaces.d.ts +64 -0
- package/dist/do-namespaces.d.ts.map +1 -0
- package/dist/do-namespaces.js +101 -0
- package/dist/do-namespaces.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -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/platform-runtime.d.ts +22 -0
- package/dist/platform-runtime.d.ts.map +1 -0
- package/dist/platform-runtime.js +2 -0
- package/dist/platform-runtime.js.map +1 -0
- 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/package.json +7 -7
package/dist/api.d.ts
CHANGED
|
@@ -6,6 +6,9 @@ import type { VerticalClient } from './vertical-client.js';
|
|
|
6
6
|
import type { DeployVerticalFn, FetchVerticalModulesFn } from './deploy.js';
|
|
7
7
|
import type { PatchScriptBindingsFn } from './wfp.js';
|
|
8
8
|
import type { ObservabilityReader } from './observability.js';
|
|
9
|
+
import type { PlatformRuntime } from './platform-runtime.js';
|
|
10
|
+
import { type DoNamespaceReader } from './do-namespaces.js';
|
|
11
|
+
import type { DirectoryBackupStore, ScopeBackupStore } from './backups.js';
|
|
9
12
|
import { type CustomHostnameProvisioner } from './custom-hostnames.js';
|
|
10
13
|
export interface ControlPlaneApiOptions {
|
|
11
14
|
host: ScopeHost;
|
|
@@ -109,6 +112,50 @@ export interface ControlPlaneApiOptions {
|
|
|
109
112
|
* means forgetting that costs a feature, never a leak.
|
|
110
113
|
*/
|
|
111
114
|
observability?: ObservabilityReader;
|
|
115
|
+
/**
|
|
116
|
+
* Where this platform's compute actually runs — the coordinates a staff surface needs
|
|
117
|
+
* to hand an operator a link INTO the provider's own console (the right script, the
|
|
118
|
+
* right database, the right bucket), rather than a bare id they have to hunt for.
|
|
119
|
+
*
|
|
120
|
+
* Host-injected like `observability`, and deliberately NOT a credential: it is the
|
|
121
|
+
* account/namespace the deployment already advertises in every dispatch URL. Absent ⇒
|
|
122
|
+
* the route answers `null` and the console renders identifiers with no links, which is
|
|
123
|
+
* exactly the self-host / pure-adapter shape (no provider console to point at).
|
|
124
|
+
*/
|
|
125
|
+
platformRuntime?: PlatformRuntime;
|
|
126
|
+
/**
|
|
127
|
+
* Resolves a script's Durable Object namespaces to the ids the provider's dashboard
|
|
128
|
+
* addresses them by (`do-namespaces.ts`) — what turns "your DO is named `<scopeId>`,
|
|
129
|
+
* somewhere in this list" into a link to the right namespace. Host-injected, credential
|
|
130
|
+
* on the host side. Absent ⇒ the route 501s and the console keeps its list-level link.
|
|
131
|
+
*/
|
|
132
|
+
doNamespaces?: DoNamespaceReader;
|
|
133
|
+
/**
|
|
134
|
+
* Where a reap's recoverable copy is stored (#493) — host-injected like
|
|
135
|
+
* `observability`, so this package holds no bucket binding. When present, reaping a
|
|
136
|
+
* scope writes a full-fidelity dump here FIRST and records its ref on the admin-log
|
|
137
|
+
* entry; a store that throws aborts the reap, because a wipe with no copy is exactly
|
|
138
|
+
* what the seam exists to prevent.
|
|
139
|
+
*
|
|
140
|
+
* Absent ⇒ a reap that did not explicitly ask for a backup proceeds without one (the
|
|
141
|
+
* self-host / embedded / test shape, where there is no platform bucket), and one that
|
|
142
|
+
* DID ask is refused 501 rather than silently reaping. That asymmetry is deliberate:
|
|
143
|
+
* the console always asks, so a control plane deployed with the binding missing fails
|
|
144
|
+
* loudly instead of quietly dropping the guarantee — the lesson `PLATFORM_BASE_DOMAINS`
|
|
145
|
+
* taught when it silently went unset.
|
|
146
|
+
*/
|
|
147
|
+
scopeBackups?: ScopeBackupStore;
|
|
148
|
+
/**
|
|
149
|
+
* Where the platform's OWN copies live (#40) — the directory, not a tenant's scope.
|
|
150
|
+
* Host-injected on the same posture as `scopeBackups`, and pointable at the same
|
|
151
|
+
* bucket (the key prefixes keep the two apart) or at a different one.
|
|
152
|
+
*
|
|
153
|
+
* Absent ⇒ the directory backup routes answer 501 and the cron's backup phase is
|
|
154
|
+
* skipped. Loud, never silent: a control plane running with no directory copy is a
|
|
155
|
+
* platform one bug away from unrecoverable, and that must be visible rather than
|
|
156
|
+
* inferred from an absence of backups nobody looked for.
|
|
157
|
+
*/
|
|
158
|
+
directoryBackups?: DirectoryBackupStore;
|
|
112
159
|
/**
|
|
113
160
|
* Issues + polls Cloudflare-for-SaaS custom hostnames (#305, §4.7) — host-injected
|
|
114
161
|
* like `deployVertical`, so this package holds no Cloudflare credential (D-34). When
|
package/dist/api.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AA+B5B,OAAO,KAAK,EAGV,eAAe,
|
|
1
|
+
{"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"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"}
|
package/dist/api.js
CHANGED
|
@@ -5,10 +5,12 @@ 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 { namespacesForScript } from './do-namespaces.js';
|
|
13
|
+
import { backupDirectoryIfDue } from './directory-backup.js';
|
|
12
14
|
import { isCustomHostname, validateBindableHostname, } from './custom-hostnames.js';
|
|
13
15
|
// -- request schemas ---------------------------------------------------------
|
|
14
16
|
// Parse, don't trust: every input crosses Zod at the boundary. The ids stay
|
|
@@ -142,6 +144,31 @@ const snapshotScopeBody = z.object({
|
|
|
142
144
|
kind: z.string().min(1).optional(),
|
|
143
145
|
expiresAt: z.string().datetime({ offset: true }).optional(),
|
|
144
146
|
});
|
|
147
|
+
// A reap request (#493). `backup` is deliberately TRI-STATE, not a defaulted boolean:
|
|
148
|
+
// true — back up or refuse (the console always sends this, so a control plane
|
|
149
|
+
// deployed without a backup store fails loudly instead of quietly wiping)
|
|
150
|
+
// false — the explicit "I accept an unrecoverable wipe"
|
|
151
|
+
// undefined — back up if a store is configured, proceed without one if not, which is
|
|
152
|
+
// what keeps every pre-#493 caller (and self-host) working unchanged
|
|
153
|
+
const reapScopeBody = z.object({
|
|
154
|
+
backup: z.boolean().optional(),
|
|
155
|
+
});
|
|
156
|
+
// A directory-restore request (#40). `capturedAt` addresses the copy — there is one
|
|
157
|
+
// directory, so that is its whole address. `overwrite` is the guard against the
|
|
158
|
+
// dangerous case: replaying a restore onto a control plane that already recovered.
|
|
159
|
+
const restoreDirectoryBody = z.object({
|
|
160
|
+
capturedAt: z.string().min(1),
|
|
161
|
+
overwrite: z.boolean().optional(),
|
|
162
|
+
});
|
|
163
|
+
/**
|
|
164
|
+
* How a stored backup is named in the admin log and to callers: the route that fetches
|
|
165
|
+
* it. Store-neutral by construction — the R2 key scheme stays private to the store — and
|
|
166
|
+
* an operator reading a reap entry gets an address they can actually GET, rather than a
|
|
167
|
+
* bucket path they would have to know the platform's internals to use.
|
|
168
|
+
*/
|
|
169
|
+
function backupRefOf(b) {
|
|
170
|
+
return `/tenants/${b.tenantId}/scopes/${b.scopeId}/backups/${b.capturedAt}`;
|
|
171
|
+
}
|
|
145
172
|
// A per-PR preview request (preview-and-snapshots.md §2/§9 — the "run a new version
|
|
146
173
|
// against a fork of prod" slice). `tag` is a short DNS-safe label (`pr-123`): it names
|
|
147
174
|
// the preview both in its scope slug (`<vertical>--<tag>`) and in its hostname
|
|
@@ -391,6 +418,7 @@ export function createControlPlaneApi(options) {
|
|
|
391
418
|
app.post('/tenants/:tenantId/reap', async (c) => {
|
|
392
419
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
393
420
|
const actor = c.get('actor');
|
|
421
|
+
const { backup: wantsBackup } = reapScopeBody.parse(await c.req.json().catch(() => ({})));
|
|
394
422
|
const tenant = await admin.getTenant(actor, tenantId);
|
|
395
423
|
if (!tenant)
|
|
396
424
|
return c.json({ error: `unknown tenant: ${tenantId}` }, 404);
|
|
@@ -405,12 +433,22 @@ export function createControlPlaneApi(options) {
|
|
|
405
433
|
continue;
|
|
406
434
|
if (scope.status !== 'archived')
|
|
407
435
|
await admin.archiveScope(actor, tenantId, scope.id);
|
|
436
|
+
// A backup here is OPT-IN, the inverse of the per-scope reap's default (#493).
|
|
437
|
+
// A scope reap is operational cleanup, so leaving a copy is the safe default; a
|
|
438
|
+
// TENANT reap is the deletion of a customer, and §4.8 exists partly to serve an
|
|
439
|
+
// Art. 17 erasure — silently writing that customer's data to a bucket the reap
|
|
440
|
+
// does not clear would defeat the request it was made to satisfy. Staff who are
|
|
441
|
+
// retiring (not erasing) a tenant pass `backup: true` deliberately.
|
|
442
|
+
const backup = wantsBackup === true ? await backupScope(c, tenantId, scope) : null;
|
|
408
443
|
const vertical = await verticalForScope(c, scope);
|
|
409
444
|
if (vertical)
|
|
410
445
|
await vertical.deleteScope({ scopeId: scope.id });
|
|
411
446
|
// Tenant teardown reaps every scope and releases every name by design — force past
|
|
412
447
|
// the bound-hostname guard (which fences the interactive per-scope reap route below).
|
|
413
|
-
await admin.reapScope(actor, tenantId, scope.id, {
|
|
448
|
+
await admin.reapScope(actor, tenantId, scope.id, {
|
|
449
|
+
force: true,
|
|
450
|
+
...(backup ? { backupRef: backupRefOf(backup) } : {}),
|
|
451
|
+
});
|
|
414
452
|
}
|
|
415
453
|
await admin.reapTenant(actor, tenantId);
|
|
416
454
|
return c.json(await admin.getTenant(actor, tenantId));
|
|
@@ -438,6 +476,22 @@ export function createControlPlaneApi(options) {
|
|
|
438
476
|
await admin.revokeEntitlement(c.get('actor'), tenantId, c.req.param('key'));
|
|
439
477
|
return c.json(await admin.listEntitlements(c.get('actor'), tenantId));
|
|
440
478
|
});
|
|
479
|
+
// -- per-tenant stores (#301, #473) ----------------------------------------
|
|
480
|
+
// The two ledgers as INVENTORY — what `listTenantStores`/`listBlobStores` were always
|
|
481
|
+
// meant to answer for a staff surface: which database and which bucket hold this
|
|
482
|
+
// tenant's bytes, by the provider's own id. Read-only by construction (there is no
|
|
483
|
+
// route that mints a store; provisioning does that), and staff-only — not in
|
|
484
|
+
// BUILDER_ROUTES, because a builder asking "which D1 backs my install" is a different,
|
|
485
|
+
// owner-narrowed question than staff asking "where does this tenant live".
|
|
486
|
+
app.get('/tenants/:tenantId/stores', async (c) => {
|
|
487
|
+
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
488
|
+
const actor = c.get('actor');
|
|
489
|
+
const [tenantStores, blobStores] = await Promise.all([
|
|
490
|
+
admin.listTenantStores(actor, { tenantId }),
|
|
491
|
+
admin.listBlobStores(actor, { tenantId }),
|
|
492
|
+
]);
|
|
493
|
+
return c.json({ tenantStores, blobStores });
|
|
494
|
+
});
|
|
441
495
|
// -- identity mirror (builder-plane.md §4) ---------------------------------
|
|
442
496
|
// Builder auth (`whoami`, the CLI session reader) resolves `userId → tenants`
|
|
443
497
|
// against THIS deployment's identity directory, but the links are created at
|
|
@@ -1051,6 +1105,154 @@ export function createControlPlaneApi(options) {
|
|
|
1051
1105
|
throw e;
|
|
1052
1106
|
}
|
|
1053
1107
|
});
|
|
1108
|
+
// -- backups (#493) --------------------------------------------------------
|
|
1109
|
+
// The recoverable copy a reap leaves behind. Delegation mirrors the export route
|
|
1110
|
+
// exactly: `admin.exportScope` is the canonical call — it writes the K-24 access-log
|
|
1111
|
+
// entry, and IS the bytes when the host is co-located — and a vertical-held scope's
|
|
1112
|
+
// real tables overlay it. FULL fidelity, never masked: a masked dump cannot restore,
|
|
1113
|
+
// and a backup that cannot restore is a false promise (see `backups.ts`).
|
|
1114
|
+
const backupScope = async (c, tenantId, scope) => {
|
|
1115
|
+
const store = options.scopeBackups;
|
|
1116
|
+
if (!store) {
|
|
1117
|
+
throw new ControlPlaneError(501, 'no backup target configured — this control plane cannot store a scope backup ' +
|
|
1118
|
+
'(bind one, or reap with backup=false to accept an unrecoverable wipe)');
|
|
1119
|
+
}
|
|
1120
|
+
// Residency (K-7/K-32): the platform bucket is global, so writing a jurisdiction-
|
|
1121
|
+
// pinned scope's bytes into it would move them out of the region the scope was
|
|
1122
|
+
// promised. Refuse rather than back up to the wrong place — and, because the reap
|
|
1123
|
+
// aborts with it, rather than wipe a scope we cannot legally copy.
|
|
1124
|
+
if (scope.jurisdiction !== 'global') {
|
|
1125
|
+
throw new ControlPlaneError(409, `scope ${scope.id} is pinned to '${scope.jurisdiction}' — the platform backup ` +
|
|
1126
|
+
`store is global, so a backup would move its data out of that jurisdiction ` +
|
|
1127
|
+
`(K-32); refused until a per-jurisdiction store exists`);
|
|
1128
|
+
}
|
|
1129
|
+
const dump = await admin.exportScope(c.get('actor'), tenantId, scope.id);
|
|
1130
|
+
const vertical = await verticalForScope(c, scope);
|
|
1131
|
+
const tables = vertical ? await vertical.exportScope(scope.id) : dump.tables;
|
|
1132
|
+
return store.put({ vertical: scope.vertical, dump: { ...dump, tables } });
|
|
1133
|
+
};
|
|
1134
|
+
// The copies held for one scope — metadata only, so listing a reaped scope's backups
|
|
1135
|
+
// is cheap and hands out no bytes. Readable AFTER the reap (that is the point): the
|
|
1136
|
+
// directory row survives as a tombstone, and this is what tells the operator a
|
|
1137
|
+
// recoverable copy exists and when it was taken.
|
|
1138
|
+
app.get('/tenants/:tenantId/scopes/:scopeId/backups', async (c) => {
|
|
1139
|
+
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
1140
|
+
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
1141
|
+
if (!options.scopeBackups)
|
|
1142
|
+
return c.json({ error: 'no backup target configured' }, 501);
|
|
1143
|
+
// Reads the directory, not the store, for the tenant cross-check: the store is keyed
|
|
1144
|
+
// by (tenant, scope) but nothing there proves the caller's tenant owns the scope.
|
|
1145
|
+
const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
1146
|
+
if (!scope)
|
|
1147
|
+
return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
|
|
1148
|
+
return c.json(await options.scopeBackups.list({ tenantId, scopeId }));
|
|
1149
|
+
});
|
|
1150
|
+
// One backup's DUMP — the restore source. Staff-only like the export it came from
|
|
1151
|
+
// (not in BUILDER_ROUTES), and full-fidelity, so it is the same governed-pull posture:
|
|
1152
|
+
// K-3 cross-checked above, and `POST …/restore` is where it goes back.
|
|
1153
|
+
app.get('/tenants/:tenantId/scopes/:scopeId/backups/:capturedAt', async (c) => {
|
|
1154
|
+
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
1155
|
+
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
1156
|
+
const capturedAt = c.req.param('capturedAt');
|
|
1157
|
+
if (!options.scopeBackups)
|
|
1158
|
+
return c.json({ error: 'no backup target configured' }, 501);
|
|
1159
|
+
const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
1160
|
+
if (!scope)
|
|
1161
|
+
return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
|
|
1162
|
+
const dump = await options.scopeBackups.get({ tenantId, scopeId, capturedAt });
|
|
1163
|
+
if (!dump)
|
|
1164
|
+
return c.json({ error: `no backup for scope ${scopeId} at ${capturedAt}` }, 404);
|
|
1165
|
+
return c.json(dump);
|
|
1166
|
+
});
|
|
1167
|
+
// Take a backup WITHOUT reaping — the standalone copy (a pre-migration checkpoint, an
|
|
1168
|
+
// export-to-keep). The reap route below takes its own; this is the same act made
|
|
1169
|
+
// available on its own, so "back up" is not something only a destructive path can do.
|
|
1170
|
+
app.post('/tenants/:tenantId/scopes/:scopeId/backups', async (c) => {
|
|
1171
|
+
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
1172
|
+
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
1173
|
+
const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
|
|
1174
|
+
if (!scope)
|
|
1175
|
+
return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
|
|
1176
|
+
try {
|
|
1177
|
+
return c.json(await backupScope(c, tenantId, scope), 201);
|
|
1178
|
+
}
|
|
1179
|
+
catch (e) {
|
|
1180
|
+
if (e instanceof ControlPlaneError) {
|
|
1181
|
+
return c.json({ error: e.message }, e.status);
|
|
1182
|
+
}
|
|
1183
|
+
throw e;
|
|
1184
|
+
}
|
|
1185
|
+
});
|
|
1186
|
+
// -- directory backups (#40) -----------------------------------------------
|
|
1187
|
+
// The platform's own disaster recovery, on the same store posture as the scope
|
|
1188
|
+
// backups above and deliberately on a different axis from them: a scope has ~30-day
|
|
1189
|
+
// point-in-time recovery, the directory has one Durable Object and no second copy of
|
|
1190
|
+
// the mapping that makes every scope addressable. These routes are the manual arms of
|
|
1191
|
+
// the cron phase (`backupDirectoryIfDue`) — take one now, see what is held, and the
|
|
1192
|
+
// break-glass restore. Staff-only: none is in BUILDER_ROUTES, and none is per-tenant,
|
|
1193
|
+
// because the subject of all three is every tenant at once.
|
|
1194
|
+
// What copies exist, newest first — metadata only, so this is cheap and hands out no
|
|
1195
|
+
// bytes. This is also the answer to "is the backup actually running?", which is the
|
|
1196
|
+
// question an unrehearsed backup story never has a way to ask.
|
|
1197
|
+
app.get('/directory/backups', async (c) => {
|
|
1198
|
+
if (!options.directoryBackups)
|
|
1199
|
+
return c.json({ error: 'no directory backup target configured' }, 501);
|
|
1200
|
+
return c.json(await options.directoryBackups.list());
|
|
1201
|
+
});
|
|
1202
|
+
// Take one NOW, cadence ignored — the pre-migration checkpoint an operator takes by
|
|
1203
|
+
// hand before touching the directory, and the way a fresh deployment gets its first
|
|
1204
|
+
// copy without waiting a day for the cron.
|
|
1205
|
+
app.post('/directory/backups', async (c) => {
|
|
1206
|
+
if (!options.directoryBackups)
|
|
1207
|
+
return c.json({ error: 'no directory backup target configured' }, 501);
|
|
1208
|
+
const result = await backupDirectoryIfDue({
|
|
1209
|
+
admin,
|
|
1210
|
+
store: options.directoryBackups,
|
|
1211
|
+
actor: c.get('actor'),
|
|
1212
|
+
force: true,
|
|
1213
|
+
});
|
|
1214
|
+
return c.json(result.taken, 201);
|
|
1215
|
+
});
|
|
1216
|
+
// One copy's DUMP — the restore source, and the off-platform escape hatch: an operator
|
|
1217
|
+
// who wants the directory on their own disk GETs this. The most privileged read the
|
|
1218
|
+
// control plane offers (every tenant, every hostname, every identity), so staff-only
|
|
1219
|
+
// and audited by `exportDirectory` underneath.
|
|
1220
|
+
app.get('/directory/backups/:capturedAt', async (c) => {
|
|
1221
|
+
if (!options.directoryBackups)
|
|
1222
|
+
return c.json({ error: 'no directory backup target configured' }, 501);
|
|
1223
|
+
const capturedAt = c.req.param('capturedAt');
|
|
1224
|
+
const dump = await options.directoryBackups.get({ capturedAt });
|
|
1225
|
+
if (!dump)
|
|
1226
|
+
return c.json({ error: `no directory backup at ${capturedAt}` }, 404);
|
|
1227
|
+
return c.json(dump);
|
|
1228
|
+
});
|
|
1229
|
+
// Break-glass: REPLACE the directory with a stored copy.
|
|
1230
|
+
//
|
|
1231
|
+
// Guarded by an explicit `overwrite` rather than a confirmation string, because the
|
|
1232
|
+
// dangerous case is not a slip of the fingers — it is a well-formed retry against a
|
|
1233
|
+
// control plane that has already recovered, which would silently roll the platform
|
|
1234
|
+
// back to the copy's moment and lose every tenant created since. So a directory that
|
|
1235
|
+
// still holds tenants refuses (409) unless the caller says, in the body, that
|
|
1236
|
+
// replacing them is the intent. An EMPTY directory — the actual disaster, a fresh DO
|
|
1237
|
+
// with nothing in it — needs no such ceremony.
|
|
1238
|
+
app.post('/directory/restore', async (c) => {
|
|
1239
|
+
if (!options.directoryBackups)
|
|
1240
|
+
return c.json({ error: 'no directory backup target configured' }, 501);
|
|
1241
|
+
const body = restoreDirectoryBody.parse(await c.req.json().catch(() => ({})));
|
|
1242
|
+
const dump = await options.directoryBackups.get({ capturedAt: body.capturedAt });
|
|
1243
|
+
if (!dump)
|
|
1244
|
+
return c.json({ error: `no directory backup at ${body.capturedAt}` }, 404);
|
|
1245
|
+
const actor = c.get('actor');
|
|
1246
|
+
const live = await admin.listTenants(actor, { limit: 1 });
|
|
1247
|
+
if (live.length > 0 && !body.overwrite) {
|
|
1248
|
+
return c.json({
|
|
1249
|
+
error: 'the directory is not empty — a restore REPLACES it, so anything created ' +
|
|
1250
|
+
'since this copy was taken would be lost; pass overwrite=true to confirm',
|
|
1251
|
+
}, 409);
|
|
1252
|
+
}
|
|
1253
|
+
await admin.restoreDirectory(actor, dump);
|
|
1254
|
+
return c.json({ capturedAt: dump.capturedAt, tables: dump.tables.length });
|
|
1255
|
+
});
|
|
1054
1256
|
// Reap an ARCHIVED primary scope (control-plane.md §4.4): free its DO storage —
|
|
1055
1257
|
// Cloudflare never garbage-collects a Durable Object, so a deleted app's bytes persist
|
|
1056
1258
|
// forever otherwise — while keeping the directory row as a tombstone. A POST verb, not
|
|
@@ -1064,6 +1266,9 @@ export function createControlPlaneApi(options) {
|
|
|
1064
1266
|
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
1065
1267
|
const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
|
|
1066
1268
|
const actor = c.get('actor');
|
|
1269
|
+
// Body is optional so the bare `POST …/reap` every existing caller sends still
|
|
1270
|
+
// parses; `backup` tri-states on purpose (see the ordering comment below).
|
|
1271
|
+
const { backup: wantsBackup } = reapScopeBody.parse(await c.req.json().catch(() => ({})));
|
|
1067
1272
|
const scope = await admin.getScopeRecord(actor, tenantId, scopeId);
|
|
1068
1273
|
if (!scope)
|
|
1069
1274
|
return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
|
|
@@ -1082,12 +1287,41 @@ export function createControlPlaneApi(options) {
|
|
|
1082
1287
|
`unbind it before reaping (reap wipes storage and cannot be undone)`,
|
|
1083
1288
|
}, 409);
|
|
1084
1289
|
}
|
|
1290
|
+
// The recoverable copy, BEFORE any byte is wiped (#493). Ordering is the whole
|
|
1291
|
+
// guarantee: `backupScope` has to have resolved — durably stored — before
|
|
1292
|
+
// `deleteScope` runs, so a store that throws (or is missing when one was asked for)
|
|
1293
|
+
// aborts the reap with the scope intact. `backup: false` is the explicit "I accept an
|
|
1294
|
+
// unrecoverable wipe"; omitting it backs up when a store is configured and proceeds
|
|
1295
|
+
// without one when the platform has none.
|
|
1296
|
+
//
|
|
1297
|
+
// Its own try/catch, OUTSIDE the reap's: a backup failure and a reap failure are
|
|
1298
|
+
// different facts to an operator, and collapsing a dead bucket into the generic 500
|
|
1299
|
+
// would read as "the reap broke" when the scope is in fact untouched (#321's lesson).
|
|
1300
|
+
let backup = null;
|
|
1301
|
+
if (!(wantsBackup === false || (wantsBackup === undefined && !options.scopeBackups))) {
|
|
1302
|
+
try {
|
|
1303
|
+
backup = await backupScope(c, tenantId, scope);
|
|
1304
|
+
}
|
|
1305
|
+
catch (e) {
|
|
1306
|
+
if (e instanceof ControlPlaneError) {
|
|
1307
|
+
return c.json({ error: e.message }, e.status);
|
|
1308
|
+
}
|
|
1309
|
+
return c.json({
|
|
1310
|
+
error: 'backup failed — the scope was NOT reaped and its data is intact; ' +
|
|
1311
|
+
'retry, or reap with backup=false to accept an unrecoverable wipe',
|
|
1312
|
+
detail: e instanceof Error ? e.message : String(e),
|
|
1313
|
+
}, 502);
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1085
1316
|
try {
|
|
1086
1317
|
const vertical = await verticalForScope(c, scope);
|
|
1087
1318
|
if (vertical)
|
|
1088
1319
|
await vertical.deleteScope({ scopeId });
|
|
1089
|
-
await admin.reapScope(actor, tenantId, scopeId
|
|
1090
|
-
|
|
1320
|
+
await admin.reapScope(actor, tenantId, scopeId, {
|
|
1321
|
+
...(backup ? { backupRef: backupRefOf(backup) } : {}),
|
|
1322
|
+
});
|
|
1323
|
+
const reaped = await admin.getScopeRecord(actor, tenantId, scopeId);
|
|
1324
|
+
return c.json({ ...reaped, backup });
|
|
1091
1325
|
}
|
|
1092
1326
|
catch (e) {
|
|
1093
1327
|
if (e instanceof ControlPlaneError) {
|
|
@@ -1136,6 +1370,117 @@ export function createControlPlaneApi(options) {
|
|
|
1136
1370
|
throw e;
|
|
1137
1371
|
}
|
|
1138
1372
|
});
|
|
1373
|
+
// -- tenant export (#36) ----------------------------------------------------
|
|
1374
|
+
//
|
|
1375
|
+
// GDPR Art. 20 portability, and the escrow handover: one tenant, whole, in one file.
|
|
1376
|
+
//
|
|
1377
|
+
// Composed ENTIRELY from the sanctioned reads above — `listScopes`, `listOrgs`,
|
|
1378
|
+
// `listMembers`, `listRoles`, `listEntitlements`, `listIdentityLinks`,
|
|
1379
|
+
// `listHostnames`, the store ledgers, `listConnections`, `exportScope`. That is the
|
|
1380
|
+
// constraint the design puts on this route, not an implementation preference:
|
|
1381
|
+
// control-plane.md §7 says the control plane must not acquire a back door into scope
|
|
1382
|
+
// databases, and the only sanctioned path is the audited admin surface. An export
|
|
1383
|
+
// that reached past it would BE the back door — and every read here is already
|
|
1384
|
+
// K-24 access-logged, so the trail is a property of the parts.
|
|
1385
|
+
//
|
|
1386
|
+
// It is deliberately NOT the same shape as a directory dump (#40): that one is raw
|
|
1387
|
+
// tables for recovery, this one is the platform's documented vocabulary for a reader
|
|
1388
|
+
// who does not know the schema. Only the per-scope `data` is raw, because that is the
|
|
1389
|
+
// half that has to be reloadable.
|
|
1390
|
+
app.get('/tenants/:tenantId/export', async (c) => {
|
|
1391
|
+
const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
|
|
1392
|
+
const actor = c.get('actor');
|
|
1393
|
+
const tenant = await admin.getTenant(actor, tenantId);
|
|
1394
|
+
if (!tenant)
|
|
1395
|
+
return c.json({ error: `unknown tenant: ${tenantId}` }, 404);
|
|
1396
|
+
// Every scope, tombstones included: an archived or reaped scope is part of the
|
|
1397
|
+
// tenant's history, and an export that quietly dropped them would misrepresent what
|
|
1398
|
+
// the tenant was. Their DATA is a different question, handled below.
|
|
1399
|
+
const scopes = await admin.listScopes(actor, { tenantId });
|
|
1400
|
+
// Residency (K-7/K-32), checked across the WHOLE tenant before anything is read: an
|
|
1401
|
+
// export lands on a machine outside the platform's control, so one pinned scope
|
|
1402
|
+
// taints the file. Refused as a unit rather than silently exporting the global
|
|
1403
|
+
// scopes and omitting the pinned ones — a partial export that does not say it is
|
|
1404
|
+
// partial is the failure mode worth avoiding.
|
|
1405
|
+
const pinned = scopes.filter((s) => s.jurisdiction !== 'global');
|
|
1406
|
+
if (pinned.length > 0) {
|
|
1407
|
+
return c.json({
|
|
1408
|
+
error: `tenant ${tenantId} has ${pinned.length} scope(s) pinned to a jurisdiction ` +
|
|
1409
|
+
`(${[...new Set(pinned.map((s) => s.jurisdiction))].join(', ')}) — an export would ` +
|
|
1410
|
+
`move that data out of the region it was promised; refused (K-32). Export the ` +
|
|
1411
|
+
`global scopes individually, or wait for a per-jurisdiction path.`,
|
|
1412
|
+
}, 403);
|
|
1413
|
+
}
|
|
1414
|
+
const full = c.req.query('full') === 'true';
|
|
1415
|
+
try {
|
|
1416
|
+
const orgs = await admin.listOrgs(actor, tenantId);
|
|
1417
|
+
// Revoked memberships included: K-21 makes a removal a tombstone precisely because
|
|
1418
|
+
// "was a member until March" is the fact an audit asks for.
|
|
1419
|
+
const members = (await Promise.all(orgs.map((o) => admin.listMembers(actor, tenantId, o.id, { includeRevoked: true })))).flat();
|
|
1420
|
+
const [roles, entitlements, identityLinks, hostnames, stores, blobStores, connections] = await Promise.all([
|
|
1421
|
+
admin.listRoles(actor, { tenantId }),
|
|
1422
|
+
admin.listEntitlements(actor, tenantId),
|
|
1423
|
+
admin.listIdentityLinks(actor, tenantId),
|
|
1424
|
+
admin.listHostnames(actor, { tenantId }),
|
|
1425
|
+
admin.listTenantStores(actor, { tenantId }),
|
|
1426
|
+
admin.listBlobStores(actor, { tenantId }),
|
|
1427
|
+
admin.listConnections(actor, { tenantId }),
|
|
1428
|
+
]);
|
|
1429
|
+
// Scope DATA, from the same delegation the per-scope export route uses: the
|
|
1430
|
+
// canonical `exportScope` writes the audit entry and is the bytes when co-located;
|
|
1431
|
+
// a vertical-held scope's real tables overlay it. A reaped scope has no storage
|
|
1432
|
+
// left to read, so it is skipped here while its RECORD stays above — the tombstone
|
|
1433
|
+
// is honest, an error would not be.
|
|
1434
|
+
const live = scopes.filter((s) => s.status !== 'reaped');
|
|
1435
|
+
const data = [];
|
|
1436
|
+
for (const scope of live) {
|
|
1437
|
+
const dump = await admin.exportScope(actor, tenantId, scope.id);
|
|
1438
|
+
const vertical = await verticalForScope(c, scope);
|
|
1439
|
+
const tables = vertical ? await vertical.exportScope(scope.id) : dump.tables;
|
|
1440
|
+
data.push({ ...dump, tables: full ? tables : maskDump(tables) });
|
|
1441
|
+
}
|
|
1442
|
+
// The admin log is FULL-only (#36): it records what STAFF did, so it is not the
|
|
1443
|
+
// customer's Art. 20 data, and it carries staff actor ids and internal action
|
|
1444
|
+
// names. An escrow or a dispute needs it, which is why break-glass reaches it
|
|
1445
|
+
// rather than nothing reaching it.
|
|
1446
|
+
const adminLog = full ? await admin.auditLog(actor, { tenantId }) : null;
|
|
1447
|
+
const body = {
|
|
1448
|
+
tenantId,
|
|
1449
|
+
capturedAt: new Date().toISOString(),
|
|
1450
|
+
masked: !full,
|
|
1451
|
+
tenant: full ? tenant : maskRecords([tenant])[0],
|
|
1452
|
+
scopes: full ? scopes : maskRecords(scopes),
|
|
1453
|
+
orgs: full ? orgs : maskRecords(orgs),
|
|
1454
|
+
members: full ? members : maskRecords(members),
|
|
1455
|
+
// Roles, entitlements and hostnames are configuration rather than personal data,
|
|
1456
|
+
// so they read the same in both fidelities — but they still go through the sweep,
|
|
1457
|
+
// because deciding per-collection what "cannot contain PII" means is exactly the
|
|
1458
|
+
// assumption that ages badly. One rule, applied everywhere.
|
|
1459
|
+
roles: full ? roles : maskRecords(roles),
|
|
1460
|
+
entitlements: full ? entitlements : maskRecords(entitlements),
|
|
1461
|
+
// Identity links are the sharpest item here: `externalId` is usually an email.
|
|
1462
|
+
identityLinks: full ? identityLinks : maskRecords(identityLinks),
|
|
1463
|
+
hostnames: full ? hostnames : maskRecords(hostnames),
|
|
1464
|
+
stores: [...stores, ...blobStores].map((s) => ({
|
|
1465
|
+
kind: s.kind,
|
|
1466
|
+
vertical: s.vertical,
|
|
1467
|
+
binding: s.binding,
|
|
1468
|
+
ref: s.ref,
|
|
1469
|
+
createdAt: s.createdAt,
|
|
1470
|
+
})),
|
|
1471
|
+
connections: full ? connections : maskRecords(connections),
|
|
1472
|
+
adminLog,
|
|
1473
|
+
data,
|
|
1474
|
+
};
|
|
1475
|
+
return c.json(body);
|
|
1476
|
+
}
|
|
1477
|
+
catch (e) {
|
|
1478
|
+
if (e instanceof ControlPlaneError) {
|
|
1479
|
+
return c.json({ error: e.message }, e.status);
|
|
1480
|
+
}
|
|
1481
|
+
throw e;
|
|
1482
|
+
}
|
|
1483
|
+
});
|
|
1139
1484
|
// The write half of the governed pull (§8) — load a dump INTO an existing scope:
|
|
1140
1485
|
// restore a backup, back out to a snapshot, or land a locally-built world on a
|
|
1141
1486
|
// hosted app. Staff-only like the export (not in BUILDER_ROUTES). No jurisdiction
|
|
@@ -2101,6 +2446,25 @@ export function createControlPlaneApi(options) {
|
|
|
2101
2446
|
const version = (await admin.listVersions(c.get('actor'), slug)).find((v) => v.id === id);
|
|
2102
2447
|
return c.json({ ...version, ...(warnings.length ? { warnings } : {}) }, 201);
|
|
2103
2448
|
});
|
|
2449
|
+
// -- where this platform runs (ops ergonomics) -----------------------------
|
|
2450
|
+
// The console renders refs it already has — `servingRef`, a store's `ref`, a version's
|
|
2451
|
+
// `deploymentRef` — and this is the one thing it cannot derive: which account and which
|
|
2452
|
+
// dispatch namespace those resolve in. Answering `null` (not 501) is deliberate: an
|
|
2453
|
+
// unconfigured runtime is the ordinary self-host shape, and the console degrades to
|
|
2454
|
+
// plain identifiers rather than treating it as a failure. No credential crosses here.
|
|
2455
|
+
app.get('/platform/runtime', (c) => c.json(options.platformRuntime ?? null));
|
|
2456
|
+
// Which Durable Object namespaces one script defines — the id the dashboard addresses a
|
|
2457
|
+
// namespace by, which nothing else in the platform record carries. Narrowed to the asked-
|
|
2458
|
+
// for script SERVER-side: the account-wide listing is one row per pushed script and has no
|
|
2459
|
+
// business crossing to a browser. 501 (not an empty list) when no reader is configured,
|
|
2460
|
+
// because "no namespaces in that script" and "I cannot look" are different answers.
|
|
2461
|
+
app.get('/platform/do-namespaces', async (c) => {
|
|
2462
|
+
if (!options.doNamespaces) {
|
|
2463
|
+
return c.json({ error: 'durable-object namespace lookup is not configured on this control plane' }, 501);
|
|
2464
|
+
}
|
|
2465
|
+
const { script } = z.object({ script: z.string().min(1).max(200) }).parse({ script: c.req.query('script') });
|
|
2466
|
+
return c.json(namespacesForScript(await options.doNamespaces.list(), script));
|
|
2467
|
+
});
|
|
2104
2468
|
// -- observability (design/observability.md §4.1) --------------------------
|
|
2105
2469
|
// Proxied Cloudflare-native reads: the console's fleet view and (later, owner-
|
|
2106
2470
|
// narrowed) the dashboard's builder view. STAFF-ONLY — not in BUILDER_ROUTES; see
|