@substrat-run/control-plane-api 0.107.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'));
@@ -3901,7 +3947,7 @@ export function createControlPlaneApi(options) {
3901
3947
  throw new ControlPlaneError(409, `no platform base domain configured — a clean-room preview has no source URL to derive from`);
3902
3948
  }
3903
3949
  // Mint under `<label>.<jurisdiction>.<baseDomain>`, exactly as provisioning does
3904
- // (provision.ts `bindDefaultHostname` → `egeryds.global.substrat.run`). A clean-room
3950
+ // (provision.ts `bindDefaultHostname` → `acme.global.substrat.run`). A clean-room
3905
3951
  // preview scope is provisioned `global` by construction (see the caller), and the
3906
3952
  // wildcard DNS/cert lives on `*.global.substrat.run` — NOT the certless apex
3907
3953
  // `*.substrat.run`. `platformBaseDomains` lists every platform suffix for custom-hostname
@@ -4204,6 +4250,59 @@ export function createControlPlaneApi(options) {
4204
4250
  // cursor shape (scope-host.ts listRoles).
4205
4251
  return c.json(pageOf(entries, page.limit, (r) => `${r.tenantId}|${r.key}`));
4206
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
+ });
4207
4306
  // -- the admin log (§4.4/§4.5) ---------------------------------------------
4208
4307
  app.get('/admin-log', async (c) => {
4209
4308
  const filter = auditLogQuery.parse({