@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.
Files changed (138) hide show
  1. package/package.json +1016 -0
  2. package/src/agreement-gate.test.ts +118 -0
  3. package/src/agreement-gate.ts +887 -0
  4. package/src/asset-cache-key.ts +59 -0
  5. package/src/asset-optimize.ts +93 -0
  6. package/src/asset-proxy.ts +148 -0
  7. package/src/asset-social-card.ts +58 -0
  8. package/src/attach-agreement-magic.ts +62 -0
  9. package/src/attach-portal-members.ts +107 -0
  10. package/src/attach-proposal-accept.ts +123 -0
  11. package/src/attach-proposal-magic.ts +55 -0
  12. package/src/attach-transfers-upload.ts +100 -0
  13. package/src/auth-gate.ts +385 -0
  14. package/src/bind-page-handler.ts +223 -0
  15. package/src/build-client-assets/input-hashing.ts +206 -0
  16. package/src/build-client-assets/source-candidates.ts +115 -0
  17. package/src/build-client-assets.test.ts +216 -0
  18. package/src/build-client-assets.ts +962 -0
  19. package/src/compose-page-title.test.ts +88 -0
  20. package/src/compose-page-title.ts +73 -0
  21. package/src/content-gate/access.ts +89 -0
  22. package/src/content-gate/config-types.ts +108 -0
  23. package/src/content-gate/gate.ts +74 -0
  24. package/src/content-gate/parse-cookie.ts +23 -0
  25. package/src/content-gate/token.test.ts +70 -0
  26. package/src/content-gate/token.ts +148 -0
  27. package/src/content-page-floor.test.ts +127 -0
  28. package/src/content-page-floor.ts +70 -0
  29. package/src/discover-blocks.ts +228 -0
  30. package/src/expand-roster.ts +197 -0
  31. package/src/fn-routers/agreement-completion-email.ts +115 -0
  32. package/src/fn-routers/agreement-send-router.ts +303 -0
  33. package/src/fn-routers/ai-health-router.ts +64 -0
  34. package/src/fn-routers/auth-router.ts +131 -0
  35. package/src/fn-routers/cache-admin-router.ts +50 -0
  36. package/src/fn-routers/chat-session-router.ts +455 -0
  37. package/src/fn-routers/email-defaults.ts +22 -0
  38. package/src/fn-routers/email-link-ttl.ts +23 -0
  39. package/src/fn-routers/index.ts +70 -0
  40. package/src/fn-routers/panel-data-router.ts +93 -0
  41. package/src/fn-routers/persist-chat-messages.test.ts +86 -0
  42. package/src/fn-routers/persist-chat-messages.ts +96 -0
  43. package/src/fn-routers/plans-router.ts +141 -0
  44. package/src/fn-routers/proposal-send-router.ts +207 -0
  45. package/src/fn-routers/public-agreement-router.ts +865 -0
  46. package/src/fn-routers/public-assessment-router.ts +421 -0
  47. package/src/fn-routers/public-gpt-session-router.ts +373 -0
  48. package/src/fn-routers/sign-in-flow-router.ts +196 -0
  49. package/src/fn-routers/task-sync-bind.test.ts +105 -0
  50. package/src/fn-routers/task-sync-classify.test.ts +109 -0
  51. package/src/fn-routers/task-sync-dispatch.test.ts +44 -0
  52. package/src/fn-routers/task-sync-dispatch.ts +124 -0
  53. package/src/fn-routers/task-sync-router.test.ts +279 -0
  54. package/src/fn-routers/task-sync-router.ts +1036 -0
  55. package/src/fn-routers/tasks-router.ts +77 -0
  56. package/src/fn-routers/user-preferences-router.ts +139 -0
  57. package/src/fn-routers/work-items-router.ts +341 -0
  58. package/src/gate-ttls.ts +56 -0
  59. package/src/gpt-gate.ts +76 -0
  60. package/src/handlers/bun-stable-watch.ts +89 -0
  61. package/src/handlers/projects-digest.ts +198 -0
  62. package/src/handlers/prune-stale-data.ts +110 -0
  63. package/src/handlers/registry-stale-inactive-sweep.ts +82 -0
  64. package/src/handlers/scala-tools-currency-watch.test.ts +60 -0
  65. package/src/handlers/scala-tools-currency-watch.ts +171 -0
  66. package/src/handlers/scheduled-browser-tests.ts +85 -0
  67. package/src/handlers/sweep-event-log.ts +98 -0
  68. package/src/handlers/sweep-host-cache.ts +211 -0
  69. package/src/handlers/upstream-issue-sync.ts +210 -0
  70. package/src/handlers/whatsapp-liveness-handler.test.ts +59 -0
  71. package/src/handlers/whatsapp-liveness-logic.ts +53 -0
  72. package/src/handlers/whatsapp-liveness.test.ts +78 -0
  73. package/src/handlers/whatsapp-liveness.ts +125 -0
  74. package/src/hono-error-handler.ts +55 -0
  75. package/src/install-harness.ts +80 -0
  76. package/src/install-logging.ts +182 -0
  77. package/src/load-service-install-row.ts +98 -0
  78. package/src/load-service-prisma-client.ts +53 -0
  79. package/src/mount-orpc-router.ts +148 -0
  80. package/src/mount-server-packages.ts +155 -0
  81. package/src/mount.ts +18 -0
  82. package/src/mutable-app-ref.ts +68 -0
  83. package/src/page-route-shell-types.ts +16 -0
  84. package/src/page-server-orpc/audit-revert-router.ts +102 -0
  85. package/src/page-server-orpc/hub-mcp-client.ts +20 -0
  86. package/src/page-server-orpc/portal-diagnostics-router.ts +170 -0
  87. package/src/page-server-orpc/puck-pages-router.ts +272 -0
  88. package/src/page-server-orpc/server-client.ts +31 -0
  89. package/src/page-server-orpc/templates-router.ts +116 -0
  90. package/src/page-server-orpc/user-preferences-router.ts +132 -0
  91. package/src/pg-boss-handler-registry.ts +36 -0
  92. package/src/pg-boss-mount.ts +295 -0
  93. package/src/proposal-authorized-emails.ts +65 -0
  94. package/src/proposal-chat-inject.ts +141 -0
  95. package/src/proposal-gate.ts +175 -0
  96. package/src/read-html-template.test.ts +74 -0
  97. package/src/read-html-template.ts +102 -0
  98. package/src/read-viewport-from-request.ts +36 -0
  99. package/src/readiness-gate.ts +39 -0
  100. package/src/register-api-routes.ts +127 -0
  101. package/src/register-crash-handlers.ts +39 -0
  102. package/src/register-page-routes.ts +62 -0
  103. package/src/register-page-server-client.ts +109 -0
  104. package/src/register-registry-page-route/ai-chat-prefetch.ts +106 -0
  105. package/src/register-registry-page-route/graph-cache.test.ts +84 -0
  106. package/src/register-registry-page-route/graph-cache.ts +90 -0
  107. package/src/register-registry-page-route/meta-title.ts +73 -0
  108. package/src/register-registry-page-route/proposal-approval-email-token.ts +78 -0
  109. package/src/register-registry-page-route/proposal-brand-stamping.ts +60 -0
  110. package/src/register-registry-page-route/resolve-page-data.ts +518 -0
  111. package/src/register-registry-page-route.ts +765 -0
  112. package/src/register-static-assets.ts +180 -0
  113. package/src/register-ws-routes.ts +180 -0
  114. package/src/resolve-app-base-url.ts +28 -0
  115. package/src/resolve-brand-favicon.ts +65 -0
  116. package/src/resolve-from-loader-config.test.ts +44 -0
  117. package/src/resolve-from-loader-config.ts +291 -0
  118. package/src/resolve-service-context.ts +67 -0
  119. package/src/resolve-theme-mode.ts +61 -0
  120. package/src/resolvers/fetch-org-brand.ts +28 -0
  121. package/src/resolvers/portal-route-paths.test.ts +102 -0
  122. package/src/resolvers/portal-route-paths.ts +83 -0
  123. package/src/resolvers/website-by-host-map.ts +49 -0
  124. package/src/resource-chat-inject.ts +168 -0
  125. package/src/resource-not-found.ts +263 -0
  126. package/src/run-boot-hook-list.ts +75 -0
  127. package/src/run-boot-hooks.ts +97 -0
  128. package/src/scala-tools-currency-watchdog.ts +160 -0
  129. package/src/security-headers.ts +59 -0
  130. package/src/serve/serve-ws.test.ts +135 -0
  131. package/src/serve/serve.ts +190 -0
  132. package/src/server-error-page.test.ts +102 -0
  133. package/src/server-error-page.ts +95 -0
  134. package/src/start-scheduled-jobs.ts +84 -0
  135. package/src/two-phase-boot.ts +449 -0
  136. package/src/types.ts +52 -0
  137. package/src/ui-service.ts +171 -0
  138. package/src/ws-boot-self-test.ts +114 -0
@@ -0,0 +1,55 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * The proposal MAGIC-LINK endpoint — the proposal twin of attach-agreement-magic.
7
+ * A recipient who clicks an emailed "View your proposal" link (carrying a signed,
8
+ * per-recipient prop_token) lands here: the token is verified for the proposal,
9
+ * the prop_token cookie is set, and the browser is 302-redirected straight into
10
+ * the proposal page. No email-entry gate, no OTP. An invalid/expired token sets
11
+ * no cookie and the page falls through to the normal email gate.
12
+ *
13
+ * The prop_token is host-scoped (proposals are served on the proposals host only),
14
+ * unlike the agreement ag_token which spans .scala.business for the proposal→sign
15
+ * hand-off. GET so it is a plain clickable link in an email.
16
+ */
17
+
18
+ import type { Hono } from "hono";
19
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
20
+ import { verifyAccessToken } from "./content-gate/token.ts";
21
+ import { propTtlMs } from "./gate-ttls.ts";
22
+
23
+ const PROP_COOKIE = "prop_token";
24
+
25
+ export async function attachProposalMagic(
26
+ app: Hono,
27
+ serviceSlug: string,
28
+ ): Promise<void> {
29
+ const log = getAppLogger();
30
+
31
+ app.get("/api/proposal-magic", (c) => {
32
+ const token = c.req.query("t") ?? "";
33
+ const proposalId = c.req.query("p") ?? "";
34
+ const toRaw = c.req.query("to") ?? "/";
35
+ const dest =
36
+ toRaw.startsWith("/") && !toRaw.startsWith("//") ? toRaw : "/";
37
+
38
+ const email = verifyAccessToken(token, PROP_COOKIE, proposalId, Date.now());
39
+ if (email) {
40
+ const maxAge = Math.floor(propTtlMs() / 1000);
41
+ c.header(
42
+ "Set-Cookie",
43
+ `${PROP_COOKIE}=${encodeURIComponent(token)}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`,
44
+ );
45
+ log.info(
46
+ `[proposal-magic] ${proposalId} → authorised ${email}; redirecting to ${dest}`,
47
+ );
48
+ }
49
+ return c.redirect(dest, 302);
50
+ });
51
+
52
+ log.info(
53
+ `[scala-install:${serviceSlug}] proposal magic-link endpoint mounted: /api/proposal-magic`,
54
+ );
55
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Authenticated asset upload — POST /api/transfers/upload. Restores the
7
+ * handler deleted with @teamscala/server-files (commit ca5643ef); avatar/logo
8
+ * upload had 404'd on every service since. Writes to S3 via
9
+ * @teamscala/aws/create/write-ops and returns the STORED-form URL
10
+ * (`/api/files/<key>`) the client persists into avatar_url/logo_url — the
11
+ * display path rewrites it to the public `/api/assets/<encoded-key>` proxy
12
+ * via `toAssetPath` (@teamscala/formatters/asset-url). Self-authenticates
13
+ * (getSession) so it mounts BEFORE the auth gate.
14
+ *
15
+ * Key scheme is USER-scoped (`users/<userId>/<type>/<file>-<ts>.<ext>`) per
16
+ * the operator ruling: a profile picture belongs to the individual, not any
17
+ * org (the prior handler's org-scoped `<orgId>/avatars/...` encoded the
18
+ * uploading org into a personal asset). Spec + history:
19
+ * `reference/asset-upload-system.md`.
20
+ *
21
+ * `thumbUrl` mirrors `url` — the full image is served for both; a separate
22
+ * generated thumbnail is a future refinement (the prior handler generated
23
+ * one, but the image-resize dependency is not wired here yet and a missing
24
+ * thumb must not block the upload restoring).
25
+ */
26
+ import type { Hono } from "hono";
27
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
28
+ import { upload } from "@teamscala/aws/create/write-ops";
29
+ import { getSession } from "@teamscala/auth/get-session";
30
+ import { buildErrorReport } from "@teamscala/os/errors/error-report";
31
+
32
+ const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25MB
33
+ const ALLOWED_TYPES = new Set([
34
+ "image/png",
35
+ "image/jpeg",
36
+ "image/gif",
37
+ "image/webp",
38
+ "image/svg+xml",
39
+ "application/pdf",
40
+ "video/mp4",
41
+ "video/webm",
42
+ "text/csv",
43
+ "text/plain",
44
+ ]);
45
+ // `type` (FormData) → S3 key sub-directory. Unknown/absent → "uploads".
46
+ const TYPE_DIR: Record<string, string> = {
47
+ avatars: "avatars",
48
+ logos: "logos",
49
+ uploads: "uploads",
50
+ };
51
+
52
+ function extOf(filename: string, fallback = "bin"): string {
53
+ const m = filename.match(/\.([a-z0-9]+)$/i);
54
+ return (m?.[1] ?? fallback).toLowerCase();
55
+ }
56
+
57
+ export async function attachTransfersUpload(app: Hono): Promise<void> {
58
+ const log = getAppLogger();
59
+
60
+ app.post("/api/transfers/upload", async (c) => {
61
+ const session = await getSession(c.req.raw.headers);
62
+ const userId = session?.user?.id;
63
+ if (!userId) return c.json({ error: "Not authenticated" }, 401);
64
+
65
+ const form = await c.req.formData();
66
+ const file = form.get("file");
67
+ const type = String(form.get("type") ?? "uploads");
68
+ if (!(file instanceof File)) return c.json({ error: "'file' field required" }, 400);
69
+ if (file.size <= 0) return c.json({ error: "empty file" }, 400);
70
+ if (file.size > MAX_FILE_SIZE) return c.json({ error: "file too large (max 25MB)" }, 413);
71
+ if (file.type && !ALLOWED_TYPES.has(file.type)) {
72
+ return c.json({ error: `file type '${file.type}' not allowed` }, 415);
73
+ }
74
+
75
+ const dir = TYPE_DIR[type] ?? "uploads";
76
+ const ext = extOf(file.name);
77
+ const key = `users/${userId}/${dir}/${dir.slice(0, -1)}-${Date.now()}.${ext}`;
78
+
79
+ const body = new Uint8Array(await file.arrayBuffer());
80
+ const result = await upload({
81
+ key,
82
+ body,
83
+ contentType: file.type || "application/octet-stream",
84
+ });
85
+ if (!result.success) {
86
+ log.error("attach-transfers-upload", { error: result.error, key });
87
+ const report = buildErrorReport(new Error(result.error ?? "upload failed"), {
88
+ service: "transfers-upload",
89
+ url: c.req.url,
90
+ });
91
+ return new Response(JSON.stringify(report.http.body), {
92
+ status: report.http.status,
93
+ headers: report.http.headers,
94
+ });
95
+ }
96
+
97
+ const storedUrl = `/api/files/${key}`;
98
+ return c.json({ url: storedUrl, thumbUrl: storedUrl });
99
+ });
100
+ }
@@ -0,0 +1,385 @@
1
+ /**
2
+ * @system server-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Auth middleware factory. Three modes, same BetterAuth email-OTP mechanism:
7
+ *
8
+ * service_install.auth values:
9
+ * - omitted | "none" → no gate, no handler (scala-public, scala-sign)
10
+ * - "better-auth-cookie" → full session auth: mount handler + session gate
11
+ * - "email-otp-only" → mount handler (OTP send/verify), no session gate
12
+ *
13
+ * The sign-in block determines the UX per mode:
14
+ * - "full" → email → OTP → session → portal selection (portal, agents, docs, gpt)
15
+ * - "email-gate" → email → OTP → verify-only, no session (forms, proposals, quiz)
16
+ * - "capture-only" → email field only, no OTP (sign)
17
+ *
18
+ * There is NO localhost bypass: 127.0.0.1 is gated identically to the tunnel
19
+ * (no session → /sign-in). Tools needing an authed view sign in
20
+ * (getSuperAdminSessionCookie); the dev OTP is DB-readable.
21
+ *
22
+ * When SCALA_OTP_URL is set, /api/auth/* forwards to the centralised
23
+ * scala-otp service (per otp-centralised-via-otp-service). Otherwise
24
+ * the local better-auth handler is used (dev-host fallback).
25
+ */
26
+
27
+ import type { ServiceInstallConfig } from "@teamscala/db/registry/loader/load-service-install-row";
28
+ import { getPrisma } from "@teamscala/db/prisma-registry";
29
+ import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
30
+ import { resolveAccess } from "@teamscala/auth/resolve-access";
31
+ import * as v from "valibot";
32
+ import { configure as configureEventLog } from "@teamscala/event-log/configure";
33
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
34
+ import type { Hono } from "hono";
35
+
36
+ const SIGN_IN_PATHS = new Set([
37
+ "/login",
38
+ "/sign-in",
39
+ "/sign-up",
40
+ "/forgot-password",
41
+ "/reset-password",
42
+ "/email-otp",
43
+ "/magic-link",
44
+ ]);
45
+
46
+ const PUBLIC_INFRA_PREFIXES = [
47
+ "/assets/",
48
+ "/api/assets/",
49
+ "/api/auth/",
50
+ // fn.signInFlow procedures serve the unauthenticated sign-in flow (config
51
+ // load, session check, portal list) — anonymous-allowed by design (return {}
52
+ // for no session). They replaced the former /api/data/sign-in-flow-config,
53
+ // /check-session, /portals HANDLER_MAP entries (handler-map-retirement).
54
+ "/api/rpc/fn/signInFlow/",
55
+ // fn.taskSync procedures (lane task-list <-> work_items). Exempt so a
56
+ // localhost hook/plugin (Claude PostToolUse curl, OpenCode scala-hooks
57
+ // fetch) reaches the ORPC router, where mount-orpc-router's resolveUser
58
+ // grants localhost -> super_admin (the documented internal caller model) and
59
+ // a non-localhost caller still needs a session (resolveUser returns null ->
60
+ // authed throws 401). Safety rests on Host:127.0.0.1 being unforgeable
61
+ // through the Cloudflare tunnel (the edge sets Host to the public hostname)
62
+ // — verified by the negative case (a non-localhost caller is rejected).
63
+ // Tighter than signInFlow: NOT anonymous-allowed (signInFlow returns {} for
64
+ // no session; taskSync writes require super-admin or a real session).
65
+ "/api/rpc/fn/taskSync/",
66
+ // scala-enroll device-onboarding: the machine-facing installer + register
67
+ // routes are TOKEN-authenticated (a single-use enrollment token issued by a
68
+ // logged-in, dev_access-approved operator), fetched by the onboardee via
69
+ // `irm`/`curl` with NO session cookie — so they must skip the page login
70
+ // gate, exactly like /mcp (Bearer) and /api/rpc/ws self-authenticate. The
71
+ // TOKEN is the delegated capability; issuance (/issue) stays login-gated.
72
+ "/provision/",
73
+ ];
74
+
75
+ const PUBLIC_INFRA_PATHS = new Set([
76
+ "/health",
77
+ "/favicon.ico",
78
+ "/favicon.svg",
79
+ "/api/session",
80
+ "/api/rpc/ws",
81
+ // The MCP endpoint is a service-to-service surface with its OWN Bearer auth
82
+ // (the gateway sends `Authorization: Bearer SCALA_DEV_KEY`, validated by the
83
+ // MCP route handler). It must never be session-page-gated: on a kind:ui
84
+ // service that also mounts an MCP server (scala-agents — the fleet
85
+ // orchestration + cross-pane channel), the auth-gate would 302 /mcp to
86
+ // /sign-in, so the gateway sees toolCount=0 and the whole channel goes
87
+ // degraded. Backend-only MCP hosts have no auth-gate, so this only bit the
88
+ // one UI MCP host once the localhost super-admin bypass was removed
89
+ // (the bypass had been masking it). Exempt alongside /api/rpc/ws and
90
+ // /api/auth/, which self-authenticate the same way.
91
+ "/mcp",
92
+ // build-id is polled by the client self-heal; it must always return JSON
93
+ // (a tiny bundle hash) regardless of session, never a /sign-in redirect —
94
+ // otherwise an expired-session tab would compare a redirect body to its
95
+ // booted build and never heal.
96
+ "/api/build-id",
97
+ // /_heap is the localhost-gated heap-attribution probe (register-static-assets);
98
+ // like /health it is infra, not a page, so it must never 302 to /sign-in on an
99
+ // auth-gated service. The probe's own localhost Host check is the real gate.
100
+ "/_heap",
101
+ // /api/fleet/events is the SSE fleet event stream consumed self-contained by
102
+ // the generated opencode scala-hooks plugin (and any future client without
103
+ // an @orpc/client dep) for native cross-pane channel delivery. The plugin
104
+ // runs in the opencode process with no scala-agents session cookie, so the
105
+ // route must be public; the stream is keyed by the orchestratorSessionId
106
+ // query param (the subscriber's own tmux pane id) and carries no sensitive
107
+ // data, and dispatch only emits events targeted at that identity.
108
+ "/api/fleet/events",
109
+ // /api/fleet/claude-sessions is a non-sensitive lane-identity read (active
110
+ // Claude lane sessionIDs + cwds) consumed by the session-picker daemon to
111
+ // RESTORE transcript tails after its own restart. The picker runs with no
112
+ // scala-agents session cookie; the data is less sensitive than the
113
+ // /api/fleet/events stream above (no message content), so it is public like
114
+ // the other dev-host infra reads.
115
+ "/api/fleet/claude-sessions",
116
+ // /api/fleet/ingest-event receives parsed lane-action events
117
+ // (message.part.updated etc.) from the cli-session Claude transcript-tail — a
118
+ // dev-host INTERNAL machine caller that POSTs with no session cookie, exactly
119
+ // like the lane-event hook. Without this exemption the page auth-gate 302s
120
+ // every POST and the lane's tool/text action stream never reaches the
121
+ // ingestEvent pipeline (fleet_run_events stays empty → the dashboard console
122
+ // shows only lifecycle lines, never the per-tab action feed). A first-class
123
+ // api_route (sibling to lane-event); security is the handler's loopback gate
124
+ // on the platform's x-caller-remote-address (set by register-api-routes).
125
+ "/api/fleet/ingest-event",
126
+ // /api/fleet/lane-event receives Claude Code http lifecycle hooks
127
+ // (Stop/UserPromptSubmit/Pre*ToolUse) for live-lane visibility. Like /mcp it
128
+ // is a service-to-service surface with its OWN Bearer auth (the hook sends
129
+ // `Authorization: Bearer SCALA_DEV_KEY`, validated by the handler); the hook
130
+ // runs in the Claude process with no scala-agents session cookie, so it must
131
+ // not be session-page-gated. The handler filters to fleet lanes by workspace
132
+ // cwd + resolves the lane via the session-picker, so an unauth'd caller
133
+ // writes no lane state.
134
+ "/api/fleet/lane-event",
135
+ // /api/fleet/sweep-executor receives the loga monitor engine's sweep dispatch
136
+ // (POST {slug}) -- a service-to-service surface like /api/fleet/lane-event /
137
+ // /api/fleet/ingest-event (loga -> scala-agents, localhost). Security is the
138
+ // handler's loopback gate (x-caller-remote-address), identical to its siblings.
139
+ "/api/fleet/sweep-executor",
140
+ // /api/terminal-ws is the in-browser terminal WebSocket bridge (a real PTY
141
+ // login shell). It is a dev-tool surface analogous to /api/fleet/events:
142
+ // the operator's own shell, no sensitive data beyond what the operator
143
+ // already has in their terminal. Must not be session-gated — the test
144
+ // harness and the terminal block both connect without a session cookie.
145
+ "/api/terminal-ws",
146
+ // /unauthorised is the access-gate's rejection target (the PageGuard access
147
+ // page). It MUST be exempt: an authed-but-not-allowed viewer is redirected
148
+ // here, so gating it would re-reject and loop. It renders no sensitive data.
149
+ "/unauthorised",
150
+ ]);
151
+
152
+ export async function attachAuthGate(
153
+ app: Hono,
154
+ config: ServiceInstallConfig,
155
+ serviceSlug: string,
156
+ ): Promise<void> {
157
+ const authMode = (config as { auth?: string }).auth ?? "none";
158
+ if (authMode === "none") return;
159
+
160
+ const log = getAppLogger();
161
+ // Driver-agnostic `write` callback backed by the process's Prisma singleton
162
+ // (one-db-surface — no second postgres). Lazy + takes precedence over any
163
+ // `prisma:` configure, so this writer is authoritative for authed services.
164
+ // The previous form passed the ORPC server client (`getServerClient()`) cast
165
+ // `as never` as `prisma:` — no `$executeRaw`, so recordEvent silently failed.
166
+ // Retention is owned by the event-log-sweep pg-boss job.
167
+ configureEventLog({ prisma: getPrisma() });
168
+ const { registerAuthFactory, getAuth } = await import(
169
+ "@teamscala/auth/auth-registry.ts"
170
+ );
171
+ const { createAuth } = await import("@teamscala/auth/auth-factory.ts");
172
+ registerAuthFactory(createAuth);
173
+
174
+ // Bridge the SAME factory into @teamscala/orpc's configure surface so the ORPC
175
+ // session-auth middleware (which resolves the caller's user for RLS) validates
176
+ // sessions through the identical BetterAuth instance the page gate uses. orpc
177
+ // is primitives-tier and cannot import the auth package; it reads getAuth() off
178
+ // its injected provider (configured-primitives), exactly as it reads getLogger.
179
+ // Without this the middleware throws "auth not configured" and every cookie-
180
+ // authenticated ORPC call falls back to no-user (RLS then skips scoping).
181
+ const { configure: orpcConfigure } = await import("@teamscala/orpc/configure");
182
+ orpcConfigure({ auth: { getAuth: () => getAuth() } });
183
+
184
+ const otpUrl = process.env.SCALA_OTP_URL;
185
+
186
+ // Eager-warm the local BetterAuth runtime at boot whenever a request path
187
+ // will build it: the session-READ path (getSession, better-auth-cookie mode)
188
+ // always uses the local instance — even when /api/auth/* forwards to
189
+ // scala-otp — and the local /api/auth handler uses it when no otpUrl is set.
190
+ // Building lazily on the first such request races a node_modules rebuild
191
+ // window: Bun transpiles better-auth on demand, and against a half-written
192
+ // tree that throws ENOENT → 500 on every auth + session check while /health
193
+ // stays green (nothing restarts it) — the 2026-06-12 login outage. Building
194
+ // here resolves the better-auth module graph once while node_modules is
195
+ // stable, and getAuth() memoizes it for the process lifetime. Best-effort and
196
+ // non-fatal: getAuth() never caches a failed build, so if this warm fails
197
+ // (e.g. mid-install) the first successful request rebuilds and caches — the
198
+ // service is never left wedged, and never boot-loops on a transient.
199
+ if (authMode === "better-auth-cookie" || !otpUrl) {
200
+ try {
201
+ await getAuth();
202
+ log.info(`[scala-install:${serviceSlug}] auth: local BetterAuth runtime warmed at boot`);
203
+ } catch (err) {
204
+ log.error(
205
+ `[scala-install:${serviceSlug}] auth: boot warm failed — will rebuild on first request`,
206
+ { error: err instanceof Error ? err.message : String(err) },
207
+ );
208
+ }
209
+ }
210
+
211
+ // Register the specific clear-stale-cookies route BEFORE the /api/auth/*
212
+ // wildcard below. Hono matches in registration order, so a wildcard
213
+ // registered first SHADOWS this exact path — and when SCALA_OTP_URL is set
214
+ // the wildcard forwards it to scala-otp, which has no such route, so the
215
+ // call 404s (silently: the sign-in flow fires it fire-and-forget before
216
+ // every verify to drop stale pre-__Secure session cookies that would
217
+ // otherwise shadow the new session, and a swallowed 404 left them in place).
218
+ // Only better-auth-cookie mode manages session cookies.
219
+ if (authMode === "better-auth-cookie") {
220
+ app.all("/api/auth/clear-stale-cookies", async (c) => {
221
+ const cookieNames = [
222
+ "better-auth.session_token",
223
+ "better-auth.session_token_legacy",
224
+ "better-auth.csrf_token",
225
+ ];
226
+ for (const name of cookieNames) {
227
+ c.header(
228
+ "Set-Cookie",
229
+ `${name}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`,
230
+ { append: true },
231
+ );
232
+ }
233
+ return c.json({ ok: true });
234
+ });
235
+ }
236
+
237
+ if (otpUrl) {
238
+ const otpBase = otpUrl.replace(/\/+$/, "");
239
+ log.info(`[scala-install:${serviceSlug}] auth: forwarding /api/auth/* → ${otpBase}`);
240
+
241
+ app.all("/api/auth/*", async (c) => {
242
+ const url = new URL(c.req.url);
243
+ const target = `${otpBase}${url.pathname}${url.search}`;
244
+ try {
245
+ const headers = new Headers(c.req.raw.headers);
246
+ headers.set("host", new URL(otpBase).host);
247
+ const init: RequestInit = {
248
+ method: c.req.method,
249
+ headers,
250
+ };
251
+ if (c.req.method !== "GET" && c.req.method !== "HEAD") {
252
+ init.body = c.req.raw.body;
253
+ if (c.req.raw.headers.has("content-type")) {
254
+ headers.set("content-type", c.req.raw.headers.get("content-type")!);
255
+ }
256
+ }
257
+ const upstream = await fetch(target, init);
258
+ const resHeaders = new Headers(upstream.headers);
259
+ resHeaders.set("X-Auth-Forwarded", "scala-otp");
260
+ resHeaders.delete("Content-Encoding");
261
+ resHeaders.delete("Content-Length");
262
+ for (const sc of upstream.headers.getSetCookie()) {
263
+ resHeaders.append("Set-Cookie", sc);
264
+ }
265
+ return new Response(upstream.body, {
266
+ status: upstream.status,
267
+ statusText: upstream.statusText,
268
+ headers: resHeaders,
269
+ });
270
+ } catch (err) {
271
+ log.error(`[auth-forward] failed to reach scala-otp`, {
272
+ target,
273
+ error: err instanceof Error ? err.message : String(err),
274
+ });
275
+ return Response.json(
276
+ { error: "auth service unavailable" },
277
+ { status: 502 },
278
+ );
279
+ }
280
+ });
281
+ } else {
282
+ app.all("/api/auth/*", async (c) => {
283
+ const auth = await getAuth();
284
+ return auth.handler(c.req.raw);
285
+ });
286
+ }
287
+
288
+ if (authMode === "email-otp-only") {
289
+ log.info(`[scala-install:${serviceSlug}] auth: email-otp-only (OTP handler, no session gate)`);
290
+ return;
291
+ }
292
+
293
+ const { getSession } = await import("@teamscala/auth/get-session.ts");
294
+
295
+ app.use("*", async (c, next) => {
296
+ const url = new URL(c.req.url);
297
+ const isInfra =
298
+ PUBLIC_INFRA_PATHS.has(url.pathname) ||
299
+ PUBLIC_INFRA_PREFIXES.some((p) => url.pathname.startsWith(p));
300
+ if (isInfra) return next();
301
+
302
+ // NO localhost bypass: a 127.0.0.1 page request is gated EXACTLY like a
303
+ // tunnel request (no session → redirect to /sign-in). The old
304
+ // `if (isLocalhost) return next()` let any tool — and the agent's own
305
+ // chrome-devtools/curl — see authed pages on localhost WITHOUT a session,
306
+ // so verification on 127.0.0.1 diverged from what a signed-in user sees
307
+ // through the domain (the CSS regression that hid for three runs,
308
+ // 2026-06-13). The bypass is redundant: anything needing an authed view
309
+ // signs in (the OTP is DB-readable on the dev host) — host-side harnesses
310
+ // use getSuperAdminSessionCookie (scala-tools/lib). Internal
311
+ // service-to-service auth still flows through the cookie/secret on the
312
+ // request, never the source IP.
313
+ const host = c.req.header("host") ?? "";
314
+ const cookie = c.req.header("cookie") ?? "";
315
+ const tokenMatch = cookie.match(/better-auth\.session_token=([^;]+)/);
316
+ const session = await getSession(c.req.raw.headers);
317
+ log.info(`[auth-gate] session resolution`, {
318
+ host,
319
+ path: url.pathname,
320
+ hasUser: !!session?.user,
321
+ userEmail: session?.user?.email,
322
+ cookiePresent: cookie.length > 0,
323
+ sessionTokenPresent: !!tokenMatch,
324
+ tokenPrefix: tokenMatch?.[1]?.slice(0, 8) ?? null,
325
+ });
326
+
327
+ const onSignInPath = SIGN_IN_PATHS.has(url.pathname);
328
+
329
+ if (session?.user && onSignInPath) {
330
+ const cb = url.searchParams.get("callbackURL");
331
+ return c.redirect(cb?.startsWith("/") ? cb : "/", 302);
332
+ }
333
+ if (!session?.user && !onSignInPath) {
334
+ const callbackURL = encodeURIComponent(url.pathname + url.search);
335
+ return c.redirect(`/sign-in?callbackURL=${callbackURL}`, 302);
336
+ }
337
+ // Optional org-allowlist gate (service_install.config.access.allowlist_config_slug):
338
+ // a service that declares an allowlist admits a signed-in viewer who is EITHER a
339
+ // platform super_admin (user.role='super_admin' — platform owner, sees every org;
340
+ // security.md `role-axes-platform-and-org`) OR a member of at least one allowed
341
+ // organisation (better_auth_member). No-op for every service without `access`
342
+ // (only scala-roadmap today), so this cannot affect existing authed services. The
343
+ // data-owner org is NOT the viewer's org: the session authenticates, the role +
344
+ // allowlist authorise. A signed-in viewer who is NEITHER is redirected to
345
+ // /unauthorised — never /sign-in (they are already authenticated; /sign-in
346
+ // bounces authed users back to the callback, so redirecting there loops forever).
347
+ const accessCfg = (config as { access?: { allowlist_config_slug?: string } })
348
+ .access;
349
+ if (accessCfg?.allowlist_config_slug && session?.user && !onSignInPath) {
350
+ const allowRow = await loadRegistryConfig(
351
+ "config",
352
+ accessCfg.allowlist_config_slug,
353
+ v.looseObject({}),
354
+ );
355
+ const allowed =
356
+ (allowRow?.allowed_organisation_ids as string[] | undefined) ?? [];
357
+ if (allowed.length > 0) {
358
+ // One access decision via the unified resolver (@teamscala/auth/resolve-access):
359
+ // platform super_admin OR allowlisted-org member. The gate is now a thin
360
+ // caller of the single permission decision point (unified permission system
361
+ // foundation, work_items 019f7d6b) — replaces the bespoke inline query.
362
+ const decision = await resolveAccess({
363
+ prisma: getPrisma() as never,
364
+ userId: session.user.id,
365
+ allowedOrganisationIds: allowed,
366
+ });
367
+ if (!decision.admitted) {
368
+ return c.redirect(`/unauthorised`, 302);
369
+ }
370
+ }
371
+ }
372
+ // Optional home redirect (service_install.config.home_path): an authenticated
373
+ // viewer who lands on "/" is redirected to the service's home path — scala-roadmap
374
+ // uses this to send "/" → "/<GBG Indo orgId>" (the roadmap's data owner). No-op for
375
+ // every service without a home_path. Runs after the access gate so a denied viewer
376
+ // is still sent to /sign-in.
377
+ const homePath = (config as { home_path?: string }).home_path;
378
+ if (homePath && session?.user && url.pathname === "/") {
379
+ return c.redirect(homePath, 302);
380
+ }
381
+ return next();
382
+ });
383
+
384
+ log.info(`[scala-install:${serviceSlug}] auth: better-auth-cookie wired`);
385
+ }