@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,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
|
+
}
|
package/src/auth-gate.ts
ADDED
|
@@ -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
|
+
}
|