@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,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Injects the ProposalChatLauncher block into an AUTHORISED proposal page (called
|
|
7
|
+
* from proposalGateConfig.onAuthorized — so the bubble lives strictly behind the
|
|
8
|
+
* email gate, and every proposal gets it with no per-page Puck edit). It builds
|
|
9
|
+
* the proposal's full page content into a runtime-context string and passes it to
|
|
10
|
+
* the launcher, which forwards it to the chat as <runtime_context> — so ONE shared
|
|
11
|
+
* "Proposal Assistant" agent answers about whichever proposal is being viewed.
|
|
12
|
+
*
|
|
13
|
+
* The agent id is config, not code (registries-are-source-of-truth): the
|
|
14
|
+
* `config / proposal-assistant-agent` row carries { agentId }. No row (or no
|
|
15
|
+
* agent) → no injection, the page renders exactly as before (fail-soft).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
19
|
+
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
20
|
+
import { getServerClient } from "./page-server-orpc/server-client.ts";
|
|
21
|
+
import * as v from "valibot";
|
|
22
|
+
|
|
23
|
+
const AgentConfigSchema = v.object({ agentId: v.string() });
|
|
24
|
+
|
|
25
|
+
/** Serialise the entire page content tree as text for the GPT context. */
|
|
26
|
+
async function buildProposalContext(resolved: Record<string, unknown>): Promise<string> {
|
|
27
|
+
const proposal = resolved.proposal as Record<string, unknown> | undefined;
|
|
28
|
+
const parts: string[] = [];
|
|
29
|
+
|
|
30
|
+
if (typeof proposal?.name === "string" && proposal.name) {
|
|
31
|
+
parts.push(`Proposal title: ${proposal.name}`);
|
|
32
|
+
}
|
|
33
|
+
if (typeof proposal?.client_name === "string" && proposal.client_name) {
|
|
34
|
+
parts.push(`Prepared for: ${proposal.client_name}`);
|
|
35
|
+
}
|
|
36
|
+
if (typeof proposal?.description === "string" && proposal.description) {
|
|
37
|
+
parts.push(`Description: ${proposal.description}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Dump the entire page content tree — every block, every prop, verbatim.
|
|
41
|
+
const page = (resolved.page as { puck_data?: { content?: unknown[] } } | undefined)?.puck_data;
|
|
42
|
+
if (page && Array.isArray(page.content)) {
|
|
43
|
+
parts.push("\n--- Page content ---");
|
|
44
|
+
parts.push(JSON.stringify(page.content, null, 2));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Resolve DB-backed product tiers so the GPT sees actual pricing data.
|
|
48
|
+
const client = getServerClient() as {
|
|
49
|
+
products?: {
|
|
50
|
+
findFirst: (args: {
|
|
51
|
+
where: { id: string };
|
|
52
|
+
select: { id: true; billing_type: true; tiers: true; components: true };
|
|
53
|
+
}) => Promise<{
|
|
54
|
+
id: string;
|
|
55
|
+
billing_type: string;
|
|
56
|
+
tiers: unknown;
|
|
57
|
+
components: unknown;
|
|
58
|
+
} | null>;
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
for (const block of (page?.content ?? []) as Array<Record<string, unknown>>) {
|
|
63
|
+
if (block?.type !== "ProductTiers") continue;
|
|
64
|
+
const props = block.props as Record<string, unknown> | undefined;
|
|
65
|
+
if (!props) continue;
|
|
66
|
+
|
|
67
|
+
const productIds = [
|
|
68
|
+
props.productId,
|
|
69
|
+
props.productId2,
|
|
70
|
+
props.productId3,
|
|
71
|
+
].filter((id): id is string => typeof id === "string" && id.trim().length > 0);
|
|
72
|
+
|
|
73
|
+
for (const pid of productIds) {
|
|
74
|
+
try {
|
|
75
|
+
const product = await client.products?.findFirst({
|
|
76
|
+
where: { id: pid },
|
|
77
|
+
select: { id: true, billing_type: true, tiers: true, components: true },
|
|
78
|
+
});
|
|
79
|
+
if (product) {
|
|
80
|
+
parts.push(`\n--- Product tiers (productId: ${pid}) ---`);
|
|
81
|
+
parts.push(JSON.stringify(product, null, 2));
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
// best-effort
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return parts.join("\n");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function brandPrimaryColor(resolved: Record<string, unknown>): string {
|
|
93
|
+
const brand = resolved.brand as { primaryColor?: unknown } | undefined;
|
|
94
|
+
return typeof brand?.primaryColor === "string" ? brand.primaryColor : "";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function injectProposalChatLauncher(
|
|
98
|
+
resolved: Record<string, unknown>,
|
|
99
|
+
viewerEmail: string,
|
|
100
|
+
): Promise<void> {
|
|
101
|
+
const page = (resolved.page as { puck_data?: { content?: unknown[] } } | undefined)?.puck_data;
|
|
102
|
+
if (!page || !Array.isArray(page.content)) return;
|
|
103
|
+
|
|
104
|
+
const proposal = resolved.proposal as Record<string, unknown> | undefined;
|
|
105
|
+
const organisationId =
|
|
106
|
+
typeof proposal?.organisation_id === "string" ? proposal.organisation_id : "";
|
|
107
|
+
if (!organisationId) return;
|
|
108
|
+
|
|
109
|
+
let agentId = "";
|
|
110
|
+
try {
|
|
111
|
+
agentId = (
|
|
112
|
+
await loadRegistryConfig("config", "proposal-assistant-agent", AgentConfigSchema)
|
|
113
|
+
).agentId;
|
|
114
|
+
} catch {
|
|
115
|
+
return; // no agent configured yet → no bubble (fail-soft, page unchanged)
|
|
116
|
+
}
|
|
117
|
+
if (!agentId) return;
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const runtimeContext = await buildProposalContext(resolved);
|
|
121
|
+
(page.content as Array<Record<string, unknown>>).push({
|
|
122
|
+
type: "ChatLauncherBlock",
|
|
123
|
+
props: {
|
|
124
|
+
id: "proposal-chat-launcher",
|
|
125
|
+
agentId,
|
|
126
|
+
organisationId,
|
|
127
|
+
viewerEmail,
|
|
128
|
+
runtimeContext,
|
|
129
|
+
primaryColor: brandPrimaryColor(resolved),
|
|
130
|
+
title: "Proposal Assistant",
|
|
131
|
+
welcomeMessage:
|
|
132
|
+
"Hi! Ask me anything about this proposal — scope, pricing, timeline or next steps.",
|
|
133
|
+
launchLabel: "Ask about this proposal",
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
} catch (err) {
|
|
137
|
+
getAppLogger().error(
|
|
138
|
+
`[proposal-chat] launcher injection failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* The PROPOSAL content gate, expressed as config for the content-gate primitive
|
|
7
|
+
* (content-gate/). No bespoke gate plumbing — just the proposal-specific bits:
|
|
8
|
+
* which resource (resolved.proposal.id), who's authorized (issuing-org team +
|
|
9
|
+
* recipient), what to seed (initialVisitor for the chat), and how to withhold
|
|
10
|
+
* (replace the proposal body with an injected SignIn gate block + neutralize the
|
|
11
|
+
* meta tags). The engine handles cookie/token/verify/orchestration.
|
|
12
|
+
*
|
|
13
|
+
* Universal: every proposal route exposes resolved.proposal, so this gates every
|
|
14
|
+
* proposal (existing + new) with no per-page block or migration.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { AccessConfig, ContentGateConfig } from "./content-gate/config-types.ts";
|
|
18
|
+
import { signAccessToken, verifyAccessToken } from "./content-gate/token.ts";
|
|
19
|
+
import { resolveProposalAuthorizedEmails } from "./proposal-authorized-emails.ts";
|
|
20
|
+
import { injectProposalChatLauncher } from "./proposal-chat-inject.ts";
|
|
21
|
+
import { propTtlMs } from "./gate-ttls.ts";
|
|
22
|
+
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
23
|
+
import * as v from "valibot";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Proposal gate display + behaviour, DB-driven (config/content-gate-proposal),
|
|
27
|
+
* never hardcoded. requireOtp is the per-gate OTP switch (proposals default
|
|
28
|
+
* off — email-only; agreements opt in). A missing/invalid row falls back to the
|
|
29
|
+
* proposal-appropriate defaults so the gate UI ALWAYS renders.
|
|
30
|
+
*/
|
|
31
|
+
interface ProposalGateSettings {
|
|
32
|
+
title: string;
|
|
33
|
+
subtitle: string;
|
|
34
|
+
submitLabel: string;
|
|
35
|
+
requireOtp: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const PROPOSAL_GATE_DEFAULTS: ProposalGateSettings = {
|
|
39
|
+
title: "This proposal is private",
|
|
40
|
+
subtitle: "Enter the email this proposal was shared with to view it.",
|
|
41
|
+
submitLabel: "View proposal",
|
|
42
|
+
requireOtp: false,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const ProposalGateSettingsSchema = v.object({
|
|
46
|
+
title: v.optional(v.string(), PROPOSAL_GATE_DEFAULTS.title),
|
|
47
|
+
subtitle: v.optional(v.string(), PROPOSAL_GATE_DEFAULTS.subtitle),
|
|
48
|
+
submitLabel: v.optional(v.string(), PROPOSAL_GATE_DEFAULTS.submitLabel),
|
|
49
|
+
requireOtp: v.optional(v.boolean(), PROPOSAL_GATE_DEFAULTS.requireOtp),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
async function loadProposalGateSettings(): Promise<ProposalGateSettings> {
|
|
53
|
+
try {
|
|
54
|
+
return await loadRegistryConfig(
|
|
55
|
+
"config",
|
|
56
|
+
"content-gate-proposal",
|
|
57
|
+
ProposalGateSettingsSchema,
|
|
58
|
+
);
|
|
59
|
+
} catch {
|
|
60
|
+
return PROPOSAL_GATE_DEFAULTS;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const PROP_COOKIE = "prop_token";
|
|
65
|
+
|
|
66
|
+
interface PuckHolder {
|
|
67
|
+
puck_data?: { content?: unknown[] };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function brandPrimaryColor(resolved: Record<string, unknown>): string {
|
|
71
|
+
const brand = resolved.brand as { primaryColor?: unknown } | undefined;
|
|
72
|
+
return typeof brand?.primaryColor === "string" ? brand.primaryColor : "";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function buildGateBlock(
|
|
76
|
+
proposalId: string,
|
|
77
|
+
primaryColor: string,
|
|
78
|
+
settings: ProposalGateSettings,
|
|
79
|
+
) {
|
|
80
|
+
return {
|
|
81
|
+
type: "SignIn",
|
|
82
|
+
props: {
|
|
83
|
+
id: "proposal-gate",
|
|
84
|
+
mode: "gate",
|
|
85
|
+
gateContextField: "proposalId",
|
|
86
|
+
gateContextValue: proposalId,
|
|
87
|
+
gateAction: "/api/proposal-access",
|
|
88
|
+
// Display + behaviour are DB-driven (config/content-gate-proposal),
|
|
89
|
+
// not hardcoded. requireOtp is the per-gate OTP switch.
|
|
90
|
+
title: settings.title,
|
|
91
|
+
subtitle: settings.subtitle,
|
|
92
|
+
gateRequireOtp: settings.requireOtp,
|
|
93
|
+
gateSubmitLabel: settings.submitLabel,
|
|
94
|
+
primaryColor,
|
|
95
|
+
showBackToHome: false,
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const proposalGateConfig: ContentGateConfig = {
|
|
101
|
+
cookieName: PROP_COOKIE,
|
|
102
|
+
getResourceId(resolved) {
|
|
103
|
+
const proposal = resolved.proposal as { id?: unknown } | null | undefined;
|
|
104
|
+
return typeof proposal?.id === "string" ? proposal.id : "";
|
|
105
|
+
},
|
|
106
|
+
verifyToken(token, resourceId, nowMs) {
|
|
107
|
+
const email = verifyAccessToken(token, PROP_COOKIE, resourceId, nowMs);
|
|
108
|
+
return email ? { email } : null;
|
|
109
|
+
},
|
|
110
|
+
// Baked-in unified auth (engine default): a logged-in viewer whose session
|
|
111
|
+
// email is in this set (issuing-org team ∪ recipient ∪ partner) is released
|
|
112
|
+
// with no email/OTP entry. Just exposing the resolver opts this gate into the
|
|
113
|
+
// platform-wide behaviour — no per-gate session code.
|
|
114
|
+
resolveAuthorizedEmails: resolveProposalAuthorizedEmails,
|
|
115
|
+
async onAuthorized(resolved, _resourceId, { email }) {
|
|
116
|
+
// Seed the verified viewer identity (building-applications initialVisitor).
|
|
117
|
+
(resolved as { initialVisitor?: { email: string } }).initialVisitor = {
|
|
118
|
+
email,
|
|
119
|
+
};
|
|
120
|
+
// Inject the floating Proposal Assistant chat bubble — strictly behind the
|
|
121
|
+
// gate, so only an authorised viewer ever sees it. Content otherwise intact.
|
|
122
|
+
await injectProposalChatLauncher(resolved, email);
|
|
123
|
+
},
|
|
124
|
+
async onUnauthorized(resolved, resourceId) {
|
|
125
|
+
const settings = await loadProposalGateSettings();
|
|
126
|
+
// Replace the proposal BODY with the gate block — no proposal content
|
|
127
|
+
// reaches the HTML. Header/footer chrome stays branded.
|
|
128
|
+
const page = resolved.page as
|
|
129
|
+
| (PuckHolder & {
|
|
130
|
+
meta_title?: unknown;
|
|
131
|
+
meta_description?: unknown;
|
|
132
|
+
name?: unknown;
|
|
133
|
+
})
|
|
134
|
+
| null
|
|
135
|
+
| undefined;
|
|
136
|
+
if (page?.puck_data && Array.isArray(page.puck_data.content)) {
|
|
137
|
+
page.puck_data.content = [
|
|
138
|
+
buildGateBlock(resourceId, brandPrimaryColor(resolved), settings),
|
|
139
|
+
];
|
|
140
|
+
}
|
|
141
|
+
// Neutralize the PAGE record's meta too: page.meta_title/meta_description
|
|
142
|
+
// feed pageData.meta → the <title>/og tags AND the __PAGE_DATA__ hydration
|
|
143
|
+
// JSON. Without this the proposal topic + partner name leak via meta even
|
|
144
|
+
// though the body is withheld (the 2026-06-13 meta-leak: a gated proposal
|
|
145
|
+
// still exposed "Webinar Campaign · in partnership with <partner>").
|
|
146
|
+
if (page) {
|
|
147
|
+
page.meta_title = "Private proposal";
|
|
148
|
+
page.meta_description = "";
|
|
149
|
+
page.name = "Private proposal";
|
|
150
|
+
}
|
|
151
|
+
// Neutralize the proposal record so the meta_context resolver + any block
|
|
152
|
+
// reading it can't leak name/partner/cover/recipient.
|
|
153
|
+
const prop = resolved.proposal as Record<string, unknown> | undefined;
|
|
154
|
+
if (prop) {
|
|
155
|
+
prop.name = "Private proposal";
|
|
156
|
+
prop.description = "";
|
|
157
|
+
prop.client_name = "";
|
|
158
|
+
prop.prepared_for = "";
|
|
159
|
+
prop.cover_image_url = "";
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
export const proposalAccessConfig: AccessConfig = {
|
|
165
|
+
path: "/api/proposal-access",
|
|
166
|
+
cookieName: PROP_COOKIE,
|
|
167
|
+
get ttlMs() {
|
|
168
|
+
return propTtlMs();
|
|
169
|
+
},
|
|
170
|
+
recordIdField: "proposalId",
|
|
171
|
+
resolveAuthorizedEmails: resolveProposalAuthorizedEmails,
|
|
172
|
+
signToken(resourceId, email, nowMs) {
|
|
173
|
+
return signAccessToken(PROP_COOKIE, resourceId, email, nowMs, propTtlMs());
|
|
174
|
+
},
|
|
175
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system ui-service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Regression guard for the manifest-keyed HTML cache. The lifetime-pinned
|
|
7
|
+
* cache served a deleted bundle hash after a post-boot rebuild (scala-docs
|
|
8
|
+
* 2026-06-13): build-client-assets rewrites asset-manifest.json + deletes the
|
|
9
|
+
* old hashed bundle, but the cached HTML kept the old hash → 404 → no
|
|
10
|
+
* hydration. These tests pin that a manifest change re-substitutes the HTML.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
14
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { readHtmlTemplate } from "./read-html-template.ts";
|
|
18
|
+
|
|
19
|
+
let dir: string;
|
|
20
|
+
let origCwd: string;
|
|
21
|
+
|
|
22
|
+
function writeManifest(hydrate: string, styles: string): void {
|
|
23
|
+
writeFileSync(
|
|
24
|
+
join(dir, "dist", "client", "asset-manifest.json"),
|
|
25
|
+
`${JSON.stringify({ stylesPath: styles, hydratePath: hydrate }, null, 2)}\n`,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
origCwd = process.cwd();
|
|
31
|
+
dir = mkdtempSync(join(tmpdir(), "read-html-template-"));
|
|
32
|
+
mkdirSync(join(dir, "src"), { recursive: true });
|
|
33
|
+
mkdirSync(join(dir, "dist", "client"), { recursive: true });
|
|
34
|
+
writeFileSync(
|
|
35
|
+
join(dir, "src", "index.html"),
|
|
36
|
+
'<link href="{{stylesPath}}"><script src="{{hydratePath}}"></script>',
|
|
37
|
+
);
|
|
38
|
+
process.chdir(dir);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
process.chdir(origCwd);
|
|
43
|
+
rmSync(dir, { recursive: true, force: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("readHtmlTemplate manifest-keyed cache", () => {
|
|
47
|
+
test("substitutes the current manifest hashes", async () => {
|
|
48
|
+
writeManifest("/assets/hydrate-aaa.js", "/assets/styles-aaa.css");
|
|
49
|
+
const html = await readHtmlTemplate();
|
|
50
|
+
expect(html).toContain("/assets/hydrate-aaa.js");
|
|
51
|
+
expect(html).toContain("/assets/styles-aaa.css");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("re-substitutes after a post-boot manifest change (the 404 regression)", async () => {
|
|
55
|
+
writeManifest("/assets/hydrate-old.js", "/assets/styles-old.css");
|
|
56
|
+
const first = await readHtmlTemplate();
|
|
57
|
+
expect(first).toContain("/assets/hydrate-old.js");
|
|
58
|
+
|
|
59
|
+
// Simulate build-client-assets rebuilding: new manifest, old bundle gone.
|
|
60
|
+
// mtime must advance for the stat-keyed cache to invalidate.
|
|
61
|
+
await Bun.sleep(10);
|
|
62
|
+
writeManifest("/assets/hydrate-new.js", "/assets/styles-new.css");
|
|
63
|
+
|
|
64
|
+
const second = await readHtmlTemplate();
|
|
65
|
+
expect(second).toContain("/assets/hydrate-new.js");
|
|
66
|
+
expect(second).not.toContain("/assets/hydrate-old.js");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("falls back to fixed paths when the manifest is absent", async () => {
|
|
70
|
+
const html = await readHtmlTemplate();
|
|
71
|
+
expect(html).toContain("/assets/hydrate.js");
|
|
72
|
+
expect(html).toContain("/assets/styles.css");
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system ui-service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Reads the per-service HTML template (codegen'd from
|
|
7
|
+
* `@teamscala/scala-install/src/runtime/index.html`) and substitutes the
|
|
8
|
+
* `{{stylesPath}}` / `{{hydratePath}}` placeholders with the current
|
|
9
|
+
* content-hashed asset URLs from `dist/client/asset-manifest.json`.
|
|
10
|
+
*
|
|
11
|
+
* Build-time produces hashed filenames (e.g. `styles-abc123.css`); the
|
|
12
|
+
* static-asset handler serves them with `cache-control: immutable`. New
|
|
13
|
+
* deployments emit new hashes → new URLs → browsers refetch without ever
|
|
14
|
+
* holding a stale copy. Without the substitution, a fixed
|
|
15
|
+
* `/assets/styles.css` paired with `immutable` would pin every visitor to
|
|
16
|
+
* whatever version they first downloaded.
|
|
17
|
+
*
|
|
18
|
+
* Cached per service process, keyed on the manifest's mtime. The cache MUST
|
|
19
|
+
* invalidate when `asset-manifest.json` changes: `buildClientAssets` can
|
|
20
|
+
* rebuild the bundle AFTER the first request — producing a new content hash,
|
|
21
|
+
* rewriting the manifest, AND deleting the old hashed bundle
|
|
22
|
+
* (`removeStaleHashedFiles`). A lifetime-pinned HTML cache then keeps serving
|
|
23
|
+
* the OLD hash, which now 404s → hydration never runs → the page renders SSR
|
|
24
|
+
* HTML but is dead client-side (the doc-editor overlay never mounts; scala-docs
|
|
25
|
+
* 2026-06-13, exposed by the ui-foundation publish-storm's repeated rebuilds).
|
|
26
|
+
* Re-stat'ing the tiny manifest per request is negligible (OS-cached, no
|
|
27
|
+
* network); the substituted HTML is reused while the mtime is unchanged.
|
|
28
|
+
*
|
|
29
|
+
* NO-SILENT-REVERTS: when the manifest is a TOMBSTONE (the last client build
|
|
30
|
+
* failed), the served HTML is stamped with a `<meta name="x-client-build-status"
|
|
31
|
+
* content="failed">` tag in <head> so any operator / chrome-devtools / theme
|
|
32
|
+
* probe inspecting the page can see the served bundle is STALE (a published fix
|
|
33
|
+
* that reached node_modules but failed to bundle is silently served from the
|
|
34
|
+
* prior bundle otherwise — scala-agents 2026-07-18, the white-on-white toolbar
|
|
35
|
+
* regression that looked "not fixed" while the bundle kept serving pre-fix
|
|
36
|
+
* bytes across restarts).
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { readClientBuildStatus } from "./build-client-assets.ts";
|
|
40
|
+
import { join } from "node:path";
|
|
41
|
+
|
|
42
|
+
interface AssetManifest {
|
|
43
|
+
stylesPath: string;
|
|
44
|
+
hydratePath: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const FALLBACK: AssetManifest = {
|
|
48
|
+
stylesPath: "/assets/styles.css",
|
|
49
|
+
hydratePath: "/assets/hydrate.js",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
let cached: { html: string; manifestMtime: number } | null = null;
|
|
53
|
+
|
|
54
|
+
function manifestPath(): string {
|
|
55
|
+
return join(process.cwd(), "dist", "client", "asset-manifest.json");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function manifestMtimeMs(): Promise<number> {
|
|
59
|
+
try {
|
|
60
|
+
return (await Bun.file(manifestPath()).stat()).mtimeMs;
|
|
61
|
+
} catch {
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function loadManifest(): Promise<AssetManifest> {
|
|
67
|
+
const path = manifestPath();
|
|
68
|
+
if (await Bun.file(path).exists()) {
|
|
69
|
+
try {
|
|
70
|
+
const parsed = await Bun.file(path).json() as Partial<AssetManifest>;
|
|
71
|
+
if (parsed.stylesPath && parsed.hydratePath) {
|
|
72
|
+
return { stylesPath: parsed.stylesPath, hydratePath: parsed.hydratePath };
|
|
73
|
+
}
|
|
74
|
+
} catch {}
|
|
75
|
+
}
|
|
76
|
+
return FALLBACK;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function readHtmlTemplate(): Promise<string> {
|
|
80
|
+
const mtime = await manifestMtimeMs();
|
|
81
|
+
if (cached && cached.manifestMtime === mtime) return cached.html;
|
|
82
|
+
const templatePath = join(process.cwd(), "src", "index.html");
|
|
83
|
+
const raw = await Bun.file(templatePath).text();
|
|
84
|
+
const manifest = await loadManifest();
|
|
85
|
+
let html = raw
|
|
86
|
+
.replace(/\{\{stylesPath\}\}/g, manifest.stylesPath)
|
|
87
|
+
.replace(/\{\{hydratePath\}\}/g, manifest.hydratePath);
|
|
88
|
+
// Tombstone stamp — surfaces a failed client build at the served layer.
|
|
89
|
+
// `<meta>` in <head> is the minimum-invasive vehicle (no layout shift, no
|
|
90
|
+
// JS dependency, visible via view-source / devtools / a future status
|
|
91
|
+
// probe). `x-` prefix keeps it out of any standard meta namespace.
|
|
92
|
+
const status = await readClientBuildStatus();
|
|
93
|
+
if (status.failed) {
|
|
94
|
+
const reason = (status.reason ?? "unknown").replace(/"/g, "'").slice(0, 200);
|
|
95
|
+
const meta = `<meta name="x-client-build-status" content="failed" data-reason="${reason}" data-at="${status.at ?? ""}">`;
|
|
96
|
+
html = html.includes("</head>")
|
|
97
|
+
? html.replace("</head>", `${meta}</head>`)
|
|
98
|
+
: `${meta}${html}`;
|
|
99
|
+
}
|
|
100
|
+
cached = { html, manifestMtime: mtime };
|
|
101
|
+
return html;
|
|
102
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Server-side viewport detection from request headers. Two tiers:
|
|
7
|
+
* desktop (tablets, laptops, desktops) and mobile (phones).
|
|
8
|
+
*
|
|
9
|
+
* Tablets are NOT a separate tier — CSS handles responsive layout
|
|
10
|
+
* within the desktop tier via flex min/max and @container queries.
|
|
11
|
+
* Only phones (where the layout is fundamentally single-pane) get
|
|
12
|
+
* a different server-rendered page.
|
|
13
|
+
*
|
|
14
|
+
* Order of authority:
|
|
15
|
+
* cookie → Sec-CH-UA-Mobile client hint → User-Agent regex → desktop default.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type ViewportTier = "desktop" | "mobile";
|
|
19
|
+
|
|
20
|
+
const PHONE_UA_RE =
|
|
21
|
+
/iPhone|iPod|Android.*Mobile|Mobile Safari|Opera Mini|IEMobile|webOS|BlackBerry/i;
|
|
22
|
+
|
|
23
|
+
export function readViewportFromRequest(request: Request): ViewportTier {
|
|
24
|
+
const cookie = request.headers.get("cookie") ?? "";
|
|
25
|
+
const cookieMatch = cookie.match(/(?:^|;\s*)viewport=(mobile|desktop)\b/);
|
|
26
|
+
if (cookieMatch?.[1] === "mobile") return "mobile";
|
|
27
|
+
if (cookieMatch?.[1] === "desktop") return "desktop";
|
|
28
|
+
|
|
29
|
+
const chMobile = request.headers.get("sec-ch-ua-mobile");
|
|
30
|
+
if (chMobile === "?1") return "mobile";
|
|
31
|
+
|
|
32
|
+
const ua = request.headers.get("user-agent") ?? "";
|
|
33
|
+
if (PHONE_UA_RE.test(ua)) return "mobile";
|
|
34
|
+
|
|
35
|
+
return "desktop";
|
|
36
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Readiness gate — returns 503 + Retry-After: 1 until the boot pipeline
|
|
7
|
+
* flips the ready flag true as its last step. Registered as the FIRST
|
|
8
|
+
* middleware in both UI and backend boot paths per readiness-gated-boot.
|
|
9
|
+
*
|
|
10
|
+
* The gate fails open: if it ever throws, the request passes through to
|
|
11
|
+
* the normal handler rather than producing a hard 500.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Context, Next } from "hono";
|
|
15
|
+
|
|
16
|
+
let _ready = false;
|
|
17
|
+
|
|
18
|
+
export function isReady(): boolean {
|
|
19
|
+
return _ready;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function setReady(): void {
|
|
23
|
+
_ready = true;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function readinessGate(c: Context, next: Next): Promise<Response | void> {
|
|
27
|
+
try {
|
|
28
|
+
if (_ready) return next();
|
|
29
|
+
return c.json({ status: "starting" }, 503, { "Retry-After": "1" });
|
|
30
|
+
} catch (err) {
|
|
31
|
+
// Fail-open-NOISY (protective-layers-fail-explicitly): a thrown gate
|
|
32
|
+
// must never hard-500 (readiness-gated-boot) — it passes through to the
|
|
33
|
+
// handler — but the failure must be OBSERVABLE, not silent. Practically
|
|
34
|
+
// unreachable (the body is pure reads of module flags + c.req.path); the
|
|
35
|
+
// log surfaces a regression if it ever fires.
|
|
36
|
+
console.error("[readiness-gate] gate threw — failing open to handler", err);
|
|
37
|
+
return next();
|
|
38
|
+
}
|
|
39
|
+
}
|