@substrat-run/control-plane-api 0.124.0 → 0.126.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.
Files changed (39) hide show
  1. package/dist/aggregate-reads.d.ts +67 -0
  2. package/dist/aggregate-reads.d.ts.map +1 -0
  3. package/dist/aggregate-reads.js +186 -0
  4. package/dist/aggregate-reads.js.map +1 -0
  5. package/dist/aggregate-source.d.ts +125 -0
  6. package/dist/aggregate-source.d.ts.map +1 -0
  7. package/dist/aggregate-source.js +134 -0
  8. package/dist/aggregate-source.js.map +1 -0
  9. package/dist/api.d.ts.map +1 -1
  10. package/dist/api.js +63 -18
  11. package/dist/api.js.map +1 -1
  12. package/dist/cf-observability.d.ts +7 -0
  13. package/dist/cf-observability.d.ts.map +1 -1
  14. package/dist/cf-observability.js +316 -214
  15. package/dist/cf-observability.js.map +1 -1
  16. package/dist/cube-store-sql.d.ts +34 -0
  17. package/dist/cube-store-sql.d.ts.map +1 -0
  18. package/dist/cube-store-sql.js +74 -0
  19. package/dist/cube-store-sql.js.map +1 -0
  20. package/dist/deploy.d.ts +1 -1
  21. package/dist/deploy.d.ts.map +1 -1
  22. package/dist/deploy.js +1 -1
  23. package/dist/deploy.js.map +1 -1
  24. package/dist/index.d.ts +5 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +5 -0
  27. package/dist/index.js.map +1 -1
  28. package/dist/observability.d.ts +16 -0
  29. package/dist/observability.d.ts.map +1 -1
  30. package/dist/observability.js.map +1 -1
  31. package/dist/tenant-request-query.d.ts +21 -0
  32. package/dist/tenant-request-query.d.ts.map +1 -1
  33. package/dist/tenant-request-query.js +27 -0
  34. package/dist/tenant-request-query.js.map +1 -1
  35. package/dist/vertical-client.d.ts +6 -1
  36. package/dist/vertical-client.d.ts.map +1 -1
  37. package/dist/vertical-client.js +17 -3
  38. package/dist/vertical-client.js.map +1 -1
  39. package/package.json +7 -7
package/dist/api.js CHANGED
@@ -17,7 +17,7 @@ import { mapError } from './errors.js';
17
17
  import { maskDump, maskRecords } from './mask.js';
18
18
  import { createPseudonymizer } from './pseudonymize.js';
19
19
  import { openDump, sealDump } from './seal.js';
20
- import { assertSandboxContract, deployManifest, storedDeployManifest, deploymentRefFor, stableDeploymentRefFor, nextMigrationTag, upstreamStatusOf, } from './deploy.js';
20
+ import { assertSandboxContract, deployManifest, storedDeployManifest, pushedPermissionRegistry, deploymentRefFor, stableDeploymentRefFor, nextMigrationTag, upstreamStatusOf, } from './deploy.js';
21
21
  /**
22
22
  * Answer with a problem document (#113 phase 4).
23
23
  *
@@ -34,7 +34,7 @@ function problem(c, answer) {
34
34
  import { backfillDeclaredStores, blobStoreBindings, collectBlobStoreHandles, collectTenantStoreHandles, missingStoresForTenant, tenantStoreBindings, } from './tenant-stores.js';
35
35
  import { mintPushToken, pushActorFor } from './push-token.js';
36
36
  import { TENANT_SERIES_SCOPE_CAP, REQUEST_FACET_KEYS } from './observability.js';
37
- import { parseTenantRequestQuery, parseTenantWindowQuery, refineTenantWindow } from './tenant-request-query.js';
37
+ import { parseTenantRequestQuery, parseTenantWindowQuery, refineTenantWindow, scriptFamiliesOfScopes } from './tenant-request-query.js';
38
38
  import { namespacesForScript } from './do-namespaces.js';
39
39
  import { backupDirectoryIfDue } from './directory-backup.js';
40
40
  import { STORAGE_PAGE_DEFAULT, STORAGE_PAGE_MAX, readStoragePage } from './storage-meter.js';
@@ -1719,8 +1719,15 @@ export function createControlPlaneApi(options) {
1719
1719
  * as it stood before the failed attempt. Every caller still re-asserts after it (the
1720
1720
  * fallback), passing what this reports.
1721
1721
  */
1722
- const restoreCarryingSwitches = async (actor, dest, tenantId, scopeId, tables) => {
1723
- return retryTransient(async () => dest.restoreScope(tenantId, scopeId, tables, await switchCarryFor(admin, actor, { tenantId, scopeId })));
1722
+ const restoreCarryingSwitches = async (actor, dest, tenantId, scopeId, tables,
1723
+ /** #1869: the scope `tables` were captured from — `scopeId` itself unless the copy moves —
1724
+ * and `exact` when the platform exported them itself rather than a caller supplying them. */
1725
+ source) => {
1726
+ return retryTransient(async () => dest.restoreScope(tenantId, scopeId, tables, {
1727
+ ...(await switchCarryFor(admin, actor, { tenantId, scopeId })),
1728
+ sourceScopeId: source.scopeId,
1729
+ exact: source.exact,
1730
+ }));
1724
1731
  };
1725
1732
  const adoptScopeOntoServing = async (c, tenantId, scopeId, opts = {}) => {
1726
1733
  const actor = c.get('actor');
@@ -1758,7 +1765,7 @@ export function createControlPlaneApi(options) {
1758
1765
  }
1759
1766
  const dump = await source.exportScope(scopeId);
1760
1767
  // #1742: the recorded OFF positions ride the restore, applied in the replay's own event.
1761
- const restored = await restoreCarryingSwitches(actor, dest, tenantId, scopeId, dump);
1768
+ const restored = await restoreCarryingSwitches(actor, dest, tenantId, scopeId, dump, { scopeId, exact: true });
1762
1769
  // Data landed — only now flip routing and move the version pointer.
1763
1770
  await admin
1764
1771
  .setScopeServingRef(actor, tenantId, scopeId, serving.ref, { acknowledge: opts.acknowledge })
@@ -1902,7 +1909,7 @@ export function createControlPlaneApi(options) {
1902
1909
  throw new ControlPlaneError(501, 'rebind-vertical needs dispatch resolution for both ends');
1903
1910
  }
1904
1911
  const dump = await source.exportScope(scopeId);
1905
- const restored = await restoreCarryingSwitches(actor, dest, tenantId, scopeId, dump); // #1742, as adopt
1912
+ const restored = await restoreCarryingSwitches(actor, dest, tenantId, scopeId, dump, { scopeId, exact: true }); // #1742, as adopt
1906
1913
  // Data landed on the target script — only now flip routing and cross the pointer.
1907
1914
  // `bindScopeVersion` rewrites `scopes.vertical` from the version row, audited. No
1908
1915
  // extra snapshot here (adopt-serving's precedent): the source script's copy is the
@@ -1989,7 +1996,10 @@ export function createControlPlaneApi(options) {
1989
1996
  }
1990
1997
  const dump = await retryTransient(() => source.exportScope(scope.id));
1991
1998
  // #1742: the recorded OFF positions ride the restore, as on adopt and rebind.
1992
- const restored = await restoreCarryingSwitches(actor, dest, scope.tenantId, scope.id, dump);
1999
+ const restored = await restoreCarryingSwitches(actor, dest, scope.tenantId, scope.id, dump, {
2000
+ scopeId: scope.id,
2001
+ exact: true,
2002
+ });
1993
2003
  return { from, to, tables: restored.tables, switchedOff: restored.switchedOff };
1994
2004
  };
1995
2005
  app.get('/tenants/:tenantId/scopes/:scopeId/tables', async (c) => {
@@ -3262,6 +3272,8 @@ export function createControlPlaneApi(options) {
3262
3272
  // a vertical deployment, the same dump is then loaded THERE — the bytes the router
3263
3273
  // actually serves. (Yes, that stores the dump twice in delegated mode; the
3264
3274
  // placeholder copy is the price of one canonical audited path, same as export.)
3275
+ /** #1869: the optional re-point hint a restore body may carry beside the dump. */
3276
+ const restoreSourceHint = z.object({ sourceScopeId: scopeIdSchema.optional() });
3265
3277
  app.post('/tenants/:tenantId/scopes/:scopeId/restore', async (c) => {
3266
3278
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
3267
3279
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
@@ -3269,7 +3281,12 @@ export function createControlPlaneApi(options) {
3269
3281
  const scope = await admin.getScopeRecord(actor, tenantId, scopeId);
3270
3282
  if (!scope)
3271
3283
  return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3272
- const dump = scopeDump.parse(await c.req.json());
3284
+ const raw = await c.req.json();
3285
+ const dump = scopeDump.parse(raw);
3286
+ // #1869: where the uploaded file says it came from, when the caller had to set the dump's
3287
+ // own `scopeId` to something else. A hint for the grant re-point ONLY: the dump's
3288
+ // `tenantId`/`scopeId` below still choose the keys that open its sealed payloads.
3289
+ const { sourceScopeId: sourceHint } = restoreSourceHint.parse(raw);
3273
3290
  // A backup with no tables is not a scope dump — name that plainly rather than letting
3274
3291
  // the empty replay reach the checker and surface as a bare `internal error` (#321).
3275
3292
  if (dump.tables.length === 0) {
@@ -3289,11 +3306,16 @@ export function createControlPlaneApi(options) {
3289
3306
  const origin = { tenantId: tenantIdSchema.parse(dump.tenantId), scopeId: scopeIdSchema.parse(dump.scopeId) };
3290
3307
  const tables = await openDump(dump.tables, sealerFor(c, origin.tenantId, origin.scopeId));
3291
3308
  const landing = { ...dump, tables };
3292
- await host.restoreScope(actor, tenantId, scopeId, landing);
3309
+ await host.restoreScope(actor, tenantId, scopeId, landing, sourceHint ? { sourceScopeId: sourceHint } : undefined);
3293
3310
  const vertical = await verticalForScope(c, scope);
3294
3311
  // #1742: the recorded OFF positions ride the restore, so the deployment switches them
3295
3312
  // off in the replay's own event — its own sweeper cannot land in between.
3296
- const restored = vertical ? await restoreCarryingSwitches(actor, vertical, tenantId, scopeId, tables) : undefined;
3313
+ const restored = vertical
3314
+ ? await restoreCarryingSwitches(actor, vertical, tenantId, scopeId, tables, {
3315
+ scopeId: sourceHint ?? origin.scopeId,
3316
+ exact: false,
3317
+ })
3318
+ : undefined;
3297
3319
  // #1674: a backup from before a module was switched off brings it back on; the
3298
3320
  // directory's record puts it back off now, not at the next reconcile. Where the
3299
3321
  // deployment already did (#1742), this finds it done and audits what it reported.
@@ -3962,10 +3984,17 @@ export function createControlPlaneApi(options) {
3962
3984
  catch {
3963
3985
  return c.json({ error: 'manifestJson is not valid JSON' }, 400);
3964
3986
  }
3987
+ const refuse = (issue, under = []) => c.json({ error: `manifestJson is not a deploy manifest: ${[...under, ...issue.path].join('.') || '(root)'}: ${issue.message}` }, 400);
3965
3988
  const checked = storedDeployManifest.safeParse(raw);
3966
- if (!checked.success) {
3967
- const issue = checked.error.issues[0];
3968
- return c.json({ error: `manifestJson is not a deploy manifest: ${issue.path.join('.') || '(root)'}: ${issue.message}` }, 400);
3989
+ if (!checked.success)
3990
+ return refuse(checked.error.issues[0]);
3991
+ // #1869: a registry handed to this route now is NEW, so it answers to the push-time rule on
3992
+ // entity-grant shapes (no kernel namespace as an entity type), as `deployManifest` does.
3993
+ // An absent registry stays accepted, as above.
3994
+ if (checked.data.registry) {
3995
+ const registry = pushedPermissionRegistry.safeParse(checked.data.registry);
3996
+ if (!registry.success)
3997
+ return refuse(registry.error.issues[0], ['registry']);
3969
3998
  }
3970
3999
  }
3971
4000
  await admin.publishVersion(c.get('actor'), input);
@@ -5121,7 +5150,9 @@ export function createControlPlaneApi(options) {
5121
5150
  until: c.req.query('until') || undefined,
5122
5151
  limit: c.req.query('limit'),
5123
5152
  });
5124
- return c.json(await options.observability.tenantLogs(input));
5153
+ // #1877: scoped to the app's own scripts — the scan and trust boundary.
5154
+ const services = await appServices(c.get('actor'), input.tenantId, input.scopeId, input.vertical);
5155
+ return c.json(await options.observability.tenantLogs({ ...input, services }));
5125
5156
  });
5126
5157
  // #1746: the per-request record, read at the tenant grain. The three routes take ONE
5127
5158
  // query — tenant, window, facet filters (`tenant-request-query.ts`) — so the histogram,
@@ -5138,6 +5169,16 @@ export function createControlPlaneApi(options) {
5138
5169
  throw new ControlPlaneError(400, 'tenantId is required');
5139
5170
  return tenantId;
5140
5171
  };
5172
+ /**
5173
+ * #1877: the script families a tenant's app — or, with no scope named, its apps — run
5174
+ * as (`scriptFamiliesOfScopes`). Every tenant log read is scoped to these: a query then
5175
+ * reads one vertical's scripts instead of the whole account's, and a line counts only if
5176
+ * one of them wrote it. From the directory, never the caller, and within the tenant: a
5177
+ * scope id that is not this tenant's resolves no family, and a read over none answers
5178
+ * nothing. `listScopes`, not `getScopeRecord`: a dashboard read must not write an access
5179
+ * row per page view.
5180
+ */
5181
+ const appServices = async (actor, tenantId, scopeId, vertical) => scriptFamiliesOfScopes(await admin.listScopes(actor, { tenantId: tenantIdSchema.parse(tenantId) }), { scopeId, vertical });
5141
5182
  app.get('/observability/tenant-request-volume', async (c) => {
5142
5183
  if (!options.observability?.tenantRequestVolume) {
5143
5184
  return c.json({ error: 'request aggregation is not configured on this control plane' }, 501);
@@ -5146,7 +5187,8 @@ export function createControlPlaneApi(options) {
5146
5187
  // A bucket COUNT: zooming in keeps it and narrows the window, so the bucket shrinks.
5147
5188
  // 90 is the design's histogram; the bounds keep a chart drawable and a query cheap.
5148
5189
  const buckets = z.coerce.number().int().min(10).max(240).default(90).parse(c.req.query('buckets'));
5149
- return c.json(await options.observability.tenantRequestVolume({ ...scope, buckets }));
5190
+ const services = await appServices(c.get('actor'), scope.tenantId, scope.scopeId, scope.vertical);
5191
+ return c.json(await options.observability.tenantRequestVolume({ ...scope, services, buckets }));
5150
5192
  });
5151
5193
  app.get('/observability/tenant-request-facets', async (c) => {
5152
5194
  if (!options.observability?.tenantRequestFacets) {
@@ -5157,7 +5199,8 @@ export function createControlPlaneApi(options) {
5157
5199
  // that shows three asks for three.
5158
5200
  const asked = (c.req.queries('facet') ?? []).filter((k) => k.length > 0);
5159
5201
  const keys = asked.length === 0 ? undefined : z.array(z.enum(REQUEST_FACET_KEYS)).parse([...new Set(asked)]);
5160
- return c.json(await options.observability.tenantRequestFacets({ ...scope, ...(keys ? { keys } : {}) }));
5202
+ const services = await appServices(c.get('actor'), scope.tenantId, scope.scopeId, scope.vertical);
5203
+ return c.json(await options.observability.tenantRequestFacets({ ...scope, services, ...(keys ? { keys } : {}) }));
5161
5204
  });
5162
5205
  // #1747: the tenant's `ctx.log` lines grouped by the template they were written from.
5163
5206
  app.get('/observability/tenant-log-patterns', async (c) => {
@@ -5185,6 +5228,7 @@ export function createControlPlaneApi(options) {
5185
5228
  buckets: narrow.buckets,
5186
5229
  ...(narrow.level.length ? { level: narrow.level } : {}),
5187
5230
  ...(narrow.operation.length ? { operation: narrow.operation } : {}),
5231
+ services: await appServices(c.get('actor'), scope.tenantId, scope.scopeId, scope.vertical),
5188
5232
  }));
5189
5233
  });
5190
5234
  app.get('/observability/tenant-requests', async (c) => {
@@ -5193,7 +5237,8 @@ export function createControlPlaneApi(options) {
5193
5237
  }
5194
5238
  const scope = parseTenantRequestQuery(requestTenantOf(c), requestQueryOf(c));
5195
5239
  const limit = z.coerce.number().int().min(1).max(200).default(50).parse(c.req.query('limit'));
5196
- return c.json(await options.observability.tenantRequests({ ...scope, limit }));
5240
+ const services = await appServices(c.get('actor'), scope.tenantId, scope.scopeId, scope.vertical);
5241
+ return c.json(await options.observability.tenantRequests({ ...scope, services, limit }));
5197
5242
  });
5198
5243
  /**
5199
5244
  * Observed-vs-declared outbound egress for a vertical's deployed versions (#859, D-46).
@@ -5836,7 +5881,7 @@ export function createControlPlaneApi(options) {
5836
5881
  // there; restore re-projects the vertical's roles from the dump's tuples). A one-shot
5837
5882
  // DO storage blip heals on the in-request retry WITHOUT burning a CI attempt (which
5838
5883
  // pushes a fresh version per try) — #559 (2).
5839
- await restoreOrRecord(() => target.restoreScope(tenantId, previewId, tables));
5884
+ await restoreOrRecord(() => target.restoreScope(tenantId, previewId, tables, { sourceScopeId: source.id, exact: true }));
5840
5885
  }
5841
5886
  else {
5842
5887
  // A clean-room preview (#509 (b)): an EMPTY scope, no source to export. No `forkedFrom`