@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.
- package/package.json +1016 -0
- package/src/agreement-gate.test.ts +118 -0
- package/src/agreement-gate.ts +887 -0
- package/src/asset-cache-key.ts +59 -0
- package/src/asset-optimize.ts +93 -0
- package/src/asset-proxy.ts +148 -0
- package/src/asset-social-card.ts +58 -0
- package/src/attach-agreement-magic.ts +62 -0
- package/src/attach-portal-members.ts +107 -0
- package/src/attach-proposal-accept.ts +123 -0
- package/src/attach-proposal-magic.ts +55 -0
- package/src/attach-transfers-upload.ts +100 -0
- package/src/auth-gate.ts +385 -0
- package/src/bind-page-handler.ts +223 -0
- package/src/build-client-assets/input-hashing.ts +206 -0
- package/src/build-client-assets/source-candidates.ts +115 -0
- package/src/build-client-assets.test.ts +216 -0
- package/src/build-client-assets.ts +962 -0
- package/src/compose-page-title.test.ts +88 -0
- package/src/compose-page-title.ts +73 -0
- package/src/content-gate/access.ts +89 -0
- package/src/content-gate/config-types.ts +108 -0
- package/src/content-gate/gate.ts +74 -0
- package/src/content-gate/parse-cookie.ts +23 -0
- package/src/content-gate/token.test.ts +70 -0
- package/src/content-gate/token.ts +148 -0
- package/src/content-page-floor.test.ts +127 -0
- package/src/content-page-floor.ts +70 -0
- package/src/discover-blocks.ts +228 -0
- package/src/expand-roster.ts +197 -0
- package/src/fn-routers/agreement-completion-email.ts +115 -0
- package/src/fn-routers/agreement-send-router.ts +303 -0
- package/src/fn-routers/ai-health-router.ts +64 -0
- package/src/fn-routers/auth-router.ts +131 -0
- package/src/fn-routers/cache-admin-router.ts +50 -0
- package/src/fn-routers/chat-session-router.ts +455 -0
- package/src/fn-routers/email-defaults.ts +22 -0
- package/src/fn-routers/email-link-ttl.ts +23 -0
- package/src/fn-routers/index.ts +70 -0
- package/src/fn-routers/panel-data-router.ts +93 -0
- package/src/fn-routers/persist-chat-messages.test.ts +86 -0
- package/src/fn-routers/persist-chat-messages.ts +96 -0
- package/src/fn-routers/plans-router.ts +141 -0
- package/src/fn-routers/proposal-send-router.ts +207 -0
- package/src/fn-routers/public-agreement-router.ts +865 -0
- package/src/fn-routers/public-assessment-router.ts +421 -0
- package/src/fn-routers/public-gpt-session-router.ts +373 -0
- package/src/fn-routers/sign-in-flow-router.ts +196 -0
- package/src/fn-routers/task-sync-bind.test.ts +105 -0
- package/src/fn-routers/task-sync-classify.test.ts +109 -0
- package/src/fn-routers/task-sync-dispatch.test.ts +44 -0
- package/src/fn-routers/task-sync-dispatch.ts +124 -0
- package/src/fn-routers/task-sync-router.test.ts +279 -0
- package/src/fn-routers/task-sync-router.ts +1036 -0
- package/src/fn-routers/tasks-router.ts +77 -0
- package/src/fn-routers/user-preferences-router.ts +139 -0
- package/src/fn-routers/work-items-router.ts +341 -0
- package/src/gate-ttls.ts +56 -0
- package/src/gpt-gate.ts +76 -0
- package/src/handlers/bun-stable-watch.ts +89 -0
- package/src/handlers/projects-digest.ts +198 -0
- package/src/handlers/prune-stale-data.ts +110 -0
- package/src/handlers/registry-stale-inactive-sweep.ts +82 -0
- package/src/handlers/scala-tools-currency-watch.test.ts +60 -0
- package/src/handlers/scala-tools-currency-watch.ts +171 -0
- package/src/handlers/scheduled-browser-tests.ts +85 -0
- package/src/handlers/sweep-event-log.ts +98 -0
- package/src/handlers/sweep-host-cache.ts +211 -0
- package/src/handlers/upstream-issue-sync.ts +210 -0
- package/src/handlers/whatsapp-liveness-handler.test.ts +59 -0
- package/src/handlers/whatsapp-liveness-logic.ts +53 -0
- package/src/handlers/whatsapp-liveness.test.ts +78 -0
- package/src/handlers/whatsapp-liveness.ts +125 -0
- package/src/hono-error-handler.ts +55 -0
- package/src/install-harness.ts +80 -0
- package/src/install-logging.ts +182 -0
- package/src/load-service-install-row.ts +98 -0
- package/src/load-service-prisma-client.ts +53 -0
- package/src/mount-orpc-router.ts +148 -0
- package/src/mount-server-packages.ts +155 -0
- package/src/mount.ts +18 -0
- package/src/mutable-app-ref.ts +68 -0
- package/src/page-route-shell-types.ts +16 -0
- package/src/page-server-orpc/audit-revert-router.ts +102 -0
- package/src/page-server-orpc/hub-mcp-client.ts +20 -0
- package/src/page-server-orpc/portal-diagnostics-router.ts +170 -0
- package/src/page-server-orpc/puck-pages-router.ts +272 -0
- package/src/page-server-orpc/server-client.ts +31 -0
- package/src/page-server-orpc/templates-router.ts +116 -0
- package/src/page-server-orpc/user-preferences-router.ts +132 -0
- package/src/pg-boss-handler-registry.ts +36 -0
- package/src/pg-boss-mount.ts +295 -0
- package/src/proposal-authorized-emails.ts +65 -0
- package/src/proposal-chat-inject.ts +141 -0
- package/src/proposal-gate.ts +175 -0
- package/src/read-html-template.test.ts +74 -0
- package/src/read-html-template.ts +102 -0
- package/src/read-viewport-from-request.ts +36 -0
- package/src/readiness-gate.ts +39 -0
- package/src/register-api-routes.ts +127 -0
- package/src/register-crash-handlers.ts +39 -0
- package/src/register-page-routes.ts +62 -0
- package/src/register-page-server-client.ts +109 -0
- package/src/register-registry-page-route/ai-chat-prefetch.ts +106 -0
- package/src/register-registry-page-route/graph-cache.test.ts +84 -0
- package/src/register-registry-page-route/graph-cache.ts +90 -0
- package/src/register-registry-page-route/meta-title.ts +73 -0
- package/src/register-registry-page-route/proposal-approval-email-token.ts +78 -0
- package/src/register-registry-page-route/proposal-brand-stamping.ts +60 -0
- package/src/register-registry-page-route/resolve-page-data.ts +518 -0
- package/src/register-registry-page-route.ts +765 -0
- package/src/register-static-assets.ts +180 -0
- package/src/register-ws-routes.ts +180 -0
- package/src/resolve-app-base-url.ts +28 -0
- package/src/resolve-brand-favicon.ts +65 -0
- package/src/resolve-from-loader-config.test.ts +44 -0
- package/src/resolve-from-loader-config.ts +291 -0
- package/src/resolve-service-context.ts +67 -0
- package/src/resolve-theme-mode.ts +61 -0
- package/src/resolvers/fetch-org-brand.ts +28 -0
- package/src/resolvers/portal-route-paths.test.ts +102 -0
- package/src/resolvers/portal-route-paths.ts +83 -0
- package/src/resolvers/website-by-host-map.ts +49 -0
- package/src/resource-chat-inject.ts +168 -0
- package/src/resource-not-found.ts +263 -0
- package/src/run-boot-hook-list.ts +75 -0
- package/src/run-boot-hooks.ts +97 -0
- package/src/scala-tools-currency-watchdog.ts +160 -0
- package/src/security-headers.ts +59 -0
- package/src/serve/serve-ws.test.ts +135 -0
- package/src/serve/serve.ts +190 -0
- package/src/server-error-page.test.ts +102 -0
- package/src/server-error-page.ts +95 -0
- package/src/start-scheduled-jobs.ts +84 -0
- package/src/two-phase-boot.ts +449 -0
- package/src/types.ts +52 -0
- package/src/ui-service.ts +171 -0
- 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
|
+
}
|