@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 +1 -1
- package/dist/api.js +200 -2
- package/dist/api.js.map +1 -1
- package/dist/cf-observability.d.ts +18 -0
- package/dist/cf-observability.d.ts.map +1 -1
- package/dist/cf-observability.js +360 -23
- package/dist/cf-observability.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/mask.d.ts.map +1 -1
- package/dist/mask.js +20 -6
- package/dist/mask.js.map +1 -1
- package/dist/observability.d.ts +68 -0
- package/dist/observability.d.ts.map +1 -1
- package/dist/pseudonymize.d.ts +9 -1
- package/dist/pseudonymize.d.ts.map +1 -1
- package/dist/pseudonymize.js +53 -1
- package/dist/pseudonymize.js.map +1 -1
- package/dist/r2-backups.d.ts +20 -12
- package/dist/r2-backups.d.ts.map +1 -1
- package/dist/r2-backups.js +90 -0
- package/dist/r2-backups.js.map +1 -1
- package/dist/vertical-client.d.ts +5 -1
- package/dist/vertical-client.d.ts.map +1 -1
- package/dist/vertical-client.js +30 -0
- package/dist/vertical-client.js.map +1 -1
- package/package.json +5 -5
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;
|
|
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` → `
|
|
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({
|