@substrat-run/control-plane-api 0.92.1 → 0.95.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 CHANGED
@@ -45,6 +45,12 @@ export interface ConnectionInspector {
45
45
  probeCandidate?: (secret: Record<string, string>) => Promise<ConnectionProbe>;
46
46
  }
47
47
  export interface ControlPlaneApiOptions {
48
+ /**
49
+ * The platform's margin over list price for model usage it provides (#1054), whole
50
+ * percent, applied at read time by `GET /model-usage/summary`. One global number
51
+ * today; a per-provider rate is an additive change later. Default 20.
52
+ */
53
+ modelMarginPercent?: number;
48
54
  host: ScopeHost;
49
55
  /**
50
56
  * How to reach each vertical, by slug (K-31). Absent slugs simply cannot be
@@ -158,6 +164,17 @@ export interface ControlPlaneApiOptions {
158
164
  * token; set once, out of routine rotation (rotating it revokes every issued token).
159
165
  */
160
166
  pushTokenSecret?: string;
167
+ /**
168
+ * Keys the pseudonymizer behind a masked export (#1034, `pseudonymize.ts`). The salt
169
+ * never reaches the dump and no mapping is stored, so it is not a decryption key —
170
+ * what it buys is STABILITY: with one configured, two pulls of the same scope read
171
+ * the same, which is what makes a masked copy usable as a standing preview.
172
+ *
173
+ * Absent ⇒ a fresh random salt per export. Still deterministic within one response
174
+ * (a customer reads the same on every screen and in the timeline) and strictly
175
+ * safer, since two exports cannot be correlated — just not stable across pulls.
176
+ */
177
+ maskSalt?: string;
161
178
  /**
162
179
  * Cloudflare-native observability reads (design/observability.md §4.1) —
163
180
  * host-injected like `deployVertical`, so this package holds no credential and the
@@ -248,6 +265,7 @@ type Vars = {
248
265
  actor: PlatformActorId;
249
266
  principal: Principal;
250
267
  };
268
+ export declare const DEFAULT_MODEL_MARGIN_PERCENT = 20;
251
269
  /**
252
270
  * The audited HTTP surface over `HostAdmin` (control-plane.md §4.5).
253
271
  *
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 { adminAction, ASSET_PART_PREFIX, assetHash, channelName, connectionActivity, connectionActivitySource, connectionCredential, connectionFilter, connectionProbe, createTenantInput, entitlementGrantInput, hostname as hostnameSchema, hostnameRegion, hostnameStatus, identityLink, listPageQuery, matchesOutboundHost, pageOf, platformRequestFilter, principalId as principalIdSchema, promotionAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, DEFAULT_DENIAL_LIMIT, DENIAL_LIMIT_MAX, registerVerticalInput, 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, listPageQuery, matchesOutboundHost, pageOf, platformRequestFilter, principalId as principalIdSchema, promotionAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, DEFAULT_DENIAL_LIMIT, DENIAL_LIMIT_MAX, registerVerticalInput, 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 { migrationProgress, ulid } from '@substrat-run/kernel';
4
4
  import { TENANT_HEADER } from './auth.js';
5
5
  import { connectionGrantsForScope } from './vertical-client.js';
@@ -9,6 +9,7 @@ import { ControlPlaneError } from './client.js';
9
9
  import { provisionSiblingScope } from './platform-drain.js';
10
10
  import { mapError } from './errors.js';
11
11
  import { maskDump, maskRecords } from './mask.js';
12
+ import { createPseudonymizer } from './pseudonymize.js';
12
13
  import { openDump, sealDump } from './seal.js';
13
14
  import { assertSandboxContract, deployManifest, storedDeployManifest, deploymentRefFor, stableDeploymentRefFor, nextMigrationTag, upstreamStatusOf, } from './deploy.js';
14
15
  /**
@@ -260,6 +261,22 @@ const opsFailuresQuery = z.object({
260
261
  // Bounded by default exactly as /admin-log, and for the same reason.
261
262
  ...listPageQuery.shape,
262
263
  });
264
+ const modelUsageQuery = z.object({
265
+ tenantId: tenantIdSchema.optional(),
266
+ scopeId: scopeIdSchema.optional(),
267
+ vertical: z.string().optional(),
268
+ model: z.string().optional(),
269
+ since: z.string().optional(),
270
+ until: z.string().optional(),
271
+ ...listPageQuery.shape,
272
+ });
273
+ /** The summary window: a half-open `[since, until)`; defaults to the current calendar month so far. */
274
+ const modelUsageSummaryQuery = z.object({
275
+ tenantId: tenantIdSchema.optional(),
276
+ since: z.string().optional(),
277
+ until: z.string().optional(),
278
+ });
279
+ export const DEFAULT_MODEL_MARGIN_PERCENT = 20;
263
280
  /** The upstream provider's trace handle, when a failure message carries one —
264
281
  * Cloudflare's `internal error; reference = <id>` shape (#559). */
265
282
  const UPSTREAM_REFERENCE = /\breference\s*=\s*([a-z0-9]+)/i;
@@ -1992,7 +2009,14 @@ export function createControlPlaneApi(options) {
1992
2009
  const dump = await admin.exportScope(actor, tenantId, scopeId);
1993
2010
  const vertical = await verticalForScope(c, scope);
1994
2011
  const tables = vertical ? await vertical.exportScope(scopeId) : dump.tables;
1995
- return c.json({ ...dump, tables: full ? tables : maskDump(tables), masked: !full });
2012
+ if (full)
2013
+ return c.json({ ...dump, tables, masked: false });
2014
+ // One pseudonymizer for the whole response (#1034): the same customer has to read
2015
+ // the same in their own row, in every event payload that quoted them, and in the
2016
+ // timeline — a per-table generator would break exactly the joins that make a
2017
+ // masked copy worth pulling.
2018
+ const mask = await createPseudonymizer(options.maskSalt ?? crypto.randomUUID());
2019
+ return c.json({ ...dump, tables: await maskDump(tables, mask), masked: true });
1996
2020
  }
1997
2021
  catch (e) {
1998
2022
  if (e instanceof ControlPlaneError) {
@@ -2062,13 +2086,17 @@ export function createControlPlaneApi(options) {
2062
2086
  // a vertical-held scope's real tables overlay it. A reaped scope has no storage
2063
2087
  // left to read, so it is skipped here while its RECORD stays above — the tombstone
2064
2088
  // is honest, an error would not be.
2089
+ // ONE pseudonymizer for the whole export (#1034), shared by the directory half
2090
+ // and every scope's tables: the person named in `members` has to read the same in
2091
+ // the scope row that references them and in the event payload that quoted them.
2092
+ const mask = full ? undefined : await createPseudonymizer(options.maskSalt ?? crypto.randomUUID());
2065
2093
  const live = scopes.filter((s) => s.status !== 'reaped');
2066
2094
  const data = [];
2067
2095
  for (const scope of live) {
2068
2096
  const dump = await admin.exportScope(actor, tenantId, scope.id);
2069
2097
  const vertical = await verticalForScope(c, scope);
2070
2098
  const tables = vertical ? await vertical.exportScope(scope.id) : dump.tables;
2071
- data.push({ ...dump, tables: full ? tables : maskDump(tables) });
2099
+ data.push({ ...dump, tables: mask ? await maskDump(tables, mask) : tables });
2072
2100
  }
2073
2101
  // The admin log is FULL-only (#36): it records what STAFF did, so it is not the
2074
2102
  // customer's Art. 20 data, and it carries staff actor ids and internal action
@@ -2079,19 +2107,19 @@ export function createControlPlaneApi(options) {
2079
2107
  tenantId,
2080
2108
  capturedAt: new Date().toISOString(),
2081
2109
  masked: !full,
2082
- tenant: full ? tenant : maskRecords([tenant])[0],
2083
- scopes: full ? scopes : maskRecords(scopes),
2084
- orgs: full ? orgs : maskRecords(orgs),
2085
- members: full ? members : maskRecords(members),
2110
+ tenant: mask ? (await maskRecords([tenant], mask))[0] : tenant,
2111
+ scopes: mask ? await maskRecords(scopes, mask) : scopes,
2112
+ orgs: mask ? await maskRecords(orgs, mask) : orgs,
2113
+ members: mask ? await maskRecords(members, mask) : members,
2086
2114
  // Roles, entitlements and hostnames are configuration rather than personal data,
2087
2115
  // so they read the same in both fidelities — but they still go through the sweep,
2088
2116
  // because deciding per-collection what "cannot contain PII" means is exactly the
2089
2117
  // assumption that ages badly. One rule, applied everywhere.
2090
- roles: full ? roles : maskRecords(roles),
2091
- entitlements: full ? entitlements : maskRecords(entitlements),
2118
+ roles: mask ? await maskRecords(roles, mask) : roles,
2119
+ entitlements: mask ? await maskRecords(entitlements, mask) : entitlements,
2092
2120
  // Identity links are the sharpest item here: `externalId` is usually an email.
2093
- identityLinks: full ? identityLinks : maskRecords(identityLinks),
2094
- hostnames: full ? hostnames : maskRecords(hostnames),
2121
+ identityLinks: mask ? await maskRecords(identityLinks, mask) : identityLinks,
2122
+ hostnames: mask ? await maskRecords(hostnames, mask) : hostnames,
2095
2123
  stores: [...stores, ...blobStores].map((s) => ({
2096
2124
  kind: s.kind,
2097
2125
  vertical: s.vertical,
@@ -2099,7 +2127,7 @@ export function createControlPlaneApi(options) {
2099
2127
  ref: s.ref,
2100
2128
  createdAt: s.createdAt,
2101
2129
  })),
2102
- connections: full ? connections : maskRecords(connections),
2130
+ connections: mask ? await maskRecords(connections, mask) : connections,
2103
2131
  adminLog,
2104
2132
  data,
2105
2133
  };
@@ -2746,6 +2774,9 @@ export function createControlPlaneApi(options) {
2746
2774
  modules,
2747
2775
  doClasses: manifest.doClasses,
2748
2776
  bindings: [...manifest.bindings, ...storeBindings],
2777
+ // #1054: the model runtime is bound only for a version that ASKED for it, so the
2778
+ // capability is visible in the manifest diff at admit rather than fleet-wide.
2779
+ ...(manifest.usesModels ? { usesModels: true } : {}),
2749
2780
  // #340/#578: the version's static files travel with it onto the serving script —
2750
2781
  // from the RETAINED manifest, with no bytes. The runtime's asset store dedupes
2751
2782
  // per SCRIPT (not namespace-wide — the #578 finding), so the serving script only
@@ -3602,8 +3633,10 @@ export function createControlPlaneApi(options) {
3602
3633
  * hostname `<label>--<tag>.<domain>` bound to `previewId`. Non-canonical, so it never
3603
3634
  * demotes the prod surface. Shared by the fork and clean-room paths. */
3604
3635
  const bindPreviewHostname = async (actor, baseHostname, tenantId, previewId, tag, surface) => {
3605
- const [label, ...rest] = baseHostname.split('.');
3606
- const hostname = `${label}--${tag}.${rest.join('.')}`;
3636
+ const parsed = parseHostname(baseHostname);
3637
+ if (!parsed)
3638
+ throw new ControlPlaneError(400, `'${baseHostname}' is not a hostname a preview can be minted beside`);
3639
+ const hostname = withLabel(parsed, `${parsed.label}${RESERVED_LABEL_SEPARATOR}${tag}`);
3607
3640
  const existing = (await admin.listHostnames(actor, { scopeId: previewId })).find((h) => h.hostname === hostname);
3608
3641
  if (!existing) {
3609
3642
  await admin.bindHostname(actor, {
@@ -3658,7 +3691,7 @@ export function createControlPlaneApi(options) {
3658
3691
  const actor = c.get('actor');
3659
3692
  const surface = opts.surface ?? 'app';
3660
3693
  // #527 guard: a preview MUST serve the version it just bound. Routing resolves
3661
- // `COALESCE(scope.servingRef, version.deploymentRef)` (control-plane-do `readHostname`),
3694
+ // `COALESCE(scope.servingRef, version.deploymentRef)` (control-plane-do `readRoute`),
3662
3695
  // so a preview that still carries an inherited `serving_ref` — or otherwise resolves away
3663
3696
  // from this version's own dispatch script — would answer with OTHER code (the promoted
3664
3697
  // prod build) while we report success. That is worse than a failure: it sends a reviewer
@@ -3981,6 +4014,39 @@ export function createControlPlaneApi(options) {
3981
4014
  const entries = await admin.listOpsFailures(c.get('actor'), filter);
3982
4015
  return c.json(pageOf(entries, filter.limit, (e) => e.id));
3983
4016
  });
4017
+ // -- model usage (#1054): meter 3, the one D-30 could not compute -------------
4018
+ // The ledger the `model-usage` intents drain into. Staff-only: the tenant-facing
4019
+ // read is the vertical's own usage screen over its metering engine; this is the
4020
+ // platform's copy, the one an invoice reconciles against.
4021
+ app.get('/model-usage', async (c) => {
4022
+ const filter = modelUsageQuery.parse({
4023
+ tenantId: c.req.query('tenantId'),
4024
+ scopeId: c.req.query('scopeId'),
4025
+ vertical: c.req.query('vertical'),
4026
+ model: c.req.query('model'),
4027
+ since: c.req.query('since'),
4028
+ until: c.req.query('until'),
4029
+ limit: c.req.query('limit'),
4030
+ cursor: c.req.query('cursor'),
4031
+ order: c.req.query('order'),
4032
+ });
4033
+ const entries = await admin.listModelUsage(c.get('actor'), filter);
4034
+ return c.json(pageOf(entries, filter.limit, (e) => e.id));
4035
+ });
4036
+ // Folded per (tenant, vertical, model) with the platform's margin applied at read
4037
+ // time. Nothing is stored by the read: D-30's "meter, don't bill" holds.
4038
+ app.get('/model-usage/summary', async (c) => {
4039
+ const q = modelUsageSummaryQuery.parse({
4040
+ tenantId: c.req.query('tenantId'),
4041
+ since: c.req.query('since'),
4042
+ until: c.req.query('until'),
4043
+ });
4044
+ const now = new Date();
4045
+ const since = q.since ?? new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString();
4046
+ const until = q.until ?? now.toISOString();
4047
+ const summary = await admin.summarizeModelUsage(c.get('actor'), { ...(q.tenantId ? { tenantId: q.tenantId } : {}), since, until }, options.modelMarginPercent ?? DEFAULT_MODEL_MARGIN_PERCENT);
4048
+ return c.json(summary);
4049
+ });
3984
4050
  return app;
3985
4051
  }
3986
4052
  //# sourceMappingURL=api.js.map