@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,71 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @system service-boot
|
|
3
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
4
|
*/
|
|
11
5
|
|
|
6
|
+
import type { GateSegmentation } from "@teamscala/agreement-generation/content-gate-token";
|
|
7
|
+
|
|
12
8
|
type Resolved = Record<string, unknown>;
|
|
13
9
|
|
|
14
|
-
|
|
15
|
-
* Optional prospect-segmentation a lead-capture gate may capture alongside the
|
|
16
|
-
* identity. The quiz gate collects these on its composed identity screen; the
|
|
17
|
-
* token carries them so the next render and the final submit know the visitor
|
|
18
|
-
* fully, with NO client storage (same reasoning as the captured name/phone).
|
|
19
|
-
*/
|
|
20
|
-
export interface GateSegmentation {
|
|
21
|
-
company?: string;
|
|
22
|
-
linkedinUrl?: string;
|
|
23
|
-
website?: string;
|
|
24
|
-
teamSize?: string;
|
|
25
|
-
}
|
|
10
|
+
export type { GateSegmentation };
|
|
26
11
|
|
|
27
|
-
/** Drives the SSR-time gate (gate.ts / runContentGate). */
|
|
28
12
|
export interface ContentGateConfig {
|
|
29
|
-
/** Cookie that carries this gate's access token (e.g. "ag_token"). */
|
|
30
13
|
cookieName: string;
|
|
31
|
-
/**
|
|
32
|
-
* The gated resource id from the resolved graph, or "" when this gate does
|
|
33
|
-
* not apply to the current page (the engine then no-ops). This is also how a
|
|
34
|
-
* gate detects "is this my kind of page". Async to allow DB-backed resolution
|
|
35
|
-
* (e.g. agreement gate resolving the UUID from the page→agreement association
|
|
36
|
-
* when no pre-placed gate block exists).
|
|
37
|
-
*/
|
|
38
14
|
getResourceId(resolved: Resolved): string | Promise<string>;
|
|
39
|
-
/**
|
|
40
|
-
* Verify the cookie's token for this resource → the verified identity
|
|
41
|
-
* ({email, optional display name}), or null. Token-agnostic by design:
|
|
42
|
-
* proposals use the shared content-gate token; agreements keep their existing
|
|
43
|
-
* token (also minted by the signing ORPC), so the engine never assumes one
|
|
44
|
-
* token format. Allow-list gates return just {email}; the GPT lead-capture
|
|
45
|
-
* gate also returns the captured name (carried in its token).
|
|
46
|
-
*/
|
|
47
15
|
verifyToken(
|
|
48
16
|
token: string | undefined,
|
|
49
17
|
resourceId: string,
|
|
50
18
|
nowMs: number,
|
|
51
19
|
): { email: string; name?: string; phone?: string; segmentation?: GateSegmentation } | null;
|
|
52
|
-
/**
|
|
53
|
-
* BAKED-IN unified auth. When a gate provides this resolver, the engine
|
|
54
|
-
* AUTOMATICALLY releases a logged-in viewer whose session email is in the
|
|
55
|
-
* authorized set — with no valid token and no per-gate code. This is the
|
|
56
|
-
* platform default for EVERY gated resource: any application surface with
|
|
57
|
-
* authentication gets "a signed-in, authorized viewer is never re-asked for
|
|
58
|
-
* their email/OTP" for free, just by exposing its authorized-email set (the
|
|
59
|
-
* same resolver its AccessConfig uses). Not called when the session is
|
|
60
|
-
* anonymous. Omit only for open-capture gates that have no authorized set.
|
|
61
|
-
*/
|
|
62
20
|
resolveAuthorizedEmails?(resourceId: string): Promise<Set<string>>;
|
|
63
|
-
/**
|
|
64
|
-
* OPTIONAL OVERRIDE of the baked-in session check above — for the rare gate
|
|
65
|
-
* whose session→identity rule isn't simply "session email ∈ authorized set".
|
|
66
|
-
* When present it wins; otherwise the engine uses `resolveAuthorizedEmails`.
|
|
67
|
-
* Called only when no valid access token is present.
|
|
68
|
-
*/
|
|
69
21
|
sessionAuthorize?(
|
|
70
22
|
resolved: Resolved,
|
|
71
23
|
resourceId: string,
|
|
@@ -73,62 +25,23 @@ export interface ContentGateConfig {
|
|
|
73
25
|
| { email: string; name?: string }
|
|
74
26
|
| null
|
|
75
27
|
| Promise<{ email: string; name?: string } | null>;
|
|
76
|
-
/** Reveal + seed the verified viewer's identity/session (token was valid). */
|
|
77
28
|
onAuthorized(
|
|
78
29
|
resolved: Resolved,
|
|
79
30
|
resourceId: string,
|
|
80
31
|
identity: { email: string; name?: string; phone?: string; segmentation?: GateSegmentation },
|
|
81
32
|
): void | Promise<void>;
|
|
82
|
-
/** Withhold content (strip/inject the gate) — token absent/invalid. Async-
|
|
83
|
-
* capable so a gate can load its display/behaviour settings from a registry
|
|
84
|
-
* row (DB-driven, not hardcoded); sync implementations remain valid. */
|
|
85
33
|
onUnauthorized(resolved: Resolved, resourceId: string): void | Promise<void>;
|
|
86
|
-
/**
|
|
87
|
-
* OPT-IN REQUIRED-GATE-ENTRY. When set, a valid token authorizes ONLY on a
|
|
88
|
-
* request that also carries this marker cookie — set by the access endpoint
|
|
89
|
-
* alongside the token and consumed (cleared) by the render it authorizes, so
|
|
90
|
-
* the marker lives exactly one view: the submit's own redirect releases the
|
|
91
|
-
* content, and every later load (a refresh) is unauthorized, showing the
|
|
92
|
-
* gate again. Omit for gates whose token is session-scoped by design.
|
|
93
|
-
*/
|
|
94
34
|
entryCookieName?: string;
|
|
95
35
|
}
|
|
96
36
|
|
|
97
|
-
/** Drives the public access endpoint (access.ts / attachContentGateAccess). */
|
|
98
37
|
export interface AccessConfig {
|
|
99
|
-
/** Route to mount, e.g. "/api/proposal-access". */
|
|
100
38
|
path: string;
|
|
101
|
-
/** Cookie to set on a match. */
|
|
102
39
|
cookieName: string;
|
|
103
|
-
/** Cookie lifetime in ms (Max-Age). */
|
|
104
40
|
ttlMs: number;
|
|
105
|
-
/**
|
|
106
|
-
* Marker cookie set alongside the token on a match. The gate requires it
|
|
107
|
-
* (ContentGateConfig.entryCookieName) and the render it authorizes clears
|
|
108
|
-
* it, so it authorizes exactly the submit's own redirect.
|
|
109
|
-
*/
|
|
110
41
|
entryCookieName?: string;
|
|
111
|
-
/** Form field carrying the resource id, e.g. "proposalId". */
|
|
112
42
|
recordIdField: string;
|
|
113
|
-
/**
|
|
114
|
-
* Open lead-capture gate: ANY non-empty email is accepted (no allow-list).
|
|
115
|
-
* For surfaces whose "gate" captures a visitor rather than authorizing one
|
|
116
|
-
* against a fixed set (the public GPT). When true, `resolveAuthorizedEmails`
|
|
117
|
-
* is not called. Default/omitted = allow-list mode (proposals, agreements).
|
|
118
|
-
*/
|
|
119
43
|
openCapture?: boolean;
|
|
120
|
-
/**
|
|
121
|
-
* The authorized email set for a resource (lowercased), checked server-side.
|
|
122
|
-
* Not called when `openCapture` is true.
|
|
123
|
-
*/
|
|
124
44
|
resolveAuthorizedEmails(resourceId: string): Promise<Set<string>>;
|
|
125
|
-
/**
|
|
126
|
-
* Mint the access token for a matched email (token-agnostic per gate).
|
|
127
|
-
* `name` and `phone` are the optional captured identity, `segmentation` the
|
|
128
|
-
* optional captured prospect fields (open-capture gates carry them in the
|
|
129
|
-
* token; allow-list gates ignore them). They exist so a capture gate needs
|
|
130
|
-
* no browser storage to remember who this is.
|
|
131
|
-
*/
|
|
132
45
|
signToken(
|
|
133
46
|
resourceId: string,
|
|
134
47
|
email: string,
|
|
@@ -1,24 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @system service-boot
|
|
3
3
|
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* REQUEST-SCOPED channel for gate-entry marker clears. A gate that declares an
|
|
7
|
-
* entry marker (config.entryCookieName) consumes it on the render that the
|
|
8
|
-
* access endpoint's own redirect produces; that render's response must clear
|
|
9
|
-
* the marker, or the next load (a refresh) would still carry it and skip the
|
|
10
|
-
* gate. The gate writes the name here; bindPageHandler — which owns the page
|
|
11
|
-
* Response — drains it into Set-Cookie, so the marker lives exactly one view.
|
|
12
|
-
*
|
|
13
|
-
* THE CLEARS COME BACK AS THE CALL'S RETURN VALUE, never from a post-hoc
|
|
14
|
-
* read: the store only exists inside the run() scope, so a reader called after
|
|
15
|
-
* the awaited call has already left it and would see an empty list — which is
|
|
16
|
-
* exactly how the first version of this file shipped a marker that was never
|
|
17
|
-
* cleared (measured live 2026-09-14: the cookie survived the render).
|
|
18
|
-
*
|
|
19
|
-
* AsyncLocalStorage rather than a module-level map: request-scoped by
|
|
20
|
-
* construction, no accumulated process state that a restart would silently
|
|
21
|
-
* reset (in-memory-state-is-not-durable).
|
|
22
4
|
*/
|
|
23
5
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
24
6
|
|
package/src/content-gate/gate.ts
CHANGED
|
@@ -1,29 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @system service-boot
|
|
3
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
4
|
*/
|
|
12
5
|
|
|
13
|
-
/**
|
|
14
|
-
* What a gate decided, so a caller can act on it without re-deriving it.
|
|
15
|
-
*
|
|
16
|
-
* THE ASSISTANT MUST NOT OUTRUN THE GATE. Its runtime context is the whole
|
|
17
|
-
* document serialised, so injecting it for an unauthorized viewer would hand
|
|
18
|
-
* the gated bytes to the chat -- the exact thing the gate exists to withhold.
|
|
19
|
-
* The gate already knows the answer; returning it means the page-route
|
|
20
|
-
* assistant setting reads ONE decision instead of inventing a second notion of
|
|
21
|
-
* "is this viewer allowed" that could drift from this one.
|
|
22
|
-
*
|
|
23
|
-
* `applies: false` means the gate did not govern this page at all (a public
|
|
24
|
-
* page), which is different from an unauthorized viewer and must not be
|
|
25
|
-
* confused with one.
|
|
26
|
-
*/
|
|
27
6
|
|
|
28
7
|
import type { ContentGateConfig } from "./config-types.ts";
|
|
29
8
|
import { parseCookie } from "./parse-cookie.ts";
|
|
@@ -88,12 +67,6 @@ export async function runContentGate(
|
|
|
88
67
|
return { applies: true, authorized: false };
|
|
89
68
|
}
|
|
90
69
|
|
|
91
|
-
/**
|
|
92
|
-
* The signed-in viewer's authorized identity for this resource, or null. The
|
|
93
|
-
* default (used by every gate) is "session email ∈ the gate's authorized set";
|
|
94
|
-
* a gate may supply a custom sessionAuthorize to override. Anonymous sessions
|
|
95
|
-
* and gates with no authorized-email set return null (stay token-only).
|
|
96
|
-
*/
|
|
97
70
|
async function resolveSessionIdentity(
|
|
98
71
|
resolved: Record<string, unknown>,
|
|
99
72
|
resourceId: string,
|
|
@@ -1,31 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @system service-boot
|
|
3
3
|
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* Pure decision: does a CONTENT route (no resource_type) whose loader resolved
|
|
7
|
-
* a `page` body source warrant the branded not-found floor? Extracted from
|
|
8
|
-
* register-registry-page-route so the rule is unit-testable independent of the
|
|
9
|
-
* Hono/DB/graph machinery that wraps it (the fix's correctness does not depend
|
|
10
|
-
* on the publish — verify-before-done).
|
|
11
|
-
*
|
|
12
|
-
* Three outcomes:
|
|
13
|
-
* - no page row (deleted/unmatched path, e.g. /roadmap) → dead-link 404 floor
|
|
14
|
-
* - page row exists but zero content blocks → empty-page 200 floor
|
|
15
|
-
* - otherwise → null (render normally)
|
|
16
|
-
*
|
|
17
|
-
* The "Vision Toolbar" leak this closes: a content catchall synthesized an empty
|
|
18
|
-
* page for an unmatched path and served a cacheable 200; resource-not-found-is-
|
|
19
|
-
* the-root-floor only fired for resource_type routes, so dead content links fell
|
|
20
|
-
* through. See resource-not-found.ts for the floor + operator ruling 2026-07-21.
|
|
21
4
|
*/
|
|
22
5
|
import { PrimaryResourceMissingError } from "./resource-not-found.ts";
|
|
23
6
|
|
|
24
|
-
/**
|
|
25
|
-
* A page ROW exists but its body has zero content blocks — a content gap, not a
|
|
26
|
-
* dead link. True only when the row is present yet `puck_data` is absent or its
|
|
27
|
-
* `content` array is empty. Handles string (raw) and object (normalized) puck_data.
|
|
28
|
-
*/
|
|
29
7
|
export function pageBodyIsEmpty(page: unknown): boolean {
|
|
30
8
|
if (!page || typeof page !== "object") return false;
|
|
31
9
|
const obj = page as Record<string, unknown>;
|
|
@@ -44,12 +22,6 @@ export function pageBodyIsEmpty(page: unknown): boolean {
|
|
|
44
22
|
return !Array.isArray(data?.content) || (data.content as unknown[]).length === 0;
|
|
45
23
|
}
|
|
46
24
|
|
|
47
|
-
/**
|
|
48
|
-
* Decide whether a content route should fall to the branded not-found floor.
|
|
49
|
-
* Returns the error to throw (mapped to the floor by bind-page-handler), or null
|
|
50
|
-
* to render normally. `hasResourceType` routes are handled by the existing
|
|
51
|
-
* resource_type missing check (caller-side) — they return null here.
|
|
52
|
-
*/
|
|
53
25
|
export function decideContentPageFloor(opts: {
|
|
54
26
|
hasResourceType: boolean;
|
|
55
27
|
hasPageContentSource: boolean;
|
package/src/csrf-protection.ts
CHANGED
|
@@ -1,33 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @system
|
|
2
|
+
* @system service-boot
|
|
3
3
|
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* csrf-protection.ts — platform CSRF middleware (`security-csrf-protection`).
|
|
7
|
-
*
|
|
8
|
-
* Hono's csrf() with a DERIVED trusted-origin set, never a hand-authored
|
|
9
|
-
* allowlist: every trusted origin comes from ONE source per
|
|
10
|
-
* `offered-is-derived` —
|
|
11
|
-
* - the managed base domain (registry portal-domains) covers the platform's
|
|
12
|
-
* own `*.scala.business` origins, INCLUDING the cross-service form POST
|
|
13
|
-
* redirect chains CSP form-action allowlists (proposals → sign etc.), so
|
|
14
|
-
* the CSRF origin set and the CSP form-action set cannot disagree about
|
|
15
|
-
* what "one of ours" means;
|
|
16
|
-
* - customer custom domains resolve through `isTrustedAuthOrigin`
|
|
17
|
-
* (builda_websites lookup) — the SAME lookup better-auth's trustedOrigins
|
|
18
|
-
* uses in @teamscala/auth, so a new custom domain is trusted everywhere at
|
|
19
|
-
* once or nowhere.
|
|
20
|
-
*
|
|
21
|
-
* hono csrf() arms only on form-element content types (urlencoded, multipart,
|
|
22
|
-
* text/plain) — browsers cannot produce a cross-site application/json request
|
|
23
|
-
* from a form, so the JSON API surface (ORPC) is protected by cookie
|
|
24
|
-
* SameSite=Lax + this middleware's text/plain coverage.
|
|
25
|
-
*
|
|
26
|
-
* Custom-domain lookups are cached per-hostname with a TTL: a POSITIVE result
|
|
27
|
-
* is cached for the full TTL (a domain that stopped being ours keeps working
|
|
28
|
-
* for at most the TTL), a NEGATIVE result for a short window (a brand-new
|
|
29
|
-
* custom domain becomes trusted within seconds). The cache is a request
|
|
30
|
-
* accelerator over a DB fact, never the source of truth.
|
|
31
4
|
*/
|
|
32
5
|
|
|
33
6
|
import { getManagedBaseDomain } from "@teamscala/auth/auth-env-resolution";
|
package/src/error-page-logo.ts
CHANGED
|
@@ -1,29 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @system service-boot
|
|
3
3
|
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* The error/holding page's brand logo: resolve the host org's dark-theme
|
|
7
|
-
* wordmark from its install row and inline it as a data URI. Split from
|
|
8
|
-
* resource-not-found.ts under single-purpose-file (the builder consumes a
|
|
9
|
-
* logoUrl argument; only these resolvers touch storage and the registry).
|
|
10
|
-
*
|
|
11
|
-
* The host org's LOGO (wordmark) for the dark error page — the favicon is a
|
|
12
|
-
* tab glyph, the wrong asset at page scale (operator-observed: a lone "h"
|
|
13
|
-
* where the launcha wordmark belonged). Resolution: the service's own
|
|
14
|
-
* install row names its owner (brand_org); the org's dark-theme logo (a
|
|
15
|
-
* light-coloured mark, correct on the dark page) is preferred, light-theme
|
|
16
|
-
* second. Every step skew-tolerant: any miss → null → the builder renders a
|
|
17
|
-
* logo-less dark page.
|
|
18
|
-
*
|
|
19
|
-
* Returns the logo as an INLINE data URI (not a `/api/assets/` proxy URL) so
|
|
20
|
-
* the branded holding/error page ships the logo IN the initial HTML bytes —
|
|
21
|
-
* no second fetch, no post-paint pop-in, no dependency on the asset proxy the
|
|
22
|
-
* degraded service is failing to serve (incident 2026-07-27: the logo on a
|
|
23
|
-
* DEGRADED render-layer holding page appeared after the page painted). The
|
|
24
|
-
* logo sibling of icons-render-ssr-inline. Size-capped: a pathological
|
|
25
|
-
* (>MAX_INLINE_LOGO_BYTES) logo falls back to null rather than bloating the
|
|
26
|
-
* HTML; the page renders logo-less, never broken.
|
|
27
4
|
*/
|
|
28
5
|
|
|
29
6
|
import { getClient } from "@teamscala/object-storage/create/client/get-client";
|
|
@@ -32,7 +9,7 @@ import { registerCacheInvalidation } from "@teamscala/db/notify-listener/cache-i
|
|
|
32
9
|
import { getServerClient } from "@teamscala/orpc/server-client-registry";
|
|
33
10
|
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
34
11
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
35
|
-
import {
|
|
12
|
+
import { ServiceInstallBootSchema } from "./load-service-install-row.ts";
|
|
36
13
|
|
|
37
14
|
const logoCache = createCache<string | null>("service-boot:error-page-logo", {
|
|
38
15
|
ttlMs: 5 * 60_000,
|
|
@@ -48,7 +25,7 @@ export async function resolveErrorPageLogo(
|
|
|
48
25
|
const cfg = await loadRegistryConfig(
|
|
49
26
|
"service_install",
|
|
50
27
|
serviceSlug,
|
|
51
|
-
|
|
28
|
+
ServiceInstallBootSchema,
|
|
52
29
|
);
|
|
53
30
|
const org = (cfg as { brand_org?: string }).brand_org;
|
|
54
31
|
if (!org) {
|
|
@@ -95,20 +72,10 @@ export async function resolveErrorPageLogo(
|
|
|
95
72
|
});
|
|
96
73
|
}
|
|
97
74
|
|
|
98
|
-
/** Above this byte size, inlining bloats the HTML more than it helps — fall
|
|
99
|
-
* back to a logo-less page. Typical org logos are 5–30 KB, so this only
|
|
100
|
-
* catches a genuinely oversized upload. */
|
|
101
75
|
const MAX_INLINE_LOGO_BYTES = 100 * 1024;
|
|
102
76
|
|
|
103
77
|
const ASSETS_PREFIX = "/api/assets/";
|
|
104
78
|
|
|
105
|
-
/**
|
|
106
|
-
* Inline a stored logo value as a `data:<mime>;base64,…` URI by fetching its
|
|
107
|
-
* bytes from S3 (the same store `/api/assets/` proxies to). The stored value
|
|
108
|
-
* IS the servable form — `/api/assets/<key>` — so the bucket key is the path
|
|
109
|
-
* segment after that prefix (single-logo-pathway). Skew-tolerant: any miss →
|
|
110
|
-
* null.
|
|
111
|
-
*/
|
|
112
79
|
type LogoStorage = {
|
|
113
80
|
file(key: string): {
|
|
114
81
|
stat(): Promise<unknown>;
|
package/src/expand-roster.ts
CHANGED
|
@@ -1,21 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @system
|
|
2
|
+
* @system service-boot
|
|
3
3
|
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* Server-side roster expansion — the executor that handles "render N of block X
|
|
7
|
-
* per data row" so the page never needs a domain-specific panel/list block. A
|
|
8
|
-
* block authored with `props.dataRepeat: "parties"` is a TEMPLATE; before SSR
|
|
9
|
-
* this clones it once per roster item into its parent slot (top-level content or
|
|
10
|
-
* any Container's `content` slot), then drops the marker. Each clone:
|
|
11
|
-
* - sets `role` + `dataKey` to the item's key, so a prop-driven block (the
|
|
12
|
-
* SignatureField cell) finds its own party with no per-clone wiring;
|
|
13
|
-
* - substitutes `{{name}}` / `{{role}}` / `{{company}}` / `{{date}}` /
|
|
14
|
-
* `{{status}}` placeholders in any string prop (so a recipient row is plain
|
|
15
|
-
* `Container` + `Text` primitives — the copy lives on the page, not here).
|
|
16
|
-
* `{{status}}` resolves from the template's own `signedLabel` /
|
|
17
|
-
* `awaitingLabel` props (DB-driven copy), never hardcoded.
|
|
18
|
-
* Generic over any roster; the agreement page passes its parties.
|
|
19
4
|
*/
|
|
20
5
|
|
|
21
6
|
export interface RosterItem {
|
|
@@ -46,7 +31,6 @@ function formatDate(iso: string | null): string {
|
|
|
46
31
|
}
|
|
47
32
|
}
|
|
48
33
|
|
|
49
|
-
/** Resolve a `{{field}}` token for one roster item. */
|
|
50
34
|
function tokenValue(
|
|
51
35
|
field: string,
|
|
52
36
|
item: RosterItem,
|
|
@@ -70,7 +54,6 @@ function tokenValue(
|
|
|
70
54
|
}
|
|
71
55
|
}
|
|
72
56
|
|
|
73
|
-
/** Replace every `{{field}}` token in a string with the item's value. */
|
|
74
57
|
function substitute(
|
|
75
58
|
value: string,
|
|
76
59
|
item: RosterItem,
|
|
@@ -81,7 +64,6 @@ function substitute(
|
|
|
81
64
|
);
|
|
82
65
|
}
|
|
83
66
|
|
|
84
|
-
/** The `content` slot arrays a block exposes (Container.props.content, …). */
|
|
85
67
|
function slotArrays(block: Block): unknown[][] {
|
|
86
68
|
const out: unknown[][] = [];
|
|
87
69
|
const props = block.props;
|
|
@@ -94,7 +76,6 @@ function slotArrays(block: Block): unknown[][] {
|
|
|
94
76
|
return out;
|
|
95
77
|
}
|
|
96
78
|
|
|
97
|
-
/** Deep-clone a template block, binding it to one roster item. */
|
|
98
79
|
function cloneForItem(template: Block, item: RosterItem): Block {
|
|
99
80
|
const labels = {
|
|
100
81
|
signed: String(template.props?.signedLabel ?? ""),
|
|
@@ -125,7 +106,6 @@ function cloneForItem(template: Block, item: RosterItem): Block {
|
|
|
125
106
|
return clone;
|
|
126
107
|
}
|
|
127
108
|
|
|
128
|
-
/** Is this block a per-party template to expand? */
|
|
129
109
|
function isPartyTemplate(block: Block): boolean {
|
|
130
110
|
// Generic marker (any block that declares `dataRepeat`)…
|
|
131
111
|
if (block?.props?.dataRepeat === "parties") return true;
|
|
@@ -139,7 +119,6 @@ function isPartyTemplate(block: Block): boolean {
|
|
|
139
119
|
return false;
|
|
140
120
|
}
|
|
141
121
|
|
|
142
|
-
/** Expand every per-party template in a content array, recursing into slots. */
|
|
143
122
|
function expandArray(content: unknown[], parties: RosterItem[]): unknown[] {
|
|
144
123
|
const out: unknown[] = [];
|
|
145
124
|
for (const raw of content) {
|
|
@@ -159,12 +138,10 @@ function expandArray(content: unknown[], parties: RosterItem[]): unknown[] {
|
|
|
159
138
|
return out;
|
|
160
139
|
}
|
|
161
140
|
|
|
162
|
-
/** Agreement-level tokens (one substitution, not per-party). */
|
|
163
141
|
export interface RosterMeta {
|
|
164
142
|
[token: string]: string;
|
|
165
143
|
}
|
|
166
144
|
|
|
167
|
-
/** Substitute `{{agreement.token}}` placeholders in every string prop, in place. */
|
|
168
145
|
function substituteMeta(node: unknown, meta: RosterMeta): unknown {
|
|
169
146
|
if (typeof node === "string") {
|
|
170
147
|
return node.replace(/\{\{\s*agreement\.(\w+)\s*\}\}/g, (_m, t) =>
|
|
@@ -181,7 +158,6 @@ function substituteMeta(node: unknown, meta: RosterMeta): unknown {
|
|
|
181
158
|
return node;
|
|
182
159
|
}
|
|
183
160
|
|
|
184
|
-
/** Expand roster templates + bind agreement-meta placeholders across regions. */
|
|
185
161
|
export function expandRosterTemplates(
|
|
186
162
|
resolved: Record<string, unknown>,
|
|
187
163
|
parties: RosterItem[],
|
package/src/gate-ttls.ts
CHANGED
|
@@ -1,18 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @system service-boot
|
|
3
3
|
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* Cached readers for the content-gate access-token TTL config rows
|
|
7
|
-
* (agreement-gate / proposal-gate / gpt-gate / quiz-gate). initGateTtls() runs once at boot
|
|
8
|
-
* (two-phase-boot, before the gates mount) to populate the cache from the
|
|
9
|
-
* registry rows; the sync getters (agTtlMs/propTtlMs/gptTtlMs) return the
|
|
10
|
-
* cached value — or the former hardcoded default before init / on row-miss — so
|
|
11
|
-
* the sync AccessConfig.ttlMs fields + sync hono magic-link handlers read them
|
|
12
|
-
* without async. Replaces the per-file AG_TTL_MS / PROP_TTL_MS / GPT_TTL_MS
|
|
13
|
-
* constants (config-constants: the registry row is the source, dedup'd across
|
|
14
|
-
* each gate + its attach-* magic counterpart so token TTL and cookie maxAge
|
|
15
|
-
* never drift).
|
|
16
4
|
*/
|
|
17
5
|
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
18
6
|
import * as v from "valibot";
|
package/src/gpt-gate.ts
CHANGED
|
@@ -1,28 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @system service-boot
|
|
3
3
|
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* The PUBLIC GPT identity gate, expressed as config for the content-gate
|
|
7
|
-
* primitive (content-gate/). Unlike proposals/agreements this is NOT access
|
|
8
|
-
* control — every email is a valid visitor (lead capture). Its whole job is to
|
|
9
|
-
* make a returning visitor's identity known AT SSR so the GPT body renders the
|
|
10
|
-
* real conversation on first paint instead of server-rendering the capture gate
|
|
11
|
-
* and then swapping to the chat after hydration (the ssr-then-live-handoff +
|
|
12
|
-
* "know the visitor at SSR first paint" violation: building-applications).
|
|
13
|
-
*
|
|
14
|
-
* Flow: the capture form POSTs {gptId, email} to /api/gpt-access (openCapture →
|
|
15
|
-
* any email matches); the access endpoint sets a signed `gpt_token` cookie. On
|
|
16
|
-
* the next request runContentGate verifies that cookie for this agent and seeds
|
|
17
|
-
* resolved.initialVisitor — which the GPT body serverLoader (ssrGptSession) then
|
|
18
|
-
* uses to SSR-restore the conversation, and GptBodyBlock uses to skip the gate.
|
|
19
|
-
* onUnauthorized is a no-op: an un-gated (first-time) visitor sees the capture
|
|
20
|
-
* form the block renders itself when no visitor is known — that is correct, not
|
|
21
|
-
* a flash (they ARE anonymous), so there is nothing to withhold.
|
|
22
4
|
*/
|
|
23
5
|
|
|
24
6
|
import type { AccessConfig, ContentGateConfig } from "./content-gate/config-types.ts";
|
|
25
|
-
import { signAccessToken, verifyAccessTokenIdentity } from "
|
|
7
|
+
import { signAccessToken, verifyAccessTokenIdentity } from "@teamscala/agreement-generation/content-gate-token";
|
|
26
8
|
import { gptTtlMs } from "./gate-ttls.ts";
|
|
27
9
|
|
|
28
10
|
const GPT_COOKIE = "gpt_token";
|
|
@@ -1,21 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @system service-boot
|
|
3
3
|
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* The single app.onError factory for every Hono app (UI + backend). Two error
|
|
7
|
-
* classes, deliberately separate:
|
|
8
|
-
* - HTTPException: a DELIBERATE status-bearing throw (e.g. hono bodyLimit's
|
|
9
|
-
* 413). Preserve its status + message verbatim — buildErrorReport only knows
|
|
10
|
-
* 500/503 and would erase a deliberate 413/401/etc.
|
|
11
|
-
* - anything else: an UNEXPECTED error → buildErrorReport (errorId correlation,
|
|
12
|
-
* transient-vs-bug classification, 503 + Retry-After when retryable, else 500).
|
|
13
|
-
*
|
|
14
|
-
* Resolves the apparent tension between `hono-global-error-handler` (HTTPException
|
|
15
|
-
* → status + JSON) and `error-responses-via-error-report` (every error response
|
|
16
|
-
* via buildErrorReport): the latter governs UNEXPECTED errors; HTTPException is
|
|
17
|
-
* the documented deliberate-status carve-out. The handler is a consumer of
|
|
18
|
-
* buildErrorReport, never a second classifier for the unexpected branch.
|
|
19
4
|
*/
|
|
20
5
|
|
|
21
6
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
@@ -23,10 +8,6 @@ import { buildErrorReport } from "@teamscala/os/errors/error-report";
|
|
|
23
8
|
import type { Context } from "hono";
|
|
24
9
|
import { HTTPException } from "hono/http-exception";
|
|
25
10
|
|
|
26
|
-
/**
|
|
27
|
-
* Returns an `app.onError` handler tagged with `label` (used in the log line so
|
|
28
|
-
* the boot site that surfaced the error is identifiable).
|
|
29
|
-
*/
|
|
30
11
|
export function createRequestErrorHandler(label: string) {
|
|
31
12
|
return (err: unknown, c: Context): Response => {
|
|
32
13
|
// Errors must never be swallowed (errors-always-logged): every unhandled
|
package/src/install-harness.ts
CHANGED
|
@@ -1,17 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @system
|
|
2
|
+
* @system service-boot
|
|
3
3
|
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* Install harness — sandbox-mounts each declared package and captures
|
|
7
|
-
* registry.require() calls via AsyncLocalStorage. Validates all required
|
|
8
|
-
* rows exist before returning. Throws HarnessInstallError with full
|
|
9
|
-
* context on failure. Per Constitution Rule 2 step 3.
|
|
10
|
-
*
|
|
11
|
-
* Marked: HarnessInstallError is the error class
|
|
12
|
-
* validatePackageDependencies (might) throw — they form one canonical
|
|
13
|
-
* "install harness validation" unit (the validator + the failure-mode
|
|
14
|
-
* class it produces).
|
|
15
4
|
*/
|
|
16
5
|
|
|
17
6
|
import {
|
package/src/install-logging.ts
CHANGED
|
@@ -1,20 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @system
|
|
2
|
+
* @system service-boot
|
|
3
3
|
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* One-call install of the entire logging pipeline. After installLogging()
|
|
7
|
-
* runs, every error in the service — process-level, HTTP middleware, Hono
|
|
8
|
-
* handlers, Puck blocks, even third-party packages calling console.* — is
|
|
9
|
-
* captured by @teamscala/logger and flows to Sentry/GlitchTip (prod) or
|
|
10
|
-
* loga (dev) with full context. Application code never imports the logger
|
|
11
|
-
* or wraps anything in try/catch for telemetry purposes.
|
|
12
|
-
*
|
|
13
|
-
* Two phases, two files (single-purpose-file: the guard counts them as two
|
|
14
|
-
* export groups in one file): installLogging (early bootstrap, hardcoded
|
|
15
|
-
* defaults) lives here; applyLoggerConfig (late-stage, DB-driven) lives in
|
|
16
|
-
* apply-logger-config.ts. The service boot pipeline calls them in sequence
|
|
17
|
-
* with the same serviceSlug.
|
|
18
4
|
*/
|
|
19
5
|
|
|
20
6
|
import { ManagedCache } from "@teamscala/cache/cache";
|
|
@@ -31,16 +17,9 @@ import { loadRegistryConfig as dbLoadRegistryConfig } from "@teamscala/db/regist
|
|
|
31
17
|
import { configure as httpConfigure } from "@teamscala/http/configure";
|
|
32
18
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
33
19
|
import { initSentry } from "@teamscala/logger/init-sentry";
|
|
34
|
-
import { attachRequestLogger } from "@teamscala/logger/install-logging";
|
|
35
20
|
import { getRequestContext } from "@teamscala/logger/request-context-async";
|
|
36
21
|
import { configure as orpcConfigure } from "@teamscala/orpc/configure";
|
|
37
22
|
|
|
38
|
-
// Re-exported from @teamscala/logger (the canonical home, per
|
|
39
|
-
// vertical-dependency-only). service-boot consumers that previously
|
|
40
|
-
// imported from service-boot/install-logging continue to work; the
|
|
41
|
-
// implementation lives once in @teamscala/logger.
|
|
42
|
-
export { attachRequestLogger };
|
|
43
|
-
|
|
44
23
|
const CONSOLE_METHODS = [
|
|
45
24
|
"log",
|
|
46
25
|
"info",
|
|
@@ -65,29 +44,13 @@ export function installLogging(opts: InstallLoggingOptions): void {
|
|
|
65
44
|
installConsoleShim(serviceSlug);
|
|
66
45
|
installProcessHandlers(serviceSlug);
|
|
67
46
|
|
|
68
|
-
// Bootloader injection per `bootloader-injection-contract`. After
|
|
69
|
-
// initSentry + the console redirect, the logger surface is live; inject it
|
|
70
|
-
// into every configurable primitive that defaults to noop. The list is
|
|
71
|
-
// enumerated here; it becomes registry-driven once #57 lands.
|
|
72
47
|
const log = getAppLogger();
|
|
73
|
-
// Persisted last-known-good tier for registry config reads — created here
|
|
74
|
-
// (runtime tier) so host-path resolution stays out of the foundation-tier
|
|
75
|
-
// db package, which cannot import @teamscala/cache or resolve userHome itself.
|
|
76
|
-
// load-config serves this on a transient DB failure instead of crash-looping.
|
|
77
|
-
// The root is stateRoot(), not userHome(): a container that declares a
|
|
78
|
-
// read-only HOME (scala-sign prod: HOME=/var/empty) made this cache EACCES on
|
|
79
|
-
// every request — the tier died in exactly the outage class it exists for.
|
|
80
48
|
const registryConfigDiskCache = createDiskCache("db:registry-config", {
|
|
81
49
|
dir: join(stateRoot(), ".scala-db-cache", "registry-config"),
|
|
82
50
|
ttlMs: 24 * 60 * 60 * 1000,
|
|
83
51
|
});
|
|
84
52
|
dbConfigure({
|
|
85
53
|
logger: log,
|
|
86
|
-
// `as never` bridges the configured-primitives type gap: db holds
|
|
87
|
-
// structural approximations of these foundation factories (it cannot
|
|
88
|
-
// import @teamscala/cache/timing for the real CacheOptions/TimerOptions
|
|
89
|
-
// without re-creating the same-tier edge). The runtime values are the
|
|
90
|
-
// real factories; the cast only reconciles the approximate signatures.
|
|
91
54
|
ManagedCache: ManagedCache as never,
|
|
92
55
|
createCache: createCache as never,
|
|
93
56
|
createTimer: createTimer as never,
|
|
@@ -106,10 +69,6 @@ export function installLogging(opts: InstallLoggingOptions): void {
|
|
|
106
69
|
logger: log,
|
|
107
70
|
requestContextProvider: () => getRequestContext(),
|
|
108
71
|
});
|
|
109
|
-
// @teamscala/mcp-multi-session and @teamscala/tool-executor are MCP-only;
|
|
110
|
-
// their configure() calls moved to @teamscala/mcp-tool-runtime/mount.ts
|
|
111
|
-
// per `runtime-deps-universal-only`. MCP services declare mcp-tool-runtime
|
|
112
|
-
// in service_install.server_packages and pick up the configures there.
|
|
113
72
|
}
|
|
114
73
|
|
|
115
74
|
function installConsoleShim(serviceSlug: string): void {
|
|
@@ -163,8 +122,3 @@ function safeStringify(v: unknown): string {
|
|
|
163
122
|
return String(v);
|
|
164
123
|
}
|
|
165
124
|
}
|
|
166
|
-
// No event-driven invalidation subscription here: this module caches nothing.
|
|
167
|
-
// The removed block opened a dedicated LISTEN connection whose handler was
|
|
168
|
-
// empty `() => {}` — a connection held for the process lifetime to run no
|
|
169
|
-
// code. Registering a no-op against the shared listener would be the same
|
|
170
|
-
// defect one layer down, so it is deleted rather than migrated.
|