@teamscala/service-boot 0.23.455 → 0.23.457

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.455",
9
+ "version": "0.23.457",
10
10
  "sideEffects": false,
11
11
  "imports": {
12
12
  "#service-boot/*": "./src/*",
@@ -0,0 +1,157 @@
1
+ /**
2
+ * @system server-handlers
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Behavioural pins for the stylesheet resolver (operator ruling 2026-09-03:
7
+ * the fallback chain IS the drift — a bare id resolved via Bun.resolve
8
+ * returned whatever the package's main was (a .js for a normal package),
9
+ * the resolve(base, id) tail invented nonexistent paths whose ENOENT
10
+ * surfaced as misleading downstream CSS errors, and inlineCssImports
11
+ * SWALLOWED unresolved imports, leaving literals that failed downstream
12
+ * instead of at the build. Every case below pins the ruled contract:
13
+ * resolve or THROW naming the specifier; remote https imports are skipped
14
+ * explicitly, never resolved and never thrown on.
15
+ */
16
+ import { describe, expect, test } from "bun:test";
17
+ import { mkdir, rm, writeFile } from "node:fs/promises";
18
+ import { join } from "node:path";
19
+ import { tmpdir } from "node:os";
20
+ import { bunResolveStylesheet } from "./styles-build.ts";
21
+
22
+ async function makeTree(tag: string, files: Record<string, string>) {
23
+ const root = join(tmpdir(), `styles-build-${tag}-${process.pid}`);
24
+ await rm(root, { recursive: true, force: true });
25
+ for (const [rel, content] of Object.entries(files)) {
26
+ const p = join(root, rel);
27
+ await mkdir(p.slice(0, p.lastIndexOf("/")), { recursive: true });
28
+ await writeFile(p, content);
29
+ }
30
+ return root;
31
+ }
32
+
33
+ describe("bunResolveStylesheet — relative ids", () => {
34
+ test("a relative import that exists resolves and inlines its nested @imports", async () => {
35
+ const root = await makeTree("rel-ok", {
36
+ "src/entry.css": '@import "./leaf.css";\n.body { color: red; }\n',
37
+ "src/leaf.css": ".leaf { color: blue; }\n",
38
+ });
39
+ const r = await bunResolveStylesheet("./entry.css", join(root, "src"), root);
40
+ expect(r.content).toContain(".body");
41
+ expect(r.content).toContain(".leaf");
42
+ await rm(root, { recursive: true, force: true });
43
+ });
44
+
45
+ test("a relative import that does NOT exist throws naming the specifier and the path", async () => {
46
+ const root = await makeTree("rel-missing", {
47
+ "src/entry.css": ".body { color: red; }\n",
48
+ });
49
+ const err = await bunResolveStylesheet(
50
+ "./nope.css",
51
+ join(root, "src"),
52
+ root,
53
+ ).catch((e) => e);
54
+ expect(err).toBeInstanceOf(Error);
55
+ expect((err as Error).message).toContain('"./nope.css"');
56
+ expect((err as Error).message).toContain("not found");
57
+ await rm(root, { recursive: true, force: true });
58
+ });
59
+ });
60
+
61
+ describe("bunResolveStylesheet — bare ids", () => {
62
+ test("a CSS-only package resolves via its declared style entry", async () => {
63
+ const root = await makeTree("bare-style", {
64
+ "node_modules/tw-animate-css/package.json": JSON.stringify({
65
+ name: "tw-animate-css",
66
+ exports: { ".": { style: "./dist/tw-animate.css" } },
67
+ }),
68
+ "node_modules/tw-animate-css/dist/tw-animate.css":
69
+ ".tw-animate { animation: none; }\n",
70
+ "src/app.css": '.x { color: red; }\n',
71
+ });
72
+ const r = await bunResolveStylesheet(
73
+ "tw-animate-css",
74
+ join(root, "src"),
75
+ root,
76
+ );
77
+ expect(r.content).toContain(".tw-animate");
78
+ await rm(root, { recursive: true, force: true });
79
+ });
80
+
81
+ test("a normal JS package (no style entry) THROWS naming the specifier — never its .js main", async () => {
82
+ const root = await makeTree("bare-js-main", {
83
+ "node_modules/lodash/package.json": JSON.stringify({
84
+ name: "lodash",
85
+ main: "lodash.js",
86
+ }),
87
+ "node_modules/lodash/lodash.js": "module.exports = {};\n",
88
+ "src/app.css": ".x { color: red; }\n",
89
+ });
90
+ const err = await bunResolveStylesheet(
91
+ "lodash",
92
+ join(root, "src"),
93
+ root,
94
+ ).catch((e) => e);
95
+ expect(err).toBeInstanceOf(Error);
96
+ expect((err as Error).message).toContain('"lodash"');
97
+ expect((err as Error).message).toContain("no style entry");
98
+ expect((err as Error).message).not.toContain("lodash.js");
99
+ await rm(root, { recursive: true, force: true });
100
+ });
101
+
102
+ test("a bare id with no package anywhere THROWS naming the specifier and both roots", async () => {
103
+ const root = await makeTree("bare-missing", {
104
+ "src/app.css": ".x { color: red; }\n",
105
+ });
106
+ const src = join(root, "src");
107
+ const err = await bunResolveStylesheet("no-such-pkg", src, root).catch(
108
+ (e) => e,
109
+ );
110
+ expect(err).toBeInstanceOf(Error);
111
+ expect((err as Error).message).toContain('"no-such-pkg"');
112
+ expect((err as Error).message).toContain("unresolved");
113
+ expect((err as Error).message).toContain(src);
114
+ await rm(root, { recursive: true, force: true });
115
+ });
116
+ });
117
+
118
+ describe("bunResolveStylesheet — remote @imports", () => {
119
+ test("a remote https @import is kept verbatim (stripped later by stripRemoteImports), never resolved", async () => {
120
+ const root = await makeTree("remote-skip", {
121
+ "node_modules/cdn-fonts/package.json": JSON.stringify({
122
+ name: "cdn-fonts",
123
+ exports: { ".": { style: "./index.css" } },
124
+ }),
125
+ "node_modules/cdn-fonts/index.css":
126
+ '@import "https://rsms.me/inter/inter.css";\n.cdn { font-family: inter; }\n',
127
+ "src/app.css": ".x { color: red; }\n",
128
+ });
129
+ const r = await bunResolveStylesheet(
130
+ "cdn-fonts",
131
+ join(root, "src"),
132
+ root,
133
+ );
134
+ expect(r.content).toContain("https://rsms.me/inter/inter.css");
135
+ expect(r.content).toContain(".cdn");
136
+ await rm(root, { recursive: true, force: true });
137
+ });
138
+
139
+ test("a LOCAL @import of a missing file THROWS at the build instead of leaving a literal", async () => {
140
+ const root = await makeTree("local-missing-nested", {
141
+ "node_modules/broken-pkg/package.json": JSON.stringify({
142
+ name: "broken-pkg",
143
+ exports: { ".": { style: "./index.css" } },
144
+ }),
145
+ "node_modules/broken-pkg/index.css": '@import "./gone.css";\n.real { color: red; }\n',
146
+ "src/app.css": ".x { color: red; }\n",
147
+ });
148
+ const err = await bunResolveStylesheet(
149
+ "broken-pkg",
150
+ join(root, "src"),
151
+ root,
152
+ ).catch((e) => e);
153
+ expect(err).toBeInstanceOf(Error);
154
+ expect((err as Error).message).toContain('"./gone.css"');
155
+ await rm(root, { recursive: true, force: true });
156
+ });
157
+ });
@@ -31,31 +31,91 @@ async function resolveStylesheetRaw(
31
31
  const r = await Bun.resolve("tailwindcss/index.css", cwd);
32
32
  return { path: r, base: dirname(r), content: await Bun.file(r).text() };
33
33
  }
34
- try {
35
- const r = await Bun.resolve(id, base);
36
- return { path: r, base: dirname(r), content: await Bun.file(r).text() };
37
- } catch (error) {
38
- getAppLogger().warn("style import resolve failed — trying package fallback", error);
34
+ if (id.startsWith(".")) {
35
+ // Relative import: resolve against the importing file's directory and
36
+ // REQUIRE existence — a missing leaf is a build-breaking error naming
37
+ // the specifier, never a silently-dropped subtree.
38
+ const p = resolve(base, id);
39
+ if (!(await Bun.file(p).exists())) {
40
+ throw new Error(
41
+ `style import "${id}" not found at ${p} (resolved relative to ${base})`,
42
+ );
43
+ }
44
+ return { path: p, base: dirname(p), content: await Bun.file(p).text() };
39
45
  }
40
- if (!id.startsWith(".")) {
41
- try {
42
- const pkgPath = await Bun.resolve(id + "/package.json", cwd);
43
- const pkg = JSON.parse(await Bun.file(pkgPath).text());
44
- const entry =
45
- pkg.exports?.["."]?.style || pkg.style || pkg.main;
46
- if (entry) {
47
- const r = resolve(dirname(pkgPath), entry);
46
+ // A BARE SPECIFIER IS A PACKAGE NAME PLUS AN OPTIONAL SUBPATH, and the two
47
+ // must be separated before anything is looked up. `@teamscala/ui-foundation`
48
+ // is a package; `@teamscala/ui-foundation/theme/styles.css` is that same
49
+ // package plus an explicit file inside it. Treating the whole string as a
50
+ // package name looks for node_modules/@teamscala/ui-foundation/theme/
51
+ // styles.css/package.json, which cannot exist — so EVERY subpath import
52
+ // fails and the styles bundle dies, taking the theme layer with it.
53
+ //
54
+ // A scoped name is the FIRST TWO segments (@scope/name); an unscoped name
55
+ // is the first. Everything after that is a path inside the package and is
56
+ // resolved directly — it names a file, so there is nothing to negotiate and
57
+ // no style entry to consult.
58
+ const segments = id.split("/");
59
+ const nameLength = id.startsWith("@") ? 2 : 1;
60
+ const packageName = segments.slice(0, nameLength).join("/");
61
+ const subpath = segments.slice(nameLength).join("/");
62
+
63
+ // Find node_modules/<packageName>/package.json by walking upward from the
64
+ // importing file's dir then cwd. Bun.resolve is not used: it hands back
65
+ // whatever the package's main is — for a normal JS package a .js entry,
66
+ // not the declared stylesheet — and an `exports` allow-list blocks the
67
+ // package.json subpath on exactly the CSS-only packages this must serve.
68
+ const tried: string[] = [];
69
+ for (const root of [base, cwd]) {
70
+ let dir = root;
71
+ for (;;) {
72
+ const pkgPath = join(dir, "node_modules", packageName, "package.json");
73
+ if (await Bun.file(pkgPath).exists()) {
74
+ const pkgDir = dirname(pkgPath);
75
+ const pkg = await Bun.file(pkgPath).json();
76
+ if (subpath) {
77
+ // THE EXPORTS MAP IS THE AUTHORITY ON WHERE A SUBPATH LIVES,
78
+ // and it commonly REMAPS. `@teamscala/ui-foundation` publishes
79
+ // "./theme/styles.css" -> "./src/theme/styles.css", so resolving
80
+ // the subpath literally against the package root looks for a
81
+ // file that has never existed there. Consult exports first and
82
+ // fall back to the literal path only for a package that
83
+ // declares none.
84
+ const exported = pkg.exports?.[`./${subpath}`];
85
+ const mapped: string | undefined =
86
+ typeof exported === "string"
87
+ ? exported
88
+ : (exported?.style ?? exported?.default);
89
+ const r = resolve(pkgDir, mapped ?? subpath);
90
+ if (!(await Bun.file(r).exists())) {
91
+ throw new Error(
92
+ `style import "${id}" resolves to ${r}, which does not exist` +
93
+ (mapped
94
+ ? ` (exports maps "./${subpath}" to "${mapped}")`
95
+ : ` (${packageName} declares no exports entry for "./${subpath}")`),
96
+ );
97
+ }
98
+ return { path: r, base: dirname(r), content: await Bun.file(r).text() };
99
+ }
100
+ // No subpath: the package itself must declare its stylesheet.
101
+ const entry: string | undefined = pkg.exports?.["."]?.style ?? pkg.style;
102
+ if (!entry) {
103
+ throw new Error(
104
+ `style import "${id}" resolved to ${pkgPath} but the package declares no style entry (exports["."].style / style) — not a stylesheet package`,
105
+ );
106
+ }
107
+ const r = resolve(pkgDir, entry);
48
108
  return { path: r, base: dirname(r), content: await Bun.file(r).text() };
49
109
  }
50
- } catch (error) {
51
- getAppLogger().warn(
52
- "package style entry resolve failed — falling back to bare resolve",
53
- error,
54
- );
110
+ tried.push(pkgPath);
111
+ const parent = dirname(dir);
112
+ if (parent === dir) break;
113
+ dir = parent;
55
114
  }
56
115
  }
57
- const r = resolve(base, id);
58
- return { path: r, base: dirname(r), content: await Bun.file(r).text() };
116
+ throw new Error(
117
+ `style import "${id}" unresolved — no node_modules/${packageName}/package.json found walking up from ${base} or ${cwd} (${tried.length} location(s) tried)`,
118
+ );
59
119
  }
60
120
 
61
121
  // Tailwind v4's compile() invokes `loadStylesheet` for DIRECT @imports in the entry (app.css) but does NOT re-invoke it for @imports WITHIN the loaded
@@ -87,14 +147,18 @@ async function inlineCssImports(
87
147
  result += m[0];
88
148
  continue;
89
149
  }
150
+ // Remote @imports are CSP-forbidden and stripped by
151
+ // stripRemoteImports AFTER the compile: skip them here EXPLICITLY —
152
+ // they must never reach resolveStylesheetRaw, and an unresolved
153
+ // LOCAL import below is a throw, not a swallow.
154
+ if (spec.startsWith("http://") || spec.startsWith("https://")) {
155
+ result += m[0];
156
+ continue;
157
+ }
90
158
  if (seen.has(spec)) continue; // cycle guard
91
159
  seen.add(spec);
92
- try {
93
- const resolved = await resolveStylesheetRaw(spec, base, cwd);
94
- result += await inlineCssImports(resolved.content, resolved.base, cwd, seen);
95
- } catch {
96
- result += m[0]; // unresolved — keep literal; CSS/Tailwind will surface it
97
- }
160
+ const resolved = await resolveStylesheetRaw(spec, base, cwd);
161
+ result += await inlineCssImports(resolved.content, resolved.base, cwd, seen);
98
162
  }
99
163
  result += css.slice(lastIdx);
100
164
  return result;
@@ -111,6 +175,8 @@ async function bunResolveStylesheet(
111
175
  return { ...resolved, content };
112
176
  }
113
177
 
178
+ export { bunResolveStylesheet };
179
+
114
180
  // The app owns its fonts via an explicit, CSP-allowlisted <link> in index.html (Google Fonts — `style-src ... fonts.googleapis.com`, `font-src ...
115
181
  // fonts.gstatic.com`). Third-party package CSS MUST NOT inject its own remote `@import url(http…)`: @puckeditor/core/dist/index.css opens with
116
182
  // `@import "https://rsms.me/inter/inter.css"`, an arbitrary CDN our CSP does NOT allow. The Tailwind compile inlines that mid-output, where it was either
@@ -33,8 +33,6 @@ export async function resolveProposalAuthorizedEmails(
33
33
  proposalId: string,
34
34
  ): Promise<Set<string>> {
35
35
  try {
36
- const { getPrisma } = await import("@teamscala/db/prisma-registry");
37
- const prisma = getPrisma();
38
36
  const rows = await queryRows`
39
37
  SELECT lower(u.email) AS email
40
38
  FROM better_auth_member m
@@ -52,8 +52,7 @@ describe("interpolateMeta (resolver_input ref contract)", () => {
52
52
  });
53
53
 
54
54
  describe("resource-loader model capability", () => {
55
- test("an optional source still refuses a model absent from the generated client", async () => {
56
- const original = isServerClientRegistered() ? getServerClient() : null;
55
+ test("an optional source still refuses a model absent from the generated client", async () => { const original = isServerClientRegistered() ? getServerClient() : null;
57
56
  setServerClient({} as never);
58
57
  try {
59
58
  await expect(
@@ -104,3 +103,114 @@ describe("resource-loader model capability", () => {
104
103
  }
105
104
  });
106
105
  });
106
+
107
+ describe("required sources refuse unresolvable where refs", () => {
108
+ // The 2026-09-03 proposal outage class: interpolateTemplate DROPS a key
109
+ // whose ref resolves undefined, so a required source queries with a
110
+ // partial (or empty) filter — which matches an ARBITRARY row or none,
111
+ // serving a silent empty page instead of failing. Null is a real value
112
+ // (an unset column) and must keep flowing through.
113
+ const original = isServerClientRegistered() ? getServerClient() : null;
114
+ const capturingClient = (captured: { where: unknown }) => ({
115
+ builda_pages: {
116
+ findFirst: async (args: { where: unknown }) => {
117
+ captured.where = args.where;
118
+ return { id: "row" };
119
+ },
120
+ },
121
+ } as never);
122
+
123
+ test("a required source with an undefined ref throws naming it", async () => {
124
+ setServerClient(capturingClient({ where: null }));
125
+ try {
126
+ await expect(
127
+ resolveFromLoaderConfig(
128
+ {
129
+ sources: [
130
+ {
131
+ key: "page",
132
+ source: "orpc",
133
+ model: "builda_pages",
134
+ where: { id: ":proposal.page_id" },
135
+ optional: false,
136
+ },
137
+ ],
138
+ },
139
+ { id: "p1" },
140
+ ),
141
+ ).rejects.toThrow(/required source "page".*:proposal\.page_id.*undefined/);
142
+ } finally {
143
+ setServerClient(original as never);
144
+ }
145
+ });
146
+
147
+ test("an optional source with the same undefined ref still resolves null", async () => {
148
+ setServerClient({
149
+ builda_pages: { findFirst: async () => null },
150
+ } as never);
151
+ try {
152
+ await expect(
153
+ resolveFromLoaderConfig(
154
+ {
155
+ sources: [
156
+ {
157
+ key: "page",
158
+ source: "orpc",
159
+ model: "builda_pages",
160
+ where: { id: ":proposal.page_id" },
161
+ optional: true,
162
+ },
163
+ ],
164
+ },
165
+ { id: "p1" },
166
+ ),
167
+ ).resolves.toEqual({ page: null });
168
+ } finally {
169
+ setServerClient(original as never);
170
+ }
171
+ });
172
+
173
+ test("a resolved null flows through as a null filter on a required source", async () => {
174
+ const captured = { where: null as unknown };
175
+ // The first source resolves a row whose partner_id IS null — null is a
176
+ // real value flowing through interpolation, not an unresolved ref.
177
+ setServerClient({
178
+ builda_pages: {
179
+ findFirst: async (args: { where: unknown }) => {
180
+ captured.where = args.where;
181
+ return (args.where as { id: string }).id === "p1"
182
+ ? { id: "p1", partner_id: null }
183
+ : null;
184
+ },
185
+ },
186
+ } as never);
187
+ try {
188
+ await resolveFromLoaderConfig(
189
+ {
190
+ sources: [
191
+ {
192
+ key: "proposal",
193
+ source: "orpc",
194
+ model: "builda_pages",
195
+ where: { id: ":id" },
196
+ optional: false,
197
+ },
198
+ {
199
+ key: "partnerOrg",
200
+ source: "orpc",
201
+ model: "builda_pages",
202
+ where: { partner_id: ":proposal.partner_id" },
203
+ optional: false,
204
+ depends_on: ["proposal"],
205
+ },
206
+ ],
207
+ },
208
+ { id: "p1" },
209
+ );
210
+ } finally {
211
+ setServerClient(original as never);
212
+ }
213
+ // The partnerOrg source queried WITH { partner_id: null }, not refused.
214
+ expect(captured.where).toEqual({ partner_id: null });
215
+ });
216
+ });
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
15
+ import type { ResolverFnKey } from "@teamscala/db/registry/generated/resolver-fn";
15
16
  import { ResolverFnConfigSchema } from "@teamscala/db-validation/registry-schemas/resolver-fn";
16
17
  import { getServerClient } from "./page-server-orpc/server-client";
17
18
 
@@ -120,7 +121,7 @@ function toSelectMap(
120
121
  * hardcoded if(resolver_fn==='X') chain — adding a resolver is now a registry_edit row.
121
122
  */
122
123
  async function resolveByResolverFn(
123
- slug: string,
124
+ slug: ResolverFnKey,
124
125
  input: Record<string, unknown>,
125
126
  params: Record<string, unknown>,
126
127
  ): Promise<unknown> {
@@ -145,6 +146,40 @@ async function resolveByResolverFn(
145
146
  return fn({ input, client: await getServerClient(), params });
146
147
  }
147
148
 
149
+ /**
150
+ * Exact `:ref` strings inside a raw where/resolver_input whose value resolves
151
+ * to undefined — the keys interpolateTemplate DROPS. A dropped where key on a
152
+ * required source would query with a partial (or empty) filter, which matches
153
+ * an ARBITRARY row — worse than an error, so required sources refuse here
154
+ * instead (`a-component-may-not-report-a-state-it-has-not-verified`). Null is
155
+ * a real value (an unset column filtered as null) and never counts as broken.
156
+ */
157
+ function unresolvedTemplateRefs(
158
+ template: unknown,
159
+ params: Record<string, unknown>,
160
+ resolved: Record<string, unknown>,
161
+ ): string[] {
162
+ const broken: string[] = [];
163
+ const walk = (node: unknown): void => {
164
+ if (typeof node === "string") {
165
+ const exact = node.match(/^:([a-zA-Z0-9_.]+)$/);
166
+ if (exact && resolveParamRef(exact[1], params, resolved) === undefined) {
167
+ broken.push(exact[1]);
168
+ }
169
+ return;
170
+ }
171
+ if (Array.isArray(node)) {
172
+ for (const v of node) walk(v);
173
+ return;
174
+ }
175
+ if (node != null && typeof node === "object") {
176
+ for (const v of Object.values(node)) walk(v);
177
+ }
178
+ };
179
+ walk(template);
180
+ return broken;
181
+ }
182
+
148
183
  async function resolveSource(
149
184
  source: SourceConfig,
150
185
  params: Record<string, unknown>,
@@ -156,6 +191,14 @@ async function resolveSource(
156
191
  case "orpc":
157
192
  case "prisma": {
158
193
  if (!source.model) return null;
194
+ if (!source.optional && source.where) {
195
+ const broken = unresolvedTemplateRefs(source.where, params, resolved);
196
+ if (broken.length > 0) {
197
+ throw new Error(
198
+ `[resource_loader] required source "${source.key}": where ref(s) ${broken.map((ref) => `:${ref}`).join(", ")} resolved to undefined — refusing to query without them (a dropped filter silently matches an arbitrary row)`,
199
+ );
200
+ }
201
+ }
159
202
  const where = source.where
160
203
  ? (interpolateTemplate(source.where, params, resolved) as Record<
161
204
  string,
@@ -187,7 +230,11 @@ async function resolveSource(
187
230
  ) as Record<string, unknown>)
188
231
  : {};
189
232
 
190
- return resolveByResolverFn(source.resolver_fn ?? "", input, params);
233
+ return resolveByResolverFn(
234
+ source.resolver_fn as ResolverFnKey,
235
+ input,
236
+ params,
237
+ );
191
238
  }
192
239
  default:
193
240
  throw new Error(