@teamscala/service-boot 0.23.524 → 0.23.525

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.
Files changed (81) hide show
  1. package/package.json +1 -9
  2. package/src/agreement-gate/authorized-emails.ts +0 -7
  3. package/src/agreement-gate/placeholder-strip.ts +0 -2
  4. package/src/agreement-gate/settings.ts +0 -15
  5. package/src/agreement-gate.ts +0 -34
  6. package/src/attach-proposal-accept.ts +1 -1
  7. package/src/attach-proposal-magic.ts +1 -1
  8. package/src/auth/sign-out.ts +0 -2
  9. package/src/auth-gate/public-paths.ts +0 -86
  10. package/src/auth-gate.ts +0 -19
  11. package/src/bind-page-handler.ts +0 -76
  12. package/src/build-client-assets/hydrate-bundle.ts +0 -126
  13. package/src/build-client-assets/manifest.ts +0 -18
  14. package/src/build-client-assets/source-candidates.ts +0 -34
  15. package/src/build-client-assets/sourcemaps-upload.ts +0 -42
  16. package/src/build-client-assets/styles-build.ts +0 -50
  17. package/src/capability-slug.ts +0 -10
  18. package/src/compose-page-title.ts +0 -9
  19. package/src/content-gate/access.ts +0 -26
  20. package/src/content-gate/catalog.ts +0 -29
  21. package/src/content-gate/config-types.ts +3 -84
  22. package/src/content-gate/gate.ts +0 -20
  23. package/src/content-page-floor.ts +0 -11
  24. package/src/error-page-logo.ts +2 -12
  25. package/src/expand-roster.ts +0 -9
  26. package/src/gpt-gate.ts +1 -1
  27. package/src/hono-error-handler.ts +0 -4
  28. package/src/install-logging.ts +0 -32
  29. package/src/load-page-manifest.ts +0 -6
  30. package/src/load-service-install-row.ts +41 -4
  31. package/src/module-not-found.ts +0 -11
  32. package/src/mount-orpc-router.ts +1 -88
  33. package/src/mount-server-packages.ts +0 -29
  34. package/src/mutable-app-ref.ts +0 -11
  35. package/src/orpc-contract-rows.json +289 -0
  36. package/src/orpc-openapi-document.ts +0 -6
  37. package/src/orpc-openapi-gate.ts +0 -10
  38. package/src/pg-boss-handler-registry.ts +0 -12
  39. package/src/pg-boss-mount.ts +0 -101
  40. package/src/proposal-gate.ts +1 -41
  41. package/src/quiz-gate.ts +1 -9
  42. package/src/register-page-server-client.ts +0 -53
  43. package/src/register-registry-page-route/ai-chat-prefetch.ts +0 -18
  44. package/src/register-registry-page-route/apply-page-branding.ts +0 -90
  45. package/src/register-registry-page-route/captured-loader-config.ts +0 -1
  46. package/src/register-registry-page-route/graph-cache.ts +0 -17
  47. package/src/register-registry-page-route/meta-title.ts +0 -24
  48. package/src/register-registry-page-route/page-data-shared.ts +0 -6
  49. package/src/register-registry-page-route/prefetch-org-id.ts +0 -17
  50. package/src/register-registry-page-route/proposal-approval-email-token.ts +0 -11
  51. package/src/register-registry-page-route/proposal-brand-stamping.ts +0 -20
  52. package/src/register-registry-page-route/public-shell-page-data.ts +0 -26
  53. package/src/register-registry-page-route/resolve-request-graph.ts +0 -57
  54. package/src/register-registry-page-route/run-content-gates.ts +2 -54
  55. package/src/register-registry-page-route/warn-uncovered-blocks.ts +0 -10
  56. package/src/register-registry-page-route.ts +0 -75
  57. package/src/register-static-assets.ts +0 -45
  58. package/src/resolve-brand-favicon.ts +0 -9
  59. package/src/resolve-service-port.ts +0 -11
  60. package/src/resolve-theme-mode.ts +0 -8
  61. package/src/resolvers/app-header.ts +0 -1
  62. package/src/resolvers/fetch-org-brand.ts +0 -1
  63. package/src/resolvers/portal-route-paths.ts +0 -17
  64. package/src/resource-not-found.ts +0 -24
  65. package/src/select-service-routes.ts +0 -14
  66. package/src/theme-fonts-link.ts +0 -5
  67. package/src/two-phase-full-app.ts +0 -76
  68. package/src/two-phase-minimal-app.ts +1 -105
  69. package/src/types.ts +0 -14
  70. package/src/ui-service.ts +1 -42
  71. package/src/content-gate/gate-entry-marker.test.ts +0 -111
  72. package/src/content-gate/token.segmentation.test.ts +0 -76
  73. package/src/content-gate/token.test.ts +0 -65
  74. package/src/content-gate/token.ts +0 -197
  75. package/src/is-react-element-type.test.ts +0 -45
  76. package/src/is-react-element-type.ts +0 -10
  77. package/src/page-server-orpc/hub-mcp-client.ts +0 -14
  78. package/src/pg-boss-mount.test.ts +0 -101
  79. package/src/resource-chat-inject.page-assistant.test.ts +0 -145
  80. package/src/resource-chat-inject.test.ts +0 -240
  81. package/src/resource-chat-inject.ts +0 -357
@@ -3,63 +3,21 @@
3
3
  * @status handwritten
4
4
  */
5
5
 
6
+ import type { GateSegmentation } from "@teamscala/agreement-generation/content-gate-token";
7
+
6
8
  type Resolved = Record<string, unknown>;
7
9
 
8
- /**
9
- * Optional prospect-segmentation a lead-capture gate may capture alongside the
10
- * identity. The quiz gate collects these on its composed identity screen; the
11
- * token carries them so the next render and the final submit know the visitor
12
- * fully, with NO client storage (same reasoning as the captured name/phone).
13
- */
14
- export interface GateSegmentation {
15
- company?: string;
16
- linkedinUrl?: string;
17
- website?: string;
18
- teamSize?: string;
19
- }
10
+ export type { GateSegmentation };
20
11
 
21
- /** Drives the SSR-time gate (gate.ts / runContentGate). */
22
12
  export interface ContentGateConfig {
23
- /** Cookie that carries this gate's access token (e.g. "ag_token"). */
24
13
  cookieName: string;
25
- /**
26
- * The gated resource id from the resolved graph, or "" when this gate does
27
- * not apply to the current page (the engine then no-ops). This is also how a
28
- * gate detects "is this my kind of page". Async to allow DB-backed resolution
29
- * (e.g. agreement gate resolving the UUID from the page→agreement association
30
- * when no pre-placed gate block exists).
31
- */
32
14
  getResourceId(resolved: Resolved): string | Promise<string>;
33
- /**
34
- * Verify the cookie's token for this resource → the verified identity
35
- * ({email, optional display name}), or null. Token-agnostic by design:
36
- * proposals use the shared content-gate token; agreements keep their existing
37
- * token (also minted by the signing ORPC), so the engine never assumes one
38
- * token format. Allow-list gates return just {email}; the GPT lead-capture
39
- * gate also returns the captured name (carried in its token).
40
- */
41
15
  verifyToken(
42
16
  token: string | undefined,
43
17
  resourceId: string,
44
18
  nowMs: number,
45
19
  ): { email: string; name?: string; phone?: string; segmentation?: GateSegmentation } | null;
46
- /**
47
- * BAKED-IN unified auth. When a gate provides this resolver, the engine
48
- * AUTOMATICALLY releases a logged-in viewer whose session email is in the
49
- * authorized set — with no valid token and no per-gate code. This is the
50
- * platform default for EVERY gated resource: any application surface with
51
- * authentication gets "a signed-in, authorized viewer is never re-asked for
52
- * their email/OTP" for free, just by exposing its authorized-email set (the
53
- * same resolver its AccessConfig uses). Not called when the session is
54
- * anonymous. Omit only for open-capture gates that have no authorized set.
55
- */
56
20
  resolveAuthorizedEmails?(resourceId: string): Promise<Set<string>>;
57
- /**
58
- * OPTIONAL OVERRIDE of the baked-in session check above — for the rare gate
59
- * whose session→identity rule isn't simply "session email ∈ authorized set".
60
- * When present it wins; otherwise the engine uses `resolveAuthorizedEmails`.
61
- * Called only when no valid access token is present.
62
- */
63
21
  sessionAuthorize?(
64
22
  resolved: Resolved,
65
23
  resourceId: string,
@@ -67,62 +25,23 @@ export interface ContentGateConfig {
67
25
  | { email: string; name?: string }
68
26
  | null
69
27
  | Promise<{ email: string; name?: string } | null>;
70
- /** Reveal + seed the verified viewer's identity/session (token was valid). */
71
28
  onAuthorized(
72
29
  resolved: Resolved,
73
30
  resourceId: string,
74
31
  identity: { email: string; name?: string; phone?: string; segmentation?: GateSegmentation },
75
32
  ): void | Promise<void>;
76
- /** Withhold content (strip/inject the gate) — token absent/invalid. Async-
77
- * capable so a gate can load its display/behaviour settings from a registry
78
- * row (DB-driven, not hardcoded); sync implementations remain valid. */
79
33
  onUnauthorized(resolved: Resolved, resourceId: string): void | Promise<void>;
80
- /**
81
- * OPT-IN REQUIRED-GATE-ENTRY. When set, a valid token authorizes ONLY on a
82
- * request that also carries this marker cookie — set by the access endpoint
83
- * alongside the token and consumed (cleared) by the render it authorizes, so
84
- * the marker lives exactly one view: the submit's own redirect releases the
85
- * content, and every later load (a refresh) is unauthorized, showing the
86
- * gate again. Omit for gates whose token is session-scoped by design.
87
- */
88
34
  entryCookieName?: string;
89
35
  }
90
36
 
91
- /** Drives the public access endpoint (access.ts / attachContentGateAccess). */
92
37
  export interface AccessConfig {
93
- /** Route to mount, e.g. "/api/proposal-access". */
94
38
  path: string;
95
- /** Cookie to set on a match. */
96
39
  cookieName: string;
97
- /** Cookie lifetime in ms (Max-Age). */
98
40
  ttlMs: number;
99
- /**
100
- * Marker cookie set alongside the token on a match. The gate requires it
101
- * (ContentGateConfig.entryCookieName) and the render it authorizes clears
102
- * it, so it authorizes exactly the submit's own redirect.
103
- */
104
41
  entryCookieName?: string;
105
- /** Form field carrying the resource id, e.g. "proposalId". */
106
42
  recordIdField: string;
107
- /**
108
- * Open lead-capture gate: ANY non-empty email is accepted (no allow-list).
109
- * For surfaces whose "gate" captures a visitor rather than authorizing one
110
- * against a fixed set (the public GPT). When true, `resolveAuthorizedEmails`
111
- * is not called. Default/omitted = allow-list mode (proposals, agreements).
112
- */
113
43
  openCapture?: boolean;
114
- /**
115
- * The authorized email set for a resource (lowercased), checked server-side.
116
- * Not called when `openCapture` is true.
117
- */
118
44
  resolveAuthorizedEmails(resourceId: string): Promise<Set<string>>;
119
- /**
120
- * Mint the access token for a matched email (token-agnostic per gate).
121
- * `name` and `phone` are the optional captured identity, `segmentation` the
122
- * optional captured prospect fields (open-capture gates carry them in the
123
- * token; allow-list gates ignore them). They exist so a capture gate needs
124
- * no browser storage to remember who this is.
125
- */
126
45
  signToken(
127
46
  resourceId: string,
128
47
  email: string,
@@ -3,20 +3,6 @@
3
3
  * @status handwritten
4
4
  */
5
5
 
6
- /**
7
- * What a gate decided, so a caller can act on it without re-deriving it.
8
- *
9
- * THE ASSISTANT MUST NOT OUTRUN THE GATE. Its runtime context is the whole
10
- * document serialised, so injecting it for an unauthorized viewer would hand
11
- * the gated bytes to the chat -- the exact thing the gate exists to withhold.
12
- * The gate already knows the answer; returning it means the page-route
13
- * assistant setting reads ONE decision instead of inventing a second notion of
14
- * "is this viewer allowed" that could drift from this one.
15
- *
16
- * `applies: false` means the gate did not govern this page at all (a public
17
- * page), which is different from an unauthorized viewer and must not be
18
- * confused with one.
19
- */
20
6
 
21
7
  import type { ContentGateConfig } from "./config-types.ts";
22
8
  import { parseCookie } from "./parse-cookie.ts";
@@ -81,12 +67,6 @@ export async function runContentGate(
81
67
  return { applies: true, authorized: false };
82
68
  }
83
69
 
84
- /**
85
- * The signed-in viewer's authorized identity for this resource, or null. The
86
- * default (used by every gate) is "session email ∈ the gate's authorized set";
87
- * a gate may supply a custom sessionAuthorize to override. Anonymous sessions
88
- * and gates with no authorized-email set return null (stay token-only).
89
- */
90
70
  async function resolveSessionIdentity(
91
71
  resolved: Record<string, unknown>,
92
72
  resourceId: string,
@@ -4,11 +4,6 @@
4
4
  */
5
5
  import { PrimaryResourceMissingError } from "./resource-not-found.ts";
6
6
 
7
- /**
8
- * A page ROW exists but its body has zero content blocks — a content gap, not a
9
- * dead link. True only when the row is present yet `puck_data` is absent or its
10
- * `content` array is empty. Handles string (raw) and object (normalized) puck_data.
11
- */
12
7
  export function pageBodyIsEmpty(page: unknown): boolean {
13
8
  if (!page || typeof page !== "object") return false;
14
9
  const obj = page as Record<string, unknown>;
@@ -27,12 +22,6 @@ export function pageBodyIsEmpty(page: unknown): boolean {
27
22
  return !Array.isArray(data?.content) || (data.content as unknown[]).length === 0;
28
23
  }
29
24
 
30
- /**
31
- * Decide whether a content route should fall to the branded not-found floor.
32
- * Returns the error to throw (mapped to the floor by bind-page-handler), or null
33
- * to render normally. `hasResourceType` routes are handled by the existing
34
- * resource_type missing check (caller-side) — they return null here.
35
- */
36
25
  export function decideContentPageFloor(opts: {
37
26
  hasResourceType: boolean;
38
27
  hasPageContentSource: boolean;
@@ -9,7 +9,7 @@ import { registerCacheInvalidation } from "@teamscala/db/notify-listener/cache-i
9
9
  import { getServerClient } from "@teamscala/orpc/server-client-registry";
10
10
  import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
11
11
  import { getAppLogger } from "@teamscala/logger/app-loggers";
12
- import { ServiceInstallConfigSchema } from "./load-service-install-row.ts";
12
+ import { ServiceInstallBootSchema } from "./load-service-install-row.ts";
13
13
 
14
14
  const logoCache = createCache<string | null>("service-boot:error-page-logo", {
15
15
  ttlMs: 5 * 60_000,
@@ -25,7 +25,7 @@ export async function resolveErrorPageLogo(
25
25
  const cfg = await loadRegistryConfig(
26
26
  "service_install",
27
27
  serviceSlug,
28
- ServiceInstallConfigSchema,
28
+ ServiceInstallBootSchema,
29
29
  );
30
30
  const org = (cfg as { brand_org?: string }).brand_org;
31
31
  if (!org) {
@@ -72,20 +72,10 @@ export async function resolveErrorPageLogo(
72
72
  });
73
73
  }
74
74
 
75
- /** Above this byte size, inlining bloats the HTML more than it helps — fall
76
- * back to a logo-less page. Typical org logos are 5–30 KB, so this only
77
- * catches a genuinely oversized upload. */
78
75
  const MAX_INLINE_LOGO_BYTES = 100 * 1024;
79
76
 
80
77
  const ASSETS_PREFIX = "/api/assets/";
81
78
 
82
- /**
83
- * Inline a stored logo value as a `data:<mime>;base64,…` URI by fetching its
84
- * bytes from S3 (the same store `/api/assets/` proxies to). The stored value
85
- * IS the servable form — `/api/assets/<key>` — so the bucket key is the path
86
- * segment after that prefix (single-logo-pathway). Skew-tolerant: any miss →
87
- * null.
88
- */
89
79
  type LogoStorage = {
90
80
  file(key: string): {
91
81
  stat(): Promise<unknown>;
@@ -31,7 +31,6 @@ function formatDate(iso: string | null): string {
31
31
  }
32
32
  }
33
33
 
34
- /** Resolve a `{{field}}` token for one roster item. */
35
34
  function tokenValue(
36
35
  field: string,
37
36
  item: RosterItem,
@@ -55,7 +54,6 @@ function tokenValue(
55
54
  }
56
55
  }
57
56
 
58
- /** Replace every `{{field}}` token in a string with the item's value. */
59
57
  function substitute(
60
58
  value: string,
61
59
  item: RosterItem,
@@ -66,7 +64,6 @@ function substitute(
66
64
  );
67
65
  }
68
66
 
69
- /** The `content` slot arrays a block exposes (Container.props.content, …). */
70
67
  function slotArrays(block: Block): unknown[][] {
71
68
  const out: unknown[][] = [];
72
69
  const props = block.props;
@@ -79,7 +76,6 @@ function slotArrays(block: Block): unknown[][] {
79
76
  return out;
80
77
  }
81
78
 
82
- /** Deep-clone a template block, binding it to one roster item. */
83
79
  function cloneForItem(template: Block, item: RosterItem): Block {
84
80
  const labels = {
85
81
  signed: String(template.props?.signedLabel ?? ""),
@@ -110,7 +106,6 @@ function cloneForItem(template: Block, item: RosterItem): Block {
110
106
  return clone;
111
107
  }
112
108
 
113
- /** Is this block a per-party template to expand? */
114
109
  function isPartyTemplate(block: Block): boolean {
115
110
  // Generic marker (any block that declares `dataRepeat`)…
116
111
  if (block?.props?.dataRepeat === "parties") return true;
@@ -124,7 +119,6 @@ function isPartyTemplate(block: Block): boolean {
124
119
  return false;
125
120
  }
126
121
 
127
- /** Expand every per-party template in a content array, recursing into slots. */
128
122
  function expandArray(content: unknown[], parties: RosterItem[]): unknown[] {
129
123
  const out: unknown[] = [];
130
124
  for (const raw of content) {
@@ -144,12 +138,10 @@ function expandArray(content: unknown[], parties: RosterItem[]): unknown[] {
144
138
  return out;
145
139
  }
146
140
 
147
- /** Agreement-level tokens (one substitution, not per-party). */
148
141
  export interface RosterMeta {
149
142
  [token: string]: string;
150
143
  }
151
144
 
152
- /** Substitute `{{agreement.token}}` placeholders in every string prop, in place. */
153
145
  function substituteMeta(node: unknown, meta: RosterMeta): unknown {
154
146
  if (typeof node === "string") {
155
147
  return node.replace(/\{\{\s*agreement\.(\w+)\s*\}\}/g, (_m, t) =>
@@ -166,7 +158,6 @@ function substituteMeta(node: unknown, meta: RosterMeta): unknown {
166
158
  return node;
167
159
  }
168
160
 
169
- /** Expand roster templates + bind agreement-meta placeholders across regions. */
170
161
  export function expandRosterTemplates(
171
162
  resolved: Record<string, unknown>,
172
163
  parties: RosterItem[],
package/src/gpt-gate.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import type { AccessConfig, ContentGateConfig } from "./content-gate/config-types.ts";
7
- import { signAccessToken, verifyAccessTokenIdentity } from "./content-gate/token.ts";
7
+ import { signAccessToken, verifyAccessTokenIdentity } from "@teamscala/agreement-generation/content-gate-token";
8
8
  import { gptTtlMs } from "./gate-ttls.ts";
9
9
 
10
10
  const GPT_COOKIE = "gpt_token";
@@ -8,10 +8,6 @@ import { buildErrorReport } from "@teamscala/os/errors/error-report";
8
8
  import type { Context } from "hono";
9
9
  import { HTTPException } from "hono/http-exception";
10
10
 
11
- /**
12
- * Returns an `app.onError` handler tagged with `label` (used in the log line so
13
- * the boot site that surfaced the error is identifiable).
14
- */
15
11
  export function createRequestErrorHandler(label: string) {
16
12
  return (err: unknown, c: Context): Response => {
17
13
  // Errors must never be swallowed (errors-always-logged): every unhandled
@@ -17,16 +17,9 @@ import { loadRegistryConfig as dbLoadRegistryConfig } from "@teamscala/db/regist
17
17
  import { configure as httpConfigure } from "@teamscala/http/configure";
18
18
  import { getAppLogger } from "@teamscala/logger/app-loggers";
19
19
  import { initSentry } from "@teamscala/logger/init-sentry";
20
- import { attachRequestLogger } from "@teamscala/logger/install-logging";
21
20
  import { getRequestContext } from "@teamscala/logger/request-context-async";
22
21
  import { configure as orpcConfigure } from "@teamscala/orpc/configure";
23
22
 
24
- // Re-exported from @teamscala/logger (the canonical home, per
25
- // vertical-dependency-only). service-boot consumers that previously
26
- // imported from service-boot/install-logging continue to work; the
27
- // implementation lives once in @teamscala/logger.
28
- export { attachRequestLogger };
29
-
30
23
  const CONSOLE_METHODS = [
31
24
  "log",
32
25
  "info",
@@ -51,29 +44,13 @@ export function installLogging(opts: InstallLoggingOptions): void {
51
44
  installConsoleShim(serviceSlug);
52
45
  installProcessHandlers(serviceSlug);
53
46
 
54
- // Bootloader injection per `bootloader-injection-contract`. After
55
- // initSentry + the console redirect, the logger surface is live; inject it
56
- // into every configurable primitive that defaults to noop. The list is
57
- // enumerated here; it becomes registry-driven once #57 lands.
58
47
  const log = getAppLogger();
59
- // Persisted last-known-good tier for registry config reads — created here
60
- // (runtime tier) so host-path resolution stays out of the foundation-tier
61
- // db package, which cannot import @teamscala/cache or resolve userHome itself.
62
- // load-config serves this on a transient DB failure instead of crash-looping.
63
- // The root is stateRoot(), not userHome(): a container that declares a
64
- // read-only HOME (scala-sign prod: HOME=/var/empty) made this cache EACCES on
65
- // every request — the tier died in exactly the outage class it exists for.
66
48
  const registryConfigDiskCache = createDiskCache("db:registry-config", {
67
49
  dir: join(stateRoot(), ".scala-db-cache", "registry-config"),
68
50
  ttlMs: 24 * 60 * 60 * 1000,
69
51
  });
70
52
  dbConfigure({
71
53
  logger: log,
72
- // `as never` bridges the configured-primitives type gap: db holds
73
- // structural approximations of these foundation factories (it cannot
74
- // import @teamscala/cache/timing for the real CacheOptions/TimerOptions
75
- // without re-creating the same-tier edge). The runtime values are the
76
- // real factories; the cast only reconciles the approximate signatures.
77
54
  ManagedCache: ManagedCache as never,
78
55
  createCache: createCache as never,
79
56
  createTimer: createTimer as never,
@@ -92,10 +69,6 @@ export function installLogging(opts: InstallLoggingOptions): void {
92
69
  logger: log,
93
70
  requestContextProvider: () => getRequestContext(),
94
71
  });
95
- // @teamscala/mcp-multi-session and @teamscala/tool-executor are MCP-only;
96
- // their configure() calls moved to @teamscala/mcp-tool-runtime/mount.ts
97
- // per `runtime-deps-universal-only`. MCP services declare mcp-tool-runtime
98
- // in service_install.server_packages and pick up the configures there.
99
72
  }
100
73
 
101
74
  function installConsoleShim(serviceSlug: string): void {
@@ -149,8 +122,3 @@ function safeStringify(v: unknown): string {
149
122
  return String(v);
150
123
  }
151
124
  }
152
- // No event-driven invalidation subscription here: this module caches nothing.
153
- // The removed block opened a dedicated LISTEN connection whose handler was
154
- // empty `() => {}` — a connection held for the process lifetime to run no
155
- // code. Registering a no-op against the shared listener would be the same
156
- // defect one layer down, so it is deleted rather than migrated.
@@ -10,12 +10,6 @@ interface PageManifestModule {
10
10
  components?: Record<string, RendererComponentEntry>;
11
11
  }
12
12
 
13
- /**
14
- * Reads the generated manifest's `components` export. Throws when the file is
15
- * absent or exports nothing — an empty manifest means codegen has not run for
16
- * this service, which is a build problem the operator must see named, not a
17
- * degraded runtime state to paper over.
18
- */
19
13
  export async function loadPageManifest(
20
14
  serviceSlug: string,
21
15
  ): Promise<Record<string, RendererComponentEntry>> {
@@ -9,7 +9,6 @@ import {
9
9
  RegistryValidationError,
10
10
  } from "@teamscala/db/registry/require";
11
11
  import { normalizeRegistryConfigObject } from "@teamscala/db/registry/validation";
12
- import type { ServiceInstallConfig } from "@teamscala/db-validation/registry-schemas/service-install";
13
12
  import {
14
13
  enforceExplicitRenderMode,
15
14
  ServiceInstallConfigSchema,
@@ -17,8 +16,32 @@ import {
17
16
  } from "@teamscala/db-validation/registry-schemas/service-install";
18
17
  import * as v from "valibot";
19
18
 
20
- export { ServiceInstallConfigSchema } from "@teamscala/db-validation/registry-schemas/service-install";
21
- export type { ServiceInstallConfig };
19
+ const BOOT_ROW_KEYS = [
20
+ "access",
21
+ "api_routes",
22
+ "app_port",
23
+ "auth",
24
+ "block_packages",
25
+ "boot_hooks",
26
+ "brand_org",
27
+ "home_path",
28
+ "kind",
29
+ "orpc_router",
30
+ "portal_scoped",
31
+ "port",
32
+ "providers",
33
+ "render_mode",
34
+ "server_packages",
35
+ "websocket_routes",
36
+ ] as const;
37
+
38
+ export const ServiceInstallBootSchema = v.object(
39
+ v.pick(ServiceInstallConfigSchema.pipe[0], BOOT_ROW_KEYS).entries,
40
+ );
41
+
42
+ export type ServiceInstallConfig = v.InferOutput<
43
+ typeof ServiceInstallBootSchema
44
+ >;
22
45
 
23
46
  export interface LoadServiceInstallRowResult {
24
47
  success: true;
@@ -49,9 +72,23 @@ export async function loadServiceInstallRow(
49
72
  `service_install:${serviceSlug}`,
50
73
  ) as Record<string, unknown>;
51
74
  enforceExplicitRenderMode(serviceSlug, rawConfig);
52
- const config = v.parse(ServiceInstallConfigSchema, rawConfig);
75
+ const config = v.parse(ServiceInstallBootSchema, rawConfig);
53
76
  return { success: true, config };
54
77
  } catch (err) {
78
+ if (err instanceof v.ValiError) {
79
+ const issues = err.issues
80
+ .map((issue) => {
81
+ const path = v.getDotPath(issue);
82
+ return path ? `${path}: ${issue.message}` : issue.message;
83
+ })
84
+ .join("; ");
85
+ return {
86
+ success: false,
87
+ error: "validation_failed",
88
+ slug: serviceSlug,
89
+ message: `service_install row for "${serviceSlug}" has a key the boot reads missing or malformed: ${issues}`,
90
+ };
91
+ }
55
92
  if (err instanceof RegistryMissingError) {
56
93
  const sql = `INSERT INTO registry_entries (type, slug, config, is_active) VALUES ('service_install', '${serviceSlug}', '{"kind":"ui","render_mode":"public","port":null,"page_routes":[],"providers":[],"api_routes":[],"block_packages":[],"teamgbg_packages":[],"env_vars":[]}'::jsonb, true);`;
57
94
  return {
@@ -3,22 +3,11 @@
3
3
  * @status handwritten
4
4
  */
5
5
 
6
- /** `@scope/name/sub/path.ts` -> `@scope/name`; `name/sub` -> `name`. */
7
6
  export function barePackageOf(specifier: string): string {
8
7
  const parts = specifier.split("/");
9
8
  return specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
10
9
  }
11
10
 
12
- /**
13
- * True when the import failed because `requestedPath` itself is absent.
14
- *
15
- * Bun reports an unexported SUBPATH as `Cannot find package '<bare>'`, naming
16
- * the package rather than the subpath, so the bare name of THIS candidate
17
- * counts as not-found. Without that the first candidate's miss is
18
- * misclassified as fatal and the fallbacks never run (measured 2026-08-24:
19
- * scala-agents-ui crash-looped because cli-session exports ./boot/mount.ts and
20
- * not ./mount.ts). A transitive miss naming a DIFFERENT module still surfaces.
21
- */
22
11
  export function isModuleNotFound(err: unknown, requestedPath: string): boolean {
23
12
  const msg =
24
13
  (err as { message?: string })?.message ??