@teamscala/service-boot 0.23.284

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.
Files changed (138) hide show
  1. package/package.json +1016 -0
  2. package/src/agreement-gate.test.ts +118 -0
  3. package/src/agreement-gate.ts +887 -0
  4. package/src/asset-cache-key.ts +59 -0
  5. package/src/asset-optimize.ts +93 -0
  6. package/src/asset-proxy.ts +148 -0
  7. package/src/asset-social-card.ts +58 -0
  8. package/src/attach-agreement-magic.ts +62 -0
  9. package/src/attach-portal-members.ts +107 -0
  10. package/src/attach-proposal-accept.ts +123 -0
  11. package/src/attach-proposal-magic.ts +55 -0
  12. package/src/attach-transfers-upload.ts +100 -0
  13. package/src/auth-gate.ts +385 -0
  14. package/src/bind-page-handler.ts +223 -0
  15. package/src/build-client-assets/input-hashing.ts +206 -0
  16. package/src/build-client-assets/source-candidates.ts +115 -0
  17. package/src/build-client-assets.test.ts +216 -0
  18. package/src/build-client-assets.ts +962 -0
  19. package/src/compose-page-title.test.ts +88 -0
  20. package/src/compose-page-title.ts +73 -0
  21. package/src/content-gate/access.ts +89 -0
  22. package/src/content-gate/config-types.ts +108 -0
  23. package/src/content-gate/gate.ts +74 -0
  24. package/src/content-gate/parse-cookie.ts +23 -0
  25. package/src/content-gate/token.test.ts +70 -0
  26. package/src/content-gate/token.ts +148 -0
  27. package/src/content-page-floor.test.ts +127 -0
  28. package/src/content-page-floor.ts +70 -0
  29. package/src/discover-blocks.ts +228 -0
  30. package/src/expand-roster.ts +197 -0
  31. package/src/fn-routers/agreement-completion-email.ts +115 -0
  32. package/src/fn-routers/agreement-send-router.ts +303 -0
  33. package/src/fn-routers/ai-health-router.ts +64 -0
  34. package/src/fn-routers/auth-router.ts +131 -0
  35. package/src/fn-routers/cache-admin-router.ts +50 -0
  36. package/src/fn-routers/chat-session-router.ts +455 -0
  37. package/src/fn-routers/email-defaults.ts +22 -0
  38. package/src/fn-routers/email-link-ttl.ts +23 -0
  39. package/src/fn-routers/index.ts +70 -0
  40. package/src/fn-routers/panel-data-router.ts +93 -0
  41. package/src/fn-routers/persist-chat-messages.test.ts +86 -0
  42. package/src/fn-routers/persist-chat-messages.ts +96 -0
  43. package/src/fn-routers/plans-router.ts +141 -0
  44. package/src/fn-routers/proposal-send-router.ts +207 -0
  45. package/src/fn-routers/public-agreement-router.ts +865 -0
  46. package/src/fn-routers/public-assessment-router.ts +421 -0
  47. package/src/fn-routers/public-gpt-session-router.ts +373 -0
  48. package/src/fn-routers/sign-in-flow-router.ts +196 -0
  49. package/src/fn-routers/task-sync-bind.test.ts +105 -0
  50. package/src/fn-routers/task-sync-classify.test.ts +109 -0
  51. package/src/fn-routers/task-sync-dispatch.test.ts +44 -0
  52. package/src/fn-routers/task-sync-dispatch.ts +124 -0
  53. package/src/fn-routers/task-sync-router.test.ts +279 -0
  54. package/src/fn-routers/task-sync-router.ts +1036 -0
  55. package/src/fn-routers/tasks-router.ts +77 -0
  56. package/src/fn-routers/user-preferences-router.ts +139 -0
  57. package/src/fn-routers/work-items-router.ts +341 -0
  58. package/src/gate-ttls.ts +56 -0
  59. package/src/gpt-gate.ts +76 -0
  60. package/src/handlers/bun-stable-watch.ts +89 -0
  61. package/src/handlers/projects-digest.ts +198 -0
  62. package/src/handlers/prune-stale-data.ts +110 -0
  63. package/src/handlers/registry-stale-inactive-sweep.ts +82 -0
  64. package/src/handlers/scala-tools-currency-watch.test.ts +60 -0
  65. package/src/handlers/scala-tools-currency-watch.ts +171 -0
  66. package/src/handlers/scheduled-browser-tests.ts +85 -0
  67. package/src/handlers/sweep-event-log.ts +98 -0
  68. package/src/handlers/sweep-host-cache.ts +211 -0
  69. package/src/handlers/upstream-issue-sync.ts +210 -0
  70. package/src/handlers/whatsapp-liveness-handler.test.ts +59 -0
  71. package/src/handlers/whatsapp-liveness-logic.ts +53 -0
  72. package/src/handlers/whatsapp-liveness.test.ts +78 -0
  73. package/src/handlers/whatsapp-liveness.ts +125 -0
  74. package/src/hono-error-handler.ts +55 -0
  75. package/src/install-harness.ts +80 -0
  76. package/src/install-logging.ts +182 -0
  77. package/src/load-service-install-row.ts +98 -0
  78. package/src/load-service-prisma-client.ts +53 -0
  79. package/src/mount-orpc-router.ts +148 -0
  80. package/src/mount-server-packages.ts +155 -0
  81. package/src/mount.ts +18 -0
  82. package/src/mutable-app-ref.ts +68 -0
  83. package/src/page-route-shell-types.ts +16 -0
  84. package/src/page-server-orpc/audit-revert-router.ts +102 -0
  85. package/src/page-server-orpc/hub-mcp-client.ts +20 -0
  86. package/src/page-server-orpc/portal-diagnostics-router.ts +170 -0
  87. package/src/page-server-orpc/puck-pages-router.ts +272 -0
  88. package/src/page-server-orpc/server-client.ts +31 -0
  89. package/src/page-server-orpc/templates-router.ts +116 -0
  90. package/src/page-server-orpc/user-preferences-router.ts +132 -0
  91. package/src/pg-boss-handler-registry.ts +36 -0
  92. package/src/pg-boss-mount.ts +295 -0
  93. package/src/proposal-authorized-emails.ts +65 -0
  94. package/src/proposal-chat-inject.ts +141 -0
  95. package/src/proposal-gate.ts +175 -0
  96. package/src/read-html-template.test.ts +74 -0
  97. package/src/read-html-template.ts +102 -0
  98. package/src/read-viewport-from-request.ts +36 -0
  99. package/src/readiness-gate.ts +39 -0
  100. package/src/register-api-routes.ts +127 -0
  101. package/src/register-crash-handlers.ts +39 -0
  102. package/src/register-page-routes.ts +62 -0
  103. package/src/register-page-server-client.ts +109 -0
  104. package/src/register-registry-page-route/ai-chat-prefetch.ts +106 -0
  105. package/src/register-registry-page-route/graph-cache.test.ts +84 -0
  106. package/src/register-registry-page-route/graph-cache.ts +90 -0
  107. package/src/register-registry-page-route/meta-title.ts +73 -0
  108. package/src/register-registry-page-route/proposal-approval-email-token.ts +78 -0
  109. package/src/register-registry-page-route/proposal-brand-stamping.ts +60 -0
  110. package/src/register-registry-page-route/resolve-page-data.ts +518 -0
  111. package/src/register-registry-page-route.ts +765 -0
  112. package/src/register-static-assets.ts +180 -0
  113. package/src/register-ws-routes.ts +180 -0
  114. package/src/resolve-app-base-url.ts +28 -0
  115. package/src/resolve-brand-favicon.ts +65 -0
  116. package/src/resolve-from-loader-config.test.ts +44 -0
  117. package/src/resolve-from-loader-config.ts +291 -0
  118. package/src/resolve-service-context.ts +67 -0
  119. package/src/resolve-theme-mode.ts +61 -0
  120. package/src/resolvers/fetch-org-brand.ts +28 -0
  121. package/src/resolvers/portal-route-paths.test.ts +102 -0
  122. package/src/resolvers/portal-route-paths.ts +83 -0
  123. package/src/resolvers/website-by-host-map.ts +49 -0
  124. package/src/resource-chat-inject.ts +168 -0
  125. package/src/resource-not-found.ts +263 -0
  126. package/src/run-boot-hook-list.ts +75 -0
  127. package/src/run-boot-hooks.ts +97 -0
  128. package/src/scala-tools-currency-watchdog.ts +160 -0
  129. package/src/security-headers.ts +59 -0
  130. package/src/serve/serve-ws.test.ts +135 -0
  131. package/src/serve/serve.ts +190 -0
  132. package/src/server-error-page.test.ts +102 -0
  133. package/src/server-error-page.ts +95 -0
  134. package/src/start-scheduled-jobs.ts +84 -0
  135. package/src/two-phase-boot.ts +449 -0
  136. package/src/types.ts +52 -0
  137. package/src/ui-service.ts +171 -0
  138. package/src/ws-boot-self-test.ts +114 -0
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @system server-handlers
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * ETag derivation for the asset proxy that incorporates the negotiated
7
+ * image format so per-Accept variants cache independently.
8
+ */
9
+
10
+ const SKIP_FORMATS = new Set(["webp", "avif", "gif", "svg", "ico"]);
11
+
12
+ export type TargetFormat =
13
+ | "webp"
14
+ | "avif"
15
+ | "jpeg"
16
+ | "png"
17
+ | "gif"
18
+ | "svg"
19
+ | "ico"
20
+ | "original";
21
+
22
+ export function negotiateFormat(
23
+ acceptHeader: string | null,
24
+ storedContentType: string,
25
+ ): TargetFormat {
26
+ const stored = contentTypeToFormat(storedContentType);
27
+
28
+ if (SKIP_FORMATS.has(stored)) return stored;
29
+
30
+ const accept = (acceptHeader || "").toLowerCase();
31
+
32
+ if (accept.includes("image/avif") && !accept.includes("image/avif;q=0"))
33
+ return "avif";
34
+ if (accept.includes("image/webp") && !accept.includes("image/webp;q=0"))
35
+ return "webp";
36
+
37
+ return stored;
38
+ }
39
+
40
+ export function buildEtag(baseEtag: string, format: TargetFormat): string {
41
+ const bare = baseEtag.replace(/"/g, "");
42
+ return `"${bare}-${format}"`;
43
+ }
44
+
45
+ function contentTypeToFormat(contentType: string): TargetFormat {
46
+ const ct = contentType.toLowerCase().split(";")[0]?.trim();
47
+ const map: Record<string, TargetFormat> = {
48
+ "image/webp": "webp",
49
+ "image/avif": "avif",
50
+ "image/gif": "gif",
51
+ "image/svg+xml": "svg",
52
+ "image/x-icon": "ico",
53
+ "image/vnd.microsoft.icon": "ico",
54
+ "image/png": "png",
55
+ "image/jpeg": "jpeg",
56
+ "image/jpg": "jpeg",
57
+ };
58
+ return map[ct] ?? "original";
59
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * @system server-handlers
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * On-the-fly image optimisation for the asset proxy. Converts stored
7
+ * PNG/JPEG to WebP or AVIF when the browser signals support, using
8
+ * @teamscala/image-optimizer's compressImage primitive. Returns null
9
+ * on failure so the caller can fall back to the original stream.
10
+ */
11
+
12
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
13
+ import type { TargetFormat } from "./asset-cache-key.ts";
14
+
15
+ export interface OptimizeResult {
16
+ data: Buffer;
17
+ format: string;
18
+ processedSize: number;
19
+ }
20
+
21
+ // Dynamic-imported optional dependency. service-runtime is the universal
22
+ // boot pipeline used by every service; only the small subset of services
23
+ // that actually serve images via the asset proxy need image-optimizer.
24
+ // Static imports here would force every service tree (loga, agents,
25
+ // mcp-gateway, etc.) to install image-optimizer as a transitive — a
26
+ // real-world incident in 2026-05 (loga unable to boot because its node
27
+ // tree was missing @teamscala/image-optimizer/config). Lazy + try/catch
28
+ // keeps image-optimizer optional: services that have it use it, services
29
+ // that don't get a null OptimizeResult and the asset proxy serves the
30
+ // original buffer.
31
+
32
+ export async function optimizeImage(
33
+ buffer: Buffer,
34
+ targetFormat: TargetFormat,
35
+ ): Promise<OptimizeResult | null> {
36
+ if (targetFormat !== "webp" && targetFormat !== "avif") return null;
37
+
38
+ let getConfig: () => {
39
+ defaultQuality: number;
40
+ defaultMaxWidth: number;
41
+ defaultMaxHeight: number;
42
+ };
43
+ let compressImage: (
44
+ buf: Buffer,
45
+ opts: {
46
+ format: TargetFormat;
47
+ lossless: boolean;
48
+ quality: number;
49
+ maxWidth: number;
50
+ maxHeight: number;
51
+ },
52
+ ) => Promise<{ data: Buffer; format: string; processedSize: number }>;
53
+ // Build specifiers via template literal so the package-json codegen
54
+ // walker (which captures literal `import("...")` strings) doesn't add
55
+ // image-optimizer to service-runtime's static deps.
56
+ const pkg = "@teamscala/image-optimizer";
57
+ try {
58
+ const configMod = await import(`${pkg}/config`);
59
+ const opsMod = await import(`${pkg}/image-operations`);
60
+ getConfig = configMod.getConfig;
61
+ compressImage = opsMod.compressImage;
62
+ } catch {
63
+ // image-optimizer not installed in this service tree — caller falls
64
+ // back to serving the original buffer.
65
+ return null;
66
+ }
67
+
68
+ const cfg = getConfig();
69
+
70
+ try {
71
+ const result = await compressImage(buffer, {
72
+ format: targetFormat,
73
+ lossless: false,
74
+ quality: cfg.defaultQuality,
75
+ maxWidth: cfg.defaultMaxWidth,
76
+ maxHeight: cfg.defaultMaxHeight,
77
+ });
78
+
79
+ return {
80
+ data: result.data,
81
+ format: result.format,
82
+ processedSize: result.processedSize,
83
+ };
84
+ } catch (err) {
85
+ getAppLogger().error(
86
+ "[asset-proxy] image optimisation failed, serving original",
87
+ {
88
+ error: err,
89
+ },
90
+ );
91
+ return null;
92
+ }
93
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * @system server-handlers
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Same-origin S3 asset proxy with on-the-fly image optimisation.
7
+ * Streams objects from the configured S3 bucket, converting PNG/JPEG
8
+ * to WebP/AVIF when the browser declares support via Accept header.
9
+ * Returns 404 for missing keys, 502 for S3 failures. Never redirects.
10
+ */
11
+
12
+ import { getClient } from "@teamscala/aws/create/client/get-client";
13
+ import {
14
+ buildEtag,
15
+ negotiateFormat,
16
+ type TargetFormat,
17
+ } from "./asset-cache-key.ts";
18
+ import { optimizeImage } from "./asset-optimize.ts";
19
+ import { renderSocialCard } from "./asset-social-card.ts";
20
+
21
+ const CACHE_HEADERS = {
22
+ "Cache-Control": "public, max-age=31536000, immutable",
23
+ Vary: "Accept",
24
+ } as const;
25
+
26
+ export async function handleAssetProxy(req: Request): Promise<Response> {
27
+ const url = new URL(req.url);
28
+ const prefix = "/api/assets/";
29
+ if (!url.pathname.startsWith(prefix)) {
30
+ return new Response("Not Found", { status: 404 });
31
+ }
32
+
33
+ const key = decodeURIComponent(url.pathname.slice(prefix.length));
34
+ if (!key) {
35
+ return new Response("Not Found", { status: 404 });
36
+ }
37
+
38
+ const s3 = await getClient();
39
+ if (!s3) {
40
+ return new Response("", { status: 502 });
41
+ }
42
+
43
+ const file = s3.file(key);
44
+
45
+ let stats: Awaited<ReturnType<typeof file.stat>>;
46
+ try {
47
+ stats = await file.stat();
48
+ } catch {
49
+ return new Response("Not Found", { status: 404 });
50
+ }
51
+
52
+ const baseEtag = stats.etag || `"${key}"`;
53
+ const contentType = stats.type || file.type || "application/octet-stream";
54
+ const ogRequested = url.searchParams.get("og") === "1";
55
+ const targetFormat: TargetFormat = ogRequested
56
+ ? "jpeg"
57
+ : negotiateFormat(req.headers.get("Accept"), contentType);
58
+ const baseFormatTag = buildEtag(baseEtag, targetFormat);
59
+ const etag = ogRequested
60
+ ? `${baseFormatTag.slice(0, -1)}-og"`
61
+ : baseFormatTag;
62
+
63
+ const ifNoneMatch = req.headers.get("If-None-Match");
64
+ if (ifNoneMatch && ifNoneMatch === etag) {
65
+ return new Response(null, {
66
+ status: 304,
67
+ headers: { ETag: etag, ...CACHE_HEADERS },
68
+ });
69
+ }
70
+
71
+ if (ogRequested) {
72
+ try {
73
+ const buffer = Buffer.from(await file.arrayBuffer());
74
+ const result = await renderSocialCard(buffer);
75
+
76
+ if (result) {
77
+ return new Response(new Uint8Array(result.data), {
78
+ status: 200,
79
+ headers: {
80
+ "Content-Type": "image/jpeg",
81
+ "Content-Length": String(result.processedSize),
82
+ ETag: etag,
83
+ ...CACHE_HEADERS,
84
+ },
85
+ });
86
+ }
87
+ } catch {
88
+ // fall through to pass-through
89
+ }
90
+ }
91
+
92
+ const stored = contentTypeToFormat(contentType);
93
+ const shouldOptimize = targetFormat === "webp" || targetFormat === "avif";
94
+ const needsTransform = shouldOptimize && stored !== targetFormat;
95
+
96
+ if (needsTransform) {
97
+ try {
98
+ const buffer = Buffer.from(await file.arrayBuffer());
99
+ const result = await optimizeImage(buffer, targetFormat as TargetFormat);
100
+
101
+ if (result) {
102
+ return new Response(new Uint8Array(result.data), {
103
+ status: 200,
104
+ headers: {
105
+ "Content-Type": `image/${result.format}`,
106
+ "Content-Length": String(result.processedSize),
107
+ ETag: etag,
108
+ ...CACHE_HEADERS,
109
+ },
110
+ });
111
+ }
112
+ } catch {
113
+ // fall through to pass-through
114
+ }
115
+ }
116
+
117
+ let body: ReadableStream<Uint8Array<ArrayBuffer>>;
118
+ try {
119
+ body = file.stream();
120
+ } catch {
121
+ return new Response("", { status: 502 });
122
+ }
123
+
124
+ return new Response(body, {
125
+ status: 200,
126
+ headers: {
127
+ "Content-Type": contentType,
128
+ ETag: etag,
129
+ ...CACHE_HEADERS,
130
+ },
131
+ });
132
+ }
133
+
134
+ function contentTypeToFormat(contentType: string): string {
135
+ const ct = contentType.toLowerCase().split(";")[0]?.trim();
136
+ const map: Record<string, string> = {
137
+ "image/webp": "webp",
138
+ "image/avif": "avif",
139
+ "image/gif": "gif",
140
+ "image/svg+xml": "svg",
141
+ "image/x-icon": "ico",
142
+ "image/vnd.microsoft.icon": "ico",
143
+ "image/png": "png",
144
+ "image/jpeg": "jpeg",
145
+ "image/jpg": "jpeg",
146
+ };
147
+ return map[ct] || "original";
148
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * @system server-handlers
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Dynamic-import wrapper around image-optimizer's in-process sharp
7
+ * social-card op. Mirrors asset-optimize.ts's optional-dependency shape:
8
+ * services without image-optimizer installed transparently get null and
9
+ * fall back to the raw asset stream. In-process (not HTTP) because the
10
+ * og:image transform is a cold path per shared link — no auth handshake,
11
+ * no per-consumer service config.
12
+ */
13
+
14
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
15
+
16
+ export interface SocialCardOutcome {
17
+ data: Buffer;
18
+ processedSize: number;
19
+ }
20
+
21
+ export async function renderSocialCard(
22
+ buffer: Buffer,
23
+ ): Promise<SocialCardOutcome | null> {
24
+ const pkg = "@teamscala/image-optimizer";
25
+
26
+ let socialCardImage: (
27
+ buf: Buffer,
28
+ opts?: {
29
+ width?: number;
30
+ height?: number;
31
+ quality?: number;
32
+ padding?: number;
33
+ },
34
+ ) => Promise<{ data: Buffer; processedSize: number }>;
35
+
36
+ try {
37
+ const mod = await import(`${pkg}/social-card-op`);
38
+ socialCardImage = mod.socialCardImage;
39
+ } catch {
40
+ return null;
41
+ }
42
+
43
+ try {
44
+ const result = await socialCardImage(buffer);
45
+ return {
46
+ data: result.data,
47
+ processedSize: result.processedSize,
48
+ };
49
+ } catch (err) {
50
+ getAppLogger().error(
51
+ "[asset-proxy] social-card render failed, serving original",
52
+ {
53
+ error: err,
54
+ },
55
+ );
56
+ return null;
57
+ }
58
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * The agreement MAGIC-LINK endpoint. A recipient who clicks an emailed
7
+ * "Review & sign" link — which carries a pre-authorised, signed ag_token for
8
+ * THEIR party — lands here: the token is verified for the agreement, the
9
+ * ag_token cookie is set on the shared parent domain, and the browser is
10
+ * 302-redirected straight into the signing page. No email-entry gate, no OTP —
11
+ * "came from our email" IS the proof of identity, exactly like the
12
+ * proposal-accept hand-off (attach-proposal-accept.ts).
13
+ *
14
+ * The token is the same HMAC ag_token the gate verifies (30-min TTL, scoped to
15
+ * {agreementId, email}); an invalid/expired/forged token sets no cookie and the
16
+ * destination page simply shows the normal email gate. GET so it is a plain
17
+ * clickable link in an email.
18
+ */
19
+
20
+ import type { Hono } from "hono";
21
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
22
+ import { verifyAgreementAccessToken } from "@teamscala/document-signing/agreement-access-token";
23
+ import { agTtlMs } from "./gate-ttls.ts";
24
+ import { getCookieDomain } from "@teamscala/db/registry/cookie-domain";
25
+
26
+ const AG_COOKIE = "ag_token";
27
+
28
+ export async function attachAgreementMagic(
29
+ app: Hono,
30
+ serviceSlug: string,
31
+ ): Promise<void> {
32
+ const log = getAppLogger();
33
+
34
+ app.get("/api/agreement-magic", async (c) => {
35
+ const token = c.req.query("t") ?? "";
36
+ const agreementId = c.req.query("a") ?? "";
37
+ const toRaw = c.req.query("to") ?? "/";
38
+ // Same-origin path only — never an open redirect.
39
+ const dest =
40
+ toRaw.startsWith("/") && !toRaw.startsWith("//") ? toRaw : "/";
41
+
42
+ // Verify before trusting. Invalid/expired/forged → no cookie; the
43
+ // destination page falls through to its normal email gate.
44
+ const email = verifyAgreementAccessToken(token, agreementId, Date.now());
45
+ if (email) {
46
+ const maxAge = Math.floor(agTtlMs() / 1000);
47
+ const domain = await getCookieDomain();
48
+ c.header(
49
+ "Set-Cookie",
50
+ `${AG_COOKIE}=${encodeURIComponent(token)}; Domain=${domain}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`,
51
+ );
52
+ log.info(
53
+ `[agreement-magic] ${agreementId} → authorised ${email}; redirecting to ${dest}`,
54
+ );
55
+ }
56
+ return c.redirect(dest, 302);
57
+ });
58
+
59
+ log.info(
60
+ `[scala-install:${serviceSlug}] agreement magic-link endpoint mounted: /api/agreement-magic`,
61
+ );
62
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Portal-secured member directory endpoint. Resolves members of an org via a
7
+ * parameterized raw join over better_auth_member + "user" — NOT the typed
8
+ * `member` ORPC model, which is deliberately omitted from the portal's
9
+ * service_schema (security: the raw table is not exposed as a typed CRUD
10
+ * surface). Mirrors the agreement-gate.ts:109-150 precedent.
11
+ *
12
+ * The portal's auth middleware (better-auth-cookie) gates the endpoint — only
13
+ * authenticated users can call it. The orgId comes from the caller's context
14
+ * (the portal resolves it from the session + passes it).
15
+ *
16
+ * Endpoint: GET /api/portal/members?orgId=<uuid>
17
+ * - orgId omitted: super-admin all-orgs mode (returns every member across
18
+ * all orgs, strictly gated to super-admin via getSession; used by the
19
+ * "View As" impersonation dropdown).
20
+ * Response: { members: Array<{ id, user_id, organization_id, role, email, name, image }> }
21
+ */
22
+
23
+ import type { Hono } from "hono";
24
+ import { getSession } from "@teamscala/auth/get-session";
25
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
26
+ import { queryRows } from "@teamscala/db/query-rows";
27
+
28
+ export async function attachPortalMembers(app: Hono): Promise<void> {
29
+ const log = getAppLogger();
30
+
31
+ app.get("/api/portal/members", async (c) => {
32
+ const orgId = c.req.query("orgId");
33
+
34
+ // No orgId = super-admin all-orgs mode. The "View As" impersonation
35
+ // dropdown requests every member across all orgs so a super-admin can
36
+ // impersonate anyone; the client omits orgId for super-admins
37
+ // (useLeftMenuBarSession). Gate strictly to super-admin — any other
38
+ // caller without an orgId is forbidden.
39
+ if (!orgId || typeof orgId !== "string") {
40
+ // isSuperAdmin is DB-derived (the better-auth session omits role) —
41
+ // mirror resolve-portal-context's gate (the same one that shows the
42
+ // View As switcher): user.role is "admin" OR "super_admin".
43
+ const session = await getSession(c.req.raw.headers);
44
+ const userId = session?.user?.id;
45
+ const { getPrisma } = await import("@teamscala/db/prisma-registry");
46
+ const superAdminUser = userId
47
+ ? await getPrisma().user.findFirst({
48
+ where: { id: userId, role: { in: ["admin", "super_admin"] } },
49
+ select: { id: true },
50
+ })
51
+ : null;
52
+ if (!superAdminUser) {
53
+ return c.json({ error: "orgId query parameter required", members: [] }, 400);
54
+ }
55
+ try {
56
+ const allRows = await queryRows`
57
+ SELECT m.id, m.user_id, m.organization_id, m.role,
58
+ u.email, u.name, u.image
59
+ FROM better_auth_member m
60
+ JOIN "user" u ON u.id = m.user_id
61
+ ORDER BY m.organization_id, u.name
62
+ ` as Array<{
63
+ id: string;
64
+ user_id: string;
65
+ organization_id: string;
66
+ role: string | null;
67
+ email: string | null;
68
+ name: string | null;
69
+ image: string | null;
70
+ }>;
71
+ return c.json({ members: allRows });
72
+ } catch (err) {
73
+ log.error(
74
+ `[portal-members] resolve failed: ${err instanceof Error ? err.message : String(err)}`,
75
+ );
76
+ return c.json({ error: "resolve-failed", members: [] });
77
+ }
78
+ }
79
+
80
+ try {
81
+ const { getPrisma } = await import("@teamscala/db/prisma-registry");
82
+ const rows = await queryRows`
83
+ SELECT m.id, m.user_id, m.organization_id, m.role,
84
+ u.email, u.name, u.image
85
+ FROM better_auth_member m
86
+ JOIN "user" u ON u.id = m.user_id
87
+ WHERE m.organization_id = ${orgId}
88
+ ` as
89
+ Array<{
90
+ id: string;
91
+ user_id: string;
92
+ organization_id: string;
93
+ role: string | null;
94
+ email: string | null;
95
+ name: string | null;
96
+ image: string | null;
97
+ }>
98
+ ;
99
+ return c.json({ members: rows });
100
+ } catch (err) {
101
+ log.error(
102
+ `[portal-members] resolve failed: ${err instanceof Error ? err.message : String(err)}`,
103
+ );
104
+ return c.json({ error: "resolve-failed", members: [] });
105
+ }
106
+ });
107
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * The proposal ACCEPT endpoint. A viewer who has passed the proposal gate (holds
7
+ * a valid prop_token) POSTs {proposalId} here to accept; the server generates a
8
+ * signing document from the proposal (createAgreementFromProposal) and
9
+ * 302-redirects them straight into signing it on the sign site. This replaces
10
+ * the old mailto CTA. Mounted BEFORE the auth gate so it is publicly reachable —
11
+ * the prop_token (not a login) is the proof of identity, exactly like the
12
+ * content-gate access endpoint.
13
+ *
14
+ * Single sign-on across the journey: the viewer already proved their email at
15
+ * the proposal gate (prop_token), so we MUST NOT make them re-enter it on the
16
+ * sign site. Before redirecting, we mint the agreement's ag_token for that same
17
+ * verified email and set it on the SHARED parent domain (Domain=.scala.business)
18
+ * so the browser carries it from proposals.scala.business to sign.scala.business
19
+ * — the agreement gate then auto-reveals + seeds the signing session with zero
20
+ * extra prompts. This requires both hosts to resolve the SAME token secret
21
+ * (AUTH_SECRET→ENCRYPTION_KEY→DATABASE_URL): scala-sign must carry ENCRYPTION_KEY
22
+ * (also required to decrypt the org's Stripe key on full sign), so both hosts
23
+ * land on ENCRYPTION_KEY and the carried token verifies.
24
+ */
25
+
26
+ import type { Hono } from "hono";
27
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
28
+ import { signAgreementAccessToken } from "@teamscala/document-signing/agreement-access-token";
29
+ import { parseCookie } from "./content-gate/parse-cookie.ts";
30
+ import { verifyAccessToken } from "./content-gate/token.ts";
31
+ import { agTtlMs } from "./gate-ttls.ts";
32
+ import { createAgreementFromProposal } from "@teamscala/agreement-generation/create-agreement-from-proposal";
33
+ import { getCookieDomain } from "@teamscala/db/registry/cookie-domain";
34
+
35
+ const PROP_COOKIE = "prop_token";
36
+ const AG_COOKIE = "ag_token";
37
+
38
+ /** Map the proposals host to the sign host (proposals[-dev].scala.business →
39
+ * sign[-dev].scala.business); fall back to the canonical prod sign site. */
40
+ function signBaseUrlFor(host: string | undefined): string {
41
+ if (host && /^proposals(-dev)?\./.test(host)) {
42
+ return `https://${host.replace(/^proposals/, "sign")}`;
43
+ }
44
+ return "https://sign.scala.business";
45
+ }
46
+
47
+ function backToProposal(proposalId: string): string {
48
+ return proposalId ? `/${proposalId}` : "/";
49
+ }
50
+
51
+ export async function attachProposalAccept(
52
+ app: Hono,
53
+ serviceSlug: string,
54
+ ): Promise<void> {
55
+ const log = getAppLogger();
56
+
57
+ app.post("/api/proposal-accept", async (c) => {
58
+ let proposalId = "";
59
+ let cartSelection: unknown = null;
60
+ try {
61
+ const form = await c.req.parseBody();
62
+ proposalId = String(form.proposalId ?? "").trim();
63
+ const raw = form.cartSelection;
64
+ if (typeof raw === "string" && raw.trim()) {
65
+ cartSelection = JSON.parse(raw);
66
+ }
67
+ } catch {
68
+ return c.redirect("/", 302);
69
+ }
70
+ const back = backToProposal(proposalId);
71
+ if (!proposalId) return c.redirect("/", 302);
72
+
73
+ // Identity = the verified email from the proposal gate token. No valid
74
+ // token → they haven't passed the gate; send them back to enter their email.
75
+ const token = parseCookie(c.req.header("cookie") ?? null, PROP_COOKIE);
76
+ const email = verifyAccessToken(token, PROP_COOKIE, proposalId, Date.now());
77
+ if (!email) {
78
+ const sep = back.includes("?") ? "&" : "?";
79
+ return c.redirect(`${back}${sep}accept_error=auth`, 302);
80
+ }
81
+
82
+ const signBase = signBaseUrlFor(c.req.header("host"));
83
+ const result = await createAgreementFromProposal(proposalId, email, signBase, cartSelection);
84
+ if (!result.ok || !result.url) {
85
+ log.error(
86
+ `[proposal-accept] ${proposalId}: ${result.error ?? "unknown error"}`,
87
+ );
88
+ const sep = back.includes("?") ? "&" : "?";
89
+ return c.redirect(`${back}${sep}accept_error=1`, 302);
90
+ }
91
+ log.info(
92
+ `[proposal-accept] ${proposalId} → agreement ${result.agreementId} (${email})`,
93
+ );
94
+
95
+ // Carry the verified identity through to the agreement gate: we already
96
+ // matched this email at the proposal gate, so mint the agreement's
97
+ // ag_token now and set it on the shared parent domain. The browser sends
98
+ // it to sign.scala.business on the redirect, so the signer never re-enters
99
+ // their email — one login across proposal → sign → pay. Best-effort: if
100
+ // the token can't be minted (no secret), the sign site simply shows its
101
+ // own email gate, which still works.
102
+ if (result.agreementId) {
103
+ const agToken = signAgreementAccessToken(
104
+ result.agreementId,
105
+ email,
106
+ Date.now(),
107
+ );
108
+ if (agToken) {
109
+ const maxAge = Math.floor(agTtlMs() / 1000);
110
+ const domain = await getCookieDomain();
111
+ c.header(
112
+ "Set-Cookie",
113
+ `${AG_COOKIE}=${encodeURIComponent(agToken)}; Domain=${domain}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`,
114
+ );
115
+ }
116
+ }
117
+ return c.redirect(result.url, 302);
118
+ });
119
+
120
+ log.info(
121
+ `[scala-install:${serviceSlug}] proposal-accept endpoint mounted: /api/proposal-accept`,
122
+ );
123
+ }