@teamscala/service-boot 0.23.516 → 0.23.518
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 +232 -1127
- package/src/agreement-gate/settings.ts +8 -8
- package/src/apply-logger-config.test.ts +46 -0
- package/src/apply-logger-config.ts +31 -0
- package/src/asset-proxy.test.ts +9 -5
- package/src/auth/sign-out.ts +122 -0
- package/src/build-client-assets/hydrate-bundle.ts +14 -4
- package/src/build-client-assets/manifest.ts +5 -18
- package/src/build-client-assets/styles-build.test.ts +11 -8
- package/src/build-client-assets/styles-build.ts +14 -63
- package/src/build-client-assets.test.ts +0 -161
- package/src/build-client-assets.ts +20 -67
- package/src/content-gate/token.ts +9 -2
- package/src/install-logging.ts +10 -26
- package/src/mount-orpc-router.ts +8 -2
- package/src/mutable-app-ref.ts +0 -7
- package/src/page-server-loader-graph.test.ts +31 -0
- package/src/page-server-loader-graph.ts +21 -0
- package/src/pg-boss-mount.ts +3 -16
- package/src/readiness-gate.ts +0 -4
- package/src/register-api-routes.ts +8 -17
- package/src/register-page-server-client.ts +6 -0
- package/src/register-registry-page-route/meta-title.test.ts +50 -0
- package/src/register-registry-page-route/meta-title.ts +1 -1
- package/src/register-registry-page-route/page-data-shared.ts +7 -11
- package/src/register-registry-page-route/resolve-request-graph.test.ts +1 -1
- package/src/register-registry-page-route/resolve-request-graph.ts +1 -2
- package/src/register-registry-page-route.ts +14 -16
- package/src/resolve-brand-favicon.test.ts +63 -0
- package/src/resolve-theme-mode.test.ts +51 -0
- package/src/resolvers/fetch-org-brand.test.ts +48 -0
- package/src/resolvers/website-by-host-map.test.ts +63 -0
- package/src/resolvers/website-by-host-map.ts +15 -3
- package/src/run-boot-hook-list.test.ts +47 -0
- package/src/run-boot-hooks.test.ts +56 -0
- package/src/scala-tools-currency-watchdog.test.ts +43 -0
- package/src/scala-tools-currency-watchdog.ts +0 -5
- package/src/transfers/presigned-upload.ts +165 -0
- package/src/two-phase-minimal-app.test.ts +24 -0
- package/src/two-phase-readiness.test.ts +102 -0
- package/src/types.test.ts +20 -0
- package/src/ui-service.test.ts +24 -0
- package/src/ws-boot-self-test.test.ts +43 -0
- package/src/build-client-assets/input-fingerprint.ts +0 -92
- package/src/build-client-assets/input-hashing.ts +0 -206
- package/src/handlers/bun-stable-watch.ts +0 -86
- package/src/handlers/messaging-send-reconcile-logic.test.ts +0 -79
- package/src/handlers/messaging-send-reconcile-logic.ts +0 -83
- package/src/handlers/messaging-send-recording-logic.test.ts +0 -86
- package/src/handlers/messaging-send-recording-logic.ts +0 -68
- package/src/handlers/messaging-send-recording-watch.test.ts +0 -91
- package/src/handlers/messaging-send-recording-watch.ts +0 -244
- package/src/handlers/messaging-send-sweep-selection.test.ts +0 -47
- package/src/handlers/messaging-send-sweep-selection.ts +0 -35
- package/src/handlers/platform-fault-escalation/config.ts +0 -95
- package/src/handlers/platform-fault-escalation/delivery.ts +0 -327
- package/src/handlers/platform-fault-escalation/event-log-queries.ts +0 -182
- package/src/handlers/platform-fault-escalation/orpc-client.ts +0 -114
- package/src/handlers/platform-fault-escalation/probe.test.ts +0 -63
- package/src/handlers/platform-fault-escalation/probe.ts +0 -19
- package/src/handlers/platform-fault-escalation-logic.ts +0 -382
- package/src/handlers/platform-fault-escalation-refresh.test.ts +0 -72
- package/src/handlers/platform-fault-escalation.test.ts +0 -497
- package/src/handlers/platform-fault-escalation.ts +0 -246
- package/src/handlers/projects-digest.ts +0 -198
- package/src/handlers/quiz-research.ts +0 -178
- package/src/handlers/registry-stale-inactive-sweep.ts +0 -82
- package/src/handlers/scala-tools-currency-watch.test.ts +0 -65
- package/src/handlers/scala-tools-currency-watch.ts +0 -162
- package/src/handlers/singleton-tools-currency-watch.test.ts +0 -51
- package/src/handlers/singleton-tools-currency-watch.ts +0 -380
- package/src/handlers/sweep-event-log.ts +0 -98
- package/src/handlers/upstream-issue-sync.ts +0 -220
- package/src/handlers/whatsapp-liveness-handler.test.ts +0 -56
- package/src/handlers/whatsapp-liveness-logic.ts +0 -53
- package/src/handlers/whatsapp-liveness.test.ts +0 -78
- package/src/handlers/whatsapp-liveness.ts +0 -138
- package/src/interpolate-meta.ts +0 -123
- package/src/load-service-prisma-client.ts +0 -53
- package/src/loader-errors.ts +0 -41
- package/src/read-viewport-from-request.ts +0 -36
- package/src/register-page-server-client.test.ts +0 -185
- package/src/resolve-app-base-url.ts +0 -35
- package/src/resolve-from-loader-config.test.ts +0 -429
- package/src/resolve-from-loader-config.ts +0 -316
- package/src/resolve-service-context.ts +0 -88
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @status generated — ts-api-smoke template (scala-test-suites
|
|
3
|
+
* templates/ts_api_smoke.pkl @1.3.0) over the exports map of @teamscala/service-boot.
|
|
4
|
+
* ./resolve-brand-favicon declares 2 runtime export(s) and 5 declared behaviour vector(s);
|
|
5
|
+
* this suite asserts the import resolves, the runtime surface matches
|
|
6
|
+
* exactly, and every export carries its declared kind. Regenerate from
|
|
7
|
+
* inputs/bun-server-service-boot/api_smoke.json (scala-test-suites) — hand edits are
|
|
8
|
+
* overwritten.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it } from "bun:test";
|
|
12
|
+
import { mock } from "bun:test";
|
|
13
|
+
const __realAssetProxy = await import("./asset-proxy");
|
|
14
|
+
let __probeCalls = 0;
|
|
15
|
+
let __probeBehaviour: "ok" | "notok" | "throw" = "ok";
|
|
16
|
+
mock.module("./asset-proxy", () => ({ ...__realAssetProxy, handleAssetProxy: async () => { __probeCalls++; if (__probeBehaviour === "throw") throw new Error("asset store unreachable"); return new Response(null, { status: __probeBehaviour === "ok" ? 200 : 404 }); } }));
|
|
17
|
+
|
|
18
|
+
const mod = await import("./resolve-brand-favicon");
|
|
19
|
+
|
|
20
|
+
const { resolveBrandFaviconPath } = mod;
|
|
21
|
+
|
|
22
|
+
describe("resolve-brand-favicon exports-map smoke", () => {
|
|
23
|
+
it("the runtime export set is exactly the declared surface", () => {
|
|
24
|
+
expect(Object.keys(mod).sort()).toEqual([
|
|
25
|
+
"resolveBrandFaviconPath",
|
|
26
|
+
"warmBrandFaviconCache",
|
|
27
|
+
]);
|
|
28
|
+
});
|
|
29
|
+
it("resolveBrandFaviconPath is a function", () => {
|
|
30
|
+
expect(typeof mod["resolveBrandFaviconPath"]).toBe("function");
|
|
31
|
+
});
|
|
32
|
+
it("warmBrandFaviconCache is a function", () => {
|
|
33
|
+
expect(typeof mod["warmBrandFaviconCache"]).toBe("function");
|
|
34
|
+
});
|
|
35
|
+
it("no brand org resolves null without probing the asset store", async () => {
|
|
36
|
+
__probeCalls = 0;
|
|
37
|
+
const got = await resolveBrandFaviconPath(undefined);
|
|
38
|
+
expect(got).toBeNull();
|
|
39
|
+
expect(__probeCalls).toBe(0);
|
|
40
|
+
});
|
|
41
|
+
it("a reachable favicon resolves the encoded convention path and caches it", async () => {
|
|
42
|
+
__probeBehaviour = "ok"; __probeCalls = 0;
|
|
43
|
+
const got = await resolveBrandFaviconPath("brand-org-probe-1");
|
|
44
|
+
expect(got).toBe("/api/assets/brand-org-probe-1%2Flogos%2Ffavicon.png");
|
|
45
|
+
expect(__probeCalls).toBe(1);
|
|
46
|
+
});
|
|
47
|
+
it("the second resolution for the same org serves from cache", async () => {
|
|
48
|
+
__probeCalls = 0;
|
|
49
|
+
const got = await resolveBrandFaviconPath("brand-org-probe-1");
|
|
50
|
+
expect(got).toBe("/api/assets/brand-org-probe-1%2Flogos%2Ffavicon.png");
|
|
51
|
+
expect(__probeCalls).toBe(0);
|
|
52
|
+
});
|
|
53
|
+
it("a missing favicon at the convention key resolves null", async () => {
|
|
54
|
+
__probeBehaviour = "notok"; __probeCalls = 0;
|
|
55
|
+
const got = await resolveBrandFaviconPath("brand-org-probe-2");
|
|
56
|
+
expect(got).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
it("an asset-store failure degrades to the generic fallback (null), never a throw", async () => {
|
|
59
|
+
__probeBehaviour = "throw"; __probeCalls = 0;
|
|
60
|
+
const got = await resolveBrandFaviconPath("brand-org-probe-3");
|
|
61
|
+
expect(got).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @status generated — ts-api-smoke template (scala-test-suites
|
|
3
|
+
* templates/ts_api_smoke.pkl @1.3.0) over the exports map of @teamscala/service-boot.
|
|
4
|
+
* ./resolve-theme-mode declares 1 runtime export(s) + 1 type(s) and 5 declared behaviour vector(s);
|
|
5
|
+
* this suite asserts the import resolves, the runtime surface matches
|
|
6
|
+
* exactly, and every export carries its declared kind. Regenerate from
|
|
7
|
+
* inputs/bun-server-service-boot/api_smoke.json (scala-test-suites) — hand edits are
|
|
8
|
+
* overwritten.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it } from "bun:test";
|
|
12
|
+
|
|
13
|
+
const mod = await import("./resolve-theme-mode");
|
|
14
|
+
|
|
15
|
+
const { resolveThemeMode } = mod;
|
|
16
|
+
|
|
17
|
+
describe("resolve-theme-mode exports-map smoke", () => {
|
|
18
|
+
it("the runtime export set is exactly the declared surface", () => {
|
|
19
|
+
expect(Object.keys(mod).sort()).toEqual([
|
|
20
|
+
"resolveThemeMode",
|
|
21
|
+
]);
|
|
22
|
+
});
|
|
23
|
+
it("resolveThemeMode is a function", () => {
|
|
24
|
+
expect(typeof mod["resolveThemeMode"]).toBe("function");
|
|
25
|
+
});
|
|
26
|
+
it("the ?theme= URL override wins and marks the render embedded", async () => {
|
|
27
|
+
const got = resolveThemeMode({ url: "https://x.local/p?theme=dark" });
|
|
28
|
+
expect(got.theme).toBe("dark");
|
|
29
|
+
expect(got.embedded).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
it("an explicit override behaves exactly like the URL form", async () => {
|
|
32
|
+
const got = resolveThemeMode({ override: "dark" });
|
|
33
|
+
expect(got.theme).toBe("dark");
|
|
34
|
+
expect(got.embedded).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
it("a non-light/dark override is not an override", async () => {
|
|
37
|
+
const got = resolveThemeMode({ override: "purple", candidates: ["dark"] });
|
|
38
|
+
expect(got.theme).toBe("dark");
|
|
39
|
+
expect(got.embedded).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
it("candidates are consulted in priority order", async () => {
|
|
42
|
+
const got = resolveThemeMode({ candidates: [null, "light", "dark"] });
|
|
43
|
+
expect(got.theme).toBe("light");
|
|
44
|
+
expect(got.embedded).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
it("nothing resolvable lands on light, never a crash", async () => {
|
|
47
|
+
const got = resolveThemeMode({});
|
|
48
|
+
expect(got.theme).toBe("light");
|
|
49
|
+
expect(got.embedded).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @status generated — ts-api-smoke template (scala-test-suites
|
|
3
|
+
* templates/ts_api_smoke.pkl @1.3.0) over the exports map of @teamscala/service-boot.
|
|
4
|
+
* ./fetch-org-brand declares 1 runtime export(s) and 4 declared behaviour vector(s);
|
|
5
|
+
* this suite asserts the import resolves, the runtime surface matches
|
|
6
|
+
* exactly, and every export carries its declared kind. Regenerate from
|
|
7
|
+
* inputs/bun-server-service-boot/api_smoke.json (scala-test-suites) — hand edits are
|
|
8
|
+
* overwritten.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it } from "bun:test";
|
|
12
|
+
|
|
13
|
+
const mod = await import("./fetch-org-brand");
|
|
14
|
+
|
|
15
|
+
const { fetchOrgBrandWithFallback } = mod;
|
|
16
|
+
|
|
17
|
+
describe("fetch-org-brand (page-org-first wrapper) exports-map smoke", () => {
|
|
18
|
+
it("the runtime export set is exactly the declared surface", () => {
|
|
19
|
+
expect(Object.keys(mod).sort()).toEqual([
|
|
20
|
+
"fetchOrgBrandWithFallback",
|
|
21
|
+
]);
|
|
22
|
+
});
|
|
23
|
+
it("fetchOrgBrandWithFallback is a function", () => {
|
|
24
|
+
expect(typeof mod["fetchOrgBrandWithFallback"]).toBe("function");
|
|
25
|
+
});
|
|
26
|
+
it("a page org with a brand row renders THAT brand, no fallback consulted", async () => {
|
|
27
|
+
const client = { organisation_profile: { findFirst: async ({ select, where }) => (where.id === "org-branded" ? { company_name: "Launcha", brand_primary_color: "#123456" } : null) } };
|
|
28
|
+
const got = await fetchOrgBrandWithFallback({ input: { orgId: "org-branded" }, client, params: {} } as never);
|
|
29
|
+
expect(got).not.toBeNull();
|
|
30
|
+
expect((got as { companyName: string }).companyName).toBe("Launcha");
|
|
31
|
+
});
|
|
32
|
+
it("a page org with no brand falls back to the website owning-org", async () => {
|
|
33
|
+
const client = { organisation_profile: { findFirst: async ({ where }) => (where.id === "org-fallback" ? { company_name: "Website Owner" } : null) } };
|
|
34
|
+
const got = await fetchOrgBrandWithFallback({ input: { orgId: "org-page", fallbackOrgId: "org-fallback" }, client, params: {} } as never);
|
|
35
|
+
expect((got as { companyName: string }).companyName).toBe("Website Owner");
|
|
36
|
+
});
|
|
37
|
+
it("a fallback identical to the page org is not consulted twice", async () => {
|
|
38
|
+
let calls = 0; const client = { organisation_profile: { findFirst: async () => { calls++; return null; } } };
|
|
39
|
+
const got = await fetchOrgBrandWithFallback({ input: { orgId: "org-x", fallbackOrgId: "org-x" }, client, params: {} } as never);
|
|
40
|
+
expect(got).toBeNull();
|
|
41
|
+
expect(calls).toBe(2);
|
|
42
|
+
});
|
|
43
|
+
it("no orgId anywhere resolves null and names it", async () => {
|
|
44
|
+
const client = { organisation_profile: { findFirst: async () => { throw new Error("must not be called"); } } };
|
|
45
|
+
const got = await fetchOrgBrandWithFallback({ input: {}, client, params: {} } as never);
|
|
46
|
+
expect(got).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @status generated — ts-api-smoke template (scala-test-suites
|
|
3
|
+
* templates/ts_api_smoke.pkl @1.3.0) over the exports map of @teamscala/service-boot.
|
|
4
|
+
* ./website-by-host-map declares 1 runtime export(s) and 7 declared behaviour vector(s);
|
|
5
|
+
* this suite asserts the import resolves, the runtime surface matches
|
|
6
|
+
* exactly, and every export carries its declared kind. Regenerate from
|
|
7
|
+
* inputs/bun-server-service-boot/api_smoke.json (scala-test-suites) — hand edits are
|
|
8
|
+
* overwritten.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it } from "bun:test";
|
|
12
|
+
|
|
13
|
+
const mod = await import("./website-by-host-map");
|
|
14
|
+
|
|
15
|
+
const { resolveWebsiteByHostMap } = mod;
|
|
16
|
+
|
|
17
|
+
describe("website-by-host-map exports-map smoke", () => {
|
|
18
|
+
it("the runtime export set is exactly the declared surface", () => {
|
|
19
|
+
expect(Object.keys(mod).sort()).toEqual([
|
|
20
|
+
"resolveWebsiteByHostMap",
|
|
21
|
+
]);
|
|
22
|
+
});
|
|
23
|
+
it("resolveWebsiteByHostMap is a function", () => {
|
|
24
|
+
expect(typeof mod["resolveWebsiteByHostMap"]).toBe("function");
|
|
25
|
+
});
|
|
26
|
+
it("no host or no hosts_map resolves null without a query", async () => {
|
|
27
|
+
let queried = 0; const client = { builda_websites: { findFirst: async () => { queried++; return null; } } };
|
|
28
|
+
const got = await resolveWebsiteByHostMap({ input: {}, client, params: {} } as never);
|
|
29
|
+
expect(got).toBeNull();
|
|
30
|
+
expect(queried).toBe(0);
|
|
31
|
+
});
|
|
32
|
+
it("a mapped host resolves ITS website with the entry body_path", async () => {
|
|
33
|
+
const client = { builda_websites: { findFirst: async ({ where }) => (where.slug === "site-launcha" ? { id: "w1", slug: "site-launcha", name: "Launcha" } : null) } };
|
|
34
|
+
const got = await resolveWebsiteByHostMap({ input: { host: "launcha.example", hosts_map: { "launcha.example": { website: "site-launcha", body_path: "/p" } } }, client, params: {} } as never);
|
|
35
|
+
expect(got).not.toBeNull();
|
|
36
|
+
expect((got as { slug: string }).slug).toBe("site-launcha");
|
|
37
|
+
expect((got as { body_path: string }).body_path).toBe("/p");
|
|
38
|
+
});
|
|
39
|
+
it("an unmapped host falls to default_website and :path follows the request path", async () => {
|
|
40
|
+
const client = { builda_websites: { findFirst: async ({ where }) => (where.slug === "site-default" ? { id: "w2", slug: "site-default" } : null) } };
|
|
41
|
+
const got = await resolveWebsiteByHostMap({ input: { host: "other.example", hosts_map: {}, default_website: "site-default", default_body_path: ":path" }, client, params: { path: "/join" } } as never);
|
|
42
|
+
expect((got as { body_path: string }).body_path).toBe("/join");
|
|
43
|
+
});
|
|
44
|
+
it("an unknown slug in the map resolves null (the lookup is the validation)", async () => {
|
|
45
|
+
const client = { builda_websites: { findFirst: async () => null } };
|
|
46
|
+
const got = await resolveWebsiteByHostMap({ input: { host: "x.example", hosts_map: { "x.example": { website: "nope" } } }, client, params: {} } as never);
|
|
47
|
+
expect(got).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
it("a mapped host whose client carries no builda_websites is a MISS, not a TypeError (the sign.scala.business 500)", async () => {
|
|
50
|
+
const client = { other_model: {} };
|
|
51
|
+
const got = await resolveWebsiteByHostMap({ input: { host: "sign.scala.business", hosts_map: { "sign.scala.business": { website: "site-sign" } } }, client, params: {} } as never);
|
|
52
|
+
expect(got).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
it("an undefined client on a mapped host is a MISS, never a throw", async () => {
|
|
55
|
+
const got = await resolveWebsiteByHostMap({ input: { host: "sign.scala.business", hosts_map: { "sign.scala.business": { website: "site-sign" } } }, client: undefined, params: {} } as never);
|
|
56
|
+
expect(got).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
it("a bound accessor with no findFirst is a MISS, never a throw", async () => {
|
|
59
|
+
const client = { builda_websites: {} };
|
|
60
|
+
const got = await resolveWebsiteByHostMap({ input: { host: "sign.scala.business", hosts_map: { "sign.scala.business": { website: "site-sign" } } }, client, params: {} } as never);
|
|
61
|
+
expect(got).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -30,9 +30,21 @@ export async function resolveWebsiteByHostMap({ input, client, params }: Resolve
|
|
|
30
30
|
if (!websiteSlug) return null;
|
|
31
31
|
const rawBodyPath = entry?.body_path ?? (input.default_body_path as string | undefined) ?? ":path";
|
|
32
32
|
const bodyPath = rawBodyPath === ":path" ? ((params.path as string) || "/") : rawBodyPath;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
// A host-map entry whose client is not bound is a MISS, not a crash.
|
|
34
|
+
// scala-sign (public catch-all, no builda_websites in its per-service
|
|
35
|
+
// scope) still receives hosts_map resolver inputs, so this arm used to
|
|
36
|
+
// reach an unbound client and escape as a TypeError: sign.scala.business
|
|
37
|
+
// answered 500 on its own host (measured 2026-09-29, stack
|
|
38
|
+
// resolveWebsiteByHostMap) while 127.0.0.1 answered 200 — only the
|
|
39
|
+
// mapped host crashed. Null lets the service's catch-all answer, and
|
|
40
|
+
// an unmapped host and an unbound client are both misses
|
|
41
|
+
// (unrecognised-input-must-refuse-never-degrade).
|
|
42
|
+
const siteClient = (client as
|
|
43
|
+
| { builda_websites?: { findFirst?: (args: Record<string, unknown>) => Promise<BrandWebsite | null> } }
|
|
44
|
+
| undefined
|
|
45
|
+
| null)?.builda_websites;
|
|
46
|
+
if (!siteClient?.findFirst) return null;
|
|
47
|
+
const website = await siteClient.findFirst({
|
|
36
48
|
where: { slug: websiteSlug },
|
|
37
49
|
select: {
|
|
38
50
|
id: true,
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @status generated — ts-api-smoke template (scala-test-suites
|
|
3
|
+
* templates/ts_api_smoke.pkl @1.3.0) over the exports map of @teamscala/service-boot.
|
|
4
|
+
* ./run-boot-hook-list declares 1 runtime export(s) and 4 declared behaviour vector(s);
|
|
5
|
+
* this suite asserts the import resolves, the runtime surface matches
|
|
6
|
+
* exactly, and every export carries its declared kind. Regenerate from
|
|
7
|
+
* inputs/bun-server-service-boot/api_smoke.json (scala-test-suites) — hand edits are
|
|
8
|
+
* overwritten.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it } from "bun:test";
|
|
12
|
+
|
|
13
|
+
const mod = await import("./run-boot-hook-list");
|
|
14
|
+
|
|
15
|
+
const { runBootHookList } = mod;
|
|
16
|
+
|
|
17
|
+
describe("run-boot-hook-list exports-map smoke", () => {
|
|
18
|
+
it("the runtime export set is exactly the declared surface", () => {
|
|
19
|
+
expect(Object.keys(mod).sort()).toEqual([
|
|
20
|
+
"runBootHookList",
|
|
21
|
+
]);
|
|
22
|
+
});
|
|
23
|
+
it("runBootHookList is a function", () => {
|
|
24
|
+
expect(typeof mod["runBootHookList"]).toBe("function");
|
|
25
|
+
});
|
|
26
|
+
it("declared hooks run in declared order, registered-but-undeclared run after", async () => {
|
|
27
|
+
const ran: string[] = []; const hooks = new Map([["late", async () => { ran.push("late"); }], ["second", async () => { ran.push("second"); }], ["first", async () => { ran.push("first"); }]]);
|
|
28
|
+
const config = { boot_hooks: [{ slug: "first" }, { slug: "second" }] };
|
|
29
|
+
await runBootHookList(config as never, { bootHooks: hooks } as never, "svc-hl");
|
|
30
|
+
expect(ran).toEqual(["first", "second", "late"]);
|
|
31
|
+
});
|
|
32
|
+
it("a declared hook with no registered handler is a boot-fatal, not a silent skip", async () => {
|
|
33
|
+
const config = { boot_hooks: [{ slug: "ghost" }] };
|
|
34
|
+
const call = () => runBootHookList(config as never, { bootHooks: new Map() } as never, "svc-hl");
|
|
35
|
+
expect(call).toThrow(/ghost/);
|
|
36
|
+
});
|
|
37
|
+
it("a throwing hook is fatal and NAMES the hook", async () => {
|
|
38
|
+
const hooks = new Map([["boom", async () => { throw new Error("disk gone"); }]]);
|
|
39
|
+
const config = { boot_hooks: [{ slug: "boom" }] };
|
|
40
|
+
const call = () => runBootHookList(config as never, { bootHooks: hooks } as never, "svc-hl");
|
|
41
|
+
expect(call).toThrow(/boom/);
|
|
42
|
+
});
|
|
43
|
+
it("no hooks and no registrations resolves without work", async () => {
|
|
44
|
+
const got = await runBootHookList({} as never, { bootHooks: new Map() } as never, "svc-hl");
|
|
45
|
+
expect(got).toBeUndefined();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @status generated — ts-api-smoke template (scala-test-suites
|
|
3
|
+
* templates/ts_api_smoke.pkl @1.3.0) over the exports map of @teamscala/service-boot.
|
|
4
|
+
* ./run-boot-hooks declares 1 runtime export(s) + 2 type(s) and 5 declared behaviour vector(s);
|
|
5
|
+
* this suite asserts the import resolves, the runtime surface matches
|
|
6
|
+
* exactly, and every export carries its declared kind. Regenerate from
|
|
7
|
+
* inputs/bun-server-service-boot/api_smoke.json (scala-test-suites) — hand edits are
|
|
8
|
+
* overwritten.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it } from "bun:test";
|
|
12
|
+
import { mock } from "bun:test";
|
|
13
|
+
const __hookCalls: string[] = [];
|
|
14
|
+
mock.module("stub-boot-hook", () => ({ default: async (ctx: { serviceSlug: string }) => { __hookCalls.push(ctx.serviceSlug); } }));
|
|
15
|
+
mock.module("stub-boot-hook-no-default", () => ({ named: async () => {} }));
|
|
16
|
+
|
|
17
|
+
const mod = await import("./run-boot-hooks");
|
|
18
|
+
|
|
19
|
+
const { runBootHooks } = mod;
|
|
20
|
+
|
|
21
|
+
describe("run-boot-hooks exports-map smoke", () => {
|
|
22
|
+
it("the runtime export set is exactly the declared surface", () => {
|
|
23
|
+
expect(Object.keys(mod).sort()).toEqual([
|
|
24
|
+
"runBootHooks",
|
|
25
|
+
]);
|
|
26
|
+
});
|
|
27
|
+
it("runBootHooks is a function", () => {
|
|
28
|
+
expect(typeof mod["runBootHooks"]).toBe("function");
|
|
29
|
+
});
|
|
30
|
+
it("no declared hooks resolves an empty ran", async () => {
|
|
31
|
+
const got = await runBootHooks("svc-bh", {} as never);
|
|
32
|
+
expect(got.ran).toEqual([]);
|
|
33
|
+
});
|
|
34
|
+
it("each hook dynamic-imports its handler_slug and receives the boot context", async () => {
|
|
35
|
+
const got = await runBootHooks("svc-bh", { boot_hooks: [{ slug: "the-hook", handler_slug: "stub-boot-hook" }] } as never);
|
|
36
|
+
expect(got.ran).toEqual(["the-hook"]);
|
|
37
|
+
expect(__hookCalls).toEqual(["svc-bh"]);
|
|
38
|
+
});
|
|
39
|
+
it("hooks run in ascending order, defaulting to 100", async () => {
|
|
40
|
+
const order: string[] = [];
|
|
41
|
+
mock.module("stub-hook-a", () => ({ default: async () => { order.push("a-200"); } }));
|
|
42
|
+
mock.module("stub-hook-b", () => ({ default: async () => { order.push("b-50"); } }));
|
|
43
|
+
mock.module("stub-hook-c", () => ({ default: async () => { order.push("c-default"); } }));
|
|
44
|
+
const got = await runBootHooks("svc-bh", { boot_hooks: [{ slug: "a", handler_slug: "stub-hook-a", order: 200 }, { slug: "b", handler_slug: "stub-hook-b", order: 50 }, { slug: "c", handler_slug: "stub-hook-c" }] } as never);
|
|
45
|
+
expect(got.ran).toEqual(["b", "c", "a"]);
|
|
46
|
+
expect(order).toEqual(["b-50", "c-default", "a-200"]);
|
|
47
|
+
});
|
|
48
|
+
it("a module with no default export is boot-fatal", async () => {
|
|
49
|
+
const call = () => runBootHooks("svc-bh", { boot_hooks: [{ slug: "nope", handler_slug: "stub-boot-hook-no-default" }] } as never);
|
|
50
|
+
expect(call).toThrow(/no default export/);
|
|
51
|
+
});
|
|
52
|
+
it("an unimportable handler is boot-fatal and names the specifier", async () => {
|
|
53
|
+
const call = () => runBootHooks("svc-bh", { boot_hooks: [{ slug: "gone", handler_slug: "stub-hook-does-not-exist" }] } as never);
|
|
54
|
+
expect(call).toThrow(/stub-hook-does-not-exist/);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @status generated — ts-api-smoke template (scala-test-suites
|
|
3
|
+
* templates/ts_api_smoke.pkl @1.3.0) over the exports map of @teamscala/service-boot.
|
|
4
|
+
* ./scala-tools-currency-watchdog declares 1 runtime export(s) and 2 declared behaviour vector(s);
|
|
5
|
+
* this suite asserts the import resolves, the runtime surface matches
|
|
6
|
+
* exactly, and every export carries its declared kind. Regenerate from
|
|
7
|
+
* inputs/bun-server-service-boot/api_smoke.json (scala-test-suites) — hand edits are
|
|
8
|
+
* overwritten.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it } from "bun:test";
|
|
12
|
+
import { mock } from "bun:test";
|
|
13
|
+
const __watchdogs: { name: string; options: Record<string, unknown>; started: boolean }[] = [];
|
|
14
|
+
const __captureCreateWatchdog = (name: string, options: Record<string, unknown>) => { const handle = { name, options, started: false, start() { this.started = true; }, stop() {} }; __watchdogs.push(handle); return handle; };
|
|
15
|
+
mock.module("@teamscala/watchdog/create-watchdog", () => ({ createWatchdog: __captureCreateWatchdog }));
|
|
16
|
+
|
|
17
|
+
const mod = await import("./scala-tools-currency-watchdog");
|
|
18
|
+
|
|
19
|
+
const { startScalaToolsCurrencyWatchdog } = mod;
|
|
20
|
+
|
|
21
|
+
describe("scala-tools-currency-watchdog exports-map smoke", () => {
|
|
22
|
+
it("the runtime export set is exactly the declared surface", () => {
|
|
23
|
+
expect(Object.keys(mod).sort()).toEqual([
|
|
24
|
+
"startScalaToolsCurrencyWatchdog",
|
|
25
|
+
]);
|
|
26
|
+
});
|
|
27
|
+
it("startScalaToolsCurrencyWatchdog is a function", () => {
|
|
28
|
+
expect(typeof mod["startScalaToolsCurrencyWatchdog"]).toBe("function");
|
|
29
|
+
});
|
|
30
|
+
it("starts the currency watchdog with the poll contract and no run-on-start", async () => {
|
|
31
|
+
__watchdogs.length = 0;
|
|
32
|
+
startScalaToolsCurrencyWatchdog();
|
|
33
|
+
expect(__watchdogs.length).toBe(1);
|
|
34
|
+
expect(__watchdogs[0].name).toBe("scala-sentinel:scala-tools-currency");
|
|
35
|
+
expect(__watchdogs[0].options.intervalMs).toBe(120_000);
|
|
36
|
+
expect(__watchdogs[0].options.runOnStart).toBe(false);
|
|
37
|
+
expect(__watchdogs[0].started).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
it("a second start does not create a second watchdog", async () => {
|
|
40
|
+
startScalaToolsCurrencyWatchdog();
|
|
41
|
+
expect(__watchdogs.length).toBe(1);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten — none derivable: searched the api_route row (fn-transfers-presigned-upload) which declares path/handler_config/input but not a body, the codegen outputs (host-bus-handler-contracts emits wiring + suites FROM rows, never procedure bodies), reference/package-catalog.md (object-storage signs a key; it carries no upload-policy primitive: no size/type/dir-scheme package to install), and the retired mount attach-transfers-upload.ts (the policy's origin, not a derivation target) — nothing generates a policy-checking procedure body.
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* The fn.transfers.presignedUpload procedure body (api_route
|
|
7
|
+
* `fn-transfers-presigned-upload`, authed: input + context). It replaces the
|
|
8
|
+
* FormData POST /api/transfers/upload mount for every UI caller: the browser
|
|
9
|
+
* asks for a SIGNED PUT URL for one key, uploads the bytes straight to the
|
|
10
|
+
* store, and persists the SERVABLE key — so the file never crosses the
|
|
11
|
+
* service and the call is one JSON request instead of a multipart body.
|
|
12
|
+
*
|
|
13
|
+
* The policy the mount carried lives here, and is the SAME policy: the
|
|
14
|
+
* MAX_FILE_SIZE / ALLOWED_TYPES refusals (with their 413/415 statuses intact
|
|
15
|
+
* through the ORPC codes below), the USER-scoped key scheme
|
|
16
|
+
* (`users/<userId>/<dir>/<dir-singular>-<ts>.<ext>`), the unknown-dir
|
|
17
|
+
* fallback to `uploads`, and the servable value returned as `url` with
|
|
18
|
+
* `thumbUrl` mirroring it (the stored value is the servable value —
|
|
19
|
+
* single-logo-pathway, so display code reads the column with no rewriting).
|
|
20
|
+
* The key is derived from `context.user.id`, never from input: a caller
|
|
21
|
+
* cannot mint a URL under another user's prefix.
|
|
22
|
+
*
|
|
23
|
+
* HONEST RESIDUAL: the size refusal reads the DECLARED fileSize, because the
|
|
24
|
+
* bytes no longer pass through here — the store enforces the signature's type
|
|
25
|
+
* and expiry, not a byte count. The mount's Content-Length check was equally
|
|
26
|
+
* a declared-value check (an attacker sent the bytes either way); what
|
|
27
|
+
* changes is that a lying client is now bounded by the URL's TTL instead of a
|
|
28
|
+
* boot-level body limit. A real byte bound belongs to the bucket, not to this
|
|
29
|
+
* handler.
|
|
30
|
+
*/
|
|
31
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
32
|
+
import { getPresignedUrl } from "@teamscala/object-storage/create/presigned-url";
|
|
33
|
+
import { ServiceError } from "@teamscala/os/errors/service-error";
|
|
34
|
+
|
|
35
|
+
const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25MB — the mount's bound, unchanged
|
|
36
|
+
const ALLOWED_TYPES = new Set([
|
|
37
|
+
"image/png",
|
|
38
|
+
"image/jpeg",
|
|
39
|
+
"image/gif",
|
|
40
|
+
"image/webp",
|
|
41
|
+
"image/svg+xml",
|
|
42
|
+
"application/pdf",
|
|
43
|
+
"video/mp4",
|
|
44
|
+
"video/webm",
|
|
45
|
+
"text/csv",
|
|
46
|
+
"text/plain",
|
|
47
|
+
]);
|
|
48
|
+
// `dir` → S3 key sub-directory. Unknown/absent → "uploads" (the mount's
|
|
49
|
+
// TYPE_DIR semantics: cloud-drive and any future dir land under uploads until
|
|
50
|
+
// a directory is declared here).
|
|
51
|
+
const TYPE_DIR: Record<string, string> = {
|
|
52
|
+
avatars: "avatars",
|
|
53
|
+
logos: "logos",
|
|
54
|
+
uploads: "uploads",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// The window the browser has to PUT after the URL is minted. Short by design:
|
|
58
|
+
// the URL is requested and used in the same breath, and a shorter TTL bounds
|
|
59
|
+
// what a leaked URL is worth.
|
|
60
|
+
const PRESIGN_EXPIRES_IN_SECONDS = 900;
|
|
61
|
+
|
|
62
|
+
export interface PresignedUploadInput {
|
|
63
|
+
fileName: string;
|
|
64
|
+
fileType?: string;
|
|
65
|
+
fileSize: number;
|
|
66
|
+
dir?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface PresignedUploadResult {
|
|
70
|
+
/** The signed URL the bytes are PUT to. */
|
|
71
|
+
uploadUrl: string;
|
|
72
|
+
/** The store key the bytes land on. */
|
|
73
|
+
key: string;
|
|
74
|
+
/** The SERVABLE value to persist — /api/assets/<key>. */
|
|
75
|
+
url: string;
|
|
76
|
+
/** Mirrors url (see the upload system's single-pathway note). */
|
|
77
|
+
thumbUrl: string;
|
|
78
|
+
/** When uploadUrl stops working, ISO 8601. */
|
|
79
|
+
expiresAt: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface PresignedUploadContext {
|
|
83
|
+
user?: { id?: string };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function extOf(filename: string, fallback = "bin"): string {
|
|
87
|
+
const m = filename.match(/\.([a-z0-9]+)$/i);
|
|
88
|
+
return (m?.[1] ?? fallback).toLowerCase();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function presignedUpload(
|
|
92
|
+
input: PresignedUploadInput,
|
|
93
|
+
context: PresignedUploadContext,
|
|
94
|
+
): Promise<PresignedUploadResult> {
|
|
95
|
+
const userId = context?.user?.id;
|
|
96
|
+
if (!userId) {
|
|
97
|
+
throw new ServiceError({
|
|
98
|
+
code: "UNAUTHORIZED",
|
|
99
|
+
message: "Authentication required",
|
|
100
|
+
service: "transfers-presigned-upload",
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!input?.fileName || input.fileName.length === 0) {
|
|
105
|
+
throw new ServiceError({
|
|
106
|
+
code: "BAD_REQUEST",
|
|
107
|
+
message: "'fileName' is required",
|
|
108
|
+
service: "transfers-presigned-upload",
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
if (!(input.fileSize > 0)) {
|
|
112
|
+
throw new ServiceError({
|
|
113
|
+
code: "BAD_REQUEST",
|
|
114
|
+
message: "empty file",
|
|
115
|
+
service: "transfers-presigned-upload",
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
if (input.fileSize > MAX_FILE_SIZE) {
|
|
119
|
+
throw new ServiceError({
|
|
120
|
+
code: "PAYLOAD_TOO_LARGE",
|
|
121
|
+
message: "file too large (max 25MB)",
|
|
122
|
+
service: "transfers-presigned-upload",
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
// An ABSENT type is allowed (the mount only checked a type it was given);
|
|
126
|
+
// a PRESENT one must be on the allowlist.
|
|
127
|
+
if (input.fileType && !ALLOWED_TYPES.has(input.fileType)) {
|
|
128
|
+
throw new ServiceError({
|
|
129
|
+
code: "UNSUPPORTED_MEDIA_TYPE",
|
|
130
|
+
message: `file type '${input.fileType}' not allowed`,
|
|
131
|
+
service: "transfers-presigned-upload",
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const dir = TYPE_DIR[input.dir ?? "uploads"] ?? "uploads";
|
|
136
|
+
const ext = extOf(input.fileName);
|
|
137
|
+
const key = `users/${userId}/${dir}/${dir.slice(0, -1)}-${Date.now()}.${ext}`;
|
|
138
|
+
|
|
139
|
+
const signed = await getPresignedUrl({
|
|
140
|
+
key,
|
|
141
|
+
operation: "put",
|
|
142
|
+
expiresIn: PRESIGN_EXPIRES_IN_SECONDS,
|
|
143
|
+
...(input.fileType ? { contentType: input.fileType } : {}),
|
|
144
|
+
});
|
|
145
|
+
if (!signed.success || !signed.url) {
|
|
146
|
+
getAppLogger().error("transfers-presigned-upload", {
|
|
147
|
+
error: signed.error,
|
|
148
|
+
key,
|
|
149
|
+
});
|
|
150
|
+
throw new ServiceError({
|
|
151
|
+
code: "INTERNAL_SERVER_ERROR",
|
|
152
|
+
message: signed.error ?? "could not sign an upload URL",
|
|
153
|
+
service: "transfers-presigned-upload",
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const storedUrl = `/api/assets/${key}`;
|
|
158
|
+
return {
|
|
159
|
+
uploadUrl: signed.url,
|
|
160
|
+
key,
|
|
161
|
+
url: storedUrl,
|
|
162
|
+
thumbUrl: storedUrl,
|
|
163
|
+
expiresAt: (signed.expiresAt ?? new Date(Date.now() + PRESIGN_EXPIRES_IN_SECONDS * 1000)).toISOString(),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @status generated — ts-api-smoke template (scala-test-suites
|
|
3
|
+
* templates/ts_api_smoke.pkl @1.3.0) over the exports map of @teamscala/service-boot.
|
|
4
|
+
* ./two-phase-minimal-app declares 1 runtime export(s) + 1 type(s);
|
|
5
|
+
* this suite asserts the import resolves, the runtime surface matches
|
|
6
|
+
* exactly, and every export carries its declared kind. Regenerate from
|
|
7
|
+
* inputs/bun-server-service-boot/api_smoke.json (scala-test-suites) — hand edits are
|
|
8
|
+
* overwritten.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it } from "bun:test";
|
|
12
|
+
|
|
13
|
+
const mod = await import("./two-phase-minimal-app");
|
|
14
|
+
|
|
15
|
+
describe("two-phase-minimal-app exports-map smoke", () => {
|
|
16
|
+
it("the runtime export set is exactly the declared surface", () => {
|
|
17
|
+
expect(Object.keys(mod).sort()).toEqual([
|
|
18
|
+
"buildMinimalApp",
|
|
19
|
+
]);
|
|
20
|
+
});
|
|
21
|
+
it("buildMinimalApp is a function", () => {
|
|
22
|
+
expect(typeof mod["buildMinimalApp"]).toBe("function");
|
|
23
|
+
});
|
|
24
|
+
});
|