@teamscala/service-boot 0.23.524 → 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/authorized-emails.ts +0 -7
- package/src/agreement-gate/placeholder-strip.ts +0 -2
- package/src/agreement-gate/settings.ts +0 -15
- package/src/agreement-gate.ts +0 -34
- package/src/attach-proposal-accept.ts +1 -1
- package/src/attach-proposal-magic.ts +1 -1
- package/src/auth/sign-out.ts +0 -2
- package/src/auth-gate/public-paths.ts +0 -86
- package/src/auth-gate.ts +0 -19
- package/src/bind-page-handler.ts +0 -76
- package/src/build-client-assets/hydrate-bundle.ts +0 -126
- package/src/build-client-assets/manifest.ts +0 -18
- package/src/build-client-assets/source-candidates.ts +0 -34
- package/src/build-client-assets/sourcemaps-upload.ts +0 -42
- package/src/build-client-assets/styles-build.ts +0 -50
- package/src/capability-slug.ts +0 -10
- package/src/compose-page-title.ts +0 -9
- package/src/content-gate/access.ts +0 -26
- package/src/content-gate/catalog.ts +0 -29
- package/src/content-gate/config-types.ts +3 -84
- package/src/content-gate/gate.ts +0 -20
- package/src/content-page-floor.ts +0 -11
- package/src/error-page-logo.ts +2 -12
- package/src/expand-roster.ts +0 -9
- package/src/gpt-gate.ts +1 -1
- package/src/hono-error-handler.ts +0 -4
- package/src/install-logging.ts +0 -32
- package/src/load-page-manifest.ts +0 -6
- package/src/load-service-install-row.ts +41 -4
- package/src/module-not-found.ts +0 -11
- package/src/mount-orpc-router.ts +1 -88
- package/src/mount-server-packages.ts +0 -29
- package/src/mutable-app-ref.ts +0 -11
- package/src/orpc-contract-rows.json +289 -0
- package/src/orpc-openapi-document.ts +0 -6
- package/src/orpc-openapi-gate.ts +0 -10
- package/src/pg-boss-handler-registry.ts +0 -12
- package/src/pg-boss-mount.ts +0 -101
- package/src/proposal-gate.ts +1 -41
- package/src/quiz-gate.ts +1 -9
- package/src/register-page-server-client.ts +0 -53
- package/src/register-registry-page-route/ai-chat-prefetch.ts +0 -18
- package/src/register-registry-page-route/apply-page-branding.ts +0 -90
- package/src/register-registry-page-route/captured-loader-config.ts +0 -1
- package/src/register-registry-page-route/graph-cache.ts +0 -17
- package/src/register-registry-page-route/meta-title.ts +0 -24
- package/src/register-registry-page-route/page-data-shared.ts +0 -6
- package/src/register-registry-page-route/prefetch-org-id.ts +0 -17
- package/src/register-registry-page-route/proposal-approval-email-token.ts +0 -11
- package/src/register-registry-page-route/proposal-brand-stamping.ts +0 -20
- package/src/register-registry-page-route/public-shell-page-data.ts +0 -26
- package/src/register-registry-page-route/resolve-request-graph.ts +0 -57
- package/src/register-registry-page-route/run-content-gates.ts +2 -54
- package/src/register-registry-page-route/warn-uncovered-blocks.ts +0 -10
- package/src/register-registry-page-route.ts +0 -75
- package/src/register-static-assets.ts +0 -45
- package/src/resolve-brand-favicon.ts +0 -9
- package/src/resolve-service-port.ts +0 -11
- package/src/resolve-theme-mode.ts +0 -8
- package/src/resolvers/app-header.ts +0 -1
- package/src/resolvers/fetch-org-brand.ts +0 -1
- package/src/resolvers/portal-route-paths.ts +0 -17
- package/src/resource-not-found.ts +0 -24
- package/src/select-service-routes.ts +0 -14
- package/src/theme-fonts-link.ts +0 -5
- package/src/two-phase-full-app.ts +0 -76
- package/src/two-phase-minimal-app.ts +1 -105
- package/src/types.ts +0 -14
- package/src/ui-service.ts +1 -42
- 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 -197
- package/src/is-react-element-type.test.ts +0 -45
- package/src/is-react-element-type.ts +0 -10
- package/src/page-server-orpc/hub-mcp-client.ts +0 -14
- 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 -357
|
@@ -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,197 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @system service-boot
|
|
3
|
-
* @status handwritten
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import type { GateSegmentation } from "./config-types.ts";
|
|
7
|
-
import { resolveDbUrlOrNull } from "@teamscala/db/db-url";
|
|
8
|
-
|
|
9
|
-
function secret(): string | null {
|
|
10
|
-
// The last resort is the tenant DB address, read through the named-need
|
|
11
|
-
// resolver (wrong-database guard, 2026-09-27) — never `process.env` here;
|
|
12
|
-
// the resolver is the only env-aware surface and it names the plane. It is
|
|
13
|
-
// used only as a stable per-deployment string (≥16 chars), never as a
|
|
14
|
-
// connection target.
|
|
15
|
-
for (const s of [
|
|
16
|
-
process.env.AUTH_SECRET,
|
|
17
|
-
process.env.ENCRYPTION_KEY,
|
|
18
|
-
resolveDbUrlOrNull("tenant"),
|
|
19
|
-
]) {
|
|
20
|
-
if (s && s.length >= 16) return s;
|
|
21
|
-
}
|
|
22
|
-
return null;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function hmac(payloadB64: string, key: string): string {
|
|
26
|
-
return new Bun.CryptoHasher("sha256", key).update(payloadB64).digest("base64url");
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** Constant-time string compare (equal-length); avoids node:crypto. */
|
|
30
|
-
function safeEqual(a: string, b: string): boolean {
|
|
31
|
-
if (a.length !== b.length) return false;
|
|
32
|
-
let diff = 0;
|
|
33
|
-
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
34
|
-
return diff === 0;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Mint a token proving `email` is authorized for `resourceId` under `cookieName`. */
|
|
38
|
-
export function signAccessToken(
|
|
39
|
-
cookieName: string,
|
|
40
|
-
resourceId: string,
|
|
41
|
-
email: string,
|
|
42
|
-
nowMs: number,
|
|
43
|
-
ttlMs: number,
|
|
44
|
-
/**
|
|
45
|
-
* Optional display name to carry in the token (the public GPT lead-capture
|
|
46
|
-
* gate uses this so a returning visitor's name is known at SSR — first paint
|
|
47
|
-
* renders the full identity, not just the email). Allow-list gates
|
|
48
|
-
* (proposals/agreements) omit it.
|
|
49
|
-
*/
|
|
50
|
-
name?: string,
|
|
51
|
-
/**
|
|
52
|
-
* Optional captured phone, same reasoning as `name`. It exists so a capture
|
|
53
|
-
* gate needs NO browser storage to remember it: the operator's ruling is
|
|
54
|
-
* that identity lives in the database and the request, never in
|
|
55
|
-
* localStorage.
|
|
56
|
-
*/
|
|
57
|
-
phone?: string,
|
|
58
|
-
/**
|
|
59
|
-
* Optional prospect segmentation captured alongside the identity on the
|
|
60
|
-
* quiz's composed gate screen (company / LinkedIn / website / team size).
|
|
61
|
-
* Same reasoning as name/phone: it rides the signed request, never client
|
|
62
|
-
* storage. OPTIONAL and absent from older tokens — a token without it
|
|
63
|
-
* verifies exactly as before.
|
|
64
|
-
*/
|
|
65
|
-
segmentation?: GateSegmentation,
|
|
66
|
-
): string | null {
|
|
67
|
-
const key = secret();
|
|
68
|
-
if (!key) return null;
|
|
69
|
-
const payload: {
|
|
70
|
-
c: string;
|
|
71
|
-
r: string;
|
|
72
|
-
e: string;
|
|
73
|
-
x: number;
|
|
74
|
-
n?: string;
|
|
75
|
-
p?: string;
|
|
76
|
-
s?: GateSegmentation;
|
|
77
|
-
} = {
|
|
78
|
-
c: cookieName,
|
|
79
|
-
r: resourceId,
|
|
80
|
-
e: email.trim().toLowerCase(),
|
|
81
|
-
x: nowMs + ttlMs,
|
|
82
|
-
};
|
|
83
|
-
const trimmedName = name?.trim();
|
|
84
|
-
if (trimmedName) payload.n = trimmedName;
|
|
85
|
-
const trimmedPhone = phone?.trim();
|
|
86
|
-
if (trimmedPhone) payload.p = trimmedPhone;
|
|
87
|
-
const seg: GateSegmentation = {};
|
|
88
|
-
const segCompany = segmentation?.company?.trim();
|
|
89
|
-
if (segCompany) seg.company = segCompany;
|
|
90
|
-
const segLinkedin = segmentation?.linkedinUrl?.trim();
|
|
91
|
-
if (segLinkedin) seg.linkedinUrl = segLinkedin;
|
|
92
|
-
const segWebsite = segmentation?.website?.trim();
|
|
93
|
-
if (segWebsite) seg.website = segWebsite;
|
|
94
|
-
const segTeamSize = segmentation?.teamSize?.trim();
|
|
95
|
-
if (segTeamSize) seg.teamSize = segTeamSize;
|
|
96
|
-
if (Object.keys(seg).length > 0) payload.s = seg;
|
|
97
|
-
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
98
|
-
return `${payloadB64}.${hmac(payloadB64, key)}`;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Validate a token for `cookieName` + `resourceId`. Returns the verified email
|
|
103
|
-
* on success, null otherwise (bad signature, wrong gate, wrong resource,
|
|
104
|
-
* expired, no secret).
|
|
105
|
-
*/
|
|
106
|
-
export function verifyAccessToken(
|
|
107
|
-
token: string | undefined | null,
|
|
108
|
-
cookieName: string,
|
|
109
|
-
resourceId: string,
|
|
110
|
-
nowMs: number,
|
|
111
|
-
): string | null {
|
|
112
|
-
if (!token) return null;
|
|
113
|
-
const key = secret();
|
|
114
|
-
if (!key) return null;
|
|
115
|
-
const dot = token.indexOf(".");
|
|
116
|
-
if (dot <= 0) return null;
|
|
117
|
-
const payloadB64 = token.slice(0, dot);
|
|
118
|
-
const sig = token.slice(dot + 1);
|
|
119
|
-
if (!safeEqual(sig, hmac(payloadB64, key))) return null;
|
|
120
|
-
try {
|
|
121
|
-
const p = JSON.parse(Buffer.from(payloadB64, "base64url").toString()) as {
|
|
122
|
-
c?: string;
|
|
123
|
-
r?: string;
|
|
124
|
-
e?: string;
|
|
125
|
-
x?: number;
|
|
126
|
-
};
|
|
127
|
-
if (p.c !== cookieName) return null;
|
|
128
|
-
if (p.r !== resourceId) return null;
|
|
129
|
-
if (typeof p.x !== "number" || p.x < nowMs) return null;
|
|
130
|
-
return typeof p.e === "string" ? p.e : null;
|
|
131
|
-
} catch {
|
|
132
|
-
return null;
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Like `verifyAccessToken` but returns the full verified identity
|
|
138
|
-
* ({email, name, phone, segmentation}) so a gate that carries a display name in
|
|
139
|
-
* the token (the public GPT lead-capture gate) can seed the visitor's name — not
|
|
140
|
-
* just the email — at SSR, and the quiz gate can seed the captured prospect
|
|
141
|
-
* segmentation the same way. Same signature/expiry/gate-binding checks; null on
|
|
142
|
-
* any failure. A token minted BEFORE segmentation existed carries no `s` key and
|
|
143
|
-
* verifies identically (segmentation comes back undefined) — never an error.
|
|
144
|
-
*/
|
|
145
|
-
export function verifyAccessTokenIdentity(
|
|
146
|
-
token: string | undefined | null,
|
|
147
|
-
cookieName: string,
|
|
148
|
-
resourceId: string,
|
|
149
|
-
nowMs: number,
|
|
150
|
-
): {
|
|
151
|
-
email: string;
|
|
152
|
-
name?: string;
|
|
153
|
-
phone?: string;
|
|
154
|
-
segmentation?: GateSegmentation;
|
|
155
|
-
} | null {
|
|
156
|
-
if (!token) return null;
|
|
157
|
-
const key = secret();
|
|
158
|
-
if (!key) return null;
|
|
159
|
-
const dot = token.indexOf(".");
|
|
160
|
-
if (dot <= 0) return null;
|
|
161
|
-
const payloadB64 = token.slice(0, dot);
|
|
162
|
-
const sig = token.slice(dot + 1);
|
|
163
|
-
if (!safeEqual(sig, hmac(payloadB64, key))) return null;
|
|
164
|
-
try {
|
|
165
|
-
const p = JSON.parse(Buffer.from(payloadB64, "base64url").toString()) as {
|
|
166
|
-
c?: string;
|
|
167
|
-
r?: string;
|
|
168
|
-
e?: string;
|
|
169
|
-
x?: number;
|
|
170
|
-
n?: string;
|
|
171
|
-
p?: string;
|
|
172
|
-
s?: GateSegmentation;
|
|
173
|
-
};
|
|
174
|
-
if (p.c !== cookieName) return null;
|
|
175
|
-
if (p.r !== resourceId) return null;
|
|
176
|
-
if (typeof p.x !== "number" || p.x < nowMs) return null;
|
|
177
|
-
if (typeof p.e !== "string") return null;
|
|
178
|
-
const segmentation =
|
|
179
|
-
p.s && typeof p.s === "object"
|
|
180
|
-
? {
|
|
181
|
-
company: typeof p.s.company === "string" ? p.s.company : undefined,
|
|
182
|
-
linkedinUrl:
|
|
183
|
-
typeof p.s.linkedinUrl === "string" ? p.s.linkedinUrl : undefined,
|
|
184
|
-
website: typeof p.s.website === "string" ? p.s.website : undefined,
|
|
185
|
-
teamSize: typeof p.s.teamSize === "string" ? p.s.teamSize : undefined,
|
|
186
|
-
}
|
|
187
|
-
: undefined;
|
|
188
|
-
return {
|
|
189
|
-
email: p.e,
|
|
190
|
-
name: typeof p.n === "string" ? p.n : undefined,
|
|
191
|
-
phone: typeof p.p === "string" ? p.p : undefined,
|
|
192
|
-
segmentation,
|
|
193
|
-
};
|
|
194
|
-
} catch {
|
|
195
|
-
return null;
|
|
196
|
-
}
|
|
197
|
-
}
|
|
@@ -1,45 +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
|
-
import { expect, test } from "bun:test";
|
|
11
|
-
import { forwardRef, lazy, memo } from "react";
|
|
12
|
-
import { isReactElementType } from "./is-react-element-type.ts";
|
|
13
|
-
|
|
14
|
-
function Plain() {
|
|
15
|
-
return null;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
test("accepts a plain function component", () => {
|
|
19
|
-
expect(isReactElementType(Plain)).toBe(true);
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
test("accepts memo() — the PresenceAvatars shape that rendered blank", () => {
|
|
23
|
-
expect(isReactElementType(memo(Plain))).toBe(true);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
test("accepts forwardRef()", () => {
|
|
27
|
-
expect(isReactElementType(forwardRef(() => null))).toBe(true);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
test("accepts lazy()", () => {
|
|
31
|
-
expect(isReactElementType(lazy(async () => ({ default: Plain })))).toBe(true);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
test("still rejects what genuinely cannot render", () => {
|
|
35
|
-
expect(isReactElementType(undefined)).toBe(false);
|
|
36
|
-
expect(isReactElementType(null)).toBe(false);
|
|
37
|
-
expect(isReactElementType({})).toBe(false);
|
|
38
|
-
expect(isReactElementType("PresenceAvatarsBlock")).toBe(false);
|
|
39
|
-
expect(isReactElementType(42)).toBe(false);
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
test("rejects a plain object wearing a non-symbol $$typeof", () => {
|
|
43
|
-
// A data blob with the key but not the symbol must not pass as renderable.
|
|
44
|
-
expect(isReactElementType({ $$typeof: "react.memo" })).toBe(false);
|
|
45
|
-
});
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @system service-boot
|
|
3
|
-
* @status handwritten
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
export function isReactElementType(value: unknown): boolean {
|
|
7
|
-
if (typeof value === "function") return true;
|
|
8
|
-
if (typeof value !== "object" || value === null) return false;
|
|
9
|
-
return typeof (value as { $$typeof?: unknown }).$$typeof === "symbol";
|
|
10
|
-
}
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @system service-boot
|
|
3
|
-
* @status handwritten
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
let _hubMcpClient: unknown | null = null;
|
|
7
|
-
|
|
8
|
-
export function setHubMcpClient(client: unknown): void {
|
|
9
|
-
_hubMcpClient = client;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export function getHubMcpClient(): unknown | undefined {
|
|
13
|
-
return _hubMcpClient ?? undefined;
|
|
14
|
-
}
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @system server-boot
|
|
3
|
-
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* The mount's poll-only contract: pg-boss v12 dispatches by polling, so the
|
|
7
|
-
* mount dials the INJECTED transaction-pooler URL unchanged (no endpoint
|
|
8
|
-
* derivation of its own — the session-port mapping died with the door,
|
|
9
|
-
* 2026-09-14) and constructs with useListenNotify false spelled explicitly
|
|
10
|
-
* rather than inherited from the supplier default, so a pg-boss bump cannot
|
|
11
|
-
* silently re-introduce a LISTEN dependency. The mock captures the opts
|
|
12
|
-
* PgBoss is constructed with — the claim is about what the mount DIALS and
|
|
13
|
-
* how it dispatches, so the assertions are on the captured opts.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { afterEach, describe, expect, it, mock } from "bun:test";
|
|
17
|
-
|
|
18
|
-
interface CapturedOpts {
|
|
19
|
-
connectionString?: string;
|
|
20
|
-
max?: number;
|
|
21
|
-
useListenNotify?: boolean;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const POOLED_URL =
|
|
25
|
-
"postgresql://scala_container_gbg.gbg:s3cret@10.205.50.1:6543/scala_container_gbg?sslmode=require";
|
|
26
|
-
|
|
27
|
-
function seedScheduledJob() {
|
|
28
|
-
return {
|
|
29
|
-
id: "test-job-1",
|
|
30
|
-
type: "scheduled_job",
|
|
31
|
-
slug: "scheduled_job:test-sync",
|
|
32
|
-
label: "Test Sync Job",
|
|
33
|
-
description: null,
|
|
34
|
-
config: {
|
|
35
|
-
handler_slug: "@teamscala/sync-worker:processSyncQueue",
|
|
36
|
-
cron: "*/5 * * * *",
|
|
37
|
-
queue_name: "sync-worker",
|
|
38
|
-
},
|
|
39
|
-
is_active: true,
|
|
40
|
-
sort_order: 0,
|
|
41
|
-
created_at: new Date(),
|
|
42
|
-
updated_at: new Date(),
|
|
43
|
-
noun: "scheduled_job",
|
|
44
|
-
kind: "scheduled_job",
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
describe("mountPgBoss poll-only contract", () => {
|
|
49
|
-
afterEach(() => {
|
|
50
|
-
mock.restore();
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it("dials the injected transaction pooler unchanged and dispatches poll-only", async () => {
|
|
54
|
-
let captured: CapturedOpts | null = null;
|
|
55
|
-
|
|
56
|
-
mock.module("pg-boss", () => ({
|
|
57
|
-
PgBoss: mock((opts: CapturedOpts) => {
|
|
58
|
-
captured = opts;
|
|
59
|
-
return {
|
|
60
|
-
start: mock(() => Promise.resolve()),
|
|
61
|
-
stop: mock(() => Promise.resolve()),
|
|
62
|
-
on: mock(() => undefined),
|
|
63
|
-
createQueue: mock(() => Promise.resolve()),
|
|
64
|
-
work: mock(() => Promise.resolve("worker-id")),
|
|
65
|
-
schedule: mock(() => Promise.resolve()),
|
|
66
|
-
getSchedules: mock(() => Promise.resolve([])),
|
|
67
|
-
unschedule: mock(() => Promise.resolve()),
|
|
68
|
-
};
|
|
69
|
-
}),
|
|
70
|
-
}));
|
|
71
|
-
mock.module("@teamscala/db/registry/accessors", () => ({
|
|
72
|
-
getAllEntries: () => [seedScheduledJob()],
|
|
73
|
-
}));
|
|
74
|
-
mock.module("@teamscala/event-log/record-event", () => ({
|
|
75
|
-
recordEvent: () => undefined,
|
|
76
|
-
}));
|
|
77
|
-
|
|
78
|
-
const { mountPgBoss } = await import("./pg-boss-mount.ts");
|
|
79
|
-
await mountPgBoss({
|
|
80
|
-
databaseUrl: POOLED_URL,
|
|
81
|
-
serviceSlug: "scala-sync-worker",
|
|
82
|
-
packages: ["@teamscala/sync-worker"],
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
const capturedOpts = captured as unknown as CapturedOpts | undefined;
|
|
86
|
-
const connectionString = capturedOpts?.connectionString ?? "";
|
|
87
|
-
// The transaction pooler, exactly as injected — the mount adds no
|
|
88
|
-
// endpoint derivation.
|
|
89
|
-
expect(connectionString).toContain(":6543");
|
|
90
|
-
// NEVER the session port: the door is closing, not widening.
|
|
91
|
-
expect(connectionString).not.toContain(":5999");
|
|
92
|
-
// The tenant suffix the pooler routes by is kept.
|
|
93
|
-
expect(connectionString).toContain("scala_container_gbg.gbg:");
|
|
94
|
-
// The pg sslmode dialect rewrite still applies.
|
|
95
|
-
expect(connectionString).toContain("sslmode=no-verify");
|
|
96
|
-
// Poll-only, spelled: no LISTEN dependency, now and after a bump.
|
|
97
|
-
expect(capturedOpts?.useListenNotify).toBe(false);
|
|
98
|
-
// pg-boss stays a single sibling connection (pool sizing doctrine).
|
|
99
|
-
expect(capturedOpts?.max).toBe(1);
|
|
100
|
-
});
|
|
101
|
-
});
|