@substrat-run/control-plane-api 0.39.0 → 0.41.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.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;AA2B5B,OAAO,KAAK,EAAE,eAAe,EAA4B,MAAM,yBAAyB,CAAC;AACzF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAGtD,OAAO,KAAK,EAAE,iBAAiB,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAC3E,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAe3D,OAAO,KAAK,EAAE,gBAAgB,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAC;AAC5E,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,UAAU,CAAC;AAGtD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAC9D,OAAO,EAGL,KAAK,yBAAyB,EAC/B,MAAM,uBAAuB,CAAC;AAE/B,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,SAAS,CAAC;IAChB;;;;;;;OAOG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;IAC3C;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,eAAe,KAAK,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC,CAAC;IAChG;;;;;;;;;OASG;IACH,sBAAsB,CAAC,EAAE,CACvB,IAAI,EAAE,MAAM,EACZ,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,eAAe,KACnB,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC,CAAC;IACzC;;;;;;;OAOG;IACH,kBAAkB,CAAC,EAAE,CAAC,aAAa,EAAE,MAAM,KAAK,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC,CAAC;IACpF;;;;;OAKG;IACH,cAAc,CAAC,EAAE,gBAAgB,CAAC;IAClC;;;;;OAKG;IACH,oBAAoB,CAAC,EAAE,sBAAsB,CAAC;IAC9C;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,qBAAqB,CAAC;IAC5C;;;;;;;;OAQG;IACH,sBAAsB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3C;;;;OAIG;IACH,YAAY,EAAE,iBAAiB,CAAC;IAChC;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,WAAW,CAAC;IAClC;;;;;OAKG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;;;;OAQG;IACH,aAAa,CAAC,EAAE,mBAAmB,CAAC;IACpC;;;;;;;OAOG;IACH,iBAAiB,CAAC,EAAE,yBAAyB,CAAC;IAC9C;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,MAAM,EAAE,CAAC;CAChC;AAKD,KAAK,IAAI,GAAG;IAAE,KAAK,EAAE,eAAe,CAAC;IAAC,SAAS,EAAE,SAAS,CAAA;CAAE,CAAC;AA6K7D;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,sBAAsB,GAAG,IAAI,CAAC;IAAE,SAAS,EAAE,IAAI,CAAA;CAAE,CAAC,CAi0EhG"}
1
+ {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AA6B5B,OAAO,KAAK,EAGV,eAAe,EAIhB,MAAM,yBAAyB,CAAC;AACjC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAGtD,OAAO,KAAK,EAAE,iBAAiB,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAC3E,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAe3D,OAAO,KAAK,EAAE,gBAAgB,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAC;AAC5E,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,UAAU,CAAC;AAQtD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAC9D,OAAO,EAGL,KAAK,yBAAyB,EAC/B,MAAM,uBAAuB,CAAC;AAE/B,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,SAAS,CAAC;IAChB;;;;;;;OAOG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;IAC3C;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,eAAe,KAAK,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC,CAAC;IAChG;;;;;;;;;OASG;IACH,sBAAsB,CAAC,EAAE,CACvB,IAAI,EAAE,MAAM,EACZ,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,eAAe,KACnB,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC,CAAC;IACzC;;;;;;;OAOG;IACH,kBAAkB,CAAC,EAAE,CAAC,aAAa,EAAE,MAAM,KAAK,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC,CAAC;IACpF;;;;;OAKG;IACH,cAAc,CAAC,EAAE,gBAAgB,CAAC;IAClC;;;;;OAKG;IACH,oBAAoB,CAAC,EAAE,sBAAsB,CAAC;IAC9C;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,qBAAqB,CAAC;IAC5C;;;;;;;;OAQG;IACH,sBAAsB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3C;;;;OAIG;IACH,YAAY,EAAE,iBAAiB,CAAC;IAChC;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,WAAW,CAAC;IAClC;;;;;OAKG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;;;;OAQG;IACH,aAAa,CAAC,EAAE,mBAAmB,CAAC;IACpC;;;;;;;OAOG;IACH,iBAAiB,CAAC,EAAE,yBAAyB,CAAC;IAC9C;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,MAAM,EAAE,CAAC;CAChC;AAKD,KAAK,IAAI,GAAG;IAAE,KAAK,EAAE,eAAe,CAAC;IAAC,SAAS,EAAE,SAAS,CAAA;CAAE,CAAC;AAsM7D;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,sBAAsB,GAAG,IAAI,CAAC;IAAE,SAAS,EAAE,IAAI,CAAA;CAAE,CAAC,CA02EhG"}
package/dist/api.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Hono } from 'hono';
2
- import { adminAction, channelName, createTenantInput, entitlementGrantInput, hostname as hostnameSchema, hostnameRegion, hostnameStatus, identityLink, 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, 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';
@@ -7,7 +7,7 @@ import { provisionSiblingScope } from './platform-drain.js';
7
7
  import { mapError } from './errors.js';
8
8
  import { maskDump } from './mask.js';
9
9
  import { assertSandboxContract, deployManifest, storedDeployManifest, deploymentRefFor, stableDeploymentRefFor, nextMigrationTag, upstreamStatusOf, } from './deploy.js';
10
- import { collectTenantStoreHandles, tenantStoreBindings } from './tenant-stores.js';
10
+ import { blobStoreBindings, collectBlobStoreHandles, collectTenantStoreHandles, tenantStoreBindings, } from './tenant-stores.js';
11
11
  import { mintPushToken, pushActorFor } from './push-token.js';
12
12
  import { isCustomHostname, validateBindableHostname, } from './custom-hostnames.js';
13
13
  // -- request schemas ---------------------------------------------------------
@@ -70,6 +70,29 @@ const listHostnamesQuery = z.object({
70
70
  tenantId: tenantIdSchema.optional(),
71
71
  scopeId: scopeIdSchema.optional(),
72
72
  });
73
+ // Every GET list below shares ONE pagination convention (contracts pagination.ts):
74
+ // keyset over the list's own sort key, `limit` DEFAULTED at this egress (the kernel
75
+ // reads stay unbounded for in-process callers — the admin-log precedent, generalized),
76
+ // `{ entries, nextCursor }` out. The cursor is the last entry's sort key verbatim.
77
+ const pageParams = (c) => listPageQuery.parse({
78
+ limit: c.req.query('limit'),
79
+ cursor: c.req.query('cursor'),
80
+ order: c.req.query('order'),
81
+ });
82
+ /**
83
+ * Page an already-materialized asc-sorted list — for the one read whose principal
84
+ * narrowing runs ABOVE the adapter (`GET /verticals`), where a pushed-down page
85
+ * would terminate early. Same cursor semantics as the keyset reads.
86
+ */
87
+ const pageSlice = (rows, page, key) => {
88
+ const found = page.cursor === undefined ? 0 : rows.findIndex((r) => key(r) > page.cursor);
89
+ const from = found === -1 ? rows.length : found;
90
+ const entries = rows.slice(from, from + page.limit);
91
+ return {
92
+ entries,
93
+ nextCursor: from + page.limit < rows.length ? key(entries[entries.length - 1]) : null,
94
+ };
95
+ };
73
96
  /** Repeatable query params arrive as `?status=active&status=suspended`. */
74
97
  const listScopesQuery = z.object({
75
98
  tenantId: tenantIdSchema.optional(),
@@ -151,15 +174,14 @@ const auditLogQuery = z.object({
151
174
  action: z.array(adminAction).optional(),
152
175
  since: z.string().optional(),
153
176
  until: z.string().optional(),
154
- // Defaulted, not merely capped: the admin log is append-only and never swept (the
155
- // retention decision — it is the compliance witness, control-plane.md §4.4/§4.8), so
156
- // it only grows. An unbounded `GET /admin-log` would dump the whole table; a default
157
- // page keeps the external read bounded while `nextCursor` still walks the entire log.
158
- // The KERNEL call stays deliberately unbounded (an in-process caller that wants
159
- // everything asks for everything) only this HTTP egress vector is bounded by default.
160
- limit: z.coerce.number().int().positive().max(1000).default(200),
161
- cursor: z.string().optional(),
162
- order: z.enum(['asc', 'desc']).optional(),
177
+ // The shared page fields (contracts pagination.ts). Defaulted, not merely capped:
178
+ // the admin log is append-only and never swept (the retention decision — it is the
179
+ // compliance witness, control-plane.md §4.4/§4.8), so it only grows. An unbounded
180
+ // `GET /admin-log` would dump the whole table; a default page keeps the external
181
+ // read bounded while `nextCursor` still walks the entire log. The KERNEL call stays
182
+ // deliberately unbounded (an in-process caller that wants everything asks for
183
+ // everything) — only the HTTP egress vectors are bounded by default.
184
+ ...listPageQuery.shape,
163
185
  });
164
186
  /**
165
187
  * The audited HTTP surface over `HostAdmin` (control-plane.md §4.5).
@@ -307,7 +329,11 @@ export function createControlPlaneApi(options) {
307
329
  return c.json(body, status);
308
330
  });
309
331
  // -- tenant registry (§4.1) ------------------------------------------------
310
- app.get('/tenants', async (c) => c.json(await admin.listTenants(c.get('actor'))));
332
+ app.get('/tenants', async (c) => {
333
+ const page = pageParams(c);
334
+ const entries = await admin.listTenants(c.get('actor'), page);
335
+ return c.json(pageOf(entries, page.limit, (t) => t.id));
336
+ });
311
337
  app.post('/tenants', async (c) => {
312
338
  const input = createTenantInput.parse(await c.req.json());
313
339
  await admin.createTenant(c.get('actor'), input);
@@ -436,7 +462,9 @@ export function createControlPlaneApi(options) {
436
462
  status: c.req.queries('status'),
437
463
  vertical: c.req.query('vertical'),
438
464
  });
439
- return c.json(await admin.listScopes(c.get('actor'), filter));
465
+ const page = pageParams(c);
466
+ const entries = await admin.listScopes(c.get('actor'), { ...filter, ...page });
467
+ return c.json(pageOf(entries, page.limit, (s) => s.id));
440
468
  });
441
469
  // -- fleet migration progress (kernel-design §5.3, #49) ---------------------
442
470
  // The ops-console view: "release N: X/Y migrated, P pending, F failed",
@@ -1343,6 +1371,16 @@ export function createControlPlaneApi(options) {
1343
1371
  tenantId: input.tenantId,
1344
1372
  patchBindings: options.patchScriptBindings,
1345
1373
  });
1374
+ // #473: per-tenant BLOB stores the vertical declared, minted + bound the same way.
1375
+ // No handle rides the provision callback (a blob store has no schema to migrate), so
1376
+ // the effect is purely the ledger row + the attached r2_bucket binding.
1377
+ await collectBlobStoreHandles({
1378
+ host: options.host,
1379
+ actor: c.get('actor'),
1380
+ slug,
1381
+ tenantId: input.tenantId,
1382
+ patchBindings: options.patchScriptBindings,
1383
+ });
1346
1384
  try {
1347
1385
  // #424 case 2: the binding attach above races Cloudflare script-settings
1348
1386
  // propagation, so the vertical's FIRST answer can be a transient 5xx that a
@@ -1440,10 +1478,13 @@ export function createControlPlaneApi(options) {
1440
1478
  return (await ownerOf(actor, prefixed)) !== undefined ? prefixed : raw;
1441
1479
  };
1442
1480
  app.get('/verticals', async (c) => {
1481
+ const page = pageParams(c);
1443
1482
  const all = await admin.listVerticals(c.get('actor'));
1444
1483
  const p = c.get('principal');
1445
- // A builder sees only what it owns; staff see the whole registry.
1446
- return c.json(p.kind === 'builder' ? all.filter((v) => v.ownerTenant === p.tenantId) : all);
1484
+ // A builder sees only what it owns; staff see the whole registry. The narrowing
1485
+ // runs above the adapter, so the page is sliced here — over the narrowed list.
1486
+ const visible = p.kind === 'builder' ? all.filter((v) => v.ownerTenant === p.tenantId) : all;
1487
+ return c.json(pageSlice(visible, page, (v) => v.slug));
1447
1488
  });
1448
1489
  app.post('/verticals', async (c) => {
1449
1490
  const input = registerVerticalInput.parse(await c.req.json());
@@ -1473,7 +1514,9 @@ export function createControlPlaneApi(options) {
1473
1514
  if (p.kind === 'builder' && (await ownerOf(p.actor, slug)) !== p.tenantId) {
1474
1515
  return c.json({ error: 'not found' }, 404);
1475
1516
  }
1476
- return c.json(await admin.listVersions(c.get('actor'), slug));
1517
+ const page = pageParams(c);
1518
+ const entries = await admin.listVersions(c.get('actor'), slug, page);
1519
+ return c.json(pageOf(entries, page.limit, (v) => v.id));
1477
1520
  });
1478
1521
  app.post('/verticals/:slug/versions', async (c) => {
1479
1522
  const input = publishVersionInput.parse(await c.req.json());
@@ -1575,7 +1618,9 @@ export function createControlPlaneApi(options) {
1575
1618
  if (p.kind === 'builder' && (await ownerOf(p.actor, slug)) !== p.tenantId) {
1576
1619
  return c.json({ error: 'not found' }, 404);
1577
1620
  }
1578
- return c.json(await admin.listChannels(c.get('actor'), slug));
1621
+ const page = pageParams(c);
1622
+ const entries = await admin.listChannels(c.get('actor'), slug, page);
1623
+ return c.json(pageOf(entries, page.limit, (ch) => ch.channel));
1579
1624
  });
1580
1625
  // The promotion timeline (newest first) — what a rollback UI picks a target from.
1581
1626
  // Owner-narrowed like the channel read above: a builder sees only its own verticals'
@@ -1587,7 +1632,9 @@ export function createControlPlaneApi(options) {
1587
1632
  if (p.kind === 'builder' && (await ownerOf(p.actor, slug)) !== p.tenantId) {
1588
1633
  return c.json({ error: 'not found' }, 404);
1589
1634
  }
1590
- return c.json(await admin.listChannelHistory(c.get('actor'), slug, channel));
1635
+ const page = pageParams(c);
1636
+ const entries = await admin.listChannelHistory(c.get('actor'), slug, channel, page);
1637
+ return c.json(pageOf(entries, page.limit, (e) => e.id));
1591
1638
  });
1592
1639
  /**
1593
1640
  * Serve a version IN PLACE (#286): upload its bundle onto the vertical's ONE stable
@@ -1622,7 +1669,15 @@ export function createControlPlaneApi(options) {
1622
1669
  // happened to carry) is what makes a re-deploy structurally unable to drop a
1623
1670
  // tenant's store. Platform-granted after the §4 sandbox check, like injectSecrets:
1624
1671
  // the builder never declared these and never named the ids.
1625
- const storeBindings = tenantStoreBindings(await admin.listTenantStores(actor, { vertical: slug })).map((b) => ({ type: 'd1', name: b.name, id: b.id }));
1672
+ const storeBindings = [
1673
+ ...tenantStoreBindings(await admin.listTenantStores(actor, { vertical: slug })),
1674
+ // #473: the per-tenant blob-store r2_bucket bindings, re-derived from the ledger on
1675
+ // every serving upload for the same reason — an upload replaces the script's binding
1676
+ // set, so a re-deploy must never drop a tenant's attachment bucket.
1677
+ ...blobStoreBindings(await admin.listBlobStores(actor, { vertical: slug })),
1678
+ ].map((b) => b.type === 'd1'
1679
+ ? { type: 'd1', name: b.name, id: b.id }
1680
+ : { type: 'r2_bucket', name: b.name, bucket_name: b.bucketName });
1626
1681
  await options.deployVertical(ref, {
1627
1682
  entry: manifest.entry,
1628
1683
  compatibilityDate: manifest.compatibilityDate,
@@ -1937,12 +1992,14 @@ export function createControlPlaneApi(options) {
1937
1992
  .object({
1938
1993
  service: z.string().min(1).max(200).optional(),
1939
1994
  level: z.enum(['log', 'info', 'warn', 'error', 'debug']).optional(),
1995
+ search: z.string().min(1).max(200).optional(),
1940
1996
  hours: z.coerce.number().int().min(1).max(72).default(1),
1941
1997
  limit: z.coerce.number().int().min(1).max(500).default(100),
1942
1998
  })
1943
1999
  .parse({
1944
2000
  service: c.req.query('service') || undefined,
1945
2001
  level: c.req.query('level') || undefined,
2002
+ search: c.req.query('search') || undefined,
1946
2003
  hours: c.req.query('hours'),
1947
2004
  limit: c.req.query('limit'),
1948
2005
  });
@@ -1996,7 +2053,9 @@ export function createControlPlaneApi(options) {
1996
2053
  // (scopeId) but never widen; a foreign tenantId in the query loses silently.
1997
2054
  if (p.kind === 'builder')
1998
2055
  filter.tenantId = p.tenantId;
1999
- return c.json(await admin.listHostnames(c.get('actor'), filter));
2056
+ const page = pageParams(c);
2057
+ const entries = await admin.listHostnames(c.get('actor'), { ...filter, ...page });
2058
+ return c.json(pageOf(entries, page.limit, (h) => h.hostname));
2000
2059
  });
2001
2060
  // Which base domains ride the platform wildcard cert (go straight to `active`);
2002
2061
  // everything else is a custom domain that walks Cloudflare-for-SaaS issuance (§4.7).
@@ -2349,7 +2408,11 @@ export function createControlPlaneApi(options) {
2349
2408
  tenantId: c.req.query('tenantId'),
2350
2409
  source: c.req.query('source'),
2351
2410
  });
2352
- return c.json(await admin.listRoles(c.get('actor'), filter));
2411
+ const page = pageParams(c);
2412
+ const entries = await admin.listRoles(c.get('actor'), { ...filter, ...page });
2413
+ // Composite sort key (tenant_id, role_key) — the `|` join is the documented
2414
+ // cursor shape (scope-host.ts listRoles).
2415
+ return c.json(pageOf(entries, page.limit, (r) => `${r.tenantId}|${r.key}`));
2353
2416
  });
2354
2417
  // -- the admin log (§4.4/§4.5) ---------------------------------------------
2355
2418
  app.get('/admin-log', async (c) => {
@@ -2367,7 +2430,7 @@ export function createControlPlaneApi(options) {
2367
2430
  const entries = await admin.auditLog(c.get('actor'), filter);
2368
2431
  // The cursor IS the last entry's id (ULID order is chronological), so the
2369
2432
  // page carries its own continuation and the console never assembles one.
2370
- return c.json({ entries, nextCursor: entries.at(-1)?.id ?? null });
2433
+ return c.json(pageOf(entries, filter.limit, (e) => e.id));
2371
2434
  });
2372
2435
  return app;
2373
2436
  }