@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,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system ui-service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Pins composePageTitle: the single canonical title composer. The title is
|
|
7
|
+
* derived from website + page/entity name with meta_title as an explicit
|
|
8
|
+
* per-page OVERRIDE only, and it NEVER degrades to a bare org name.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, test } from "bun:test";
|
|
12
|
+
import { composePageTitle } from "./compose-page-title.ts";
|
|
13
|
+
|
|
14
|
+
describe("composePageTitle", () => {
|
|
15
|
+
test("per-page meta_title override wins outright", () => {
|
|
16
|
+
expect(
|
|
17
|
+
composePageTitle({
|
|
18
|
+
pageMetaTitle: "Custom SEO Title",
|
|
19
|
+
websiteName: "creata Portal",
|
|
20
|
+
pageName: "Dashboard",
|
|
21
|
+
}),
|
|
22
|
+
).toBe("Custom SEO Title");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("whitespace-only meta_title is not treated as an override", () => {
|
|
26
|
+
expect(
|
|
27
|
+
composePageTitle({
|
|
28
|
+
pageMetaTitle: " ",
|
|
29
|
+
websiteName: "creata Portal",
|
|
30
|
+
pageName: "Dashboard",
|
|
31
|
+
}),
|
|
32
|
+
).toBe("creata Portal: Dashboard");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("curated resource template beats the default derivation", () => {
|
|
36
|
+
expect(
|
|
37
|
+
composePageTitle({
|
|
38
|
+
templateTitle: "Sales Agent — GBG Indo",
|
|
39
|
+
websiteName: "scala GPT",
|
|
40
|
+
pageName: "chat",
|
|
41
|
+
}),
|
|
42
|
+
).toBe("Sales Agent — GBG Indo");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("override beats template", () => {
|
|
46
|
+
expect(
|
|
47
|
+
composePageTitle({
|
|
48
|
+
pageMetaTitle: "Override",
|
|
49
|
+
templateTitle: "Template",
|
|
50
|
+
websiteName: "W",
|
|
51
|
+
pageName: "P",
|
|
52
|
+
}),
|
|
53
|
+
).toBe("Override");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("default derivation: '<website>: <subject>' (portal spec)", () => {
|
|
57
|
+
expect(
|
|
58
|
+
composePageTitle({ websiteName: "creata Portal", pageName: "Dashboard" }),
|
|
59
|
+
).toBe("creata Portal: Dashboard");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("entity title is the preferred subject over page name", () => {
|
|
63
|
+
expect(
|
|
64
|
+
composePageTitle({
|
|
65
|
+
websiteName: "scala Portal",
|
|
66
|
+
pageName: "view",
|
|
67
|
+
entityTitle: "Q3 Proposal",
|
|
68
|
+
}),
|
|
69
|
+
).toBe("scala Portal: Q3 Proposal");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("app root with no subject renders the website name alone", () => {
|
|
73
|
+
expect(composePageTitle({ websiteName: "scala Roadmap" })).toBe(
|
|
74
|
+
"scala Roadmap",
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("subject without a website name renders the subject alone", () => {
|
|
79
|
+
expect(composePageTitle({ pageName: "Dashboard" })).toBe("Dashboard");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("never degrades to a bare org name — no website + no subject is 'Untitled'", () => {
|
|
83
|
+
// The regression: a default titleTemplate of "{org.name}" produced
|
|
84
|
+
// "GBG Indo" with no context. The composer structurally cannot.
|
|
85
|
+
expect(composePageTitle({})).toBe("Untitled");
|
|
86
|
+
expect(composePageTitle({ websiteName: "" })).toBe("Untitled");
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system ui-service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* The single canonical page-title composer. Every SSR page handler routes
|
|
7
|
+
* through this (portal/region-shell, public shell, single-page resource). A
|
|
8
|
+
* page title is DERIVED from data that already exists — the website name +
|
|
9
|
+
* the page/entity name — with `builda_pages.meta_title` as an explicit
|
|
10
|
+
* per-page OVERRIDE only, never the primary source.
|
|
11
|
+
*
|
|
12
|
+
* Mirrors the brand-is-org-owned-full-scheme precedent: a value that should
|
|
13
|
+
* be DERIVED was instead hand-set per row, and when unset it silently fell
|
|
14
|
+
* back to something wrong. For themes the wrong fallback was the generic
|
|
15
|
+
* light palette; for titles it was the bare org name — the `default`
|
|
16
|
+
* page_meta_template `titleTemplate: "{org.name}"` produced "GBG Indo" with
|
|
17
|
+
* no site or page context, and per-row `meta_title` carried stale product
|
|
18
|
+
* literals like "Dashboard | scala Hub". The fix is identical in shape:
|
|
19
|
+
* derive by default, override explicitly.
|
|
20
|
+
*
|
|
21
|
+
* Canonical format: `<website.name>: <subject>` (subject = entity title or
|
|
22
|
+
* page name), site-first so a browser tab reads the surface then the page.
|
|
23
|
+
* A resource surface (forms/quiz/gpt) may supply a curated `templateTitle`
|
|
24
|
+
* (e.g. "{agent.name} — {org.name}"); it wins over the default derivation
|
|
25
|
+
* but NOT over an explicit per-page override. The composer NEVER returns a
|
|
26
|
+
* bare org name — that is the regression this closes, and the
|
|
27
|
+
* `page-title-not-bare-org` guard backstops it at the template layer.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export interface TitleContext {
|
|
31
|
+
/** Per-page override (builda_pages.meta_title). Wins outright when set. */
|
|
32
|
+
pageMetaTitle?: string | null;
|
|
33
|
+
/** Resolved resource-template string (forms/quiz/gpt). Curated per-surface shape. */
|
|
34
|
+
templateTitle?: string | null;
|
|
35
|
+
/** builda_websites.name — the surface, the primary site context. */
|
|
36
|
+
websiteName?: string | null;
|
|
37
|
+
/** builda_pages.name — the page (e.g. "Dashboard"). */
|
|
38
|
+
pageName?: string | null;
|
|
39
|
+
/** Resource entity title (proposal/form/quiz/agent name) — preferred subject. */
|
|
40
|
+
entityTitle?: string | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function trimToDefined(value: unknown): string | undefined {
|
|
44
|
+
if (typeof value !== "string") return undefined;
|
|
45
|
+
const trimmed = value.trim();
|
|
46
|
+
return trimmed ? trimmed : undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function composePageTitle(ctx: TitleContext): string {
|
|
50
|
+
// 1. Explicit per-page override wins outright — the operator's escape
|
|
51
|
+
// hatch for a custom SEO/social title. It is NEVER the default source.
|
|
52
|
+
const override = trimToDefined(ctx.pageMetaTitle);
|
|
53
|
+
if (override) return override;
|
|
54
|
+
|
|
55
|
+
// 2. Curated resource template (forms/quiz/gpt). Already contextual
|
|
56
|
+
// (entity + org), so it beats the generic derivation for those surfaces.
|
|
57
|
+
const template = trimToDefined(ctx.templateTitle);
|
|
58
|
+
if (template) return template;
|
|
59
|
+
|
|
60
|
+
// 3. Default derivation: <website.name>: <subject>. Site-first so the
|
|
61
|
+
// stable surface precedes the varying page — how a tab is scanned.
|
|
62
|
+
const subject = trimToDefined(ctx.entityTitle) ?? trimToDefined(ctx.pageName);
|
|
63
|
+
const websiteName = trimToDefined(ctx.websiteName);
|
|
64
|
+
if (websiteName && subject) return `${websiteName}: ${subject}`;
|
|
65
|
+
if (websiteName) return websiteName;
|
|
66
|
+
if (subject) return subject;
|
|
67
|
+
|
|
68
|
+
// No website name and no subject: the page is misconfigured (a website
|
|
69
|
+
// with no resolvable name). "Untitled" signals that honestly. We NEVER
|
|
70
|
+
// fall back to a bare org name — that is the exact regression
|
|
71
|
+
// ("GBG Indo" with no context) this composer exists to prevent.
|
|
72
|
+
return "Untitled";
|
|
73
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* The content-gate ACCESS endpoint mounter. One generic POST handler for every
|
|
7
|
+
* gate: a plain <form> posts {<recordIdField>, email}; on a match against the
|
|
8
|
+
* resource's authorized set it sets the signed access cookie and redirects back,
|
|
9
|
+
* so the page loader's gate then releases the content. No bespoke endpoint per
|
|
10
|
+
* type — each gate is one AccessConfig. Mounted BEFORE the auth gate so it is
|
|
11
|
+
* publicly reachable.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Hono } from "hono";
|
|
15
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
16
|
+
import type { AccessConfig } from "./config-types.ts";
|
|
17
|
+
|
|
18
|
+
/** Same-origin path to redirect back to (the gated page the form was on). */
|
|
19
|
+
function safeReturnPath(referer: string | undefined): string {
|
|
20
|
+
if (!referer) return "/";
|
|
21
|
+
try {
|
|
22
|
+
return new URL(referer).pathname || "/";
|
|
23
|
+
} catch {
|
|
24
|
+
return referer.startsWith("/") ? referer : "/";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function attachContentGateAccess(
|
|
29
|
+
app: Hono,
|
|
30
|
+
serviceSlug: string,
|
|
31
|
+
config: AccessConfig,
|
|
32
|
+
): Promise<void> {
|
|
33
|
+
const log = getAppLogger();
|
|
34
|
+
|
|
35
|
+
app.post(config.path, async (c) => {
|
|
36
|
+
const back = safeReturnPath(c.req.header("referer"));
|
|
37
|
+
let resourceId = "";
|
|
38
|
+
let email = "";
|
|
39
|
+
let name = "";
|
|
40
|
+
try {
|
|
41
|
+
const form = await c.req.parseBody();
|
|
42
|
+
resourceId = String(form[config.recordIdField] ?? "").trim();
|
|
43
|
+
email = String(form.email ?? "").trim().toLowerCase();
|
|
44
|
+
// Optional captured display name (open-capture gates carry it in the
|
|
45
|
+
// token; allow-list gates ignore it via signToken).
|
|
46
|
+
name = String(form.name ?? "").trim();
|
|
47
|
+
} catch {
|
|
48
|
+
return c.redirect(back, 302);
|
|
49
|
+
}
|
|
50
|
+
if (!resourceId || !email) return c.redirect(back, 302);
|
|
51
|
+
|
|
52
|
+
let matched = false;
|
|
53
|
+
if (config.openCapture) {
|
|
54
|
+
// Open lead-capture gate (the public GPT): any non-empty email is a
|
|
55
|
+
// valid visitor — there is no allow-list to authorize against. We
|
|
56
|
+
// already required a non-empty email above, so this is a match.
|
|
57
|
+
matched = true;
|
|
58
|
+
} else {
|
|
59
|
+
try {
|
|
60
|
+
const authorized = await config.resolveAuthorizedEmails(resourceId);
|
|
61
|
+
matched = authorized.has(email);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
log.error(
|
|
64
|
+
`[content-gate:${config.cookieName}] authorize failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
65
|
+
);
|
|
66
|
+
return c.redirect(back, 302);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!matched) {
|
|
71
|
+
const sep = back.includes("?") ? "&" : "?";
|
|
72
|
+
return c.redirect(`${back}${sep}gate_error=1`, 302);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const token = config.signToken(resourceId, email, Date.now(), name || undefined);
|
|
76
|
+
if (token) {
|
|
77
|
+
const maxAge = Math.floor(config.ttlMs / 1000);
|
|
78
|
+
c.header(
|
|
79
|
+
"Set-Cookie",
|
|
80
|
+
`${config.cookieName}=${encodeURIComponent(token)}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return c.redirect(back, 302);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
log.info(
|
|
87
|
+
`[scala-install:${serviceSlug}] content-gate access endpoint mounted: ${config.path}`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* The config contracts that PROGRAM a content gate. Each gated content type
|
|
7
|
+
* (agreement, proposal, …) is one ContentGateConfig + one AccessConfig — no
|
|
8
|
+
* bespoke gate functions per type; the engine (gate.ts) and the access mounter
|
|
9
|
+
* (access.ts) are the single shared primitive driven by these.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
type Resolved = Record<string, unknown>;
|
|
13
|
+
|
|
14
|
+
/** Drives the SSR-time gate (gate.ts / runContentGate). */
|
|
15
|
+
export interface ContentGateConfig {
|
|
16
|
+
/** Cookie that carries this gate's access token (e.g. "ag_token"). */
|
|
17
|
+
cookieName: string;
|
|
18
|
+
/**
|
|
19
|
+
* The gated resource id from the resolved graph, or "" when this gate does
|
|
20
|
+
* not apply to the current page (the engine then no-ops). This is also how a
|
|
21
|
+
* gate detects "is this my kind of page". Async to allow DB-backed resolution
|
|
22
|
+
* (e.g. agreement gate resolving the UUID from the page→agreement association
|
|
23
|
+
* when no pre-placed gate block exists).
|
|
24
|
+
*/
|
|
25
|
+
getResourceId(resolved: Resolved): string | Promise<string>;
|
|
26
|
+
/**
|
|
27
|
+
* Verify the cookie's token for this resource → the verified identity
|
|
28
|
+
* ({email, optional display name}), or null. Token-agnostic by design:
|
|
29
|
+
* proposals use the shared content-gate token; agreements keep their existing
|
|
30
|
+
* token (also minted by the signing ORPC), so the engine never assumes one
|
|
31
|
+
* token format. Allow-list gates return just {email}; the GPT lead-capture
|
|
32
|
+
* gate also returns the captured name (carried in its token).
|
|
33
|
+
*/
|
|
34
|
+
verifyToken(
|
|
35
|
+
token: string | undefined,
|
|
36
|
+
resourceId: string,
|
|
37
|
+
nowMs: number,
|
|
38
|
+
): { email: string; name?: string } | null;
|
|
39
|
+
/**
|
|
40
|
+
* BAKED-IN unified auth. When a gate provides this resolver, the engine
|
|
41
|
+
* AUTOMATICALLY releases a logged-in viewer whose session email is in the
|
|
42
|
+
* authorized set — with no valid token and no per-gate code. This is the
|
|
43
|
+
* platform default for EVERY gated resource: any application surface with
|
|
44
|
+
* authentication gets "a signed-in, authorized viewer is never re-asked for
|
|
45
|
+
* their email/OTP" for free, just by exposing its authorized-email set (the
|
|
46
|
+
* same resolver its AccessConfig uses). Not called when the session is
|
|
47
|
+
* anonymous. Omit only for open-capture gates that have no authorized set.
|
|
48
|
+
*/
|
|
49
|
+
resolveAuthorizedEmails?(resourceId: string): Promise<Set<string>>;
|
|
50
|
+
/**
|
|
51
|
+
* OPTIONAL OVERRIDE of the baked-in session check above — for the rare gate
|
|
52
|
+
* whose session→identity rule isn't simply "session email ∈ authorized set".
|
|
53
|
+
* When present it wins; otherwise the engine uses `resolveAuthorizedEmails`.
|
|
54
|
+
* Called only when no valid access token is present.
|
|
55
|
+
*/
|
|
56
|
+
sessionAuthorize?(
|
|
57
|
+
resolved: Resolved,
|
|
58
|
+
resourceId: string,
|
|
59
|
+
):
|
|
60
|
+
| { email: string; name?: string }
|
|
61
|
+
| null
|
|
62
|
+
| Promise<{ email: string; name?: string } | null>;
|
|
63
|
+
/** Reveal + seed the verified viewer's identity/session (token was valid). */
|
|
64
|
+
onAuthorized(
|
|
65
|
+
resolved: Resolved,
|
|
66
|
+
resourceId: string,
|
|
67
|
+
identity: { email: string; name?: string },
|
|
68
|
+
): void | Promise<void>;
|
|
69
|
+
/** Withhold content (strip/inject the gate) — token absent/invalid. Async-
|
|
70
|
+
* capable so a gate can load its display/behaviour settings from a registry
|
|
71
|
+
* row (DB-driven, not hardcoded); sync implementations remain valid. */
|
|
72
|
+
onUnauthorized(resolved: Resolved, resourceId: string): void | Promise<void>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Drives the public access endpoint (access.ts / attachContentGateAccess). */
|
|
76
|
+
export interface AccessConfig {
|
|
77
|
+
/** Route to mount, e.g. "/api/proposal-access". */
|
|
78
|
+
path: string;
|
|
79
|
+
/** Cookie to set on a match. */
|
|
80
|
+
cookieName: string;
|
|
81
|
+
/** Cookie lifetime in ms (Max-Age). */
|
|
82
|
+
ttlMs: number;
|
|
83
|
+
/** Form field carrying the resource id, e.g. "proposalId". */
|
|
84
|
+
recordIdField: string;
|
|
85
|
+
/**
|
|
86
|
+
* Open lead-capture gate: ANY non-empty email is accepted (no allow-list).
|
|
87
|
+
* For surfaces whose "gate" captures a visitor rather than authorizing one
|
|
88
|
+
* against a fixed set (the public GPT). When true, `resolveAuthorizedEmails`
|
|
89
|
+
* is not called. Default/omitted = allow-list mode (proposals, agreements).
|
|
90
|
+
*/
|
|
91
|
+
openCapture?: boolean;
|
|
92
|
+
/**
|
|
93
|
+
* The authorized email set for a resource (lowercased), checked server-side.
|
|
94
|
+
* Not called when `openCapture` is true.
|
|
95
|
+
*/
|
|
96
|
+
resolveAuthorizedEmails(resourceId: string): Promise<Set<string>>;
|
|
97
|
+
/**
|
|
98
|
+
* Mint the access token for a matched email (token-agnostic per gate).
|
|
99
|
+
* `name` is the optional captured display name (open-capture gates carry it
|
|
100
|
+
* in the token; allow-list gates ignore it).
|
|
101
|
+
*/
|
|
102
|
+
signToken(
|
|
103
|
+
resourceId: string,
|
|
104
|
+
email: string,
|
|
105
|
+
nowMs: number,
|
|
106
|
+
name?: string,
|
|
107
|
+
): string | null;
|
|
108
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* The content-gate ENGINE. Runs in the page loader after the resource graph
|
|
7
|
+
* resolves, BEFORE SSR. Orchestration only — the per-type behaviour (which
|
|
8
|
+
* resource, who's authorized, what to seed, how to withhold) is supplied by a
|
|
9
|
+
* ContentGateConfig, so agreements/proposals/future gates are configs, not new
|
|
10
|
+
* functions. The single place that decides whether gated bytes reach the HTML.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ContentGateConfig } from "./config-types.ts";
|
|
14
|
+
import { parseCookie } from "./parse-cookie.ts";
|
|
15
|
+
|
|
16
|
+
export async function runContentGate(
|
|
17
|
+
resolved: Record<string, unknown>,
|
|
18
|
+
cookieHeader: string | null,
|
|
19
|
+
nowMs: number,
|
|
20
|
+
config: ContentGateConfig,
|
|
21
|
+
): Promise<void> {
|
|
22
|
+
const resourceId = await config.getResourceId(resolved);
|
|
23
|
+
if (!resourceId) return; // gate does not apply to this page.
|
|
24
|
+
|
|
25
|
+
const token = parseCookie(cookieHeader, config.cookieName);
|
|
26
|
+
const identity = config.verifyToken(token, resourceId, nowMs);
|
|
27
|
+
|
|
28
|
+
if (identity) {
|
|
29
|
+
await config.onAuthorized(resolved, resourceId, identity);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// No valid access token — BAKED-IN unified auth: release a logged-in viewer
|
|
34
|
+
// whose session email is authorized for this resource, with no per-gate
|
|
35
|
+
// email/OTP entry. This is platform-wide and automatic: every gate that
|
|
36
|
+
// exposes its authorized-email set (resolveAuthorizedEmails) gets it for free
|
|
37
|
+
// — a signed-in, authorized viewer is never re-challenged on ANY auth-bearing
|
|
38
|
+
// surface. A gate may override with a bespoke sessionAuthorize when its
|
|
39
|
+
// session→identity rule isn't simply "session email ∈ authorized set".
|
|
40
|
+
const sessionIdentity = await resolveSessionIdentity(
|
|
41
|
+
resolved,
|
|
42
|
+
resourceId,
|
|
43
|
+
config,
|
|
44
|
+
);
|
|
45
|
+
if (sessionIdentity) {
|
|
46
|
+
await config.onAuthorized(resolved, resourceId, sessionIdentity);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
await config.onUnauthorized(resolved, resourceId);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The signed-in viewer's authorized identity for this resource, or null. The
|
|
55
|
+
* default (used by every gate) is "session email ∈ the gate's authorized set";
|
|
56
|
+
* a gate may supply a custom sessionAuthorize to override. Anonymous sessions
|
|
57
|
+
* and gates with no authorized-email set return null (stay token-only).
|
|
58
|
+
*/
|
|
59
|
+
async function resolveSessionIdentity(
|
|
60
|
+
resolved: Record<string, unknown>,
|
|
61
|
+
resourceId: string,
|
|
62
|
+
config: ContentGateConfig,
|
|
63
|
+
): Promise<{ email: string; name?: string } | null> {
|
|
64
|
+
if (config.sessionAuthorize) {
|
|
65
|
+
return config.sessionAuthorize(resolved, resourceId);
|
|
66
|
+
}
|
|
67
|
+
if (!config.resolveAuthorizedEmails) return null;
|
|
68
|
+
const sv = (resolved as { sessionVisitor?: { email: string; name?: string } })
|
|
69
|
+
.sessionVisitor;
|
|
70
|
+
if (!sv?.email) return null;
|
|
71
|
+
const email = sv.email.trim().toLowerCase();
|
|
72
|
+
const authorized = await config.resolveAuthorizedEmails(resourceId);
|
|
73
|
+
return authorized.has(email) ? { email, name: sv.name } : null;
|
|
74
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Parse a single cookie value out of a raw Cookie header — shared by the
|
|
7
|
+
* content-gate engine and any gate config.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export function parseCookie(
|
|
11
|
+
header: string | null,
|
|
12
|
+
name: string,
|
|
13
|
+
): string | undefined {
|
|
14
|
+
if (!header) return undefined;
|
|
15
|
+
for (const part of header.split(";")) {
|
|
16
|
+
const eq = part.indexOf("=");
|
|
17
|
+
if (eq < 0) continue;
|
|
18
|
+
if (part.slice(0, eq).trim() === name) {
|
|
19
|
+
return decodeURIComponent(part.slice(eq + 1).trim());
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Round-trip + binding tests for the content-gate access token. Covers the
|
|
7
|
+
* identity-carrying additions (signAccessToken `name` + verifyAccessTokenIdentity)
|
|
8
|
+
* used by the public GPT lead-capture gate, plus the gate/resource/expiry
|
|
9
|
+
* binding that every gate relies on to withhold content from a link holder.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// A stable secret so secret() resolves deterministically in the test. Set BEFORE
|
|
13
|
+
// importing the module under test (secret() reads it at call time, so order is
|
|
14
|
+
// not strictly required, but this keeps the intent explicit).
|
|
15
|
+
process.env.AUTH_SECRET = "test-secret-at-least-16-chars-long";
|
|
16
|
+
|
|
17
|
+
import { describe, expect, test } from "bun:test";
|
|
18
|
+
import {
|
|
19
|
+
signAccessToken,
|
|
20
|
+
verifyAccessToken,
|
|
21
|
+
verifyAccessTokenIdentity,
|
|
22
|
+
} from "./token.ts";
|
|
23
|
+
|
|
24
|
+
const NOW = 1_000_000_000_000;
|
|
25
|
+
const TTL = 60_000;
|
|
26
|
+
|
|
27
|
+
describe("content-gate token — identity", () => {
|
|
28
|
+
test("signs + verifies email AND name (GPT capture)", () => {
|
|
29
|
+
const t = signAccessToken("gpt_token", "gpt-1", "A@B.com", NOW, TTL, "Ann");
|
|
30
|
+
expect(t).toBeTruthy();
|
|
31
|
+
const id = verifyAccessTokenIdentity(t, "gpt_token", "gpt-1", NOW + 1);
|
|
32
|
+
expect(id).toEqual({ email: "a@b.com", name: "Ann" });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("name omitted → identity has email, name undefined", () => {
|
|
36
|
+
const t = signAccessToken("gpt_token", "gpt-1", "a@b.com", NOW, TTL);
|
|
37
|
+
const id = verifyAccessTokenIdentity(t, "gpt_token", "gpt-1", NOW + 1);
|
|
38
|
+
expect(id).toEqual({ email: "a@b.com", name: undefined });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("email-only verifyAccessToken still works (proposals/agreements)", () => {
|
|
42
|
+
const t = signAccessToken("prop_token", "p-1", "a@b.com", NOW, TTL);
|
|
43
|
+
expect(verifyAccessToken(t, "prop_token", "p-1", NOW + 1)).toBe("a@b.com");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("content-gate token — binding (withholds content)", () => {
|
|
48
|
+
const t = signAccessToken("gpt_token", "gpt-1", "a@b.com", NOW, TTL, "Ann");
|
|
49
|
+
|
|
50
|
+
test("wrong gate (cookieName) → null", () => {
|
|
51
|
+
expect(verifyAccessTokenIdentity(t, "prop_token", "gpt-1", NOW + 1)).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("wrong resource → null", () => {
|
|
55
|
+
expect(verifyAccessTokenIdentity(t, "gpt_token", "gpt-2", NOW + 1)).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("expired → null", () => {
|
|
59
|
+
expect(verifyAccessTokenIdentity(t, "gpt_token", "gpt-1", NOW + TTL + 1)).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("tampered signature → null", () => {
|
|
63
|
+
const bad = `${t?.split(".")[0]}.deadbeef`;
|
|
64
|
+
expect(verifyAccessTokenIdentity(bad, "gpt_token", "gpt-1", NOW + 1)).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("missing token → null", () => {
|
|
68
|
+
expect(verifyAccessTokenIdentity(undefined, "gpt_token", "gpt-1", NOW)).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* The single signed, short-lived access token for EVERY content gate (agreement,
|
|
7
|
+
* proposal, and any future gated content). A gate withholds page content
|
|
8
|
+
* server-side until the request carries a valid token for that resource; this is
|
|
9
|
+
* the only thing that releases the content into the SSR, so without it a gated
|
|
10
|
+
* page is never readable/scrapable by a link holder.
|
|
11
|
+
*
|
|
12
|
+
* HMAC-SHA256 (Bun-native) over {c:cookieName, r:resourceId, e:email, x:exp}.
|
|
13
|
+
* `cookieName` is bound INTO the payload so a token minted for one gate
|
|
14
|
+
* (e.g. ag_token) can never satisfy another gate's check (e.g. prop_token).
|
|
15
|
+
*
|
|
16
|
+
* Secret resolver, in preference order: AUTH_SECRET → ENCRYPTION_KEY →
|
|
17
|
+
* DATABASE_URL (first one ≥16 chars). A gate signs AND verifies inside one
|
|
18
|
+
* service, so it only needs a stable per-deployment secret; the fallback keeps a
|
|
19
|
+
* gate working on a service that was never given AUTH_SECRET (e.g. the former
|
|
20
|
+
* public-only proposals viewer) instead of failing closed and locking out every
|
|
21
|
+
* authorized viewer. Any service that has AUTH_SECRET transparently prefers it.
|
|
22
|
+
* Fail-closed: no usable secret yields null (no token, no access).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
function secret(): string | null {
|
|
26
|
+
for (const s of [
|
|
27
|
+
process.env.AUTH_SECRET,
|
|
28
|
+
process.env.ENCRYPTION_KEY,
|
|
29
|
+
process.env.DATABASE_URL,
|
|
30
|
+
]) {
|
|
31
|
+
if (s && s.length >= 16) return s;
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function hmac(payloadB64: string, key: string): string {
|
|
37
|
+
return new Bun.CryptoHasher("sha256", key).update(payloadB64).digest("base64url");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Constant-time string compare (equal-length); avoids node:crypto. */
|
|
41
|
+
function safeEqual(a: string, b: string): boolean {
|
|
42
|
+
if (a.length !== b.length) return false;
|
|
43
|
+
let diff = 0;
|
|
44
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
45
|
+
return diff === 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Mint a token proving `email` is authorized for `resourceId` under `cookieName`. */
|
|
49
|
+
export function signAccessToken(
|
|
50
|
+
cookieName: string,
|
|
51
|
+
resourceId: string,
|
|
52
|
+
email: string,
|
|
53
|
+
nowMs: number,
|
|
54
|
+
ttlMs: number,
|
|
55
|
+
/**
|
|
56
|
+
* Optional display name to carry in the token (the public GPT lead-capture
|
|
57
|
+
* gate uses this so a returning visitor's name is known at SSR — first paint
|
|
58
|
+
* renders the full identity, not just the email). Allow-list gates
|
|
59
|
+
* (proposals/agreements) omit it.
|
|
60
|
+
*/
|
|
61
|
+
name?: string,
|
|
62
|
+
): string | null {
|
|
63
|
+
const key = secret();
|
|
64
|
+
if (!key) return null;
|
|
65
|
+
const payload: { c: string; r: string; e: string; x: number; n?: string } = {
|
|
66
|
+
c: cookieName,
|
|
67
|
+
r: resourceId,
|
|
68
|
+
e: email.trim().toLowerCase(),
|
|
69
|
+
x: nowMs + ttlMs,
|
|
70
|
+
};
|
|
71
|
+
const trimmedName = name?.trim();
|
|
72
|
+
if (trimmedName) payload.n = trimmedName;
|
|
73
|
+
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
74
|
+
return `${payloadB64}.${hmac(payloadB64, key)}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Validate a token for `cookieName` + `resourceId`. Returns the verified email
|
|
79
|
+
* on success, null otherwise (bad signature, wrong gate, wrong resource,
|
|
80
|
+
* expired, no secret).
|
|
81
|
+
*/
|
|
82
|
+
export function verifyAccessToken(
|
|
83
|
+
token: string | undefined | null,
|
|
84
|
+
cookieName: string,
|
|
85
|
+
resourceId: string,
|
|
86
|
+
nowMs: number,
|
|
87
|
+
): string | null {
|
|
88
|
+
if (!token) return null;
|
|
89
|
+
const key = secret();
|
|
90
|
+
if (!key) return null;
|
|
91
|
+
const dot = token.indexOf(".");
|
|
92
|
+
if (dot <= 0) return null;
|
|
93
|
+
const payloadB64 = token.slice(0, dot);
|
|
94
|
+
const sig = token.slice(dot + 1);
|
|
95
|
+
if (!safeEqual(sig, hmac(payloadB64, key))) return null;
|
|
96
|
+
try {
|
|
97
|
+
const p = JSON.parse(Buffer.from(payloadB64, "base64url").toString()) as {
|
|
98
|
+
c?: string;
|
|
99
|
+
r?: string;
|
|
100
|
+
e?: string;
|
|
101
|
+
x?: number;
|
|
102
|
+
};
|
|
103
|
+
if (p.c !== cookieName) return null;
|
|
104
|
+
if (p.r !== resourceId) return null;
|
|
105
|
+
if (typeof p.x !== "number" || p.x < nowMs) return null;
|
|
106
|
+
return typeof p.e === "string" ? p.e : null;
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Like `verifyAccessToken` but returns the full verified identity ({email, name})
|
|
114
|
+
* so a gate that carries a display name in the token (the public GPT lead-capture
|
|
115
|
+
* gate) can seed the visitor's name — not just the email — at SSR. Same
|
|
116
|
+
* signature/expiry/gate-binding checks; null on any failure.
|
|
117
|
+
*/
|
|
118
|
+
export function verifyAccessTokenIdentity(
|
|
119
|
+
token: string | undefined | null,
|
|
120
|
+
cookieName: string,
|
|
121
|
+
resourceId: string,
|
|
122
|
+
nowMs: number,
|
|
123
|
+
): { email: string; name?: string } | null {
|
|
124
|
+
if (!token) return null;
|
|
125
|
+
const key = secret();
|
|
126
|
+
if (!key) return null;
|
|
127
|
+
const dot = token.indexOf(".");
|
|
128
|
+
if (dot <= 0) return null;
|
|
129
|
+
const payloadB64 = token.slice(0, dot);
|
|
130
|
+
const sig = token.slice(dot + 1);
|
|
131
|
+
if (!safeEqual(sig, hmac(payloadB64, key))) return null;
|
|
132
|
+
try {
|
|
133
|
+
const p = JSON.parse(Buffer.from(payloadB64, "base64url").toString()) as {
|
|
134
|
+
c?: string;
|
|
135
|
+
r?: string;
|
|
136
|
+
e?: string;
|
|
137
|
+
x?: number;
|
|
138
|
+
n?: string;
|
|
139
|
+
};
|
|
140
|
+
if (p.c !== cookieName) return null;
|
|
141
|
+
if (p.r !== resourceId) return null;
|
|
142
|
+
if (typeof p.x !== "number" || p.x < nowMs) return null;
|
|
143
|
+
if (typeof p.e !== "string") return null;
|
|
144
|
+
return { email: p.e, name: typeof p.n === "string" ? p.n : undefined };
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|