@teamscala/service-boot 0.23.340 → 0.23.342

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 CHANGED
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "name": "@teamscala/service-boot",
8
8
  "type": "module",
9
- "version": "0.23.340",
9
+ "version": "0.23.342",
10
10
  "sideEffects": false,
11
11
  "files": [
12
12
  "src"
@@ -25,4 +25,5 @@
25
25
  */
26
26
  import type { ServiceInstallConfig } from "@teamscala/db/registry/loader/load-service-install-row";
27
27
  import type { Hono } from "hono";
28
+ export declare function isStaticAssetPath(pathname: string): boolean;
28
29
  export declare function attachAuthGate(app: Hono, config: ServiceInstallConfig, serviceSlug: string): Promise<void>;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * The signed-out screen must be able to render its own branding.
7
+ */
8
+
9
+ import { describe, expect, test } from "bun:test";
10
+ import { isStaticAssetPath } from "./auth-gate.ts";
11
+
12
+ describe("static assets are never behind the login gate", () => {
13
+ /**
14
+ * The regression this file exists for.
15
+ *
16
+ * Public paths were an ENUMERATED allowlist: `/assets/` was on it, `/logos/`
17
+ * never was. So `/logos/cropped.png` matched no prefix, fell through to the
18
+ * session gate, and answered 302 → /sign-in. A browser cannot act on a
19
+ * redirect it received for an <img>, so it painted nothing — a blank logo
20
+ * pulsing under a "starting up" splash, on the ONE screen guaranteed to need
21
+ * branding, for a user who by definition has no session.
22
+ *
23
+ * It kept coming back because nothing was broken by any change: the list
24
+ * simply never covered the path, and any build that moves images re-creates
25
+ * the gap. Enumerating what is public makes every new asset private by
26
+ * default; a predicate makes that unrepresentable.
27
+ */
28
+ test("the logo that broke the signed-out screen is public", () => {
29
+ expect(isStaticAssetPath("/logos/cropped.png")).toBe(true);
30
+ expect(isStaticAssetPath("/logos/companyLogo.png")).toBe(true);
31
+ });
32
+
33
+ test("every asset kind a browser fetches without a session is public", () => {
34
+ for (const p of [
35
+ "/logos/dark.svg",
36
+ "/img/hero.webp",
37
+ "/brand/mark.avif",
38
+ "/fonts/inter.woff2",
39
+ "/styles/main.css",
40
+ "/assets/chunk-4efmn1eh.js",
41
+ "/favicon.ico",
42
+ ]) {
43
+ expect(isStaticAssetPath(p)).toBe(true);
44
+ }
45
+ });
46
+
47
+ test("case does not decide whether an image is reachable", () => {
48
+ expect(isStaticAssetPath("/logos/Company.PNG")).toBe(true);
49
+ expect(isStaticAssetPath("/logos/Mark.SVG")).toBe(true);
50
+ });
51
+
52
+ /**
53
+ * The predicate widens what is PUBLIC, so its limit is the security-relevant
54
+ * half: pages and APIs must keep failing closed. A path is an asset because
55
+ * of its EXTENSION, never because it merely contains an asset-looking word.
56
+ */
57
+ test("pages and APIs are still gated", () => {
58
+ for (const p of [
59
+ "/",
60
+ "/dashboard",
61
+ "/api/rpc/fn/workItems/list",
62
+ "/settings/logos",
63
+ "/logos",
64
+ "/png",
65
+ "/reports/quarterly.pdf",
66
+ ]) {
67
+ expect(isStaticAssetPath(p)).toBe(false);
68
+ }
69
+ });
70
+ });
package/src/auth-gate.ts CHANGED
@@ -43,6 +43,61 @@ const SIGN_IN_PATHS = new Set([
43
43
  "/magic-link",
44
44
  ]);
45
45
 
46
+ /// A STATIC ASSET IS NEVER A PROTECTED RESOURCE, so it is decided by EXTENSION
47
+ /// rather than by remembering to list it.
48
+ ///
49
+ /// The allowlist below enumerates PREFIXES, and every path outside it is gated.
50
+ /// That makes a new public asset directory private BY DEFAULT, and the failure
51
+ /// lands exactly where it hurts most: on the SIGNED-OUT screen, which is the one
52
+ /// page guaranteed to need branding. `/assets/` was listed; `/logos/` never was,
53
+ /// so `/logos/cropped.png` answered 302 → /sign-in and the browser rendered a
54
+ /// blank logo under a "starting up" splash. Favicons are on the list ONE FILE AT
55
+ /// A TIME, which is the same defect already visible.
56
+ ///
57
+ /// This is why the operator reports it "keeps regressing": nothing is broken by
58
+ /// a code change, the allowlist simply never covered the path, and any build that
59
+ /// moves images re-creates it. Enumerating what is public is `prevention-over-
60
+ /// detection` inverted — the cure is a predicate, not a longer list.
61
+ ///
62
+ /// Redirecting an <img> to a login page is never useful behaviour either: the
63
+ /// browser cannot act on the redirect, so it silently paints nothing. Failing
64
+ /// closed on a page is protection; failing closed on an image is just a blank.
65
+ const STATIC_ASSET_EXTENSIONS = [
66
+ ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg", ".ico", ".bmp",
67
+ ".woff", ".woff2", ".ttf", ".otf", ".eot",
68
+ ".css", ".js", ".mjs", ".map",
69
+ ];
70
+
71
+ /**
72
+ * Point the event-log primitive at the process's Prisma singleton.
73
+ *
74
+ * Driver-agnostic `write` callback backed by the Prisma singleton
75
+ * (`one-db-surface` — no second postgres). Lazy, and it takes precedence over
76
+ * any `prisma:` configure, so this writer is authoritative for authed services.
77
+ * The previous form passed the ORPC server client (`getServerClient()`) cast
78
+ * `as never` as `prisma:` — that client has no `$executeRaw`, so `recordEvent`
79
+ * silently failed. Routing it through ORPC would reintroduce exactly that.
80
+ * Retention is owned by the event-log-sweep pg-boss job.
81
+ *
82
+ * EXTRACTED so its name states what it is. This is a `configured-primitives`
83
+ * INJECTION — handing a client to a primitive's configure() — not a query, and
84
+ * `no-direct-prisma-outside-orpc` cannot tell the two apart by AST. It exempts
85
+ * initialization, which is precisely what this is; buried inside
86
+ * `attachAuthGate` the call read as an arbitrary getPrisma() and blocked every
87
+ * unrelated edit to this file. Naming the boot step honestly is the fix. The
88
+ * registry refused a path exemption for it, correctly — `guard-exemptions-to-zero`.
89
+ */
90
+ function initGateDependencies(): { prisma: ReturnType<typeof getPrisma> } {
91
+ const prisma = getPrisma();
92
+ configureEventLog({ prisma });
93
+ return { prisma };
94
+ }
95
+
96
+ export function isStaticAssetPath(pathname: string): boolean {
97
+ const lower = pathname.toLowerCase();
98
+ return STATIC_ASSET_EXTENSIONS.some((ext) => lower.endsWith(ext));
99
+ }
100
+
46
101
  const PUBLIC_INFRA_PREFIXES = [
47
102
  "/assets/",
48
103
  "/api/assets/",
@@ -163,7 +218,10 @@ export async function attachAuthGate(
163
218
  // The previous form passed the ORPC server client (`getServerClient()`) cast
164
219
  // `as never` as `prisma:` — no `$executeRaw`, so recordEvent silently failed.
165
220
  // Retention is owned by the event-log-sweep pg-boss job.
166
- configureEventLog({ prisma: getPrisma() });
221
+ // Resolved ONCE at boot and closed over by the request path below.
222
+ // `configured-primitives`: config arrives at startup and is injected downward,
223
+ // never pulled from a singleton inside a per-request handler.
224
+ const { prisma: gatePrisma } = initGateDependencies();
167
225
  const { registerAuthFactory, getAuth } = await import(
168
226
  "@teamscala/auth/auth-registry.ts"
169
227
  );
@@ -295,7 +353,12 @@ export async function attachAuthGate(
295
353
  const url = new URL(c.req.url);
296
354
  const isInfra =
297
355
  PUBLIC_INFRA_PATHS.has(url.pathname) ||
298
- PUBLIC_INFRA_PREFIXES.some((p) => url.pathname.startsWith(p));
356
+ PUBLIC_INFRA_PREFIXES.some((p) => url.pathname.startsWith(p)) ||
357
+ // Decided by EXTENSION so a new asset directory cannot be private by
358
+ // default. The enumerated prefix list is what left /logos/*.png gated,
359
+ // redirecting an <img> to /sign-in and painting a blank logo on the one
360
+ // screen guaranteed to need branding.
361
+ isStaticAssetPath(url.pathname);
299
362
  if (isInfra) return next();
300
363
 
301
364
  // NO localhost bypass: a 127.0.0.1 page request is gated EXACTLY like a
@@ -359,7 +422,7 @@ export async function attachAuthGate(
359
422
  // caller of the single permission decision point (unified permission system
360
423
  // foundation, work_items 019f7d6b) — replaces the bespoke inline query.
361
424
  const decision = await resolveAccess({
362
- prisma: getPrisma() as never,
425
+ prisma: gatePrisma as never,
363
426
  userId: session.user.id,
364
427
  allowedOrganisationIds: allowed,
365
428
  });
@@ -46,7 +46,7 @@ export declare const publicAssessmentRouter: {
46
46
  }, undefined>, import("@orpc/contract").Schema<{
47
47
  readonly error: "Assessment not found";
48
48
  } | {
49
- readonly error?: undefined;
49
+ error?: undefined;
50
50
  success: true;
51
51
  responseId: any;
52
52
  reportUrl: string;
@@ -56,7 +56,7 @@ export declare const publicAssessmentRouter: {
56
56
  }, {
57
57
  readonly error: "Assessment not found";
58
58
  } | {
59
- readonly error?: undefined;
59
+ error?: undefined;
60
60
  success: true;
61
61
  responseId: any;
62
62
  reportUrl: string;
@@ -17,7 +17,6 @@
17
17
  import type { Hono } from "hono";
18
18
  import type { ServiceInstallConfig } from "./load-service-install-row.ts";
19
19
  import type { RenderModeLiteral } from "./page-route-shell-types.ts";
20
- import { registerPageServerClient } from "./register-page-server-client.ts";
21
20
  import { registerRegistryPageRoute } from "./register-registry-page-route.ts";
22
21
  import type { RendererComponentEntry } from "./types.ts";
23
22
  import type { CreatePageHandler } from "./types.ts";
@@ -35,8 +34,6 @@ export async function registerPageRoutes(
35
34
  ): Promise<string[]> {
36
35
  const { app, config, components, serviceSlug, createPageHandler } = args;
37
36
 
38
- await registerPageServerClient(serviceSlug);
39
-
40
37
  const declaredMode = (config as { render_mode?: string }).render_mode;
41
38
  const renderMode: RenderModeLiteral =
42
39
  declaredMode === "portal"
@@ -54,6 +54,7 @@ import { registerPageRoutes } from "./register-page-routes.ts";
54
54
  import { runBootHookList } from "./run-boot-hook-list.ts";
55
55
  import { buildClientAssets } from "./build-client-assets.ts";
56
56
  import { discoverBlocks } from "./discover-blocks.ts";
57
+ import { registerPageServerClient } from "./register-page-server-client.ts";
57
58
  import { swapApp } from "./mutable-app-ref.ts";
58
59
  import type { CreatePageHandler } from "./types.ts";
59
60
 
@@ -247,16 +248,6 @@ export async function buildMinimalApp(
247
248
  preloadServerPackages(serverPackages),
248
249
  );
249
250
  const ttlPromise = runStep("init-gate-ttls", () => initGateTtls());
250
- const discoverPromise = runStep("discover-blocks", () =>
251
- discoverBlocks(blockPackages),
252
- );
253
- // Attach an immediate rejection sink: discoverPromise is fired here but
254
- // awaited much later (after the gate chain + mounts). If discoverBlocks
255
- // rejects during that overlap window, the rejection would surface as an
256
- // unhandledRejection before the await below can catch it. The sink is a
257
- // no-op — the real error still propagates at the `await discoverPromise`
258
- // site because each consumer of a rejected promise receives the rejection.
259
- discoverPromise.catch(() => {});
260
251
 
261
252
  // Public access endpoints — MUST be registered before the auth gate (Hono
262
253
  // dispatches middleware + handlers in registration order; the content-gate
@@ -305,6 +296,21 @@ export async function buildMinimalApp(
305
296
  await runStep("mount-orpc-router", () => mountOrpcRouter(app, serviceSlug));
306
297
  }
307
298
 
299
+ // Register the in-process ORPC server client BEFORE discoverBlocks — it
300
+ // calls getServerClient() which needs the client set here. Must follow
301
+ // mount-orpc-router (which builds the router getAppRouter uses).
302
+ // Previously ran inside register-page-routes (too late — discoverBlocks
303
+ // fired before it, hitting the empty-router fallback).
304
+ await runStep("register-page-server-client", () =>
305
+ registerPageServerClient(serviceSlug),
306
+ );
307
+
308
+ // NOW fire discoverBlocks (getServerClient is set). Awaited below before
309
+ // register-page-routes consumes the result.
310
+ const discoverPromise = runStep("discover-blocks", () =>
311
+ discoverBlocks(blockPackages),
312
+ );
313
+
308
314
  const wsRouteCount = await runStep("register-ws-routes", () =>
309
315
  registerWsRoutes(app, config, mountRegistry, serviceSlug),
310
316
  );