@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.
- package/package.json +1 -9
- package/src/agreement-gate/authorized-emails.ts +0 -7
- package/src/agreement-gate/placeholder-strip.ts +0 -2
- package/src/agreement-gate/settings.ts +0 -15
- package/src/agreement-gate.ts +0 -34
- package/src/attach-proposal-accept.ts +1 -1
- package/src/attach-proposal-magic.ts +1 -1
- package/src/auth/sign-out.ts +0 -2
- package/src/auth-gate/public-paths.ts +0 -86
- package/src/auth-gate.ts +0 -19
- package/src/bind-page-handler.ts +0 -76
- package/src/build-client-assets/hydrate-bundle.ts +0 -126
- package/src/build-client-assets/manifest.ts +0 -18
- package/src/build-client-assets/source-candidates.ts +0 -34
- package/src/build-client-assets/sourcemaps-upload.ts +0 -42
- package/src/build-client-assets/styles-build.ts +0 -50
- package/src/capability-slug.ts +0 -10
- package/src/compose-page-title.ts +0 -9
- package/src/content-gate/access.ts +0 -26
- package/src/content-gate/catalog.ts +0 -29
- package/src/content-gate/config-types.ts +3 -84
- package/src/content-gate/gate.ts +0 -20
- package/src/content-page-floor.ts +0 -11
- package/src/error-page-logo.ts +2 -12
- package/src/expand-roster.ts +0 -9
- package/src/gpt-gate.ts +1 -1
- package/src/hono-error-handler.ts +0 -4
- package/src/install-logging.ts +0 -32
- package/src/load-page-manifest.ts +0 -6
- package/src/load-service-install-row.ts +41 -4
- package/src/module-not-found.ts +0 -11
- package/src/mount-orpc-router.ts +1 -88
- package/src/mount-server-packages.ts +0 -29
- package/src/mutable-app-ref.ts +0 -11
- package/src/orpc-contract-rows.json +289 -0
- package/src/orpc-openapi-document.ts +0 -6
- package/src/orpc-openapi-gate.ts +0 -10
- package/src/pg-boss-handler-registry.ts +0 -12
- package/src/pg-boss-mount.ts +0 -101
- package/src/proposal-gate.ts +1 -41
- package/src/quiz-gate.ts +1 -9
- package/src/register-page-server-client.ts +0 -53
- package/src/register-registry-page-route/ai-chat-prefetch.ts +0 -18
- package/src/register-registry-page-route/apply-page-branding.ts +0 -90
- package/src/register-registry-page-route/captured-loader-config.ts +0 -1
- package/src/register-registry-page-route/graph-cache.ts +0 -17
- package/src/register-registry-page-route/meta-title.ts +0 -24
- package/src/register-registry-page-route/page-data-shared.ts +0 -6
- package/src/register-registry-page-route/prefetch-org-id.ts +0 -17
- package/src/register-registry-page-route/proposal-approval-email-token.ts +0 -11
- package/src/register-registry-page-route/proposal-brand-stamping.ts +0 -20
- package/src/register-registry-page-route/public-shell-page-data.ts +0 -26
- package/src/register-registry-page-route/resolve-request-graph.ts +0 -57
- package/src/register-registry-page-route/run-content-gates.ts +2 -54
- package/src/register-registry-page-route/warn-uncovered-blocks.ts +0 -10
- package/src/register-registry-page-route.ts +0 -75
- package/src/register-static-assets.ts +0 -45
- package/src/resolve-brand-favicon.ts +0 -9
- package/src/resolve-service-port.ts +0 -11
- package/src/resolve-theme-mode.ts +0 -8
- package/src/resolvers/app-header.ts +0 -1
- package/src/resolvers/fetch-org-brand.ts +0 -1
- package/src/resolvers/portal-route-paths.ts +0 -17
- package/src/resource-not-found.ts +0 -24
- package/src/select-service-routes.ts +0 -14
- package/src/theme-fonts-link.ts +0 -5
- package/src/two-phase-full-app.ts +0 -76
- package/src/two-phase-minimal-app.ts +1 -105
- package/src/types.ts +0 -14
- package/src/ui-service.ts +1 -42
- package/src/content-gate/gate-entry-marker.test.ts +0 -111
- package/src/content-gate/token.segmentation.test.ts +0 -76
- package/src/content-gate/token.test.ts +0 -65
- package/src/content-gate/token.ts +0 -197
- package/src/is-react-element-type.test.ts +0 -45
- package/src/is-react-element-type.ts +0 -10
- package/src/page-server-orpc/hub-mcp-client.ts +0 -14
- package/src/pg-boss-mount.test.ts +0 -101
- package/src/resource-chat-inject.page-assistant.test.ts +0 -145
- package/src/resource-chat-inject.test.ts +0 -240
- package/src/resource-chat-inject.ts +0 -357
|
@@ -4,11 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
export interface PortResolution {
|
|
7
|
-
/** The port to bind. */
|
|
8
7
|
port: number;
|
|
9
|
-
/** Which source decided it — for the boot log, so the choice is auditable. */
|
|
10
8
|
source: "env" | "service_routing";
|
|
11
|
-
/** Set when PORT disagreed with the row; the caller logs it. */
|
|
12
9
|
conflict?: { envPort: number; declaredPort: number };
|
|
13
10
|
}
|
|
14
11
|
|
|
@@ -16,14 +13,6 @@ function isBindablePort(value: number | undefined): value is number {
|
|
|
16
13
|
return value !== undefined && Number.isFinite(value) && value > 0 && value <= 65535;
|
|
17
14
|
}
|
|
18
15
|
|
|
19
|
-
/**
|
|
20
|
-
* @param envPort parsed PORT env, or undefined when unset/unparseable
|
|
21
|
-
* @param declaredPort `service_routing.local_port`, or undefined when the
|
|
22
|
-
* service has no routing row (the Railway-only case)
|
|
23
|
-
* @param isRailway true when booting inside Railway (caller derives from
|
|
24
|
-
* `RAILWAY_ENVIRONMENT`); there the injected PORT is authoritative
|
|
25
|
-
* regardless of the row, because Railway healthchecks it
|
|
26
|
-
*/
|
|
27
16
|
export function resolveServicePort(
|
|
28
17
|
envPort: number | undefined,
|
|
29
18
|
declaredPort: number | undefined,
|
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
|
|
6
6
|
export interface ResolvedThemeMode {
|
|
7
7
|
theme: "light" | "dark";
|
|
8
|
-
/** True iff the mode came from the ?theme= embed override. */
|
|
9
8
|
embedded: boolean;
|
|
10
9
|
}
|
|
11
10
|
|
|
@@ -14,15 +13,8 @@ function asMode(value: unknown): "light" | "dark" | undefined {
|
|
|
14
13
|
}
|
|
15
14
|
|
|
16
15
|
export function resolveThemeMode(opts: {
|
|
17
|
-
/** Request URL (or its href) — read for the ?theme= embed override. */
|
|
18
16
|
url?: URL | string | null;
|
|
19
|
-
/**
|
|
20
|
-
* Explicit embed override, when the caller already extracted ?theme= from
|
|
21
|
-
* the URL upstream (e.g. a pure data-shaper that has no Request). Same
|
|
22
|
-
* effect and priority as the URL form.
|
|
23
|
-
*/
|
|
24
17
|
override?: unknown;
|
|
25
|
-
/** Theme candidates in priority order: rootTheme, brandTheme, websiteTheme. */
|
|
26
18
|
candidates?: Array<unknown>;
|
|
27
19
|
}): ResolvedThemeMode {
|
|
28
20
|
let override = asMode(opts.override);
|
|
@@ -6,7 +6,6 @@ import { fetchOrgBrand } from "@teamscala/resolver-engine/data-resolvers/fetch-o
|
|
|
6
6
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
7
7
|
import type { ResolverArgs } from "./portal-route-paths.ts";
|
|
8
8
|
|
|
9
|
-
/** A null brand renders a page with no logo, colours or name — so each failure names which of the three it was. */
|
|
10
9
|
export async function fetchOrgBrandWithFallback({ input, client }: ResolverArgs): Promise<unknown> {
|
|
11
10
|
const orgId = input.orgId as string | undefined;
|
|
12
11
|
const fallbackOrgId = input.fallbackOrgId as string | undefined;
|
|
@@ -2,23 +2,6 @@
|
|
|
2
2
|
* @system service-boot
|
|
3
3
|
* @status handwritten
|
|
4
4
|
*/
|
|
5
|
-
/**
|
|
6
|
-
* Extract the organisation id from a portal-shaped callback/path — the single
|
|
7
|
-
* authority for "which segment is the org" so the sign-in brand resolver and the
|
|
8
|
-
* route resolver cannot drift on the answer (portal-branding-resolves-page-org-first).
|
|
9
|
-
*
|
|
10
|
-
* The canonical portal shape is `/<websiteId>/<orgId>/<path>` — the org is
|
|
11
|
-
* segment 1, NOT segment 0. Reading segment 0 fed the WEBSITE id into brand
|
|
12
|
-
* resolution, which resolved no org and fell back to the super-admin org — the
|
|
13
|
-
* exact cross-tenant mis-branding this exists to prevent (bind-page-handler.ts,
|
|
14
|
-
* found 2026-07-21).
|
|
15
|
-
*
|
|
16
|
-
* Single-UUID hosts keep segment 0 unchanged: scala-projects routes `/<orgId>/…`
|
|
17
|
-
* and proposal/form/quiz hosts route `/<resourceId>/…` (a non-org first segment
|
|
18
|
-
* that resolves to no brand and falls back as before — no regression, only the
|
|
19
|
-
* correct-org improvement for the two-UUID portal shape). Returns undefined when
|
|
20
|
-
* neither leading segment is a UUID.
|
|
21
|
-
*/
|
|
22
5
|
|
|
23
6
|
export interface ResolverArgs {
|
|
24
7
|
input: Record<string, unknown>;
|
|
@@ -15,16 +15,7 @@ const RESOURCE_LABELS: Record<string, string> = {
|
|
|
15
15
|
|
|
16
16
|
export class PrimaryResourceMissingError extends Error {
|
|
17
17
|
readonly resourceType: string;
|
|
18
|
-
// HTTP status for the holding page. 404 for a genuinely missing/invalid
|
|
19
|
-
// resource (a dead /:id link); 200 for the bare root of a UUID-driven site
|
|
20
|
-
// ("no <resource> selected") — the root is a valid landing, not an error,
|
|
21
|
-
// and kind:ui services must answer GET / with 200 (verify-prod-persistent-slot).
|
|
22
18
|
readonly httpStatus: number;
|
|
23
|
-
// A page ROW exists but has zero content blocks — a content gap, not a dead
|
|
24
|
-
// link (operator ruling 2026-07-21: "if the page is empty, it should have a
|
|
25
|
-
// Puck block that says this page is empty... and then take me home"). The
|
|
26
|
-
// floor renders the "This page is empty" surface (200) instead of the dead-
|
|
27
|
-
// link "doesn't exist" surface (404). Distinct from a dead link (no row).
|
|
28
19
|
readonly empty: boolean;
|
|
29
20
|
constructor(resourceType: string, httpStatus = 404, opts?: { empty?: boolean }) {
|
|
30
21
|
super(`primary resource "${resourceType}" not found`);
|
|
@@ -39,23 +30,10 @@ export function buildResourceNotFoundResponse(
|
|
|
39
30
|
resourceType: string,
|
|
40
31
|
logoUrl?: string | null,
|
|
41
32
|
status = 404,
|
|
42
|
-
// Optional 4th arg keeps the existing 3-arg call sites working while letting
|
|
43
|
-
// the page binder add a "Take me home" action (operator-requested) and the
|
|
44
|
-
// empty-page variant.
|
|
45
33
|
opts?: { homeUrl?: string; empty?: boolean },
|
|
46
34
|
): Response {
|
|
47
35
|
const label = RESOURCE_LABELS[resourceType] ?? "page";
|
|
48
|
-
// Single-owner hosts show the owner's wordmark; multi-tenant hosts
|
|
49
|
-
// (no brand_org — a dead id's org is unknowable) stay platform-neutral.
|
|
50
|
-
// The helper emits the inline data-URI src with reserved width+height
|
|
51
|
-
// (logos-inline-on-branded-server-pages).
|
|
52
36
|
const logoImg = brandLogoImgTag(logoUrl, { height: 40, width: 160 });
|
|
53
|
-
// Three surfaces share this one floor (resource-not-found-is-the-root-floor):
|
|
54
|
-
// 200 root — the BARE ROOT of a UUID-driven host ("no <resource> selected").
|
|
55
|
-
// 404 dead — a content path with NO page row (a deleted/moved page, e.g.
|
|
56
|
-
// /roadmap) OR a dead /:id: a link that genuinely doesn't resolve.
|
|
57
|
-
// 200 empty — a page ROW exists but has zero blocks (a content gap, not a
|
|
58
|
-
// dead link): "This page is empty", still take-me-home.
|
|
59
37
|
const isEmpty = opts?.empty === true;
|
|
60
38
|
const isRoot = status === 200 && !isEmpty;
|
|
61
39
|
const title = isEmpty ? "Nothing here yet" : isRoot ? `No ${label} here` : "Not found";
|
|
@@ -71,8 +49,6 @@ export function buildResourceNotFoundResponse(
|
|
|
71
49
|
sent to view it — there’s nothing to see at the bare address.`
|
|
72
50
|
: `The page you’re looking for may have been moved or removed, or the
|
|
73
51
|
link may be incomplete.`;
|
|
74
|
-
// "Take me home" — operator-requested action on every not-found/empty surface.
|
|
75
|
-
// Omitted on the bare-root landing (it IS home). Defaults to the host root.
|
|
76
52
|
const homeUrl = opts?.homeUrl ?? "/";
|
|
77
53
|
const homeLink = isRoot
|
|
78
54
|
? ""
|
|
@@ -5,17 +5,10 @@
|
|
|
5
5
|
|
|
6
6
|
import { toCapabilitySlug } from "./capability-slug.ts";
|
|
7
7
|
|
|
8
|
-
/** The shape this module needs from a loaded page_route row. */
|
|
9
8
|
export interface SelectableRoute {
|
|
10
9
|
config: { pattern: string; service: string };
|
|
11
10
|
}
|
|
12
11
|
|
|
13
|
-
/**
|
|
14
|
-
* Specificity rank for registration order. Hono matches by registration
|
|
15
|
-
* order, so `/*` (the portal catchall) registered before `/sign-in` would
|
|
16
|
-
* swallow the sign-in page and render it inside the PortalTemplate instead
|
|
17
|
-
* of the standalone PublicTemplate shell.
|
|
18
|
-
*/
|
|
19
12
|
function comparePatternSpecificity(a: string, b: string): number {
|
|
20
13
|
// Wildcard `/*` goes last.
|
|
21
14
|
if (a === "/*") return 1;
|
|
@@ -28,13 +21,6 @@ function comparePatternSpecificity(a: string, b: string): number {
|
|
|
28
21
|
return 0;
|
|
29
22
|
}
|
|
30
23
|
|
|
31
|
-
/**
|
|
32
|
-
* The page_route rows this service serves, in registration order.
|
|
33
|
-
*
|
|
34
|
-
* `serviceSlug` is the deployable identity of the calling lane; both sides
|
|
35
|
-
* of the join normalize through `toCapabilitySlug`, so a row may name either
|
|
36
|
-
* the capability or a deployable form and both lanes resolve the same set.
|
|
37
|
-
*/
|
|
38
24
|
export function selectServiceRoutes<T extends SelectableRoute>(
|
|
39
25
|
allRoutes: readonly T[],
|
|
40
26
|
serviceSlug: string,
|
package/src/theme-fonts-link.ts
CHANGED
|
@@ -9,15 +9,11 @@ interface ThemeFontsConfig {
|
|
|
9
9
|
fonts?: Record<string, string>;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
/** The primary family name of a CSS font stack: "Inter, sans-serif" →
|
|
13
|
-
* "Inter"; "Playfair Display" → "Playfair Display". */
|
|
14
12
|
export function primaryFontFamily(stack: string): string {
|
|
15
13
|
const first = stack.split(",")[0] ?? "";
|
|
16
14
|
return first.trim().replace(/^['"]|['"]$/g, "");
|
|
17
15
|
}
|
|
18
16
|
|
|
19
|
-
/** The declared theme families, deduplicated by primary family, in row
|
|
20
|
-
* order (body, heading, manuscript, mono…). Empty when the row is absent. */
|
|
21
17
|
export function themeFontFamilies(): string[] {
|
|
22
18
|
try {
|
|
23
19
|
const config = getEntryConfig<ThemeFontsConfig>("css_theme", "default");
|
|
@@ -37,7 +33,6 @@ export function themeFontFamilies(): string[] {
|
|
|
37
33
|
}
|
|
38
34
|
}
|
|
39
35
|
|
|
40
|
-
/** The <link> tag loading every declared theme family, or "" when none. */
|
|
41
36
|
export function themeFontsLink(): string {
|
|
42
37
|
const families = themeFontFamilies();
|
|
43
38
|
if (families.length === 0) return "";
|
|
@@ -3,52 +3,8 @@
|
|
|
3
3
|
* @status handwritten
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
* Build the full app with hydration. This adds the expensive operations that
|
|
8
|
-
* were deferred from the minimal app:
|
|
9
|
-
* - Client asset building (Vite build)
|
|
10
|
-
* - Boot hooks (may have expensive initialization)
|
|
11
|
-
* - Scheduled jobs (can start later)
|
|
12
|
-
* - pg-boss (can start later)
|
|
13
|
-
*
|
|
14
|
-
* This runs in the background after the minimal app is already serving.
|
|
15
|
-
* NOTE: All routes (including page routes) are already registered in Phase 1,
|
|
16
|
-
* so we don't need to modify the app here. The app reference stays the same.
|
|
17
|
-
*/
|
|
18
6
|
|
|
19
|
-
// Client asset building (expensive: Vite build).
|
|
20
|
-
//
|
|
21
|
-
// ISOLATED, because a PRESENTATION failure must not take out BACKGROUND WORK.
|
|
22
|
-
// This was awaited unguarded, so a throw here aborted buildFullApp before
|
|
23
|
-
// mountPgBoss ran, and the caller's catch logged
|
|
24
|
-
// "Failed to build full app, continuing with minimal app" — which reads as a
|
|
25
|
-
// UI-only degradation while having silently killed every pg-boss handler on
|
|
26
|
-
// the service.
|
|
27
|
-
//
|
|
28
|
-
// Measured 2026-07-30: a stale @teamscala/portal-ui artifact still importing
|
|
29
|
-
// the retired @teamgbg scope threw ENOENT on a missing theme CSS file in
|
|
30
|
-
// scala-agents. Zero "scheduledJobs DONE" lines appeared for ~20 minutes
|
|
31
|
-
// across repeated boots, the pane-outbox drain never mounted, and the
|
|
32
|
-
// operator's WhatsApp assistant went silent — because of a stylesheet. The
|
|
33
|
-
// service stayed `online` and healthy throughout, so nothing surfaced it.
|
|
34
|
-
//
|
|
35
|
-
// The steps after this one do not depend on client assets: pg-boss needs the
|
|
36
|
-
// registry and the DB, not a bundle. Ordering it behind a Vite build was
|
|
37
|
-
// incidental, so the coupling was never intended.
|
|
38
7
|
|
|
39
|
-
// pg-boss (mount for UI services - wasn't mounted in minimal app)
|
|
40
|
-
//
|
|
41
|
-
// Wrapped for the SAME reason buildClientAssets above is, in the other
|
|
42
|
-
// direction: a background-work failure must not cost the page. Unwrapped,
|
|
43
|
-
// anything mountPgBoss throws propagates out of the full-app build, the
|
|
44
|
-
// caller falls back to the MINIMAL app, and the service serves 0 api routes
|
|
45
|
-
// with /health 503 — so the tunnel health check fails and the site is
|
|
46
|
-
// unreachable from outside while localhost answers in 0.1s.
|
|
47
|
-
//
|
|
48
|
-
// Measured 2026-08-31 on scala-projects: a deps refresh moved pg-boss to a
|
|
49
|
-
// build expecting a newer shared pgboss schema than the database carried, its
|
|
50
|
-
// migration threw, and an outage of every page followed from a scheduled-job
|
|
51
|
-
// dependency. Jobs are the degradable half; pages are not.
|
|
52
8
|
|
|
53
9
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
54
10
|
import type { Hono } from "hono";
|
|
@@ -78,9 +34,6 @@ export async function buildFullApp(
|
|
|
78
34
|
await buildClientAssets(serviceSlug);
|
|
79
35
|
log.info(`[two-phase-boot:${serviceSlug}] phase-2 step: buildClientAssets DONE`);
|
|
80
36
|
} catch (error) {
|
|
81
|
-
// Loud but non-fatal: the tombstone manifest written by buildClientAssets
|
|
82
|
-
// already makes SSR serve the prior bundle, so degrading here costs the
|
|
83
|
-
// new bundle, never the background work.
|
|
84
37
|
log.error(
|
|
85
38
|
`[two-phase-boot:${serviceSlug}] phase-2 step: buildClientAssets FAILED — continuing to scheduled jobs and pg-boss (a client-asset failure must not stop background work)`,
|
|
86
39
|
{ error: error instanceof Error ? error.message : String(error) },
|
|
@@ -107,23 +60,6 @@ export async function buildFullApp(
|
|
|
107
60
|
);
|
|
108
61
|
}
|
|
109
62
|
|
|
110
|
-
// The readiness flip is gated on a VERIFIED client build, never on "phase 2
|
|
111
|
-
// got this far". buildClientAssets's failure above is deliberately non-fatal
|
|
112
|
-
// for background work — but a deployment whose client build never ran (or
|
|
113
|
-
// whose tombstone names unbuilt files) must not flip ready and serve pages:
|
|
114
|
-
// every served asset URL would 404 while /health and the deploy report ok.
|
|
115
|
-
// Measured 2026-09-01: proposals.scala.business served a client-facing
|
|
116
|
-
// proposal unstyled while Railway reported SUCCESS, because the phase-1
|
|
117
|
-
// catch let boot continue past the build and the flip never checked the
|
|
118
|
-
// build's outcome (`a-component-may-not-report-a-state-it-has-not-verified`).
|
|
119
|
-
// The throw lands in ui-service's boot.client-build-failed catch, which
|
|
120
|
-
// keeps the minimal app: pages and /health stay 503 (cleanly unavailable)
|
|
121
|
-
// while the pg-boss jobs mounted above KEEP RUNNING. On Railway the
|
|
122
|
-
// healthcheck (healthcheckPath: /health) then fails and the previous good
|
|
123
|
-
// deployment keeps traffic — the deploy reports FAILED, never SUCCESS.
|
|
124
|
-
// readiness-gated-boot: a service is "fully-ready or cleanly-unavailable";
|
|
125
|
-
// a UI service that cannot serve its bundle is not ready
|
|
126
|
-
// (`liveness-by-capability-not-process-existence`).
|
|
127
63
|
const clientBuild = await verifyClientBuildAssets();
|
|
128
64
|
if (!clientBuild.ok) {
|
|
129
65
|
throw new Error(
|
|
@@ -139,20 +75,12 @@ export async function buildFullApp(
|
|
|
139
75
|
`${Object.keys(components).length} component(s)`,
|
|
140
76
|
);
|
|
141
77
|
|
|
142
|
-
// Set the real page handler so the dynamic handler in Phase 1 can delegate to it.
|
|
143
|
-
// Client-only services (render_mode=client) have no SSR page handler — skip
|
|
144
|
-
// (they serve via the client bundle, never SSR). Guarded so undefined is a
|
|
145
|
-
// no-op rather than a crash.
|
|
146
78
|
if (deps.createPageHandler) {
|
|
147
79
|
setPageHandler(deps.createPageHandler);
|
|
148
80
|
log.info(
|
|
149
81
|
`[two-phase-boot:${serviceSlug}] Real page handler registered - page routes now delegate to full app`,
|
|
150
82
|
);
|
|
151
83
|
} else {
|
|
152
|
-
// The full app IS built — this service simply never renders server-side,
|
|
153
|
-
// so readiness is flipped directly rather than as a side effect of
|
|
154
|
-
// registering an SSR handler that will never exist. Without this a
|
|
155
|
-
// client-only service is permanently un-ready (see markFullAppReady).
|
|
156
84
|
markFullAppReady();
|
|
157
85
|
log.info(
|
|
158
86
|
`[two-phase-boot:${serviceSlug}] Client-only mode (render_mode=client) — no SSR page handler registered`,
|
|
@@ -172,10 +100,6 @@ export async function buildFullApp(
|
|
|
172
100
|
};
|
|
173
101
|
}
|
|
174
102
|
|
|
175
|
-
/**
|
|
176
|
-
* Swap the minimal app for the full app. This atomically replaces the
|
|
177
|
-
* app reference that Bun.serve is using.
|
|
178
|
-
*/
|
|
179
103
|
export function swapToFullApp(fullApp: Hono, serviceSlug: string): void {
|
|
180
104
|
const oldLabel = swapApp(fullApp, "full");
|
|
181
105
|
const log = getAppLogger();
|
|
@@ -3,39 +3,6 @@
|
|
|
3
3
|
* @status handwritten
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
* Build the minimal tool-only app. This excludes the expensive operations:
|
|
8
|
-
* - Client asset building (Vite build)
|
|
9
|
-
* - Boot hooks (may have expensive initialization)
|
|
10
|
-
* - Scheduled jobs (can start later)
|
|
11
|
-
* - pg-boss (can start later)
|
|
12
|
-
*
|
|
13
|
-
* CRITICAL: All route registration MUST happen here before serving, because
|
|
14
|
-
* Hono's RegExpRouter compiles on the first request and becomes immutable.
|
|
15
|
-
* Once compiled, no new routes can be added. So we include:
|
|
16
|
-
* - Block discovery and page routes (registers all page paths)
|
|
17
|
-
* - All other routes (API, WS, data, static assets)
|
|
18
|
-
*
|
|
19
|
-
* The minimal app includes everything needed for MCP tools and ORPC:
|
|
20
|
-
* - Error handling, readiness gate, health check
|
|
21
|
-
* - Security headers, CORS, compression, timing
|
|
22
|
-
* - Content gate access, auth gates, static assets
|
|
23
|
-
* - ORPC router, API routes, WS routes, data routes, page routes
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
// Fire independent I/O + compilation NOW, overlapping with the gate chain
|
|
27
|
-
// below. These three touch NO app route and have no ordering dependency on
|
|
28
|
-
// the gate/mount sequence, so they run concurrently with it:
|
|
29
|
-
// - preloadServerPackages: warms Bun's module cache (CPU-bound compilation
|
|
30
|
-
// that overlaps the gate chain's cross-internet DB I/O — same speculative
|
|
31
|
-
// pattern as create-boot-pipeline's preloads). mountServerPackages later
|
|
32
|
-
// hits the cache (instant imports), paying only the ~33ms of mount work.
|
|
33
|
-
// - initGateTtls: loads config rows into module-level vars. The TTL getters
|
|
34
|
-
// are read at REQUEST time (inside route-handler closures), not at boot,
|
|
35
|
-
// so this only needs to finish before serving — safe to run in parallel.
|
|
36
|
-
// - discoverBlocks: loads the page-manifest (no app mutation, no gate
|
|
37
|
-
// dependency). Its result is consumed by registerPageRoutes much later.
|
|
38
|
-
|
|
39
6
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
40
7
|
import { BootFatalError } from "@teamscala/os/errors/boot-error";
|
|
41
8
|
import { createTimer } from "@teamscala/timing/create-timer";
|
|
@@ -55,7 +22,7 @@ import { attachPortalMembers } from "./attach-portal-members.ts";
|
|
|
55
22
|
import { attachTransfersUpload } from "./attach-transfers-upload.ts";
|
|
56
23
|
import { attachAuthGate } from "./auth-gate.ts";
|
|
57
24
|
import { csrfProtection } from "./csrf-protection.ts";
|
|
58
|
-
import { attachRequestLogger } from "
|
|
25
|
+
import { attachRequestLogger } from "@teamscala/logger/install-logging";
|
|
59
26
|
import type { ServiceInstallConfig } from "./load-service-install-row.ts";
|
|
60
27
|
import { mountOrpcRouter } from "./mount-orpc-router.ts";
|
|
61
28
|
import { mountServerPackages, preloadServerPackages } from "./mount-server-packages.ts";
|
|
@@ -101,13 +68,6 @@ export async function buildMinimalApp(
|
|
|
101
68
|
|
|
102
69
|
const app = new Hono();
|
|
103
70
|
|
|
104
|
-
// Per-step instrumentation inside the boot-ui-service phase. Same
|
|
105
|
-
// createTimer + wall-clock convention as create-boot-pipeline's runPhase
|
|
106
|
-
// (timing-is-the-only-timing, per-phase-instrumentation) — NOT a second
|
|
107
|
-
// telemetry surface. The boot-ui-service phase is 60% of boot; this
|
|
108
|
-
// breakdown localises the cost so optimisation targets the real hotspot
|
|
109
|
-
// instead of guessing. Spans flow to GlitchTip (timing's trace-emitter is
|
|
110
|
-
// wired by the time we reach here); the breakdown line is `pm2 logs`-visible.
|
|
111
71
|
const stepTimings: Record<string, number> = {};
|
|
112
72
|
async function runStep<T>(step: string, fn: () => Promise<T>): Promise<T> {
|
|
113
73
|
const timer = createTimer(`${serviceSlug}:boot.minimal.${step}`);
|
|
@@ -119,7 +79,6 @@ export async function buildMinimalApp(
|
|
|
119
79
|
}
|
|
120
80
|
}
|
|
121
81
|
|
|
122
|
-
// Error handlers
|
|
123
82
|
app.onError(createRequestErrorHandler("service-boot:minimal"));
|
|
124
83
|
|
|
125
84
|
app.notFound(async (c) => {
|
|
@@ -131,7 +90,6 @@ export async function buildMinimalApp(
|
|
|
131
90
|
return c.json({ error: "Not Found" }, 404);
|
|
132
91
|
});
|
|
133
92
|
|
|
134
|
-
// Core middleware
|
|
135
93
|
app.use("*", readinessGate);
|
|
136
94
|
app.get("/health", healthHandler);
|
|
137
95
|
app.use("*", injectSecurityHeaders);
|
|
@@ -151,19 +109,7 @@ export async function buildMinimalApp(
|
|
|
151
109
|
);
|
|
152
110
|
app.use("*", compress());
|
|
153
111
|
app.use("*", timing());
|
|
154
|
-
// CSRF (`security-csrf-protection`): state-changing form-element requests
|
|
155
|
-
// must carry a trusted Origin — derived set (managed base domain +
|
|
156
|
-
// builda_websites custom domains via isTrustedAuthOrigin), never an
|
|
157
|
-
// allowlist beside better-auth's. Registered after security headers, before
|
|
158
|
-
// every route + gate.
|
|
159
112
|
app.use("*", csrfProtection());
|
|
160
|
-
// GLOBAL body limit (`security-body-limit`): every request body is bounded
|
|
161
|
-
// BEFORE any handler buffers it — a per-route file.size check AFTER
|
|
162
|
-
// formData() has buffered is a report, not a limit. 30MB sits above the
|
|
163
|
-
// 25MB transfers-upload maximum; routes wanting a smaller bound mount their
|
|
164
|
-
// own bodyLimit BEFORE this one (Hono runs matching middleware in
|
|
165
|
-
// registration order, and /api/rpc registers its 10MB limit in
|
|
166
|
-
// mount-orpc-router).
|
|
167
113
|
app.use(
|
|
168
114
|
"*",
|
|
169
115
|
bodyLimit({
|
|
@@ -180,20 +126,8 @@ export async function buildMinimalApp(
|
|
|
180
126
|
);
|
|
181
127
|
const ttlPromise = runStep("init-gate-ttls", () => initGateTtls());
|
|
182
128
|
|
|
183
|
-
// Public access endpoints — MUST be registered before the auth gate (Hono
|
|
184
|
-
// dispatches middleware + handlers in registration order; the content-gate
|
|
185
|
-
// POST routes and magic-link endpoints are public and must be reachable
|
|
186
|
-
// without a session, so they precede the auth gate's app.use("*") wildcard).
|
|
187
|
-
// These are mutually independent — distinct paths, no shared mutable state
|
|
188
|
-
// beyond the Hono app's synchronous route array — so they parallelise.
|
|
189
129
|
await runStep("public-routes", () =>
|
|
190
130
|
Promise.all([
|
|
191
|
-
// EVERY REGISTERED GATE'S ACCESS ENDPOINT (content-gate/catalog.ts).
|
|
192
|
-
// The same catalog the SSR gate sequence iterates, so the endpoint that
|
|
193
|
-
// MINTS a gate's token and the gate that VERIFIES it cannot disagree
|
|
194
|
-
// about which gates exist — this list and that one were two copies of
|
|
195
|
-
// one fact, and a gate added to one and missed in the other is a page
|
|
196
|
-
// that gates content with no way to unlock it.
|
|
197
131
|
...CONTENT_GATES.map((entry) =>
|
|
198
132
|
attachContentGateAccess(app, serviceSlug, entry.access),
|
|
199
133
|
),
|
|
@@ -205,27 +139,14 @@ export async function buildMinimalApp(
|
|
|
205
139
|
]),
|
|
206
140
|
);
|
|
207
141
|
|
|
208
|
-
// Auth gate — registers /api/auth/* + the session-check wildcard middleware.
|
|
209
|
-
// MUST follow the public routes above (load-bearing Hono registration order)
|
|
210
|
-
// and MUST precede mountServerPackages: server-package daemon routes (e.g.
|
|
211
|
-
// cli-session's /rpc/*) are subject to the session gate, which requires the
|
|
212
|
-
// gate's wildcard registered first to apply. Running these concurrently would
|
|
213
|
-
// make that ordering non-deterministic — so the gate stays serial here.
|
|
214
142
|
await runStep("attach-auth-gate", () =>
|
|
215
143
|
attachAuthGate(app, config, serviceSlug),
|
|
216
144
|
);
|
|
217
145
|
registerStaticAssets(app, serviceSlug, config.brand_org);
|
|
218
146
|
|
|
219
|
-
// Server packages — the cold imports were preloaded above and are now in
|
|
220
|
-
// Bun's module cache, so this pays only the mount work (~33ms, not ~2.8s of
|
|
221
|
-
// serial compilation). Route registration happens HERE (after the auth gate),
|
|
222
|
-
// preserving the gate-vs-daemon-route ordering.
|
|
223
147
|
const mountRegistry = await runStep("mount-server-packages", () =>
|
|
224
148
|
mountServerPackages(app, config, serviceSlug, getSharedDb),
|
|
225
149
|
);
|
|
226
|
-
// Ensure the speculative preload finished before proceeding (it populates
|
|
227
|
-
// the same cache mountServerPackages just consumed; awaiting now is a no-op
|
|
228
|
-
// if the preload already settled, and makes the ordering guarantee explicit).
|
|
229
150
|
await preloadPromise;
|
|
230
151
|
const apiRouteCount = registerApiRoutes(app, config, mountRegistry, serviceSlug);
|
|
231
152
|
|
|
@@ -233,17 +154,10 @@ export async function buildMinimalApp(
|
|
|
233
154
|
await runStep("mount-orpc-router", () => mountOrpcRouter(app, serviceSlug));
|
|
234
155
|
}
|
|
235
156
|
|
|
236
|
-
// Register the in-process ORPC server client BEFORE discoverBlocks — it
|
|
237
|
-
// calls getServerClient() which needs the client set here. Must follow
|
|
238
|
-
// mount-orpc-router (which builds the router getAppRouter uses).
|
|
239
|
-
// Previously ran inside register-page-routes (too late — discoverBlocks
|
|
240
|
-
// fired before it, hitting the empty-router fallback).
|
|
241
157
|
await runStep("register-page-server-client", () =>
|
|
242
158
|
registerPageServerClient(serviceSlug),
|
|
243
159
|
);
|
|
244
160
|
|
|
245
|
-
// NOW load the generated manifest (getServerClient is set). Awaited below
|
|
246
|
-
// before register-page-routes consumes the result.
|
|
247
161
|
const discoverPromise = runStep("load-page-manifest", () =>
|
|
248
162
|
loadPageManifest(serviceSlug),
|
|
249
163
|
);
|
|
@@ -252,14 +166,8 @@ export async function buildMinimalApp(
|
|
|
252
166
|
registerWsRoutes(app, config, mountRegistry, serviceSlug, upgradeWebSocket),
|
|
253
167
|
);
|
|
254
168
|
|
|
255
|
-
// Resolve the deferred TTL load (started at the top — should be long done,
|
|
256
|
-
// overlapped with the gate chain + mount work). The getters are read at
|
|
257
|
-
// request time, but the cache must be populated before the first request.
|
|
258
169
|
await ttlPromise;
|
|
259
170
|
|
|
260
|
-
// CRITICAL: Block discovery and page routes MUST be registered BEFORE serving,
|
|
261
|
-
// because Hono's RegExpRouter compiles on first request and becomes immutable.
|
|
262
|
-
// We defer only client asset building (expensive Vite build) to Phase 2.
|
|
263
171
|
let components: Awaited<ReturnType<typeof loadPageManifest>>;
|
|
264
172
|
try {
|
|
265
173
|
components = await discoverPromise;
|
|
@@ -276,15 +184,10 @@ export async function buildMinimalApp(
|
|
|
276
184
|
);
|
|
277
185
|
|
|
278
186
|
|
|
279
|
-
// Run boot hooks BEFORE serving - some hooks may register routes and must
|
|
280
|
-
// run before Hono's matcher is compiled by the first request
|
|
281
187
|
await runStep("run-boot-hooks", () =>
|
|
282
188
|
runBootHookList(config, mountRegistry, serviceSlug),
|
|
283
189
|
);
|
|
284
190
|
|
|
285
|
-
// Register page routes with a dynamic handler that delegates to the real handler once Phase 2 completes.
|
|
286
|
-
// All route paths must be registered before serving to avoid Hono
|
|
287
|
-
// RegExpRouter compilation issues (matcher becomes immutable).
|
|
288
191
|
const registeredPageRoutes = await runStep("register-page-routes", () =>
|
|
289
192
|
registerPageRoutes({
|
|
290
193
|
app,
|
|
@@ -299,10 +202,6 @@ export async function buildMinimalApp(
|
|
|
299
202
|
const steps = Object.entries(stepTimings)
|
|
300
203
|
.map(([step, ms]) => `${step}=${ms}ms`)
|
|
301
204
|
.join(" ");
|
|
302
|
-
// NOTE: several steps run concurrently (speculative preload, init-gate-ttls,
|
|
303
|
-
// discover-blocks overlap the gate chain), so the step sum exceeds the
|
|
304
|
-
// wall-clock buildMinimalApp duration. Compare to the boot-ui-service phase
|
|
305
|
-
// timing in the Boot complete line for the true wall-clock cost.
|
|
306
205
|
log.info(
|
|
307
206
|
`[two-phase-boot:${serviceSlug}] buildMinimalApp breakdown (steps overlap) — ${steps}`,
|
|
308
207
|
);
|
|
@@ -322,9 +221,6 @@ export async function buildMinimalApp(
|
|
|
322
221
|
databaseUrl,
|
|
323
222
|
blockPackages,
|
|
324
223
|
components,
|
|
325
|
-
// The generated manifest IS the loaded set — every entry it exports is a
|
|
326
|
-
// block this service renders, so there is no "skipped" half any more. A
|
|
327
|
-
// row that cannot produce a component fails codegen, not boot.
|
|
328
224
|
loaded: Object.keys(components),
|
|
329
225
|
skipped: [] as string[],
|
|
330
226
|
};
|
package/src/types.ts
CHANGED
|
@@ -8,9 +8,6 @@ type RendererComponent = (props: Record<string, unknown>) => unknown;
|
|
|
8
8
|
export interface RendererComponentEntry {
|
|
9
9
|
render: RendererComponent;
|
|
10
10
|
fields?: Record<string, { type: string }>;
|
|
11
|
-
/** block_schema.defaults, carried onto the entry by discoverBlocks so
|
|
12
|
-
* buildPuckConfig merges them under authored props at SSR
|
|
13
|
-
* (blocks-fully-programmable). */
|
|
14
11
|
defaultProps?: Record<string, unknown>;
|
|
15
12
|
}
|
|
16
13
|
|
|
@@ -31,18 +28,7 @@ export interface PageHandlerConfig {
|
|
|
31
28
|
providers?: string[];
|
|
32
29
|
loadData: (request: Request) => Promise<unknown>;
|
|
33
30
|
renderMode?: "public" | "portals" | "utility";
|
|
34
|
-
/**
|
|
35
|
-
* Owning service slug — used by the page-server render catch to resolve
|
|
36
|
-
* the branded logo on the render-error page. Mirrors service-runtime's
|
|
37
|
-
* PageHandlerConfig.
|
|
38
|
-
*/
|
|
39
31
|
serviceSlug?: string;
|
|
40
|
-
/**
|
|
41
|
-
* Redirect a signed-in user's `/` to their org-scoped portal dashboard.
|
|
42
|
-
* Set only for services serving a `/*` catchall page_route (the portal);
|
|
43
|
-
* single-site portal-mode services serve their real page at `/`.
|
|
44
|
-
* Mirrors service-runtime's PageHandlerConfig (1.15.82).
|
|
45
|
-
*/
|
|
46
32
|
portalHomeRedirect?: boolean;
|
|
47
33
|
}
|
|
48
34
|
|
package/src/ui-service.ts
CHANGED
|
@@ -17,32 +17,7 @@
|
|
|
17
17
|
* place and is NOT undone.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
// the same one the backend branch uses. The registry wins wherever it has an
|
|
22
|
-
// opinion and a contradicting PORT env is reported, never silently obeyed —
|
|
23
|
-
// full reasoning in that file.
|
|
24
|
-
//
|
|
25
|
-
// This branch previously took `process.env.PORT` unconditionally, which is
|
|
26
|
-
// how scala-quiz (declared 5203) booted onto session-picker's 3021 from a
|
|
27
|
-
// leaked env and silently split that daemon's traffic (2026-07-31). On the
|
|
28
|
-
// dev host the registry wins a conflict (below). On Railway the injected PORT
|
|
29
|
-
// wins instead — Railway healthchecks exactly that port, and the row's port
|
|
30
|
-
// is the service's LOCAL dev port (e.g. portal 5200), so binding it on Railway
|
|
31
|
-
// makes the healthcheck probe a dead port and the deploy fails (`isRailway`
|
|
32
|
-
// path in the resolver).
|
|
33
|
-
|
|
34
|
-
// NO-SILENT-REVERTS — surface the failed client build to OBSERVABILITY
|
|
35
|
-
// (event-log → GlitchTip, per event-log-is-the-only-durable-event-log).
|
|
36
|
-
// The minimal-app fallback keeps serving SSR from the LAST-successful
|
|
37
|
-
// manifest (now a tombstone, see build-client-assets.ts), so the page
|
|
38
|
-
// looks "up" while the client bundle is stale. Without this signal the
|
|
39
|
-
// failure is buried in boot stderr and a published fix that reached
|
|
40
|
-
// node_modules but failed to bundle ships invisibly stale (the
|
|
41
|
-
// scala-agents 2026-07-18 incident: ui-foundation@3.4.5 installed but
|
|
42
|
-
// served pre-fix bytes across restarts while node_modules was
|
|
43
|
-
// mid-install). The boot.client-build-failed event routes into
|
|
44
|
-
// event-log's 7-day retention → surfaces in GlitchTip as an incident
|
|
45
|
-
// trail the operator actually watches.
|
|
20
|
+
|
|
46
21
|
|
|
47
22
|
import { recordEvent } from "@teamscala/event-log/record-event";
|
|
48
23
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
@@ -91,7 +66,6 @@ export async function bootUiService(
|
|
|
91
66
|
const log = getAppLogger();
|
|
92
67
|
const startTime = Date.now();
|
|
93
68
|
|
|
94
|
-
// Phase 1: Build minimal tool-only app
|
|
95
69
|
const minimalAppResult = await buildMinimalApp(
|
|
96
70
|
config,
|
|
97
71
|
serviceSlug,
|
|
@@ -102,12 +76,6 @@ export async function bootUiService(
|
|
|
102
76
|
|
|
103
77
|
const isRailway = Boolean(process.env.RAILWAY_ENVIRONMENT);
|
|
104
78
|
const envPort = process.env.PORT ? Number(process.env.PORT) : undefined;
|
|
105
|
-
// The APP binds its PRIVATE port (app_port) when present — for scale-to-zero
|
|
106
|
-
// services `port` is the ACTIVATOR's ingress (what callers resolve), not the
|
|
107
|
-
// app's bind. Falling back to `port` keeps RESIDENT + unmigrated services
|
|
108
|
-
// unchanged. Without this, a migrated row (port=activator) would make the app
|
|
109
|
-
// bind the activator's port → SO_REUSEPORT silent load-balancing (the
|
|
110
|
-
// service-ports-from-registry trap: clean boot, two owners in ss -ltnp).
|
|
111
79
|
const declaredAppPort = config.app_port ?? config.port;
|
|
112
80
|
const resolution = resolveServicePort(envPort, declaredAppPort, isRailway);
|
|
113
81
|
if ("error" in resolution) {
|
|
@@ -129,21 +97,17 @@ export async function bootUiService(
|
|
|
129
97
|
}
|
|
130
98
|
const port = requireValidPort(resolution.port, serviceSlug);
|
|
131
99
|
|
|
132
|
-
// service-runtime owns the TCP listener. service-boot only composes the app
|
|
133
|
-
// and starts the two-phase build; the runtime binds the returned app.
|
|
134
100
|
const minimalReadyTime = Date.now();
|
|
135
101
|
const minimalDuration = minimalReadyTime - startTime;
|
|
136
102
|
log.info(
|
|
137
103
|
`[ui-service-boot:${serviceSlug}] Minimal app composed on port ${port} in ${minimalDuration}ms`,
|
|
138
104
|
);
|
|
139
105
|
|
|
140
|
-
// FLIP READY SWITCH: minimal app is ready, MCP tools can connect
|
|
141
106
|
setReady();
|
|
142
107
|
log.info(
|
|
143
108
|
`[ui-service-boot:${serviceSlug}] Ready gate flipped - MCP tools can now connect (full app building in background)`,
|
|
144
109
|
);
|
|
145
110
|
|
|
146
|
-
// Phase 2: Build full app in background (non-blocking)
|
|
147
111
|
;(async () => {
|
|
148
112
|
try {
|
|
149
113
|
const PHASE2_TIMEOUT_MS = 120_000;
|
|
@@ -159,7 +123,6 @@ export async function bootUiService(
|
|
|
159
123
|
const fullBuildTime = Date.now();
|
|
160
124
|
const fullDuration = fullBuildTime - startTime;
|
|
161
125
|
|
|
162
|
-
// Swap the app reference atomically
|
|
163
126
|
swapToFullApp(fullAppResult.app, serviceSlug);
|
|
164
127
|
|
|
165
128
|
log.info(
|
|
@@ -178,13 +141,9 @@ export async function bootUiService(
|
|
|
178
141
|
at: new Date().toISOString(),
|
|
179
142
|
},
|
|
180
143
|
});
|
|
181
|
-
// Continue with minimal app - better than crashing
|
|
182
144
|
}
|
|
183
145
|
})();
|
|
184
146
|
|
|
185
|
-
// Return result compatible with the existing UiServiceBootResult interface
|
|
186
|
-
// Note: blocks/routes will be empty initially since they're built in the background.
|
|
187
|
-
// The service is operational for MCP tools.
|
|
188
147
|
return {
|
|
189
148
|
kind: "ui",
|
|
190
149
|
blocks: [], // Will be populated when full app builds
|