@substrat-run/control-plane-api 0.57.1 → 0.59.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
@@ -2,7 +2,7 @@ import { Hono } from 'hono';
2
2
  import type { PlatformActorId } from '@substrat-run/contracts';
3
3
  import type { ScopeHost } from '@substrat-run/kernel';
4
4
  import type { PlatformActorAuth, BuilderAuth, Principal } from './auth.js';
5
- import type { VerticalClient } from './vertical-client.js';
5
+ import { type VerticalClient } from './vertical-client.js';
6
6
  import type { DeployVerticalFn, FetchVerticalAssetFn, FetchVerticalModulesFn } from './deploy.js';
7
7
  import type { PatchScriptBindingsFn } from './wfp.js';
8
8
  import type { ObservabilityReader } from './observability.js';
@@ -64,12 +64,15 @@ export interface ControlPlaneApiOptions {
64
64
  */
65
65
  fetchVerticalModules?: FetchVerticalModulesFn;
66
66
  /**
67
- * Reads one static file back from a script's runtime-served assets (#578) — how a
68
- * serving upload recovers asset bytes the stable script's upload session reports
69
- * missing (the asset store dedups per script, so the push's upload to the archive
70
- * script does not cover a re-serve). Host-injected like `fetchVerticalModules`.
71
- * Absent a re-serve of an asset-carrying version refuses when the runtime wants
72
- * bytes (the pre-#578 behavior).
67
+ * Reads one static file's bytes back from a script in the namespace (#578) — the
68
+ * asset twin of `fetchVerticalModules`. The runtime's asset store dedupes per
69
+ * SCRIPT, not namespace-wide, so the first serve of an asset-carrying version onto
70
+ * the stable serving script always finds its hashes missing there; this seam is how
71
+ * the serve recovers the bytes the push uploaded to the version's archive script.
72
+ * Host-injected like `deployVertical` (on Cloudflare, a dispatch fetch — the archive
73
+ * script's edge serves its own assets without invoking the worker). Absent ⇒ a
74
+ * re-serve can only ride what the stable script already holds and refuses honestly
75
+ * otherwise.
73
76
  */
74
77
  fetchVerticalAsset?: FetchVerticalAssetFn;
75
78
  /**
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,7 +1,9 @@
1
1
  import { Hono } from 'hono';
2
- import { adminAction, ASSET_PART_PREFIX, assetHash, channelName, createTenantInput, entitlementGrantInput, hostname as hostnameSchema, hostnameRegion, hostnameStatus, identityLink, listPageQuery, pageOf, principalId as principalIdSchema, promotionAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, registerVerticalInput, scopeDump, dataSubjectId as dataSubjectIdSchema, scopeId as scopeIdSchema, scopeStatus, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, versionOrigin, z, } from '@substrat-run/contracts';
2
+ import { adminAction, ASSET_PART_PREFIX, assetHash, channelName, connectionFilter, createTenantInput, entitlementGrantInput, hostname as hostnameSchema, hostnameRegion, hostnameStatus, identityLink, listPageQuery, pageOf, principalId as principalIdSchema, promotionAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, registerVerticalInput, scopeDump, dataSubjectId as dataSubjectIdSchema, scopeId as scopeIdSchema, scopeStatus, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, versionOrigin, z, } from '@substrat-run/contracts';
3
3
  import { migrationProgress, ulid } from '@substrat-run/kernel';
4
4
  import { TENANT_HEADER } from './auth.js';
5
+ import { connectionGrantsForScope } from './vertical-client.js';
6
+ import { ConnectionRelayError, relayConnectionUpsert } from './connection-relay.js';
5
7
  import { ControlPlaneError } from './client.js';
6
8
  import { provisionSiblingScope } from './platform-drain.js';
7
9
  import { mapError } from './errors.js';
@@ -610,6 +612,67 @@ export function createControlPlaneApi(options) {
610
612
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
611
613
  return c.json(await admin.listIdentityLinks(c.get('actor'), tenantId));
612
614
  });
615
+ // The tenant's live connection grants (#592) — the readable "what may this connection
616
+ // invoke" (connections.md §6.2.4 Q2), and the rows provision/reconcile deliver from.
617
+ app.get('/tenants/:tenantId/connection-grants', async (c) => {
618
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
619
+ return c.json(await admin.listConnectionGrants(c.get('actor'), tenantId));
620
+ });
621
+ // -- the connection store, tenant-scoped (connections.md §3.5) -------------
622
+ //
623
+ // The dashboard's door to the PLATFORM's connection store. The dashboard keeps its own
624
+ // directory (its GitHub App connections live there, consumed by the dashboard itself),
625
+ // but a provider credential a PLATFORM-run connector consumes (Scrive, Fortnox) must
626
+ // land in THIS directory — `connector:<provider>` dispatch opens connections here, and
627
+ // a row written anywhere else is invisible to it. Metadata only on the read: the
628
+ // `Connection` type cannot carry a secret (contract-tested), so listing is safe as-is.
629
+ // Not in BUILDER_ROUTES — service/staff only, fail-closed, same law as the identity
630
+ // mirror above.
631
+ app.get('/tenants/:tenantId/connections', async (c) => {
632
+ const filter = connectionFilter.parse({
633
+ tenantId: tenantIdSchema.parse(c.req.param('tenantId')),
634
+ vertical: c.req.query('vertical'),
635
+ provider: c.req.query('provider'),
636
+ includeRevoked: c.req.query('includeRevoked') === '1' ? true : undefined,
637
+ });
638
+ return c.json(await admin.listConnections(c.get('actor'), filter));
639
+ });
640
+ // The same upsert semantics as `/internal/connections/upsert` (§3.5.2) — create under a
641
+ // fresh id, or rotate the one live row in place so its grant tuples survive — behind
642
+ // platform-actor auth instead of the vertical-harness PLATFORM_SECRET. The tenant is
643
+ // the path's, never the body's: a body naming a different tenant is refused, not
644
+ // silently rewritten.
645
+ app.post('/tenants/:tenantId/connections', async (c) => {
646
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
647
+ const raw = await c.req.json();
648
+ const body = typeof raw === 'object' && raw !== null ? raw : {};
649
+ if (body.tenantId !== undefined && body.tenantId !== tenantId) {
650
+ return c.json({ error: 'body tenantId disagrees with the route tenant' }, 400);
651
+ }
652
+ try {
653
+ return c.json(await relayConnectionUpsert(host, c.get('actor'), { ...body, tenantId }));
654
+ }
655
+ catch (err) {
656
+ if (err instanceof ConnectionRelayError)
657
+ return c.json({ error: err.message }, err.status);
658
+ throw err;
659
+ }
660
+ });
661
+ // Revoke — terminal (the sealed secret is deleted, grants tombstone; a replacement is a
662
+ // new connection). Tenant-scoped with K-3 existence hiding: a foreign tenant's
663
+ // connection id is indistinguishable from an absent one. Idempotent: revoking an
664
+ // already-revoked row is a 204 no-op, not an error.
665
+ app.delete('/tenants/:tenantId/connections/:id', async (c) => {
666
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
667
+ const id = c.req.param('id');
668
+ const rows = await admin.listConnections(c.get('actor'), { tenantId, includeRevoked: true });
669
+ const row = rows.find((r) => r.id === id);
670
+ if (!row)
671
+ return c.json({ error: 'unknown connection' }, 404);
672
+ if (row.status !== 'revoked')
673
+ await admin.revokeConnection(c.get('actor'), row.id);
674
+ return c.body(null, 204);
675
+ });
613
676
  // -- the scope directory (§3.2/§4.2) ---------------------------------------
614
677
  app.get('/scopes', async (c) => {
615
678
  const p = c.get('principal');
@@ -1021,8 +1084,18 @@ export function createControlPlaneApi(options) {
1021
1084
  // #406: re-gathered and re-delivered like entitlements, so a reconcile also repairs a
1022
1085
  // dropped identity-link delivery — and is the channel a link/unlink after provision rides.
1023
1086
  const identityLinks = (await admin.listIdentityLinks(actor, tenantId)).map(({ tenantId: _tenantId, ...link }) => link);
1087
+ // #592: connection grants ride the same authoritative gather — the back-fill for a
1088
+ // scope provisioned before `grantToConnection` ran, and how a revoked connection's
1089
+ // grants stop being delivered (they are absent from the directory's live rows).
1090
+ const connectionGrants = connectionGrantsForScope(await admin.listConnectionGrants(actor, tenantId), scope.vertical, scopeId);
1024
1091
  try {
1025
- const result = await vertical.reconcileInstance({ tenantId, scopeId, entitlements, identityLinks });
1092
+ const result = await vertical.reconcileInstance({
1093
+ tenantId,
1094
+ scopeId,
1095
+ entitlements,
1096
+ identityLinks,
1097
+ connectionGrants,
1098
+ });
1026
1099
  return c.json(result);
1027
1100
  }
1028
1101
  catch (e) {
@@ -1906,6 +1979,11 @@ export function createControlPlaneApi(options) {
1906
1979
  // so its auth adapter resolves logins from local storage, and offboarding becomes an
1907
1980
  // unlink + re-deliver instead of a source edit + deploy.
1908
1981
  const identityLinks = (await admin.listIdentityLinks(c.get('actor'), input.tenantId)).map(({ tenantId: _tenantId, ...link }) => link);
1982
+ // #592: connection grants too — tenant-wide rows materialize for the NEW scope, so an
1983
+ // install provisioned after `grantToConnection` holds the same `connection:<id>` tuple
1984
+ // as one provisioned before it, and the connector return path works without a human
1985
+ // replaying grants per install.
1986
+ const connectionGrants = connectionGrantsForScope(await admin.listConnectionGrants(c.get('actor'), input.tenantId), slug, input.scopeId);
1909
1987
  // #301 PR-2: per-tenant relational stores the vertical DECLARED, minted here (before
1910
1988
  // the callback — the vertical migrates the store inside the K-31 ready-gate, so it
1911
1989
  // must exist and be bound first). Idempotent like the endpoint: a retried provision
@@ -1937,6 +2015,7 @@ export function createControlPlaneApi(options) {
1937
2015
  ...input,
1938
2016
  entitlements,
1939
2017
  identityLinks,
2018
+ connectionGrants,
1940
2019
  ...(tenantStores.length ? { tenantStores } : {}),
1941
2020
  }));
1942
2021
  return c.json(instance, 201);
@@ -2229,6 +2308,7 @@ export function createControlPlaneApi(options) {
2229
2308
  if (!version?.deploymentRef) {
2230
2309
  throw new ControlPlaneError(502, `version ${versionId} has no archive script to serve from`);
2231
2310
  }
2311
+ const archiveRef = version.deploymentRef;
2232
2312
  const manifestJson = await admin.versionManifest(actor, slug, versionId);
2233
2313
  if (!manifestJson) {
2234
2314
  throw new ControlPlaneError(502, `version ${versionId} retained no manifest — pushed pre-#286; push it again to serve in place`);
@@ -2259,24 +2339,23 @@ export function createControlPlaneApi(options) {
2259
2339
  modules,
2260
2340
  doClasses: manifest.doClasses,
2261
2341
  bindings: [...manifest.bindings, ...storeBindings],
2262
- // #340: the version's static files travel with it onto the serving script — from
2263
- // the RETAINED manifest, with no bytes up front. The asset store dedups PER
2264
- // SCRIPT (#578), so hashes the push uploaded to the archive script are still
2265
- // missing for the stable script each file therefore carries a `fetchContent`
2266
- // that reads the bytes back from the archive script's runtime-served assets,
2267
- // invoked (and hash-verified) only for hashes the upload session reports
2268
- // missing. The archive script gives back the modules (#286) AND the assets:
2269
- // nothing but the manifest is retained.
2342
+ // #340/#578: the version's static files travel with it onto the serving script —
2343
+ // from the RETAINED manifest, with no bytes. The runtime's asset store dedupes
2344
+ // per SCRIPT (not namespace-wide the #578 finding), so the serving script only
2345
+ // skips hashes it has itself held before; everything else every first serve of
2346
+ // new content is recovered on demand from the version's archive script, the
2347
+ // same store the modules come back from (#286), and verified against its
2348
+ // content-address before upload. This is why the manifest is retained rather
2349
+ // than the bytes: the archive script gives back modules AND assets.
2270
2350
  ...(manifest.assets
2271
2351
  ? {
2272
2352
  assets: {
2273
2353
  ...manifest.assets,
2274
- files: manifest.assets.files.map((f) => ({
2275
- ...f,
2276
- ...(options.fetchVerticalAsset
2277
- ? { fetchContent: () => options.fetchVerticalAsset(version.deploymentRef, f.path) }
2278
- : {}),
2279
- })),
2354
+ ...(options.fetchVerticalAsset
2355
+ ? {
2356
+ recoverContent: (asset) => options.fetchVerticalAsset(archiveRef, asset),
2357
+ }
2358
+ : {}),
2280
2359
  },
2281
2360
  }
2282
2361
  : {}),