@substrat-run/control-plane-api 0.107.0 → 0.109.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;AAqD5B,OAAO,KAAK,EACV,UAAU,EACV,kBAAkB,EAClB,wBAAwB,EACxB,oBAAoB,EACpB,eAAe,EAGf,eAAe,EAOhB,MAAM,yBAAyB,CAAC;AACjC,OAAO,KAAK,EAAmB,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAIvE,OAAO,KAAK,EAAE,iBAAiB,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAC3E,OAAO,EAA4B,KAAK,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAmBrF,OAAO,KAAK,EAEV,gBAAgB,EAChB,oBAAoB,EACpB,sBAAsB,EACvB,MAAM,aAAa,CAAC;AACrB,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,UAAU,CAAC;AAyBtD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAC9D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAC7D,OAAO,EAAuB,KAAK,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AACjF,OAAO,KAAK,EAEV,oBAAoB,EAEpB,gBAAgB,EACjB,MAAM,cAAc,CAAC;AAEtB,OAAO,EAGL,KAAK,yBAAyB,EAC/B,MAAM,uBAAuB,CAAC;AAE/B;;;;;;;;;;GAUG;AACH,MAAM,WAAW,mBAAmB;IAClC,KAAK,CAAC,EAAE,CAAC,IAAI,EAAE,SAAS,EAAE,UAAU,EAAE,UAAU,KAAK,OAAO,CAAC,eAAe,CAAC,CAAC;IAC9E,QAAQ,CAAC,EAAE,CACT,IAAI,EAAE,SAAS,EACf,UAAU,EAAE,UAAU,EACtB,IAAI,EAAE;QAAE,IAAI,EAAE,OAAO,CAAC;QAAC,MAAM,EAAE,wBAAwB,CAAA;KAAE,KACtD,OAAO,CAAC,kBAAkB,CAAC,CAAC;IACjC;;;;;OAKG;IACH,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,SAAS,EAAE,UAAU,EAAE,UAAU,KAAK,OAAO,CAAC,oBAAoB,CAAC,CAAC;IACxF;;;;;;;OAOG;IACH,cAAc,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,OAAO,CAAC,eAAe,CAAC,CAAC;CAC/E;AAED,MAAM,WAAW,sBAAsB;IACrC;;;;OAIG;IACH,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,IAAI,EAAE,SAAS,CAAC;IAChB;;;;;;;OAOG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;IAC3C;;;;;;;;OAQG;IACH,eAAe,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC,CAAC;IAC9D;;;;;;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;;;;;;;;;;OAUG;IACH,kBAAkB,CAAC,EAAE,oBAAoB,CAAC;IAC1C;;;;;;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;;;;;;;;;OASG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;;;;;OAQG;IACH,aAAa,CAAC,EAAE,mBAAmB,CAAC;IACpC;;;;;;;;;;;;OAYG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,mBAAmB,CAAC,CAAC;IAC3D;;;;;;;;;OASG;IACH,eAAe,CAAC,EAAE,eAAe,CAAC;IAClC;;;;;OAKG;IACH,YAAY,CAAC,EAAE,iBAAiB,CAAC;IACjC;;;;;;;;;;;;;OAaG;IACH,YAAY,CAAC,EAAE,gBAAgB,CAAC;IAChC;;;;;;;;;OASG;IACH,gBAAgB,CAAC,EAAE,oBAAoB,CAAC;IACxC;;;;;;;OAOG;IACH,iBAAiB,CAAC,EAAE,yBAAyB,CAAC;IAC9C;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC/B;;;;;;;;;;;OAWG;IACH,WAAW,CAAC,EAAE;QAAE,UAAU,CAAC,EAAE,MAAM,CAAC;QAAC,aAAa,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CAC/D;AAED;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,+BAA+B,CAAC;AACnE,eAAO,MAAM,yBAAyB,kCAAkC,CAAC;AAKzE,KAAK,IAAI,GAAG;IAAE,KAAK,EAAE,eAAe,CAAC;IAAC,SAAS,EAAE,SAAS,CAAA;CAAE,CAAC;AAyT7D,eAAO,MAAM,4BAA4B,KAAK,CAAC;AAM/C;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,sBAAsB,GAAG,IAAI,CAAC;IAAE,SAAS,EAAE,IAAI,CAAA;CAAE,CAAC,CA0tIhG"}
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, eventFacetInput, 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(),
@@ -465,6 +491,13 @@ export function createControlPlaneApi(options) {
465
491
  // go. Tenant-narrowed in the handler (the forced-filter pattern); the allowlist
466
492
  // alone is not authz.
467
493
  { method: 'GET', re: /\/sweep-runs$/ },
494
+ // MY traffic and MY logs — the tenant grain (observability.md §3 view 4). The only
495
+ // observability routes a non-staff caller may reach, and safe to reach precisely
496
+ // because they are a different grain: their backends are keyed on the tenant, so
497
+ // there is no fleet-wide answer for a forgotten filter to fall back to. Narrowed in
498
+ // the handler by the forced-filter pattern; the allowlist alone is not authz.
499
+ { method: 'GET', re: /\/observability\/tenant-metrics$/ },
500
+ { method: 'GET', re: /\/observability\/tenant-logs$/ },
468
501
  ];
469
502
  app.use('*', async (c, next) => {
470
503
  if (c.get('principal').kind === 'builder') {
@@ -1493,6 +1526,49 @@ export function createControlPlaneApi(options) {
1493
1526
  throw e;
1494
1527
  }
1495
1528
  });
1529
+ // #1239: facets over one scope's outbox — narrow, group, count. Delegated like
1530
+ // the history read below: through the vertical that holds the data when one
1531
+ // resolves, the co-located host otherwise.
1532
+ app.get('/tenants/:tenantId/scopes/:scopeId/facets', async (c) => {
1533
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
1534
+ const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
1535
+ const input = eventFacetInput.parse({
1536
+ groupBy: c.req.query('field') !== undefined
1537
+ ? { kind: 'payload', field: c.req.query('field') }
1538
+ : { kind: c.req.query('groupBy') },
1539
+ type: c.req.query('type') ?? undefined,
1540
+ since: c.req.query('since') ?? undefined,
1541
+ until: c.req.query('until') ?? undefined,
1542
+ limit: c.req.query('limit') ? Number(c.req.query('limit')) : undefined,
1543
+ });
1544
+ const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
1545
+ if (!scope)
1546
+ return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
1547
+ const vertical = await verticalForScope(c, scope);
1548
+ return c.json(vertical
1549
+ ? await vertical.facetEvents(scopeId, input)
1550
+ : await admin.facetEvents(c.get('actor'), tenantId, scopeId, input));
1551
+ });
1552
+ // #1235: one record's event history — the per-entity pivot of the audit spine.
1553
+ // Delegated exactly like the table reads: through the vertical that holds the
1554
+ // scope's data when one resolves, the co-located host otherwise.
1555
+ app.get('/tenants/:tenantId/scopes/:scopeId/history', async (c) => {
1556
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
1557
+ const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
1558
+ const input = entityHistoryInput.parse({
1559
+ entityType: c.req.query('entityType'),
1560
+ entityId: c.req.query('entityId'),
1561
+ limit: c.req.query('limit') ? Number(c.req.query('limit')) : undefined,
1562
+ cursor: c.req.query('cursor') ?? undefined,
1563
+ });
1564
+ const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
1565
+ if (!scope)
1566
+ return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
1567
+ const vertical = await verticalForScope(c, scope);
1568
+ return c.json(vertical
1569
+ ? await vertical.entityHistory(scopeId, input)
1570
+ : await admin.entityHistory(c.get('actor'), tenantId, scopeId, input));
1571
+ });
1496
1572
  app.get('/tenants/:tenantId/scopes/:scopeId/tables/:table', async (c) => {
1497
1573
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
1498
1574
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
@@ -3551,6 +3627,75 @@ export function createControlPlaneApi(options) {
3551
3627
  });
3552
3628
  return c.json(await options.observability.recentLogs(input));
3553
3629
  });
3630
+ // -- the TENANT grain (observability.md §3 view 4, §4.2) -----------------------
3631
+ //
3632
+ // Everything above is script grain: one deployed unit, which serves every tenant that
3633
+ // installed the vertical. Safe for staff and for the vertical's builder, forbidden to
3634
+ // an installer — §3 is explicit that per-tenant numbers from script-grain data leak.
3635
+ // These two routes are the other grain, and the difference is not a filter over the
3636
+ // same data: they read the router's own tenant-stamped datapoints and the verticals'
3637
+ // stamped log lines, which is the only place the tenant dimension exists at all.
3638
+ //
3639
+ // Unlike their neighbours these ARE in BUILDER_ROUTES, because the whole point is that
3640
+ // an installer may read them — about themselves. The tenant comes from the principal
3641
+ // for a builder and must be named explicitly by a staff/service caller: the
3642
+ // `/service-refs` forced-filter pattern, for its reason, since answering fleet-wide on
3643
+ // a forgotten parameter is the exact leak being closed here.
3644
+ app.get('/observability/tenant-metrics', async (c) => {
3645
+ if (!options.observability?.tenantMetrics) {
3646
+ return c.json({ error: 'tenant-grain observability is not configured on this control plane' }, 501);
3647
+ }
3648
+ const p = c.get('principal');
3649
+ const tenantId = p.kind === 'builder' ? p.tenantId : c.req.query('tenantId');
3650
+ if (!tenantId)
3651
+ throw new ControlPlaneError(400, 'tenantId is required');
3652
+ const input = z
3653
+ .object({
3654
+ tenantId: tenantIdSchema,
3655
+ // Narrowing WITHIN the tenant. Neither can widen the answer — the reader always
3656
+ // applies the tenant predicate — so a foreign scope id yields zero rows rather
3657
+ // than somebody else's, and needs no ownership check of its own to be safe.
3658
+ scopeId: z.string().min(1).max(64).optional(),
3659
+ vertical: z.string().min(1).max(200).optional(),
3660
+ hours: z.coerce.number().int().min(1).max(72).default(24),
3661
+ })
3662
+ .parse({
3663
+ tenantId,
3664
+ scopeId: c.req.query('scopeId') || undefined,
3665
+ vertical: c.req.query('vertical') || undefined,
3666
+ hours: c.req.query('hours'),
3667
+ });
3668
+ return c.json(await options.observability.tenantMetrics(input));
3669
+ });
3670
+ app.get('/observability/tenant-logs', async (c) => {
3671
+ if (!options.observability?.tenantLogs) {
3672
+ return c.json({ error: 'tenant-grain observability is not configured on this control plane' }, 501);
3673
+ }
3674
+ const p = c.get('principal');
3675
+ const tenantId = p.kind === 'builder' ? p.tenantId : c.req.query('tenantId');
3676
+ if (!tenantId)
3677
+ throw new ControlPlaneError(400, 'tenantId is required');
3678
+ const input = z
3679
+ .object({
3680
+ tenantId: tenantIdSchema,
3681
+ scopeId: z.string().min(1).max(64).optional(),
3682
+ vertical: z.string().min(1).max(200).optional(),
3683
+ level: z.enum(['log', 'info', 'warn', 'error', 'debug']).optional(),
3684
+ search: z.string().min(1).max(200).optional(),
3685
+ hours: z.coerce.number().int().min(1).max(72).default(24),
3686
+ limit: z.coerce.number().int().min(1).max(200).default(100),
3687
+ })
3688
+ .parse({
3689
+ tenantId,
3690
+ scopeId: c.req.query('scopeId') || undefined,
3691
+ vertical: c.req.query('vertical') || undefined,
3692
+ level: c.req.query('level') || undefined,
3693
+ search: c.req.query('search') || undefined,
3694
+ hours: c.req.query('hours'),
3695
+ limit: c.req.query('limit'),
3696
+ });
3697
+ return c.json(await options.observability.tenantLogs(input));
3698
+ });
3554
3699
  /**
3555
3700
  * Observed-vs-declared outbound egress for a vertical's deployed versions (#859, D-46).
3556
3701
  *
@@ -3901,7 +4046,7 @@ export function createControlPlaneApi(options) {
3901
4046
  throw new ControlPlaneError(409, `no platform base domain configured — a clean-room preview has no source URL to derive from`);
3902
4047
  }
3903
4048
  // Mint under `<label>.<jurisdiction>.<baseDomain>`, exactly as provisioning does
3904
- // (provision.ts `bindDefaultHostname` → `egeryds.global.substrat.run`). A clean-room
4049
+ // (provision.ts `bindDefaultHostname` → `acme.global.substrat.run`). A clean-room
3905
4050
  // preview scope is provisioned `global` by construction (see the caller), and the
3906
4051
  // wildcard DNS/cert lives on `*.global.substrat.run` — NOT the certless apex
3907
4052
  // `*.substrat.run`. `platformBaseDomains` lists every platform suffix for custom-hostname
@@ -4204,6 +4349,59 @@ export function createControlPlaneApi(options) {
4204
4349
  // cursor shape (scope-host.ts listRoles).
4205
4350
  return c.json(pageOf(entries, page.limit, (r) => `${r.tenantId}|${r.key}`));
4206
4351
  });
4352
+ // -- role ASSIGNMENTS, and orgs (#1343) ------------------------------------
4353
+ //
4354
+ // The line these sit on the far side of, and why. `defineRole` says what a role
4355
+ // MEANS — a permission change, and the permission diff is a human checkpoint
4356
+ // (D-22/D-29). It still gets no route, for the reason stated above the read.
4357
+ // ASSIGNING an already-defined role is a different act: per-principal, runtime,
4358
+ // and the same shape as the entity grants control-plane.md §4.5 already calls a
4359
+ // runtime console concern rather than a checkpoint artifact. `unassignRole`'s own
4360
+ // contract anticipates this caller — "decided above the kernel (e.g. the
4361
+ // dashboard's manage-members check)".
4362
+ //
4363
+ // Service/staff only, absent from BUILDER_ROUTES: a builder must no more be able
4364
+ // to assign itself a role than to write the directory that authenticates it.
4365
+ // Fail-closed, same law as the identity mirror. TENANT-LEVEL only — see the
4366
+ // body schema for why a scope cannot be named here.
4367
+ app.post('/tenants/:tenantId/role-assignments', async (c) => {
4368
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
4369
+ const body = tenantRoleAssignmentBody.parse(await c.req.json());
4370
+ // The whole node comes from the path. Pinning only the tenant would pin
4371
+ // nothing — see the schema's note: a node carrying a scope is written to that
4372
+ // scope's DO directly, with no tenant cross-check below.
4373
+ await admin.assignRole(c.get('actor'), {
4374
+ principalId: body.principalId,
4375
+ roleKey: body.roleKey,
4376
+ node: { tenantId, scopeId: null },
4377
+ });
4378
+ return c.body(null, 204);
4379
+ });
4380
+ app.delete('/tenants/:tenantId/role-assignments', async (c) => {
4381
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
4382
+ const body = tenantRoleAssignmentBody.parse(await c.req.json());
4383
+ // Tombstones rather than deletes (K-21) and is idempotent — unassigning what
4384
+ // was never assigned is a silent no-op, so a retry is safe. Node from the
4385
+ // path, for the same reason as the assign above.
4386
+ await admin.unassignRole(c.get('actor'), {
4387
+ principalId: body.principalId,
4388
+ roleKey: body.roleKey,
4389
+ node: { tenantId, scopeId: null },
4390
+ });
4391
+ return c.body(null, 204);
4392
+ });
4393
+ // Orgs: the portal-customer grouping (§4.1). Creating one mints no permission —
4394
+ // members reach what the org was GRANTED, and granting stays off this surface.
4395
+ app.post('/tenants/:tenantId/orgs', async (c) => {
4396
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
4397
+ const body = tenantOrgBody.parse(await c.req.json());
4398
+ await admin.createOrg(c.get('actor'), { ...body, tenantId });
4399
+ return c.body(null, 201);
4400
+ });
4401
+ app.get('/tenants/:tenantId/orgs', async (c) => {
4402
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
4403
+ return c.json(await admin.listOrgs(c.get('actor'), tenantId));
4404
+ });
4207
4405
  // -- the admin log (§4.4/§4.5) ---------------------------------------------
4208
4406
  app.get('/admin-log', async (c) => {
4209
4407
  const filter = auditLogQuery.parse({