@teamscala/service-boot 0.23.523 → 0.23.525
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 +1 -9
- package/src/agreement-gate/agreement-lookup.ts +0 -7
- package/src/agreement-gate/authorized-emails.ts +0 -14
- package/src/agreement-gate/placeholder-strip.ts +0 -10
- package/src/agreement-gate/resolution-caches.ts +0 -5
- package/src/agreement-gate/settings.ts +0 -22
- package/src/agreement-gate/signing-session.ts +0 -8
- package/src/agreement-gate/signing-shell.ts +0 -9
- package/src/agreement-gate.ts +0 -52
- package/src/apply-logger-config.ts +1 -9
- package/src/asset-cache-key.ts +1 -5
- package/src/asset-optimize.ts +1 -7
- package/src/asset-proxy.ts +1 -10
- package/src/asset-social-card.ts +1 -9
- package/src/attach-agreement-magic.ts +0 -14
- package/src/attach-portal-members.ts +0 -17
- package/src/attach-proposal-accept.ts +1 -21
- package/src/attach-proposal-magic.ts +1 -13
- package/src/attach-transfers-upload.ts +0 -21
- package/src/auth/sign-out.ts +1 -44
- package/src/auth-gate/public-paths.ts +1 -96
- package/src/auth-gate.ts +1 -41
- package/src/bind-page-handler.ts +1 -83
- package/src/build-client-assets/build-lock.ts +1 -11
- package/src/build-client-assets/hashed-output.ts +1 -11
- package/src/build-client-assets/hydrate-bundle.ts +1 -137
- package/src/build-client-assets/manifest.ts +1 -30
- package/src/build-client-assets/source-candidates.ts +0 -51
- package/src/build-client-assets/sourcemaps-upload.ts +1 -52
- package/src/build-client-assets/styles-build.ts +1 -71
- package/src/build-client-assets.ts +1 -30
- package/src/capability-slug.ts +1 -45
- package/src/compose-page-title.ts +1 -34
- package/src/content-gate/access.ts +0 -34
- package/src/content-gate/catalog.ts +0 -52
- package/src/content-gate/config-types.ts +3 -90
- package/src/content-gate/gate-entry-clears.ts +0 -18
- package/src/content-gate/gate.ts +0 -27
- package/src/content-gate/parse-cookie.ts +0 -4
- package/src/content-page-floor.ts +0 -28
- package/src/csrf-protection.ts +1 -28
- package/src/error-page-logo.ts +2 -35
- package/src/expand-roster.ts +1 -25
- package/src/gate-ttls.ts +0 -12
- package/src/gpt-gate.ts +1 -19
- package/src/hono-error-handler.ts +0 -19
- package/src/install-harness.ts +1 -12
- package/src/install-logging.ts +1 -47
- package/src/load-page-manifest.ts +1 -29
- package/src/load-service-install-row.ts +42 -10
- package/src/module-not-found.ts +0 -17
- package/src/mount-orpc-router.ts +1 -94
- package/src/mount-server-packages.ts +1 -37
- package/src/mount.ts +1 -4
- package/src/mutable-app-ref.ts +0 -21
- package/src/orpc-contract-rows.json +289 -0
- package/src/orpc-openapi-document.ts +1 -19
- package/src/orpc-openapi-gate.ts +1 -23
- package/src/page-route-shell-types.ts +1 -8
- package/src/page-server-loader-graph.ts +0 -10
- package/src/page-server-orpc/server-client.ts +1 -23
- package/src/pg-boss-handler-registry.ts +1 -24
- package/src/pg-boss-mount.ts +1 -109
- package/src/proposal-authorized-emails.ts +0 -32
- package/src/proposal-gate.ts +1 -52
- package/src/quiz-gate.ts +1 -31
- package/src/read-html-template.ts +1 -49
- package/src/readiness-gate.ts +0 -8
- package/src/register-api-routes.ts +1 -11
- package/src/register-crash-handlers.ts +1 -6
- package/src/register-page-routes.ts +1 -12
- package/src/register-page-server-client.ts +1 -66
- package/src/register-registry-page-route/ai-chat-prefetch.ts +1 -24
- package/src/register-registry-page-route/apply-page-branding.ts +1 -100
- package/src/register-registry-page-route/captured-loader-config.ts +1 -12
- package/src/register-registry-page-route/graph-cache.ts +1 -38
- package/src/register-registry-page-route/live-loader-config.ts +1 -22
- package/src/register-registry-page-route/meta-title.ts +1 -36
- package/src/register-registry-page-route/page-data-shared.ts +1 -18
- package/src/register-registry-page-route/prefetch-org-id.ts +1 -24
- package/src/register-registry-page-route/proposal-approval-email-token.ts +1 -22
- package/src/register-registry-page-route/proposal-brand-stamping.ts +1 -31
- package/src/register-registry-page-route/public-shell-page-data.ts +1 -34
- package/src/register-registry-page-route/region-slots-page-data.ts +1 -8
- package/src/register-registry-page-route/resolve-page-data.ts +1 -10
- package/src/register-registry-page-route/resolve-panel-page.ts +1 -10
- package/src/register-registry-page-route/resolve-request-graph.ts +1 -68
- package/src/register-registry-page-route/run-content-gates.ts +3 -64
- package/src/register-registry-page-route/single-page-data.ts +1 -9
- package/src/register-registry-page-route/stamp-session-context.ts +1 -10
- package/src/register-registry-page-route/warn-uncovered-blocks.ts +1 -18
- package/src/register-registry-page-route.ts +1 -93
- package/src/register-static-assets.ts +1 -53
- package/src/register-ws-routes.ts +1 -21
- package/src/resolve-brand-favicon.ts +0 -22
- package/src/resolve-service-port.ts +1 -61
- package/src/resolve-theme-mode.ts +1 -30
- package/src/resolvers/app-header.ts +1 -20
- package/src/resolvers/fetch-org-brand.ts +1 -11
- package/src/resolvers/portal-route-paths.ts +1 -29
- package/src/resolvers/website-by-host-map.ts +1 -7
- package/src/resource-not-found.ts +0 -48
- package/src/run-boot-hook-list.ts +1 -13
- package/src/run-boot-hooks.ts +1 -19
- package/src/scala-tools-currency-watchdog.ts +1 -7
- package/src/security-headers.ts +1 -7
- package/src/select-service-routes.ts +1 -31
- package/src/server-error-page.ts +0 -18
- package/src/theme-fonts-link.ts +1 -21
- package/src/transfers/presigned-upload.ts +1 -27
- package/src/two-phase-full-app.ts +0 -82
- package/src/two-phase-minimal-app.ts +1 -111
- package/src/two-phase-readiness.ts +0 -6
- package/src/types.ts +0 -17
- package/src/ui-service.ts +2 -48
- package/src/ws-boot-self-test.ts +1 -14
- package/src/content-gate/gate-entry-marker.test.ts +0 -111
- package/src/content-gate/token.segmentation.test.ts +0 -76
- package/src/content-gate/token.test.ts +0 -65
- package/src/content-gate/token.ts +0 -219
- package/src/is-react-element-type.test.ts +0 -45
- package/src/is-react-element-type.ts +0 -20
- package/src/page-server-orpc/hub-mcp-client.ts +0 -20
- package/src/pg-boss-mount.test.ts +0 -101
- package/src/resource-chat-inject.page-assistant.test.ts +0 -145
- package/src/resource-chat-inject.test.ts +0 -240
- package/src/resource-chat-inject.ts +0 -374
|
@@ -1,12 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @system service-boot
|
|
3
3
|
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* Phase-2 readiness state box for the two-phase boot: the real page handler +
|
|
7
|
-
* readiness flags (private lets), the writers/flippers, the `/health` endpoint
|
|
8
|
-
* that gates on them, and the phase-1 page handler that delegates to the real
|
|
9
|
-
* handler once phase 2 completes. Every reader goes through these exports.
|
|
10
4
|
*/
|
|
11
5
|
|
|
12
6
|
/**
|
package/src/types.ts
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @system service-boot
|
|
3
3
|
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* Shared type definitions for service-boot boot helpers.
|
|
7
4
|
*/
|
|
8
5
|
|
|
9
6
|
type RendererComponent = (props: Record<string, unknown>) => unknown;
|
|
@@ -11,9 +8,6 @@ type RendererComponent = (props: Record<string, unknown>) => unknown;
|
|
|
11
8
|
export interface RendererComponentEntry {
|
|
12
9
|
render: RendererComponent;
|
|
13
10
|
fields?: Record<string, { type: string }>;
|
|
14
|
-
/** block_schema.defaults, carried onto the entry by discoverBlocks so
|
|
15
|
-
* buildPuckConfig merges them under authored props at SSR
|
|
16
|
-
* (blocks-fully-programmable). */
|
|
17
11
|
defaultProps?: Record<string, unknown>;
|
|
18
12
|
}
|
|
19
13
|
|
|
@@ -34,18 +28,7 @@ export interface PageHandlerConfig {
|
|
|
34
28
|
providers?: string[];
|
|
35
29
|
loadData: (request: Request) => Promise<unknown>;
|
|
36
30
|
renderMode?: "public" | "portals" | "utility";
|
|
37
|
-
/**
|
|
38
|
-
* Owning service slug — used by the page-server render catch to resolve
|
|
39
|
-
* the branded logo on the render-error page. Mirrors service-runtime's
|
|
40
|
-
* PageHandlerConfig.
|
|
41
|
-
*/
|
|
42
31
|
serviceSlug?: string;
|
|
43
|
-
/**
|
|
44
|
-
* Redirect a signed-in user's `/` to their org-scoped portal dashboard.
|
|
45
|
-
* Set only for services serving a `/*` catchall page_route (the portal);
|
|
46
|
-
* single-site portal-mode services serve their real page at `/`.
|
|
47
|
-
* Mirrors service-runtime's PageHandlerConfig (1.15.82).
|
|
48
|
-
*/
|
|
49
32
|
portalHomeRedirect?: boolean;
|
|
50
33
|
}
|
|
51
34
|
|
package/src/ui-service.ts
CHANGED
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @system
|
|
2
|
+
* @system service-boot
|
|
3
3
|
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* Boots a `kind: "ui"` service. Reads the service_install registry row and
|
|
7
|
-
* orchestrates a fixed pipeline of single-purpose stages onto one Hono app
|
|
8
|
-
* served by @teamscala/service-runtime/serve (one Bun.serve in dev and prod).
|
|
9
4
|
*/
|
|
10
5
|
|
|
11
6
|
/**
|
|
@@ -22,32 +17,7 @@
|
|
|
22
17
|
* place and is NOT undone.
|
|
23
18
|
*/
|
|
24
19
|
|
|
25
|
-
|
|
26
|
-
// the same one the backend branch uses. The registry wins wherever it has an
|
|
27
|
-
// opinion and a contradicting PORT env is reported, never silently obeyed —
|
|
28
|
-
// full reasoning in that file.
|
|
29
|
-
//
|
|
30
|
-
// This branch previously took `process.env.PORT` unconditionally, which is
|
|
31
|
-
// how scala-quiz (declared 5203) booted onto session-picker's 3021 from a
|
|
32
|
-
// leaked env and silently split that daemon's traffic (2026-07-31). On the
|
|
33
|
-
// dev host the registry wins a conflict (below). On Railway the injected PORT
|
|
34
|
-
// wins instead — Railway healthchecks exactly that port, and the row's port
|
|
35
|
-
// is the service's LOCAL dev port (e.g. portal 5200), so binding it on Railway
|
|
36
|
-
// makes the healthcheck probe a dead port and the deploy fails (`isRailway`
|
|
37
|
-
// path in the resolver).
|
|
38
|
-
|
|
39
|
-
// NO-SILENT-REVERTS — surface the failed client build to OBSERVABILITY
|
|
40
|
-
// (event-log → GlitchTip, per event-log-is-the-only-durable-event-log).
|
|
41
|
-
// The minimal-app fallback keeps serving SSR from the LAST-successful
|
|
42
|
-
// manifest (now a tombstone, see build-client-assets.ts), so the page
|
|
43
|
-
// looks "up" while the client bundle is stale. Without this signal the
|
|
44
|
-
// failure is buried in boot stderr and a published fix that reached
|
|
45
|
-
// node_modules but failed to bundle ships invisibly stale (the
|
|
46
|
-
// scala-agents 2026-07-18 incident: ui-foundation@3.4.5 installed but
|
|
47
|
-
// served pre-fix bytes across restarts while node_modules was
|
|
48
|
-
// mid-install). The boot.client-build-failed event routes into
|
|
49
|
-
// event-log's 7-day retention → surfaces in GlitchTip as an incident
|
|
50
|
-
// trail the operator actually watches.
|
|
20
|
+
|
|
51
21
|
|
|
52
22
|
import { recordEvent } from "@teamscala/event-log/record-event";
|
|
53
23
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
@@ -96,7 +66,6 @@ export async function bootUiService(
|
|
|
96
66
|
const log = getAppLogger();
|
|
97
67
|
const startTime = Date.now();
|
|
98
68
|
|
|
99
|
-
// Phase 1: Build minimal tool-only app
|
|
100
69
|
const minimalAppResult = await buildMinimalApp(
|
|
101
70
|
config,
|
|
102
71
|
serviceSlug,
|
|
@@ -107,12 +76,6 @@ export async function bootUiService(
|
|
|
107
76
|
|
|
108
77
|
const isRailway = Boolean(process.env.RAILWAY_ENVIRONMENT);
|
|
109
78
|
const envPort = process.env.PORT ? Number(process.env.PORT) : undefined;
|
|
110
|
-
// The APP binds its PRIVATE port (app_port) when present — for scale-to-zero
|
|
111
|
-
// services `port` is the ACTIVATOR's ingress (what callers resolve), not the
|
|
112
|
-
// app's bind. Falling back to `port` keeps RESIDENT + unmigrated services
|
|
113
|
-
// unchanged. Without this, a migrated row (port=activator) would make the app
|
|
114
|
-
// bind the activator's port → SO_REUSEPORT silent load-balancing (the
|
|
115
|
-
// service-ports-from-registry trap: clean boot, two owners in ss -ltnp).
|
|
116
79
|
const declaredAppPort = config.app_port ?? config.port;
|
|
117
80
|
const resolution = resolveServicePort(envPort, declaredAppPort, isRailway);
|
|
118
81
|
if ("error" in resolution) {
|
|
@@ -134,21 +97,17 @@ export async function bootUiService(
|
|
|
134
97
|
}
|
|
135
98
|
const port = requireValidPort(resolution.port, serviceSlug);
|
|
136
99
|
|
|
137
|
-
// service-runtime owns the TCP listener. service-boot only composes the app
|
|
138
|
-
// and starts the two-phase build; the runtime binds the returned app.
|
|
139
100
|
const minimalReadyTime = Date.now();
|
|
140
101
|
const minimalDuration = minimalReadyTime - startTime;
|
|
141
102
|
log.info(
|
|
142
103
|
`[ui-service-boot:${serviceSlug}] Minimal app composed on port ${port} in ${minimalDuration}ms`,
|
|
143
104
|
);
|
|
144
105
|
|
|
145
|
-
// FLIP READY SWITCH: minimal app is ready, MCP tools can connect
|
|
146
106
|
setReady();
|
|
147
107
|
log.info(
|
|
148
108
|
`[ui-service-boot:${serviceSlug}] Ready gate flipped - MCP tools can now connect (full app building in background)`,
|
|
149
109
|
);
|
|
150
110
|
|
|
151
|
-
// Phase 2: Build full app in background (non-blocking)
|
|
152
111
|
;(async () => {
|
|
153
112
|
try {
|
|
154
113
|
const PHASE2_TIMEOUT_MS = 120_000;
|
|
@@ -164,7 +123,6 @@ export async function bootUiService(
|
|
|
164
123
|
const fullBuildTime = Date.now();
|
|
165
124
|
const fullDuration = fullBuildTime - startTime;
|
|
166
125
|
|
|
167
|
-
// Swap the app reference atomically
|
|
168
126
|
swapToFullApp(fullAppResult.app, serviceSlug);
|
|
169
127
|
|
|
170
128
|
log.info(
|
|
@@ -183,13 +141,9 @@ export async function bootUiService(
|
|
|
183
141
|
at: new Date().toISOString(),
|
|
184
142
|
},
|
|
185
143
|
});
|
|
186
|
-
// Continue with minimal app - better than crashing
|
|
187
144
|
}
|
|
188
145
|
})();
|
|
189
146
|
|
|
190
|
-
// Return result compatible with the existing UiServiceBootResult interface
|
|
191
|
-
// Note: blocks/routes will be empty initially since they're built in the background.
|
|
192
|
-
// The service is operational for MCP tools.
|
|
193
147
|
return {
|
|
194
148
|
kind: "ui",
|
|
195
149
|
blocks: [], // Will be populated when full app builds
|
package/src/ws-boot-self-test.ts
CHANGED
|
@@ -1,19 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @system
|
|
2
|
+
* @system service-boot
|
|
3
3
|
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* Post-listen WebSocket self-test. Runs after serveStart binds the port.
|
|
7
|
-
* For each declared websocket_routes path, opens a synthetic WebSocket
|
|
8
|
-
* connection and asserts 101 upgrade. Non-101 → BootFatalError.
|
|
9
|
-
*
|
|
10
|
-
* This catches the class of bugs where:
|
|
11
|
-
* - WS route registered after ORPC catch-all (order-dependent 404)
|
|
12
|
-
* - fetchWithPeerPort clones the Request on WS upgrade (breaks Bun.upgrade identity)
|
|
13
|
-
* - websocket_routes config row is stale / missing a handler
|
|
14
|
-
*
|
|
15
|
-
* Runs synchronously after serveStart so the boot aborts before the service
|
|
16
|
-
* reports healthy.
|
|
17
4
|
*/
|
|
18
5
|
|
|
19
6
|
import type { ServiceInstallConfig } from "./load-service-install-row";
|
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @system service-boot
|
|
3
|
-
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* Locks the OPT-IN gate-entry marker. A gate that does NOT declare
|
|
7
|
-
* entryCookieName must behave exactly as before — a valid token authorizes on
|
|
8
|
-
* any load and nothing is recorded for clearing (condition 1: every non-opting
|
|
9
|
-
* service unchanged). An opting gate authorizes ONLY the request that carries
|
|
10
|
-
* the marker (the identity submit's own redirect) and returns the marker name
|
|
11
|
-
* so the page Response clears it — one view, then the gate is presented again.
|
|
12
|
-
*
|
|
13
|
-
* The clears are asserted from the CALL'S RETURN VALUE: the store lives only
|
|
14
|
-
* inside the run() scope, and the first shipped version read it afterwards,
|
|
15
|
-
* saw nothing, and left the marker in the browser (measured live 2026-09-14).
|
|
16
|
-
*
|
|
17
|
-
* Operator ruling 2026-09-14 (refresh must return to the identity gate),
|
|
18
|
-
* mechanism measured live: the quiz_token cookie alone was the carrier that
|
|
19
|
-
* skipped the gate on reload.
|
|
20
|
-
*/
|
|
21
|
-
import { describe, expect, test } from "bun:test";
|
|
22
|
-
import type { ContentGateConfig } from "./config-types.ts";
|
|
23
|
-
import { runContentGate } from "./gate.ts";
|
|
24
|
-
import { withGateEntryClears } from "./gate-entry-clears.ts";
|
|
25
|
-
|
|
26
|
-
function makeConfig(entryCookieName?: string) {
|
|
27
|
-
const calls = { authorized: 0, unauthorized: 0 };
|
|
28
|
-
const cfg: ContentGateConfig = {
|
|
29
|
-
cookieName: "quiz_token",
|
|
30
|
-
entryCookieName,
|
|
31
|
-
getResourceId: () => "res-1",
|
|
32
|
-
verifyToken: (token) =>
|
|
33
|
-
token === "good" ? { email: "v@example.com", name: "V" } : null,
|
|
34
|
-
onAuthorized: () => {
|
|
35
|
-
calls.authorized++;
|
|
36
|
-
},
|
|
37
|
-
onUnauthorized: () => {
|
|
38
|
-
calls.unauthorized++;
|
|
39
|
-
},
|
|
40
|
-
};
|
|
41
|
-
return { cfg, calls };
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
describe("gate entry marker is opt-in and one-view", () => {
|
|
45
|
-
test("non-opting gate: a valid token authorizes on any load, nothing cleared — behaviour unchanged", async () => {
|
|
46
|
-
const { cfg, calls } = makeConfig();
|
|
47
|
-
const { clears } = await withGateEntryClears(async () => {
|
|
48
|
-
const out = await runContentGate({}, "quiz_token=good", Date.now(), cfg);
|
|
49
|
-
expect(out.authorized).toBe(true);
|
|
50
|
-
expect(calls.authorized).toBe(1);
|
|
51
|
-
});
|
|
52
|
-
expect(clears).toEqual([]);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
test("opting gate: token WITHOUT the marker is unauthorized — a refresh shows the gate", async () => {
|
|
56
|
-
const { cfg, calls } = makeConfig("quiz_token_entered");
|
|
57
|
-
const { clears } = await withGateEntryClears(async () => {
|
|
58
|
-
const out = await runContentGate({}, "quiz_token=good", Date.now(), cfg);
|
|
59
|
-
expect(out.authorized).toBe(false);
|
|
60
|
-
expect(calls.unauthorized).toBe(1);
|
|
61
|
-
expect(calls.authorized).toBe(0);
|
|
62
|
-
});
|
|
63
|
-
expect(clears).toEqual([]);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
test("opting gate: token + marker authorizes and RETURNS the marker for clearing — one view", async () => {
|
|
67
|
-
const { cfg, calls } = makeConfig("quiz_token_entered");
|
|
68
|
-
const { clears } = await withGateEntryClears(async () => {
|
|
69
|
-
const out = await runContentGate(
|
|
70
|
-
{},
|
|
71
|
-
"quiz_token=good; quiz_token_entered=1",
|
|
72
|
-
Date.now(),
|
|
73
|
-
cfg,
|
|
74
|
-
);
|
|
75
|
-
expect(out.authorized).toBe(true);
|
|
76
|
-
expect(calls.authorized).toBe(1);
|
|
77
|
-
});
|
|
78
|
-
expect(clears).toEqual(["quiz_token_entered"]);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
test("opting gate: marker present but token invalid stays unauthorized and clears nothing", async () => {
|
|
82
|
-
const { cfg, calls } = makeConfig("quiz_token_entered");
|
|
83
|
-
const { clears } = await withGateEntryClears(async () => {
|
|
84
|
-
const out = await runContentGate(
|
|
85
|
-
{},
|
|
86
|
-
"quiz_token=bad; quiz_token_entered=1",
|
|
87
|
-
Date.now(),
|
|
88
|
-
cfg,
|
|
89
|
-
);
|
|
90
|
-
expect(out.authorized).toBe(false);
|
|
91
|
-
expect(calls.unauthorized).toBe(1);
|
|
92
|
-
});
|
|
93
|
-
expect(clears).toEqual([]);
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
test("two gates in one request each report their own marker, drained once", async () => {
|
|
97
|
-
const first = makeConfig("gate_a_entered");
|
|
98
|
-
const second = makeConfig("gate_b_entered");
|
|
99
|
-
const { clears } = await withGateEntryClears(async () => {
|
|
100
|
-
await runContentGate({}, "quiz_token=good; gate_a_entered=1", Date.now(), first.cfg);
|
|
101
|
-
await runContentGate({}, "quiz_token=good; gate_b_entered=1", Date.now(), second.cfg);
|
|
102
|
-
});
|
|
103
|
-
expect(clears).toEqual(["gate_a_entered", "gate_b_entered"]);
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
test("the quiz gate and its access endpoint both opt in on the same marker name", async () => {
|
|
107
|
-
const { quizAccessConfig, quizGateConfig } = await import("../quiz-gate.ts");
|
|
108
|
-
expect(quizGateConfig.entryCookieName).toBe("quiz_token_entered");
|
|
109
|
-
expect(quizAccessConfig.entryCookieName).toBe("quiz_token_entered");
|
|
110
|
-
});
|
|
111
|
-
});
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @system service-boot
|
|
3
|
-
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* Segmentation carriage in the content-gate token. The quiz's composed gate
|
|
7
|
-
* screen captures company/linkedin_url/website/team_size alongside the
|
|
8
|
-
* identity; the token carries them to the next render so the submit can put
|
|
9
|
-
* all seven fields on the ONE response row with no client storage. The
|
|
10
|
-
* generated `token.test.ts` owns the identity/binding suite; these are the
|
|
11
|
-
* segmentation-specific cases (including the backward-compatibility contract:
|
|
12
|
-
* a token minted before `s` existed verifies identically).
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
process.env.AUTH_SECRET = "local-test-fixture-entropy-0001";
|
|
16
|
-
|
|
17
|
-
import { describe, expect, test } from "bun:test";
|
|
18
|
-
import { signAccessToken, verifyAccessTokenIdentity } from "./token.ts";
|
|
19
|
-
|
|
20
|
-
const NOW = 1_000_000_000_000;
|
|
21
|
-
const TTL = 60_000;
|
|
22
|
-
|
|
23
|
-
function decodePayload(token: string): Record<string, unknown> {
|
|
24
|
-
return JSON.parse(Buffer.from(token.split(".")[0], "base64url").toString());
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
describe("content-gate token — segmentation carriage", () => {
|
|
28
|
-
test("round-trips the four captured fields", () => {
|
|
29
|
-
const t = signAccessToken("quiz_token", "quiz-1", "a@b.com", NOW, TTL, "Ann", "+614", {
|
|
30
|
-
company: "Acme",
|
|
31
|
-
linkedinUrl: "https://www.linkedin.com/in/ann",
|
|
32
|
-
website: "https://acme.test",
|
|
33
|
-
teamSize: "Me + team of 2–5",
|
|
34
|
-
});
|
|
35
|
-
const id = verifyAccessTokenIdentity(t, "quiz_token", "quiz-1", NOW + 1);
|
|
36
|
-
expect(id?.email).toBe("a@b.com");
|
|
37
|
-
expect(id?.name).toBe("Ann");
|
|
38
|
-
expect(id?.phone).toBe("+614");
|
|
39
|
-
expect(id?.segmentation).toEqual({
|
|
40
|
-
company: "Acme",
|
|
41
|
-
linkedinUrl: "https://www.linkedin.com/in/ann",
|
|
42
|
-
website: "https://acme.test",
|
|
43
|
-
teamSize: "Me + team of 2–5",
|
|
44
|
-
});
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
test("BACKWARD COMPATIBILITY: a pre-widening token (no s key) verifies with segmentation undefined — never an error", () => {
|
|
48
|
-
// Mint with the old shape by omitting segmentation entirely, then assert
|
|
49
|
-
// the exact contract a deploy-window survivor must satisfy.
|
|
50
|
-
const t = signAccessToken("quiz_token", "quiz-1", "a@b.com", NOW, TTL, "Ann", "+614");
|
|
51
|
-
expect(decodePayload(t as string).s).toBeUndefined();
|
|
52
|
-
const id = verifyAccessTokenIdentity(t, "quiz_token", "quiz-1", NOW + 1);
|
|
53
|
-
expect(id).toEqual({ email: "a@b.com", name: "Ann", phone: "+614", segmentation: undefined });
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
test("empty/whitespace segmentation values produce no s key at all", () => {
|
|
57
|
-
const t = signAccessToken("quiz_token", "quiz-1", "a@b.com", NOW, TTL, undefined, undefined, {
|
|
58
|
-
company: " ",
|
|
59
|
-
linkedinUrl: "",
|
|
60
|
-
website: undefined,
|
|
61
|
-
teamSize: undefined,
|
|
62
|
-
});
|
|
63
|
-
expect(decodePayload(t as string).s).toBeUndefined();
|
|
64
|
-
const id = verifyAccessTokenIdentity(t, "quiz_token", "quiz-1", NOW + 1);
|
|
65
|
-
expect(id?.segmentation).toBeUndefined();
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
test("values are trimmed on sign", () => {
|
|
69
|
-
const t = signAccessToken("quiz_token", "quiz-1", "a@b.com", NOW, TTL, undefined, undefined, {
|
|
70
|
-
company: " Acme ",
|
|
71
|
-
teamSize: "Just me",
|
|
72
|
-
});
|
|
73
|
-
const id = verifyAccessTokenIdentity(t, "quiz_token", "quiz-1", NOW + 1);
|
|
74
|
-
expect(id?.segmentation).toEqual({ company: "Acme", teamSize: "Just me" });
|
|
75
|
-
});
|
|
76
|
-
});
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
// @system codegen
|
|
2
|
-
// @status generated
|
|
3
|
-
// @edit change the suite in the owned-suites band, then re-run codegen. Hand-edits are overwritten.
|
|
4
|
-
//
|
|
5
|
-
// This suite's assertions are OWNED by the codegen band: the band module
|
|
6
|
-
// carries them verbatim, this file is the emission, and hand edits here are
|
|
7
|
-
// overwritten on the next run. The rationale each assertion carries moved
|
|
8
|
-
// with it into the band.
|
|
9
|
-
|
|
10
|
-
process.env.AUTH_SECRET = "test-secret-at-least-16-chars-long";
|
|
11
|
-
|
|
12
|
-
import { describe, expect, test } from "bun:test";
|
|
13
|
-
import {
|
|
14
|
-
signAccessToken,
|
|
15
|
-
verifyAccessToken,
|
|
16
|
-
verifyAccessTokenIdentity,
|
|
17
|
-
} from "./token.ts";
|
|
18
|
-
|
|
19
|
-
const NOW = 1_000_000_000_000;
|
|
20
|
-
const TTL = 60_000;
|
|
21
|
-
|
|
22
|
-
describe("content-gate token — identity", () => {
|
|
23
|
-
test("signs + verifies email AND name (GPT capture)", () => {
|
|
24
|
-
const t = signAccessToken("gpt_token", "gpt-1", "A@B.com", NOW, TTL, "Ann");
|
|
25
|
-
expect(t).toBeTruthy();
|
|
26
|
-
const id = verifyAccessTokenIdentity(t, "gpt_token", "gpt-1", NOW + 1);
|
|
27
|
-
expect(id).toEqual({ email: "a@b.com", name: "Ann" });
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
test("name omitted → identity has email, name undefined", () => {
|
|
31
|
-
const t = signAccessToken("gpt_token", "gpt-1", "a@b.com", NOW, TTL);
|
|
32
|
-
const id = verifyAccessTokenIdentity(t, "gpt_token", "gpt-1", NOW + 1);
|
|
33
|
-
expect(id).toEqual({ email: "a@b.com", name: undefined });
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
test("email-only verifyAccessToken still works (proposals/agreements)", () => {
|
|
37
|
-
const t = signAccessToken("prop_token", "p-1", "a@b.com", NOW, TTL);
|
|
38
|
-
expect(verifyAccessToken(t, "prop_token", "p-1", NOW + 1)).toBe("a@b.com");
|
|
39
|
-
});
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
describe("content-gate token — binding (withholds content)", () => {
|
|
43
|
-
const t = signAccessToken("gpt_token", "gpt-1", "a@b.com", NOW, TTL, "Ann");
|
|
44
|
-
|
|
45
|
-
test("wrong gate (cookieName) → null", () => {
|
|
46
|
-
expect(verifyAccessTokenIdentity(t, "prop_token", "gpt-1", NOW + 1)).toBeNull();
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
test("wrong resource → null", () => {
|
|
50
|
-
expect(verifyAccessTokenIdentity(t, "gpt_token", "gpt-2", NOW + 1)).toBeNull();
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
test("expired → null", () => {
|
|
54
|
-
expect(verifyAccessTokenIdentity(t, "gpt_token", "gpt-1", NOW + TTL + 1)).toBeNull();
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
test("tampered signature → null", () => {
|
|
58
|
-
const bad = `${t?.split(".")[0]}.deadbeef`;
|
|
59
|
-
expect(verifyAccessTokenIdentity(bad, "gpt_token", "gpt-1", NOW + 1)).toBeNull();
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
test("missing token → null", () => {
|
|
63
|
-
expect(verifyAccessTokenIdentity(undefined, "gpt_token", "gpt-1", NOW)).toBeNull();
|
|
64
|
-
});
|
|
65
|
-
});
|
|
@@ -1,219 +0,0 @@
|
|
|
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
|
-
* plus the optional captured identity a lead-capture gate carries (n:name,
|
|
14
|
-
* p:phone) so a returning visitor is fully known at SSR with NO client storage.
|
|
15
|
-
* `cookieName` is bound INTO the payload so a token minted for one gate
|
|
16
|
-
* (e.g. ag_token) can never satisfy another gate's check (e.g. prop_token).
|
|
17
|
-
*
|
|
18
|
-
* Secret resolver, in preference order: AUTH_SECRET → ENCRYPTION_KEY →
|
|
19
|
-
* the tenant DB address via @teamscala/db's named-need resolver (first one
|
|
20
|
-
* ≥16 chars). A gate signs AND verifies inside one
|
|
21
|
-
* service, so it only needs a stable per-deployment secret; the fallback keeps a
|
|
22
|
-
* gate working on a service that was never given AUTH_SECRET (e.g. the former
|
|
23
|
-
* public-only proposals viewer) instead of failing closed and locking out every
|
|
24
|
-
* authorized viewer. Any service that has AUTH_SECRET transparently prefers it.
|
|
25
|
-
* Fail-closed: no usable secret yields null (no token, no access).
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
import type { GateSegmentation } from "./config-types.ts";
|
|
29
|
-
import { resolveDbUrlOrNull } from "@teamscala/db/db-url";
|
|
30
|
-
|
|
31
|
-
function secret(): string | null {
|
|
32
|
-
// The last resort is the tenant DB address, read through the named-need
|
|
33
|
-
// resolver (wrong-database guard, 2026-09-27) — never `process.env` here;
|
|
34
|
-
// the resolver is the only env-aware surface and it names the plane. It is
|
|
35
|
-
// used only as a stable per-deployment string (≥16 chars), never as a
|
|
36
|
-
// connection target.
|
|
37
|
-
for (const s of [
|
|
38
|
-
process.env.AUTH_SECRET,
|
|
39
|
-
process.env.ENCRYPTION_KEY,
|
|
40
|
-
resolveDbUrlOrNull("tenant"),
|
|
41
|
-
]) {
|
|
42
|
-
if (s && s.length >= 16) return s;
|
|
43
|
-
}
|
|
44
|
-
return null;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function hmac(payloadB64: string, key: string): string {
|
|
48
|
-
return new Bun.CryptoHasher("sha256", key).update(payloadB64).digest("base64url");
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** Constant-time string compare (equal-length); avoids node:crypto. */
|
|
52
|
-
function safeEqual(a: string, b: string): boolean {
|
|
53
|
-
if (a.length !== b.length) return false;
|
|
54
|
-
let diff = 0;
|
|
55
|
-
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
56
|
-
return diff === 0;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** Mint a token proving `email` is authorized for `resourceId` under `cookieName`. */
|
|
60
|
-
export function signAccessToken(
|
|
61
|
-
cookieName: string,
|
|
62
|
-
resourceId: string,
|
|
63
|
-
email: string,
|
|
64
|
-
nowMs: number,
|
|
65
|
-
ttlMs: number,
|
|
66
|
-
/**
|
|
67
|
-
* Optional display name to carry in the token (the public GPT lead-capture
|
|
68
|
-
* gate uses this so a returning visitor's name is known at SSR — first paint
|
|
69
|
-
* renders the full identity, not just the email). Allow-list gates
|
|
70
|
-
* (proposals/agreements) omit it.
|
|
71
|
-
*/
|
|
72
|
-
name?: string,
|
|
73
|
-
/**
|
|
74
|
-
* Optional captured phone, same reasoning as `name`. It exists so a capture
|
|
75
|
-
* gate needs NO browser storage to remember it: the operator's ruling is
|
|
76
|
-
* that identity lives in the database and the request, never in
|
|
77
|
-
* localStorage.
|
|
78
|
-
*/
|
|
79
|
-
phone?: string,
|
|
80
|
-
/**
|
|
81
|
-
* Optional prospect segmentation captured alongside the identity on the
|
|
82
|
-
* quiz's composed gate screen (company / LinkedIn / website / team size).
|
|
83
|
-
* Same reasoning as name/phone: it rides the signed request, never client
|
|
84
|
-
* storage. OPTIONAL and absent from older tokens — a token without it
|
|
85
|
-
* verifies exactly as before.
|
|
86
|
-
*/
|
|
87
|
-
segmentation?: GateSegmentation,
|
|
88
|
-
): string | null {
|
|
89
|
-
const key = secret();
|
|
90
|
-
if (!key) return null;
|
|
91
|
-
const payload: {
|
|
92
|
-
c: string;
|
|
93
|
-
r: string;
|
|
94
|
-
e: string;
|
|
95
|
-
x: number;
|
|
96
|
-
n?: string;
|
|
97
|
-
p?: string;
|
|
98
|
-
s?: GateSegmentation;
|
|
99
|
-
} = {
|
|
100
|
-
c: cookieName,
|
|
101
|
-
r: resourceId,
|
|
102
|
-
e: email.trim().toLowerCase(),
|
|
103
|
-
x: nowMs + ttlMs,
|
|
104
|
-
};
|
|
105
|
-
const trimmedName = name?.trim();
|
|
106
|
-
if (trimmedName) payload.n = trimmedName;
|
|
107
|
-
const trimmedPhone = phone?.trim();
|
|
108
|
-
if (trimmedPhone) payload.p = trimmedPhone;
|
|
109
|
-
const seg: GateSegmentation = {};
|
|
110
|
-
const segCompany = segmentation?.company?.trim();
|
|
111
|
-
if (segCompany) seg.company = segCompany;
|
|
112
|
-
const segLinkedin = segmentation?.linkedinUrl?.trim();
|
|
113
|
-
if (segLinkedin) seg.linkedinUrl = segLinkedin;
|
|
114
|
-
const segWebsite = segmentation?.website?.trim();
|
|
115
|
-
if (segWebsite) seg.website = segWebsite;
|
|
116
|
-
const segTeamSize = segmentation?.teamSize?.trim();
|
|
117
|
-
if (segTeamSize) seg.teamSize = segTeamSize;
|
|
118
|
-
if (Object.keys(seg).length > 0) payload.s = seg;
|
|
119
|
-
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
120
|
-
return `${payloadB64}.${hmac(payloadB64, key)}`;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Validate a token for `cookieName` + `resourceId`. Returns the verified email
|
|
125
|
-
* on success, null otherwise (bad signature, wrong gate, wrong resource,
|
|
126
|
-
* expired, no secret).
|
|
127
|
-
*/
|
|
128
|
-
export function verifyAccessToken(
|
|
129
|
-
token: string | undefined | null,
|
|
130
|
-
cookieName: string,
|
|
131
|
-
resourceId: string,
|
|
132
|
-
nowMs: number,
|
|
133
|
-
): string | null {
|
|
134
|
-
if (!token) return null;
|
|
135
|
-
const key = secret();
|
|
136
|
-
if (!key) return null;
|
|
137
|
-
const dot = token.indexOf(".");
|
|
138
|
-
if (dot <= 0) return null;
|
|
139
|
-
const payloadB64 = token.slice(0, dot);
|
|
140
|
-
const sig = token.slice(dot + 1);
|
|
141
|
-
if (!safeEqual(sig, hmac(payloadB64, key))) return null;
|
|
142
|
-
try {
|
|
143
|
-
const p = JSON.parse(Buffer.from(payloadB64, "base64url").toString()) as {
|
|
144
|
-
c?: string;
|
|
145
|
-
r?: string;
|
|
146
|
-
e?: string;
|
|
147
|
-
x?: number;
|
|
148
|
-
};
|
|
149
|
-
if (p.c !== cookieName) return null;
|
|
150
|
-
if (p.r !== resourceId) return null;
|
|
151
|
-
if (typeof p.x !== "number" || p.x < nowMs) return null;
|
|
152
|
-
return typeof p.e === "string" ? p.e : null;
|
|
153
|
-
} catch {
|
|
154
|
-
return null;
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* Like `verifyAccessToken` but returns the full verified identity
|
|
160
|
-
* ({email, name, phone, segmentation}) so a gate that carries a display name in
|
|
161
|
-
* the token (the public GPT lead-capture gate) can seed the visitor's name — not
|
|
162
|
-
* just the email — at SSR, and the quiz gate can seed the captured prospect
|
|
163
|
-
* segmentation the same way. Same signature/expiry/gate-binding checks; null on
|
|
164
|
-
* any failure. A token minted BEFORE segmentation existed carries no `s` key and
|
|
165
|
-
* verifies identically (segmentation comes back undefined) — never an error.
|
|
166
|
-
*/
|
|
167
|
-
export function verifyAccessTokenIdentity(
|
|
168
|
-
token: string | undefined | null,
|
|
169
|
-
cookieName: string,
|
|
170
|
-
resourceId: string,
|
|
171
|
-
nowMs: number,
|
|
172
|
-
): {
|
|
173
|
-
email: string;
|
|
174
|
-
name?: string;
|
|
175
|
-
phone?: string;
|
|
176
|
-
segmentation?: GateSegmentation;
|
|
177
|
-
} | null {
|
|
178
|
-
if (!token) return null;
|
|
179
|
-
const key = secret();
|
|
180
|
-
if (!key) return null;
|
|
181
|
-
const dot = token.indexOf(".");
|
|
182
|
-
if (dot <= 0) return null;
|
|
183
|
-
const payloadB64 = token.slice(0, dot);
|
|
184
|
-
const sig = token.slice(dot + 1);
|
|
185
|
-
if (!safeEqual(sig, hmac(payloadB64, key))) return null;
|
|
186
|
-
try {
|
|
187
|
-
const p = JSON.parse(Buffer.from(payloadB64, "base64url").toString()) as {
|
|
188
|
-
c?: string;
|
|
189
|
-
r?: string;
|
|
190
|
-
e?: string;
|
|
191
|
-
x?: number;
|
|
192
|
-
n?: string;
|
|
193
|
-
p?: string;
|
|
194
|
-
s?: GateSegmentation;
|
|
195
|
-
};
|
|
196
|
-
if (p.c !== cookieName) return null;
|
|
197
|
-
if (p.r !== resourceId) return null;
|
|
198
|
-
if (typeof p.x !== "number" || p.x < nowMs) return null;
|
|
199
|
-
if (typeof p.e !== "string") return null;
|
|
200
|
-
const segmentation =
|
|
201
|
-
p.s && typeof p.s === "object"
|
|
202
|
-
? {
|
|
203
|
-
company: typeof p.s.company === "string" ? p.s.company : undefined,
|
|
204
|
-
linkedinUrl:
|
|
205
|
-
typeof p.s.linkedinUrl === "string" ? p.s.linkedinUrl : undefined,
|
|
206
|
-
website: typeof p.s.website === "string" ? p.s.website : undefined,
|
|
207
|
-
teamSize: typeof p.s.teamSize === "string" ? p.s.teamSize : undefined,
|
|
208
|
-
}
|
|
209
|
-
: undefined;
|
|
210
|
-
return {
|
|
211
|
-
email: p.e,
|
|
212
|
-
name: typeof p.n === "string" ? p.n : undefined,
|
|
213
|
-
phone: typeof p.p === "string" ? p.p : undefined,
|
|
214
|
-
segmentation,
|
|
215
|
-
};
|
|
216
|
-
} catch {
|
|
217
|
-
return null;
|
|
218
|
-
}
|
|
219
|
-
}
|