@substrat-run/control-plane-api 0.58.0 → 0.60.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
@@ -1,5 +1,5 @@
1
1
  import { Hono } from 'hono';
2
- import type { PlatformActorId } from '@substrat-run/contracts';
2
+ import type { Connection, ConnectionActivity, ConnectionProbe, 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
5
  import { type VerticalClient } from './vertical-client.js';
@@ -10,6 +10,23 @@ import type { PlatformRuntime } from './platform-runtime.js';
10
10
  import { type DoNamespaceReader } from './do-namespaces.js';
11
11
  import type { DirectoryBackupStore, ScopeBackupStore } from './backups.js';
12
12
  import { type CustomHostnameProvisioner } from './custom-hostnames.js';
13
+ /**
14
+ * What one provider can answer about a live connection (#605) — the seam behind
15
+ * `POST /tenants/:t/connections/:id/verify` and `GET …/activity`.
16
+ *
17
+ * Both halves take the connection ROW, never a credential: opening the secret is the
18
+ * connector's own act through `HostAdmin`, so the plaintext still never crosses this
19
+ * package. `probe` says whether the provider accepts the credential and whose account
20
+ * it is; `activity` projects the connector's dispatch ledger into the declared shape —
21
+ * a projection precisely because a raw ledger row may carry connector secrets (Scrive's
22
+ * callback capability token), and redaction has to be structural.
23
+ */
24
+ export interface ConnectionInspector {
25
+ probe?: (host: ScopeHost, connection: Connection) => Promise<ConnectionProbe>;
26
+ activity?: (host: ScopeHost, connection: Connection, opts: {
27
+ live: boolean;
28
+ }) => Promise<ConnectionActivity>;
29
+ }
13
30
  export interface ControlPlaneApiOptions {
14
31
  host: ScopeHost;
15
32
  /**
@@ -64,12 +81,15 @@ export interface ControlPlaneApiOptions {
64
81
  */
65
82
  fetchVerticalModules?: FetchVerticalModulesFn;
66
83
  /**
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).
84
+ * Reads one static file's bytes back from a script in the namespace (#578) — the
85
+ * asset twin of `fetchVerticalModules`. The runtime's asset store dedupes per
86
+ * SCRIPT, not namespace-wide, so the first serve of an asset-carrying version onto
87
+ * the stable serving script always finds its hashes missing there; this seam is how
88
+ * the serve recovers the bytes the push uploaded to the version's archive script.
89
+ * Host-injected like `deployVertical` (on Cloudflare, a dispatch fetch — the archive
90
+ * script's edge serves its own assets without invoking the worker). Absent ⇒ a
91
+ * re-serve can only ride what the stable script already holds and refuses honestly
92
+ * otherwise.
73
93
  */
74
94
  fetchVerticalAsset?: FetchVerticalAssetFn;
75
95
  /**
@@ -121,6 +141,20 @@ export interface ControlPlaneApiOptions {
121
141
  * means forgetting that costs a feature, never a leak.
122
142
  */
123
143
  observability?: ObservabilityReader;
144
+ /**
145
+ * Per-provider connection **inspectors** (#605), keyed by provider slug — what makes
146
+ * an integration something an operator can interrogate rather than trust.
147
+ *
148
+ * Host-injected for the same reason `deployVertical` and `observability` are: this
149
+ * package holds no connector and must learn no provider's vocabulary. The host wires
150
+ * the same connector closure it already registers for dispatch and sweep (the
151
+ * `sweepers` idiom, `apps/control-plane/src/worker.ts`), and the routes below stay
152
+ * pure transport over a declared, provider-agnostic shape.
153
+ *
154
+ * An unregistered provider 501s rather than answering emptily — "this platform cannot
155
+ * verify a Fortnox key yet" is a true statement; "your Fortnox key is fine" is not.
156
+ */
157
+ connectionInspectors?: Record<string, ConnectionInspector>;
124
158
  /**
125
159
  * Where this platform's compute actually runs — the coordinates a staff surface needs
126
160
  * to hand an operator a link INTO the provider's own console (the right script, the
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,8 +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, connectionActivity, connectionFilter, connectionProbe, 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
5
  import { connectionGrantsForScope } from './vertical-client.js';
6
+ import { ConnectionRelayError, relayConnectionUpsert } from './connection-relay.js';
6
7
  import { ControlPlaneError } from './client.js';
7
8
  import { provisionSiblingScope } from './platform-drain.js';
8
9
  import { mapError } from './errors.js';
@@ -617,6 +618,116 @@ export function createControlPlaneApi(options) {
617
618
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
618
619
  return c.json(await admin.listConnectionGrants(c.get('actor'), tenantId));
619
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
+ });
676
+ /**
677
+ * The tenant-scoped connection lookup the two inspection routes share. Same K-3
678
+ * existence hiding as the revoke above — a foreign tenant's connection id reads
679
+ * exactly like an absent one — and revoked rows are excluded: a withdrawn credential
680
+ * has no secret left to probe with, so "revoked" is a 404 for these reads too.
681
+ */
682
+ const inspectableConnection = async (c) => {
683
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
684
+ const id = c.req.param('id');
685
+ const rows = await admin.listConnections(c.get('actor'), { tenantId });
686
+ return rows.find((r) => r.id === id);
687
+ };
688
+ /**
689
+ * **Verify a credential against the provider** (#605) — the read that turns "stored"
690
+ * into "works, and for this account".
691
+ *
692
+ * A POST because it reaches out: it spends an authenticated call at the provider and
693
+ * writes health (`recordConnectionUse` rides the connector's sanctioned `fetch`), so
694
+ * it is not the safe, cacheable read a GET promises. Not audited — like every other
695
+ * connection USE, it is machine telemetry, and §3.4's rule is that the audit log
696
+ * records control-plane mutations, not outbound calls.
697
+ *
698
+ * A rejected credential is `200 { ok: false, error }`, not an HTTP error: the
699
+ * provider answered, and its answer is the payload. Only a platform fault (no
700
+ * inspector wired, an unopenable secret) is a non-200.
701
+ */
702
+ app.post('/tenants/:tenantId/connections/:id/verify', async (c) => {
703
+ const row = await inspectableConnection(c);
704
+ if (!row)
705
+ return c.json({ error: 'unknown connection' }, 404);
706
+ const probe = options.connectionInspectors?.[row.provider]?.probe;
707
+ if (!probe) {
708
+ return c.json({ error: `no probe registered for provider '${row.provider}'` }, 501);
709
+ }
710
+ return c.json(connectionProbe.parse(await probe(host, row)));
711
+ });
712
+ /**
713
+ * **What the connection has done** (#605) — the connector's dispatch ledger, projected.
714
+ *
715
+ * `?live=1` asks the provider for current state as well; the result says which it got
716
+ * (`live`), because presenting the ledger's view as the provider's would be inventing
717
+ * facts. The projection is the connector's, never this package's: a raw ledger row can
718
+ * carry connector secrets, so it is never serialized here.
719
+ */
720
+ app.get('/tenants/:tenantId/connections/:id/activity', async (c) => {
721
+ const row = await inspectableConnection(c);
722
+ if (!row)
723
+ return c.json({ error: 'unknown connection' }, 404);
724
+ const activity = options.connectionInspectors?.[row.provider]?.activity;
725
+ if (!activity) {
726
+ return c.json({ error: `no activity view registered for provider '${row.provider}'` }, 501);
727
+ }
728
+ const live = c.req.query('live') === '1';
729
+ return c.json(connectionActivity.parse(await activity(host, row, { live })));
730
+ });
620
731
  // -- the scope directory (§3.2/§4.2) ---------------------------------------
621
732
  app.get('/scopes', async (c) => {
622
733
  const p = c.get('principal');
@@ -2252,6 +2363,7 @@ export function createControlPlaneApi(options) {
2252
2363
  if (!version?.deploymentRef) {
2253
2364
  throw new ControlPlaneError(502, `version ${versionId} has no archive script to serve from`);
2254
2365
  }
2366
+ const archiveRef = version.deploymentRef;
2255
2367
  const manifestJson = await admin.versionManifest(actor, slug, versionId);
2256
2368
  if (!manifestJson) {
2257
2369
  throw new ControlPlaneError(502, `version ${versionId} retained no manifest — pushed pre-#286; push it again to serve in place`);
@@ -2282,24 +2394,23 @@ export function createControlPlaneApi(options) {
2282
2394
  modules,
2283
2395
  doClasses: manifest.doClasses,
2284
2396
  bindings: [...manifest.bindings, ...storeBindings],
2285
- // #340: the version's static files travel with it onto the serving script — from
2286
- // the RETAINED manifest, with no bytes up front. The asset store dedups PER
2287
- // SCRIPT (#578), so hashes the push uploaded to the archive script are still
2288
- // missing for the stable script each file therefore carries a `fetchContent`
2289
- // that reads the bytes back from the archive script's runtime-served assets,
2290
- // invoked (and hash-verified) only for hashes the upload session reports
2291
- // missing. The archive script gives back the modules (#286) AND the assets:
2292
- // nothing but the manifest is retained.
2397
+ // #340/#578: the version's static files travel with it onto the serving script —
2398
+ // from the RETAINED manifest, with no bytes. The runtime's asset store dedupes
2399
+ // per SCRIPT (not namespace-wide the #578 finding), so the serving script only
2400
+ // skips hashes it has itself held before; everything else every first serve of
2401
+ // new content is recovered on demand from the version's archive script, the
2402
+ // same store the modules come back from (#286), and verified against its
2403
+ // content-address before upload. This is why the manifest is retained rather
2404
+ // than the bytes: the archive script gives back modules AND assets.
2293
2405
  ...(manifest.assets
2294
2406
  ? {
2295
2407
  assets: {
2296
2408
  ...manifest.assets,
2297
- files: manifest.assets.files.map((f) => ({
2298
- ...f,
2299
- ...(options.fetchVerticalAsset
2300
- ? { fetchContent: () => options.fetchVerticalAsset(version.deploymentRef, f.path) }
2301
- : {}),
2302
- })),
2409
+ ...(options.fetchVerticalAsset
2410
+ ? {
2411
+ recoverContent: (asset) => options.fetchVerticalAsset(archiveRef, asset),
2412
+ }
2413
+ : {}),
2303
2414
  },
2304
2415
  }
2305
2416
  : {}),