@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
package/src/auth-gate.d.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
package/src/two-phase-boot.ts
CHANGED
|
@@ -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
|
);
|