@teamscala/service-boot 0.23.284
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 +1016 -0
- package/src/agreement-gate.test.ts +118 -0
- package/src/agreement-gate.ts +887 -0
- package/src/asset-cache-key.ts +59 -0
- package/src/asset-optimize.ts +93 -0
- package/src/asset-proxy.ts +148 -0
- package/src/asset-social-card.ts +58 -0
- package/src/attach-agreement-magic.ts +62 -0
- package/src/attach-portal-members.ts +107 -0
- package/src/attach-proposal-accept.ts +123 -0
- package/src/attach-proposal-magic.ts +55 -0
- package/src/attach-transfers-upload.ts +100 -0
- package/src/auth-gate.ts +385 -0
- package/src/bind-page-handler.ts +223 -0
- package/src/build-client-assets/input-hashing.ts +206 -0
- package/src/build-client-assets/source-candidates.ts +115 -0
- package/src/build-client-assets.test.ts +216 -0
- package/src/build-client-assets.ts +962 -0
- package/src/compose-page-title.test.ts +88 -0
- package/src/compose-page-title.ts +73 -0
- package/src/content-gate/access.ts +89 -0
- package/src/content-gate/config-types.ts +108 -0
- package/src/content-gate/gate.ts +74 -0
- package/src/content-gate/parse-cookie.ts +23 -0
- package/src/content-gate/token.test.ts +70 -0
- package/src/content-gate/token.ts +148 -0
- package/src/content-page-floor.test.ts +127 -0
- package/src/content-page-floor.ts +70 -0
- package/src/discover-blocks.ts +228 -0
- package/src/expand-roster.ts +197 -0
- package/src/fn-routers/agreement-completion-email.ts +115 -0
- package/src/fn-routers/agreement-send-router.ts +303 -0
- package/src/fn-routers/ai-health-router.ts +64 -0
- package/src/fn-routers/auth-router.ts +131 -0
- package/src/fn-routers/cache-admin-router.ts +50 -0
- package/src/fn-routers/chat-session-router.ts +455 -0
- package/src/fn-routers/email-defaults.ts +22 -0
- package/src/fn-routers/email-link-ttl.ts +23 -0
- package/src/fn-routers/index.ts +70 -0
- package/src/fn-routers/panel-data-router.ts +93 -0
- package/src/fn-routers/persist-chat-messages.test.ts +86 -0
- package/src/fn-routers/persist-chat-messages.ts +96 -0
- package/src/fn-routers/plans-router.ts +141 -0
- package/src/fn-routers/proposal-send-router.ts +207 -0
- package/src/fn-routers/public-agreement-router.ts +865 -0
- package/src/fn-routers/public-assessment-router.ts +421 -0
- package/src/fn-routers/public-gpt-session-router.ts +373 -0
- package/src/fn-routers/sign-in-flow-router.ts +196 -0
- package/src/fn-routers/task-sync-bind.test.ts +105 -0
- package/src/fn-routers/task-sync-classify.test.ts +109 -0
- package/src/fn-routers/task-sync-dispatch.test.ts +44 -0
- package/src/fn-routers/task-sync-dispatch.ts +124 -0
- package/src/fn-routers/task-sync-router.test.ts +279 -0
- package/src/fn-routers/task-sync-router.ts +1036 -0
- package/src/fn-routers/tasks-router.ts +77 -0
- package/src/fn-routers/user-preferences-router.ts +139 -0
- package/src/fn-routers/work-items-router.ts +341 -0
- package/src/gate-ttls.ts +56 -0
- package/src/gpt-gate.ts +76 -0
- package/src/handlers/bun-stable-watch.ts +89 -0
- package/src/handlers/projects-digest.ts +198 -0
- package/src/handlers/prune-stale-data.ts +110 -0
- package/src/handlers/registry-stale-inactive-sweep.ts +82 -0
- package/src/handlers/scala-tools-currency-watch.test.ts +60 -0
- package/src/handlers/scala-tools-currency-watch.ts +171 -0
- package/src/handlers/scheduled-browser-tests.ts +85 -0
- package/src/handlers/sweep-event-log.ts +98 -0
- package/src/handlers/sweep-host-cache.ts +211 -0
- package/src/handlers/upstream-issue-sync.ts +210 -0
- package/src/handlers/whatsapp-liveness-handler.test.ts +59 -0
- package/src/handlers/whatsapp-liveness-logic.ts +53 -0
- package/src/handlers/whatsapp-liveness.test.ts +78 -0
- package/src/handlers/whatsapp-liveness.ts +125 -0
- package/src/hono-error-handler.ts +55 -0
- package/src/install-harness.ts +80 -0
- package/src/install-logging.ts +182 -0
- package/src/load-service-install-row.ts +98 -0
- package/src/load-service-prisma-client.ts +53 -0
- package/src/mount-orpc-router.ts +148 -0
- package/src/mount-server-packages.ts +155 -0
- package/src/mount.ts +18 -0
- package/src/mutable-app-ref.ts +68 -0
- package/src/page-route-shell-types.ts +16 -0
- package/src/page-server-orpc/audit-revert-router.ts +102 -0
- package/src/page-server-orpc/hub-mcp-client.ts +20 -0
- package/src/page-server-orpc/portal-diagnostics-router.ts +170 -0
- package/src/page-server-orpc/puck-pages-router.ts +272 -0
- package/src/page-server-orpc/server-client.ts +31 -0
- package/src/page-server-orpc/templates-router.ts +116 -0
- package/src/page-server-orpc/user-preferences-router.ts +132 -0
- package/src/pg-boss-handler-registry.ts +36 -0
- package/src/pg-boss-mount.ts +295 -0
- package/src/proposal-authorized-emails.ts +65 -0
- package/src/proposal-chat-inject.ts +141 -0
- package/src/proposal-gate.ts +175 -0
- package/src/read-html-template.test.ts +74 -0
- package/src/read-html-template.ts +102 -0
- package/src/read-viewport-from-request.ts +36 -0
- package/src/readiness-gate.ts +39 -0
- package/src/register-api-routes.ts +127 -0
- package/src/register-crash-handlers.ts +39 -0
- package/src/register-page-routes.ts +62 -0
- package/src/register-page-server-client.ts +109 -0
- package/src/register-registry-page-route/ai-chat-prefetch.ts +106 -0
- package/src/register-registry-page-route/graph-cache.test.ts +84 -0
- package/src/register-registry-page-route/graph-cache.ts +90 -0
- package/src/register-registry-page-route/meta-title.ts +73 -0
- package/src/register-registry-page-route/proposal-approval-email-token.ts +78 -0
- package/src/register-registry-page-route/proposal-brand-stamping.ts +60 -0
- package/src/register-registry-page-route/resolve-page-data.ts +518 -0
- package/src/register-registry-page-route.ts +765 -0
- package/src/register-static-assets.ts +180 -0
- package/src/register-ws-routes.ts +180 -0
- package/src/resolve-app-base-url.ts +28 -0
- package/src/resolve-brand-favicon.ts +65 -0
- package/src/resolve-from-loader-config.test.ts +44 -0
- package/src/resolve-from-loader-config.ts +291 -0
- package/src/resolve-service-context.ts +67 -0
- package/src/resolve-theme-mode.ts +61 -0
- package/src/resolvers/fetch-org-brand.ts +28 -0
- package/src/resolvers/portal-route-paths.test.ts +102 -0
- package/src/resolvers/portal-route-paths.ts +83 -0
- package/src/resolvers/website-by-host-map.ts +49 -0
- package/src/resource-chat-inject.ts +168 -0
- package/src/resource-not-found.ts +263 -0
- package/src/run-boot-hook-list.ts +75 -0
- package/src/run-boot-hooks.ts +97 -0
- package/src/scala-tools-currency-watchdog.ts +160 -0
- package/src/security-headers.ts +59 -0
- package/src/serve/serve-ws.test.ts +135 -0
- package/src/serve/serve.ts +190 -0
- package/src/server-error-page.test.ts +102 -0
- package/src/server-error-page.ts +95 -0
- package/src/start-scheduled-jobs.ts +84 -0
- package/src/two-phase-boot.ts +449 -0
- package/src/types.ts +52 -0
- package/src/ui-service.ts +171 -0
- package/src/ws-boot-self-test.ts +114 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Injects the generic ResourceChatLauncher block into an AUTHORISED resource
|
|
7
|
+
* page (proposal, agreement, or any gated document) — called from a gate's
|
|
8
|
+
* onAuthorized, so the floating GPT bubble lives strictly behind the email gate
|
|
9
|
+
* and every resource of that type gets it with no per-page Puck edit. It builds
|
|
10
|
+
* the resource's page content into a runtime-context string and passes it to the
|
|
11
|
+
* launcher, which forwards it to the chat as <runtime_context> — so ONE shared
|
|
12
|
+
* assistant agent answers about whichever resource is being viewed.
|
|
13
|
+
*
|
|
14
|
+
* RESOURCE-AGNOSTIC by design (one-ai-chat-system / chat-ui-is-one-canonical-block):
|
|
15
|
+
* the launcher, the chat engine, and this injector are generic; only the
|
|
16
|
+
* per-resource CONFIG differs (which agent, the copy, which resolved key holds
|
|
17
|
+
* the record). The agent id is config, not code (registries-are-source-of-truth):
|
|
18
|
+
* each resource type names a `config / <slug>-assistant-agent` row carrying
|
|
19
|
+
* { agentId }. No row (or no agent) → no injection, page renders unchanged
|
|
20
|
+
* (fail-soft).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
24
|
+
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
25
|
+
import { getServerClient } from "./page-server-orpc/server-client.ts";
|
|
26
|
+
import * as v from "valibot";
|
|
27
|
+
|
|
28
|
+
const AgentConfigSchema = v.object({ agentId: v.string() });
|
|
29
|
+
|
|
30
|
+
/** Per-resource-type injection config — the only thing that varies. */
|
|
31
|
+
export interface ResourceChatOptions {
|
|
32
|
+
/** Key in `resolved` holding the record (e.g. "proposal", "agreement"). */
|
|
33
|
+
resourceKey: string;
|
|
34
|
+
/** `config` row slug carrying { agentId } (e.g. "agreement-assistant-agent"). */
|
|
35
|
+
agentConfigSlug: string;
|
|
36
|
+
/** Org id to scope the chat. If omitted, read from resolved[resourceKey].organisation_id
|
|
37
|
+
* (proposals); agreements pass it explicitly (org isn't on the resolved record). */
|
|
38
|
+
organisationId?: string;
|
|
39
|
+
/** Overlay title-bar label (e.g. "Agreement Assistant"). */
|
|
40
|
+
title: string;
|
|
41
|
+
/** Floating button label / speech bubble (e.g. "Ask about this agreement"). */
|
|
42
|
+
launchLabel: string;
|
|
43
|
+
/** First assistant message. */
|
|
44
|
+
welcomeMessage?: string;
|
|
45
|
+
/** Input placeholder. */
|
|
46
|
+
placeholder?: string;
|
|
47
|
+
/** Suggested starter questions. */
|
|
48
|
+
conversationStarters?: string[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Serialise the resource record + entire page content tree as text for the GPT. */
|
|
52
|
+
async function buildResourceContext(
|
|
53
|
+
resolved: Record<string, unknown>,
|
|
54
|
+
resourceKey: string,
|
|
55
|
+
): Promise<string> {
|
|
56
|
+
const record = resolved[resourceKey] as Record<string, unknown> | undefined;
|
|
57
|
+
const parts: string[] = [];
|
|
58
|
+
|
|
59
|
+
if (typeof record?.name === "string" && record.name) {
|
|
60
|
+
parts.push(`Title: ${record.name}`);
|
|
61
|
+
}
|
|
62
|
+
if (typeof record?.client_name === "string" && record.client_name) {
|
|
63
|
+
parts.push(`For: ${record.client_name}`);
|
|
64
|
+
}
|
|
65
|
+
if (typeof record?.description === "string" && record.description) {
|
|
66
|
+
parts.push(`Description: ${record.description}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Dump the entire page content tree — every block, every prop, verbatim.
|
|
70
|
+
const page = (resolved.page as { puck_data?: { content?: unknown[] } } | undefined)?.puck_data;
|
|
71
|
+
if (page && Array.isArray(page.content)) {
|
|
72
|
+
parts.push("\n--- Document content ---");
|
|
73
|
+
parts.push(JSON.stringify(page.content, null, 2));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Resolve DB-backed product tiers when present (proposals) so the GPT sees
|
|
77
|
+
// real pricing. Harmless for resources with no ProductTiers blocks.
|
|
78
|
+
const client = getServerClient() as {
|
|
79
|
+
products?: {
|
|
80
|
+
findFirst: (args: {
|
|
81
|
+
where: { id: string };
|
|
82
|
+
select: { id: true; billing_type: true; tiers: true; components: true };
|
|
83
|
+
}) => Promise<{ id: string; billing_type: string; tiers: unknown; components: unknown } | null>;
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
for (const block of (page?.content ?? []) as Array<Record<string, unknown>>) {
|
|
87
|
+
if (block?.type !== "ProductTiers") continue;
|
|
88
|
+
const props = block.props as Record<string, unknown> | undefined;
|
|
89
|
+
if (!props) continue;
|
|
90
|
+
const productIds = [props.productId, props.productId2, props.productId3].filter(
|
|
91
|
+
(id): id is string => typeof id === "string" && id.trim().length > 0,
|
|
92
|
+
);
|
|
93
|
+
for (const pid of productIds) {
|
|
94
|
+
try {
|
|
95
|
+
const product = await client.products?.findFirst({
|
|
96
|
+
where: { id: pid },
|
|
97
|
+
select: { id: true, billing_type: true, tiers: true, components: true },
|
|
98
|
+
});
|
|
99
|
+
if (product) {
|
|
100
|
+
parts.push(`\n--- Product tiers (productId: ${pid}) ---`);
|
|
101
|
+
parts.push(JSON.stringify(product, null, 2));
|
|
102
|
+
}
|
|
103
|
+
} catch {
|
|
104
|
+
// best-effort
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return parts.join("\n");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function brandPrimaryColor(resolved: Record<string, unknown>): string {
|
|
113
|
+
const brand = resolved.brand as { primaryColor?: unknown } | undefined;
|
|
114
|
+
return typeof brand?.primaryColor === "string" ? brand.primaryColor : "";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Inject the generic ResourceChatLauncher behind the gate. `opts` is the only
|
|
119
|
+
* per-resource-type variation; everything else is shared.
|
|
120
|
+
*/
|
|
121
|
+
export async function injectResourceChatLauncher(
|
|
122
|
+
resolved: Record<string, unknown>,
|
|
123
|
+
viewerEmail: string,
|
|
124
|
+
opts: ResourceChatOptions,
|
|
125
|
+
): Promise<void> {
|
|
126
|
+
const page = (resolved.page as { puck_data?: { content?: unknown[] } } | undefined)?.puck_data;
|
|
127
|
+
if (!page || !Array.isArray(page.content)) return;
|
|
128
|
+
|
|
129
|
+
const record = resolved[opts.resourceKey] as Record<string, unknown> | undefined;
|
|
130
|
+
const organisationId =
|
|
131
|
+
opts.organisationId ||
|
|
132
|
+
(typeof record?.organisation_id === "string" ? record.organisation_id : "");
|
|
133
|
+
if (!organisationId) return;
|
|
134
|
+
|
|
135
|
+
let agentId = "";
|
|
136
|
+
try {
|
|
137
|
+
agentId = (
|
|
138
|
+
await loadRegistryConfig("config", opts.agentConfigSlug, AgentConfigSchema)
|
|
139
|
+
).agentId;
|
|
140
|
+
} catch {
|
|
141
|
+
return; // no agent configured → no bubble (fail-soft, page unchanged)
|
|
142
|
+
}
|
|
143
|
+
if (!agentId) return;
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const runtimeContext = await buildResourceContext(resolved, opts.resourceKey);
|
|
147
|
+
(page.content as Array<Record<string, unknown>>).push({
|
|
148
|
+
type: "ResourceChatLauncher",
|
|
149
|
+
props: {
|
|
150
|
+
id: `${opts.resourceKey}-chat-launcher`,
|
|
151
|
+
agentId,
|
|
152
|
+
organisationId,
|
|
153
|
+
viewerEmail,
|
|
154
|
+
runtimeContext,
|
|
155
|
+
primaryColor: brandPrimaryColor(resolved),
|
|
156
|
+
title: opts.title,
|
|
157
|
+
welcomeMessage: opts.welcomeMessage,
|
|
158
|
+
launchLabel: opts.launchLabel,
|
|
159
|
+
placeholder: opts.placeholder,
|
|
160
|
+
conversationStarters: opts.conversationStarters,
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
} catch (err) {
|
|
164
|
+
getAppLogger().error(
|
|
165
|
+
`[resource-chat] launcher injection failed (${opts.resourceKey}): ${err instanceof Error ? err.message : String(err)}`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
* @indivisible-unit
|
|
6
|
+
*
|
|
7
|
+
* One cohesive unit — the branded resource-not-found / holding page: the
|
|
8
|
+
* PrimaryResourceMissingError trigger, the org-logo resolver the page
|
|
9
|
+
* renders, and the HTML builder are tightly coupled (the builder consumes
|
|
10
|
+
* the logo; the error drives the page) and cannot be split without churn.
|
|
11
|
+
*
|
|
12
|
+
* Branded 404 for registry page routes whose PRIMARY resource is missing.
|
|
13
|
+
*
|
|
14
|
+
* A route that declares `resource_type` (proposal, form, quiz, agreement…)
|
|
15
|
+
* is meaningless without that entity. Before this existed, a dead link
|
|
16
|
+
* (wrong id, deleted row) fell through the loader with nulls and rendered
|
|
17
|
+
* whatever default content the shell could scrape together — leaking
|
|
18
|
+
* internal pages (the "Vision Toolbar" incident, operator-observed
|
|
19
|
+
* 2026-06-05) on the org's own branded host, with a 200 status that
|
|
20
|
+
* search engines and link unfurlers would happily cache.
|
|
21
|
+
*
|
|
22
|
+
* The replacement: loadData throws PrimaryResourceMissingError; the page
|
|
23
|
+
* binder maps it to a minimal branded 404 — correct status, noindex, the
|
|
24
|
+
* host's own favicon (which resolves the owning org per brand_org), and a
|
|
25
|
+
* plain human sentence. Deliberately NOT a puck page: the error surface
|
|
26
|
+
* must render even when data loading is the thing that failed. A
|
|
27
|
+
* website-editable /404 puck page can layer on top later; this is the
|
|
28
|
+
* floor that must always exist.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { getClient } from "@teamscala/aws/create/client/get-client";
|
|
32
|
+
import { createCache } from "@teamscala/cache/create-cache";
|
|
33
|
+
import { getServerClient } from "@teamscala/orpc/server-client-registry";
|
|
34
|
+
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
35
|
+
import { toAssetPath } from "@teamscala/formatters/asset-url";
|
|
36
|
+
import { brandLogoImgTag } from "@teamscala/os/brand/brand-logo-img-tag";
|
|
37
|
+
import { ServiceInstallConfigSchema } from "./load-service-install-row.ts";
|
|
38
|
+
|
|
39
|
+
const RESOURCE_LABELS: Record<string, string> = {
|
|
40
|
+
proposal: "proposal",
|
|
41
|
+
form: "form",
|
|
42
|
+
quiz: "quiz",
|
|
43
|
+
agreement: "agreement",
|
|
44
|
+
agent: "assistant",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const logoCache = createCache<string | null>("service-boot:error-page-logo", {
|
|
48
|
+
ttlMs: 5 * 60_000,
|
|
49
|
+
maxSize: 8,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The host org's LOGO (wordmark) for the dark error page — the favicon is a
|
|
54
|
+
* tab glyph, the wrong asset at page scale (operator-observed: a lone "h"
|
|
55
|
+
* where the launcha wordmark belonged). Resolution: the service's own
|
|
56
|
+
* install row names its owner (brand_org); the org's dark-theme logo (a
|
|
57
|
+
* light-coloured mark, correct on the dark page) is preferred, light-theme
|
|
58
|
+
* second. Every step skew-tolerant: any miss → null → the builder renders a
|
|
59
|
+
* logo-less dark page.
|
|
60
|
+
*
|
|
61
|
+
* Returns the logo as an INLINE data URI (not a `/api/assets/` proxy URL) so
|
|
62
|
+
* the branded holding/error page ships the logo IN the initial HTML bytes —
|
|
63
|
+
* no second fetch, no post-paint pop-in, no dependency on the asset proxy the
|
|
64
|
+
* degraded service is failing to serve (incident 2026-07-27: the logo on a
|
|
65
|
+
* DEGRADED render-layer holding page appeared after the page painted). The
|
|
66
|
+
* logo sibling of icons-render-ssr-inline. Size-capped: a pathological
|
|
67
|
+
* (>MAX_INLINE_LOGO_BYTES) logo falls back to null rather than bloating the
|
|
68
|
+
* HTML; the page renders logo-less, never broken.
|
|
69
|
+
*/
|
|
70
|
+
export async function resolveErrorPageLogo(
|
|
71
|
+
serviceSlug: string,
|
|
72
|
+
): Promise<string | null> {
|
|
73
|
+
return logoCache.fetchOrCompute(serviceSlug, async () => {
|
|
74
|
+
try {
|
|
75
|
+
const cfg = await loadRegistryConfig(
|
|
76
|
+
"service_install",
|
|
77
|
+
serviceSlug,
|
|
78
|
+
ServiceInstallConfigSchema,
|
|
79
|
+
);
|
|
80
|
+
const org = (cfg as { brand_org?: string }).brand_org;
|
|
81
|
+
if (!org) return null;
|
|
82
|
+
const prisma = (await getServerClient()) as {
|
|
83
|
+
organisation_profile?: {
|
|
84
|
+
findFirst?: (a: unknown) => Promise<unknown>;
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
const row = (await prisma.organisation_profile?.findFirst?.({
|
|
88
|
+
where: { id: org },
|
|
89
|
+
select: {
|
|
90
|
+
company_logo_for_dark_theme: true,
|
|
91
|
+
company_logo_for_light_theme: true,
|
|
92
|
+
},
|
|
93
|
+
})) as {
|
|
94
|
+
company_logo_for_dark_theme?: string | null;
|
|
95
|
+
company_logo_for_light_theme?: string | null;
|
|
96
|
+
} | null;
|
|
97
|
+
const stored =
|
|
98
|
+
row?.company_logo_for_dark_theme ||
|
|
99
|
+
row?.company_logo_for_light_theme ||
|
|
100
|
+
null;
|
|
101
|
+
return stored ? inlineLogoAsDataUri(stored) : null;
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Above this byte size, inlining bloats the HTML more than it helps — fall
|
|
109
|
+
* back to a logo-less page. Typical org logos are 5–30 KB, so this only
|
|
110
|
+
* catches a genuinely oversized upload. */
|
|
111
|
+
const MAX_INLINE_LOGO_BYTES = 100 * 1024;
|
|
112
|
+
|
|
113
|
+
const ASSETS_PREFIX = "/api/assets/";
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Inline a stored logo value as a `data:<mime>;base64,…` URI by fetching its
|
|
117
|
+
* bytes from S3 (the same store `/api/assets/` proxies to). Mirrors the asset
|
|
118
|
+
* proxy's key extraction: `toAssetPath` normalises every stored form (bare
|
|
119
|
+
* key, `/api/files/`, tigris URL) to `/api/assets/<key>`, and the proxy
|
|
120
|
+
* decodes that path segment to the S3 key. Skew-tolerant: any miss → null.
|
|
121
|
+
*/
|
|
122
|
+
async function inlineLogoAsDataUri(
|
|
123
|
+
stored: string,
|
|
124
|
+
): Promise<string | null> {
|
|
125
|
+
try {
|
|
126
|
+
const assetPath = toAssetPath(stored);
|
|
127
|
+
if (!assetPath.startsWith(ASSETS_PREFIX)) return null;
|
|
128
|
+
const key = decodeURIComponent(assetPath.slice(ASSETS_PREFIX.length));
|
|
129
|
+
const s3 = await getClient();
|
|
130
|
+
if (!s3) return null;
|
|
131
|
+
const bytes = await s3.file(key).arrayBuffer();
|
|
132
|
+
if (bytes.byteLength === 0 || bytes.byteLength > MAX_INLINE_LOGO_BYTES) {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
const mime = mimeFromKey(key) ?? "image/png";
|
|
136
|
+
const base64 = Buffer.from(bytes).toString("base64");
|
|
137
|
+
return `data:${mime};base64,${base64}`;
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function mimeFromKey(key: string): string | null {
|
|
144
|
+
const ext = key.split(".").pop()?.toLowerCase();
|
|
145
|
+
switch (ext) {
|
|
146
|
+
case "png":
|
|
147
|
+
return "image/png";
|
|
148
|
+
case "jpg":
|
|
149
|
+
case "jpeg":
|
|
150
|
+
return "image/jpeg";
|
|
151
|
+
case "webp":
|
|
152
|
+
return "image/webp";
|
|
153
|
+
case "gif":
|
|
154
|
+
return "image/gif";
|
|
155
|
+
case "svg":
|
|
156
|
+
return "image/svg+xml";
|
|
157
|
+
case "avif":
|
|
158
|
+
return "image/avif";
|
|
159
|
+
default:
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export class PrimaryResourceMissingError extends Error {
|
|
165
|
+
readonly resourceType: string;
|
|
166
|
+
// HTTP status for the holding page. 404 for a genuinely missing/invalid
|
|
167
|
+
// resource (a dead /:id link); 200 for the bare root of a UUID-driven site
|
|
168
|
+
// ("no <resource> selected") — the root is a valid landing, not an error,
|
|
169
|
+
// and kind:ui services must answer GET / with 200 (verify-prod-persistent-slot).
|
|
170
|
+
readonly httpStatus: number;
|
|
171
|
+
// A page ROW exists but has zero content blocks — a content gap, not a dead
|
|
172
|
+
// link (operator ruling 2026-07-21: "if the page is empty, it should have a
|
|
173
|
+
// Puck block that says this page is empty... and then take me home"). The
|
|
174
|
+
// floor renders the "This page is empty" surface (200) instead of the dead-
|
|
175
|
+
// link "doesn't exist" surface (404). Distinct from a dead link (no row).
|
|
176
|
+
readonly empty: boolean;
|
|
177
|
+
constructor(resourceType: string, httpStatus = 404, opts?: { empty?: boolean }) {
|
|
178
|
+
super(`primary resource "${resourceType}" not found`);
|
|
179
|
+
this.name = "PrimaryResourceMissingError";
|
|
180
|
+
this.resourceType = resourceType;
|
|
181
|
+
this.httpStatus = httpStatus;
|
|
182
|
+
this.empty = opts?.empty === true;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function buildResourceNotFoundResponse(
|
|
187
|
+
resourceType: string,
|
|
188
|
+
logoUrl?: string | null,
|
|
189
|
+
status = 404,
|
|
190
|
+
// Optional 4th arg keeps the existing 3-arg call sites working while letting
|
|
191
|
+
// the page binder add a "Take me home" action (operator-requested) and the
|
|
192
|
+
// empty-page variant.
|
|
193
|
+
opts?: { homeUrl?: string; empty?: boolean },
|
|
194
|
+
): Response {
|
|
195
|
+
const label = RESOURCE_LABELS[resourceType] ?? "page";
|
|
196
|
+
// Single-owner hosts show the owner's wordmark; multi-tenant hosts
|
|
197
|
+
// (no brand_org — a dead id's org is unknowable) stay platform-neutral.
|
|
198
|
+
// The helper emits the inline data-URI src with reserved width+height
|
|
199
|
+
// (logos-inline-on-branded-server-pages).
|
|
200
|
+
const logoImg = brandLogoImgTag(logoUrl, { height: 40, width: 160 });
|
|
201
|
+
// Three surfaces share this one floor (resource-not-found-is-the-root-floor):
|
|
202
|
+
// 200 root — the BARE ROOT of a UUID-driven host ("no <resource> selected").
|
|
203
|
+
// 404 dead — a content path with NO page row (a deleted/moved page, e.g.
|
|
204
|
+
// /roadmap) OR a dead /:id: a link that genuinely doesn't resolve.
|
|
205
|
+
// 200 empty — a page ROW exists but has zero blocks (a content gap, not a
|
|
206
|
+
// dead link): "This page is empty", still take-me-home.
|
|
207
|
+
const isEmpty = opts?.empty === true;
|
|
208
|
+
const isRoot = status === 200 && !isEmpty;
|
|
209
|
+
const title = isEmpty ? "Nothing here yet" : isRoot ? `No ${label} here` : "Not found";
|
|
210
|
+
const heading = isEmpty
|
|
211
|
+
? `This page is empty`
|
|
212
|
+
: isRoot
|
|
213
|
+
? `There’s no ${label} at this link`
|
|
214
|
+
: `This ${label} doesn’t exist`;
|
|
215
|
+
const body = isEmpty
|
|
216
|
+
? `There’s nothing on this page yet. Head back home to keep going.`
|
|
217
|
+
: isRoot
|
|
218
|
+
? `This is where ${label}s are shared. Open the ${label} link you were
|
|
219
|
+
sent to view it — there’s nothing to see at the bare address.`
|
|
220
|
+
: `The page you’re looking for may have been moved or removed, or the
|
|
221
|
+
link may be incomplete.`;
|
|
222
|
+
// "Take me home" — operator-requested action on every not-found/empty surface.
|
|
223
|
+
// Omitted on the bare-root landing (it IS home). Defaults to the host root.
|
|
224
|
+
const homeUrl = opts?.homeUrl ?? "/";
|
|
225
|
+
const homeLink = isRoot
|
|
226
|
+
? ""
|
|
227
|
+
: `<p><a href="${homeUrl}" style="display:inline-block;margin-top:1.25rem;padding:.6rem 1.1rem;border-radius:.5rem;background:#e7e7e7;color:#0a0e1a;text-decoration:none;font-weight:600;font-size:.95rem">Take me home</a></p>`;
|
|
228
|
+
const html = `<!doctype html>
|
|
229
|
+
<html lang="en">
|
|
230
|
+
<head>
|
|
231
|
+
<meta charset="utf-8">
|
|
232
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
233
|
+
<meta name="robots" content="noindex">
|
|
234
|
+
<title>${title}</title>
|
|
235
|
+
<link rel="icon" href="/favicon.ico">
|
|
236
|
+
<style>
|
|
237
|
+
body { margin: 0; min-height: 100vh; display: flex; align-items: center;
|
|
238
|
+
justify-content: center; background: #0a0e1a; color: #e7e7e7;
|
|
239
|
+
font-family: system-ui, sans-serif; text-align: center; }
|
|
240
|
+
main { padding: 2rem; max-width: 28rem; }
|
|
241
|
+
img { height: 48px; width: auto; margin-bottom: 1.5rem; }
|
|
242
|
+
h1 { font-size: 1.25rem; font-weight: 600; margin: 0 0 .5rem; }
|
|
243
|
+
p { color: #9aa0ab; line-height: 1.5; margin: 0; }
|
|
244
|
+
a:hover { opacity: .9; }
|
|
245
|
+
</style>
|
|
246
|
+
</head>
|
|
247
|
+
<body>
|
|
248
|
+
<main>
|
|
249
|
+
${logoImg}
|
|
250
|
+
<h1>${heading}</h1>
|
|
251
|
+
<p>${body}</p>
|
|
252
|
+
${homeLink}
|
|
253
|
+
</main>
|
|
254
|
+
</body>
|
|
255
|
+
</html>`;
|
|
256
|
+
return new Response(html, {
|
|
257
|
+
status,
|
|
258
|
+
headers: {
|
|
259
|
+
"content-type": "text/html; charset=utf-8",
|
|
260
|
+
"cache-control": "no-store, no-transform",
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system server-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Runs the service's boot hooks. A server_package registers a hook via
|
|
7
|
+
* ctx.registerBootHook during the mount phase; a hook registered by an
|
|
8
|
+
* INSTALLED server_package is intended to run (the package is installed
|
|
9
|
+
* because the service wants it). This therefore invokes EVERY registered
|
|
10
|
+
* hook — those declared in `service_install.boot_hooks[]` first (preserving
|
|
11
|
+
* their declared order), then any registered-but-undeclared hooks. Requiring a
|
|
12
|
+
* hook to be BOTH registered by its package AND re-declared in boot_hooks is a
|
|
13
|
+
* double-declaration footgun — it left the realtime-utils pg-notify bridge
|
|
14
|
+
* registered-but-never-invoked (boot_hooks was null), so the live-refresh
|
|
15
|
+
* relay never started (2026-06-29).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { ServiceInstallConfig } from "@teamscala/db/registry/loader/load-service-install-row";
|
|
19
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
20
|
+
import { BootFatalError } from "@teamscala/os/errors/boot-error";
|
|
21
|
+
import type { BootHookHandler, MountRegistry } from "@teamscala/os/runtime-contracts/mount-context";
|
|
22
|
+
|
|
23
|
+
export async function runBootHookList(
|
|
24
|
+
config: ServiceInstallConfig,
|
|
25
|
+
mountRegistry: MountRegistry,
|
|
26
|
+
serviceSlug: string,
|
|
27
|
+
): Promise<void> {
|
|
28
|
+
const log = getAppLogger();
|
|
29
|
+
const ran = new Set<string>();
|
|
30
|
+
const invoke = async (
|
|
31
|
+
handlerSlug: string,
|
|
32
|
+
handler: BootHookHandler,
|
|
33
|
+
): Promise<void> => {
|
|
34
|
+
try {
|
|
35
|
+
await handler({ serviceSlug, config });
|
|
36
|
+
log.info(
|
|
37
|
+
`[scala-install:${serviceSlug}] boot_hook "${handlerSlug}" complete`,
|
|
38
|
+
);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
throw new BootFatalError(
|
|
41
|
+
"run-boot-hook-list",
|
|
42
|
+
serviceSlug,
|
|
43
|
+
`boot_hook "${handlerSlug}" threw: ${err instanceof Error ? err.message : err}`,
|
|
44
|
+
err,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// Declared hooks (config.boot_hooks) in declared order — preserves ordering.
|
|
50
|
+
for (const rawHook of config.boot_hooks ?? []) {
|
|
51
|
+
const hook = rawHook as {
|
|
52
|
+
slug: string;
|
|
53
|
+
handler_slug?: string;
|
|
54
|
+
[key: string]: unknown;
|
|
55
|
+
};
|
|
56
|
+
const handlerSlug = hook.handler_slug ?? hook.slug;
|
|
57
|
+
const handler = mountRegistry.bootHooks.get(handlerSlug);
|
|
58
|
+
if (!handler) {
|
|
59
|
+
throw new BootFatalError(
|
|
60
|
+
"run-boot-hook-list",
|
|
61
|
+
serviceSlug,
|
|
62
|
+
`boot_hook "${hook.slug}" has no registered handler`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
await invoke(handlerSlug, handler);
|
|
66
|
+
ran.add(handlerSlug);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Registered-but-undeclared hooks run too — a server_package registering a
|
|
70
|
+
// boot hook intends it to run when installed (see file header).
|
|
71
|
+
for (const [handlerSlug, handler] of mountRegistry.bootHooks) {
|
|
72
|
+
if (ran.has(handlerSlug)) continue;
|
|
73
|
+
await invoke(handlerSlug, handler);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system server-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Runs service_install.boot_hooks in declaration order. A boot hook is a
|
|
7
|
+
* registry-declared, one-shot startup function that needs to execute after
|
|
8
|
+
* prisma + registry are ready but before the HTTP server starts serving.
|
|
9
|
+
* Used for custom startup logic (stale-run reconciliation, cache hydration,
|
|
10
|
+
* ALS seeding, long-running watcher start) that doesn't fit the pg-boss
|
|
11
|
+
* recurring model and is too specific to be a generic installer step.
|
|
12
|
+
*
|
|
13
|
+
* Each hook is resolved by dynamic-importing its handler_slug (or slug when
|
|
14
|
+
* handler_slug is absent) and awaiting its default export. The export must
|
|
15
|
+
* be an async function accepting a BootHookContext. Hooks are invoked in
|
|
16
|
+
* ascending order of their `order` field (default 100).
|
|
17
|
+
*
|
|
18
|
+
* Failures are fatal. The installer has no partial-boot concept: if a
|
|
19
|
+
* declared hook cannot run, the service is broken, and booting anyway would
|
|
20
|
+
* mask the regression. BootFatalError is thrown to propagate to the
|
|
21
|
+
* top-level boot-error handler.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { ServiceInstallConfig } from "@teamscala/db/registry/loader/load-service-install-row";
|
|
25
|
+
import { BootFatalError } from "@teamscala/os/errors/boot-error";
|
|
26
|
+
|
|
27
|
+
export interface BootHookContext {
|
|
28
|
+
serviceSlug: string;
|
|
29
|
+
config: ServiceInstallConfig;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type BootHookHandler = (ctx: BootHookContext) => Promise<void> | void;
|
|
33
|
+
|
|
34
|
+
interface BootHookEntry {
|
|
35
|
+
slug: string;
|
|
36
|
+
handler_slug?: string;
|
|
37
|
+
order: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function runBootHooks(
|
|
41
|
+
serviceSlug: string,
|
|
42
|
+
config: ServiceInstallConfig,
|
|
43
|
+
): Promise<{ ran: string[] }> {
|
|
44
|
+
const hooks = (config.boot_hooks ?? []) as BootHookEntry[];
|
|
45
|
+
if (hooks.length === 0) {
|
|
46
|
+
return { ran: [] };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const ordered = [...hooks].sort(
|
|
50
|
+
(a, b) => (a.order ?? 100) - (b.order ?? 100),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const ran: string[] = [];
|
|
54
|
+
const ctx: BootHookContext = { serviceSlug, config };
|
|
55
|
+
|
|
56
|
+
for (const hook of ordered) {
|
|
57
|
+
const specifier = hook.handler_slug ?? hook.slug;
|
|
58
|
+
let mod: { default?: unknown };
|
|
59
|
+
try {
|
|
60
|
+
mod = await import(specifier);
|
|
61
|
+
} catch (err) {
|
|
62
|
+
throw new BootFatalError(
|
|
63
|
+
serviceSlug,
|
|
64
|
+
"boot-hooks.import",
|
|
65
|
+
`Boot hook "${hook.slug}" failed to import from "${specifier}": ` +
|
|
66
|
+
`${err instanceof Error ? err.message : err}`,
|
|
67
|
+
err,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const handler = mod.default;
|
|
72
|
+
if (typeof handler !== "function") {
|
|
73
|
+
throw new BootFatalError(
|
|
74
|
+
serviceSlug,
|
|
75
|
+
"boot-hooks.missing-default",
|
|
76
|
+
`Boot hook "${hook.slug}" module "${specifier}" has no default export or ` +
|
|
77
|
+
`the default export is not a function (got ${typeof handler}).`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
await (handler as BootHookHandler)(ctx);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
throw new BootFatalError(
|
|
85
|
+
serviceSlug,
|
|
86
|
+
"boot-hooks.execution",
|
|
87
|
+
`Boot hook "${hook.slug}" threw during execution: ` +
|
|
88
|
+
`${err instanceof Error ? err.message : err}`,
|
|
89
|
+
err,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
ran.push(hook.slug);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { ran };
|
|
97
|
+
}
|