@teamscala/service-boot 0.23.340 → 0.23.341

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.341",
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
  });