@substrat-run/control-plane-api 0.106.0 → 0.108.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":"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"}
1
+ {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"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"}
package/dist/api.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Hono } from 'hono';
2
- import { parseHostname, withLabel, RESERVED_LABEL_SEPARATOR, adminAction, ASSET_PART_PREFIX, assetHash, channelName, connectionActivity, connectionActivitySource, connectionCredential, connectionFilter, connectionProbe, createTenantInput, entitlementGrantInput, hostname as hostnameSchema, hostnameRegion, hostnameStatus, identityLink, errorCode, issueStatus, issueStatusInput, listPageQuery, matchesOutboundHost, pageOf, platformRequestFilter, principalId as principalIdSchema, promotionAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, DEFAULT_DENIAL_LIMIT, DENIAL_LIMIT_MAX, registerVerticalInput, serviceDimensions, sweepRunKind, sweepRunOutcome, scopeDump, dataSubjectId as dataSubjectIdSchema, scopeId as scopeIdSchema, scopeStatus, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, versionOrigin, z, PROBLEM_CONTENT_TYPE, toProblem, } from '@substrat-run/contracts';
2
+ import { parseHostname, withLabel, RESERVED_LABEL_SEPARATOR, adminAction, ASSET_PART_PREFIX, assetHash, channelName, connectionActivity, connectionActivitySource, connectionCredential, connectionFilter, connectionProbe, createTenantInput, entitlementGrantInput, hostname as hostnameSchema, hostnameRegion, hostnameStatus, identityLink, errorCode, issueStatus, issueStatusInput, listPageQuery, matchesOutboundHost, pageOf, platformRequestFilter, principalId as principalIdSchema, promotionAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, entityHistoryInput, createOrgInput, roleKey as roleKeySchema, DEFAULT_DENIAL_LIMIT, DENIAL_LIMIT_MAX, registerVerticalInput, serviceDimensions, sweepRunKind, sweepRunOutcome, scopeDump, dataSubjectId as dataSubjectIdSchema, scopeId as scopeIdSchema, scopeStatus, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, versionOrigin, z, PROBLEM_CONTENT_TYPE, toProblem, } from '@substrat-run/contracts';
3
3
  import { attributeFailure } from './failure-attribution.js';
4
4
  import { migrationProgress, ulid } from '@substrat-run/kernel';
5
5
  import { TENANT_HEADER } from './auth.js';
@@ -288,6 +288,32 @@ const issueStatusUpdate = z.object({
288
288
  fingerprint: z.string().min(1),
289
289
  status: issueStatusInput,
290
290
  });
291
+ /**
292
+ * A TENANT-LEVEL role assignment under an addressed tenant (#1343) — which is
293
+ * what a team membership is, and all this surface does.
294
+ *
295
+ * `scopeId` is deliberately absent, and `.strict()` REFUSES one rather than
296
+ * stripping it. An earlier draft accepted a body `scopeId` and pinned only the
297
+ * tenant from the path, which pins nothing: `assignRole` addresses a scope DO
298
+ * directly when the node carries a scope (`writeScopeTuple` → `scopeStub(scopeId)`,
299
+ * no tenant cross-check anywhere below it), so a body naming another tenant's
300
+ * scope would have written a role tuple into it. Silently stripping the field
301
+ * would be almost as bad — a caller who believes they scoped an assignment and
302
+ * silently got a tenant-wide one is worse off than one who got a 400.
303
+ *
304
+ * If scope-level assignment is ever needed it wants its own route under
305
+ * `/tenants/:tenantId/scopes/:scopeId/role-assignments`, deriving the node from
306
+ * the PATH after `getScopeRecord(actor, tenantId, scopeId)` returns — the K-3
307
+ * cross-check every other scope-addressed route here already performs.
308
+ */
309
+ const tenantRoleAssignmentBody = z
310
+ .object({
311
+ principalId: principalIdSchema,
312
+ roleKey: roleKeySchema,
313
+ })
314
+ .strict();
315
+ /** A new org under an addressed tenant (#1343) — `tenantId` comes from the path. */
316
+ const tenantOrgBody = createOrgInput.omit({ tenantId: true });
291
317
  const sweepRunsQuery = z.object({
292
318
  kind: sweepRunKind.optional(),
293
319
  unit: z.string().optional(),
@@ -1493,6 +1519,26 @@ export function createControlPlaneApi(options) {
1493
1519
  throw e;
1494
1520
  }
1495
1521
  });
1522
+ // #1235: one record's event history — the per-entity pivot of the audit spine.
1523
+ // Delegated exactly like the table reads: through the vertical that holds the
1524
+ // scope's data when one resolves, the co-located host otherwise.
1525
+ app.get('/tenants/:tenantId/scopes/:scopeId/history', async (c) => {
1526
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
1527
+ const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
1528
+ const input = entityHistoryInput.parse({
1529
+ entityType: c.req.query('entityType'),
1530
+ entityId: c.req.query('entityId'),
1531
+ limit: c.req.query('limit') ? Number(c.req.query('limit')) : undefined,
1532
+ cursor: c.req.query('cursor') ?? undefined,
1533
+ });
1534
+ const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
1535
+ if (!scope)
1536
+ return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
1537
+ const vertical = await verticalForScope(c, scope);
1538
+ return c.json(vertical
1539
+ ? await vertical.entityHistory(scopeId, input)
1540
+ : await admin.entityHistory(c.get('actor'), tenantId, scopeId, input));
1541
+ });
1496
1542
  app.get('/tenants/:tenantId/scopes/:scopeId/tables/:table', async (c) => {
1497
1543
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
1498
1544
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
@@ -2810,8 +2856,11 @@ export function createControlPlaneApi(options) {
2810
2856
  return c.json({ error: 'not found' }, 404);
2811
2857
  }
2812
2858
  const json = await admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
2813
- const model = json ? (storedDeployManifest.parse(JSON.parse(json)).model ?? null) : null;
2814
- return c.json({ model });
2859
+ const parsed = json ? storedDeployManifest.parse(JSON.parse(json)) : null;
2860
+ // #1321 rides this read for the reason the freshness field rides the schedules
2861
+ // one: the manifest is already parsed, and the field sits beside the model the
2862
+ // caller is asking about — a sibling route would pay for a second full parse.
2863
+ return c.json({ model: parsed?.model ?? null, outputSurface: parsed?.outputSurface ?? null });
2815
2864
  });
2816
2865
  // The static files (#340) one version ships: path, size, content type, content address —
2817
2866
  // read straight out of the retained manifest, which is where they were persisted for the
@@ -3898,7 +3947,7 @@ export function createControlPlaneApi(options) {
3898
3947
  throw new ControlPlaneError(409, `no platform base domain configured — a clean-room preview has no source URL to derive from`);
3899
3948
  }
3900
3949
  // Mint under `<label>.<jurisdiction>.<baseDomain>`, exactly as provisioning does
3901
- // (provision.ts `bindDefaultHostname` → `egeryds.global.substrat.run`). A clean-room
3950
+ // (provision.ts `bindDefaultHostname` → `acme.global.substrat.run`). A clean-room
3902
3951
  // preview scope is provisioned `global` by construction (see the caller), and the
3903
3952
  // wildcard DNS/cert lives on `*.global.substrat.run` — NOT the certless apex
3904
3953
  // `*.substrat.run`. `platformBaseDomains` lists every platform suffix for custom-hostname
@@ -4201,6 +4250,59 @@ export function createControlPlaneApi(options) {
4201
4250
  // cursor shape (scope-host.ts listRoles).
4202
4251
  return c.json(pageOf(entries, page.limit, (r) => `${r.tenantId}|${r.key}`));
4203
4252
  });
4253
+ // -- role ASSIGNMENTS, and orgs (#1343) ------------------------------------
4254
+ //
4255
+ // The line these sit on the far side of, and why. `defineRole` says what a role
4256
+ // MEANS — a permission change, and the permission diff is a human checkpoint
4257
+ // (D-22/D-29). It still gets no route, for the reason stated above the read.
4258
+ // ASSIGNING an already-defined role is a different act: per-principal, runtime,
4259
+ // and the same shape as the entity grants control-plane.md §4.5 already calls a
4260
+ // runtime console concern rather than a checkpoint artifact. `unassignRole`'s own
4261
+ // contract anticipates this caller — "decided above the kernel (e.g. the
4262
+ // dashboard's manage-members check)".
4263
+ //
4264
+ // Service/staff only, absent from BUILDER_ROUTES: a builder must no more be able
4265
+ // to assign itself a role than to write the directory that authenticates it.
4266
+ // Fail-closed, same law as the identity mirror. TENANT-LEVEL only — see the
4267
+ // body schema for why a scope cannot be named here.
4268
+ app.post('/tenants/:tenantId/role-assignments', async (c) => {
4269
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
4270
+ const body = tenantRoleAssignmentBody.parse(await c.req.json());
4271
+ // The whole node comes from the path. Pinning only the tenant would pin
4272
+ // nothing — see the schema's note: a node carrying a scope is written to that
4273
+ // scope's DO directly, with no tenant cross-check below.
4274
+ await admin.assignRole(c.get('actor'), {
4275
+ principalId: body.principalId,
4276
+ roleKey: body.roleKey,
4277
+ node: { tenantId, scopeId: null },
4278
+ });
4279
+ return c.body(null, 204);
4280
+ });
4281
+ app.delete('/tenants/:tenantId/role-assignments', async (c) => {
4282
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
4283
+ const body = tenantRoleAssignmentBody.parse(await c.req.json());
4284
+ // Tombstones rather than deletes (K-21) and is idempotent — unassigning what
4285
+ // was never assigned is a silent no-op, so a retry is safe. Node from the
4286
+ // path, for the same reason as the assign above.
4287
+ await admin.unassignRole(c.get('actor'), {
4288
+ principalId: body.principalId,
4289
+ roleKey: body.roleKey,
4290
+ node: { tenantId, scopeId: null },
4291
+ });
4292
+ return c.body(null, 204);
4293
+ });
4294
+ // Orgs: the portal-customer grouping (§4.1). Creating one mints no permission —
4295
+ // members reach what the org was GRANTED, and granting stays off this surface.
4296
+ app.post('/tenants/:tenantId/orgs', async (c) => {
4297
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
4298
+ const body = tenantOrgBody.parse(await c.req.json());
4299
+ await admin.createOrg(c.get('actor'), { ...body, tenantId });
4300
+ return c.body(null, 201);
4301
+ });
4302
+ app.get('/tenants/:tenantId/orgs', async (c) => {
4303
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
4304
+ return c.json(await admin.listOrgs(c.get('actor'), tenantId));
4305
+ });
4204
4306
  // -- the admin log (§4.4/§4.5) ---------------------------------------------
4205
4307
  app.get('/admin-log', async (c) => {
4206
4308
  const filter = auditLogQuery.parse({