@teamscala/service-boot 0.23.524 → 0.23.526
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 -11
- 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 +4102 -61
- 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/live-loader-config.ts +0 -16
- 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 +2 -155
- 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 -85
- 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/register-registry-page-route/graph-cache.test.ts +0 -81
- package/src/register-registry-page-route/graph-cache.ts +0 -71
- 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
|
@@ -3,63 +3,21 @@
|
|
|
3
3
|
* @status handwritten
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import type { GateSegmentation } from "@teamscala/agreement-generation/content-gate-token";
|
|
7
|
+
|
|
6
8
|
type Resolved = Record<string, unknown>;
|
|
7
9
|
|
|
8
|
-
|
|
9
|
-
* Optional prospect-segmentation a lead-capture gate may capture alongside the
|
|
10
|
-
* identity. The quiz gate collects these on its composed identity screen; the
|
|
11
|
-
* token carries them so the next render and the final submit know the visitor
|
|
12
|
-
* fully, with NO client storage (same reasoning as the captured name/phone).
|
|
13
|
-
*/
|
|
14
|
-
export interface GateSegmentation {
|
|
15
|
-
company?: string;
|
|
16
|
-
linkedinUrl?: string;
|
|
17
|
-
website?: string;
|
|
18
|
-
teamSize?: string;
|
|
19
|
-
}
|
|
10
|
+
export type { GateSegmentation };
|
|
20
11
|
|
|
21
|
-
/** Drives the SSR-time gate (gate.ts / runContentGate). */
|
|
22
12
|
export interface ContentGateConfig {
|
|
23
|
-
/** Cookie that carries this gate's access token (e.g. "ag_token"). */
|
|
24
13
|
cookieName: string;
|
|
25
|
-
/**
|
|
26
|
-
* The gated resource id from the resolved graph, or "" when this gate does
|
|
27
|
-
* not apply to the current page (the engine then no-ops). This is also how a
|
|
28
|
-
* gate detects "is this my kind of page". Async to allow DB-backed resolution
|
|
29
|
-
* (e.g. agreement gate resolving the UUID from the page→agreement association
|
|
30
|
-
* when no pre-placed gate block exists).
|
|
31
|
-
*/
|
|
32
14
|
getResourceId(resolved: Resolved): string | Promise<string>;
|
|
33
|
-
/**
|
|
34
|
-
* Verify the cookie's token for this resource → the verified identity
|
|
35
|
-
* ({email, optional display name}), or null. Token-agnostic by design:
|
|
36
|
-
* proposals use the shared content-gate token; agreements keep their existing
|
|
37
|
-
* token (also minted by the signing ORPC), so the engine never assumes one
|
|
38
|
-
* token format. Allow-list gates return just {email}; the GPT lead-capture
|
|
39
|
-
* gate also returns the captured name (carried in its token).
|
|
40
|
-
*/
|
|
41
15
|
verifyToken(
|
|
42
16
|
token: string | undefined,
|
|
43
17
|
resourceId: string,
|
|
44
18
|
nowMs: number,
|
|
45
19
|
): { email: string; name?: string; phone?: string; segmentation?: GateSegmentation } | null;
|
|
46
|
-
/**
|
|
47
|
-
* BAKED-IN unified auth. When a gate provides this resolver, the engine
|
|
48
|
-
* AUTOMATICALLY releases a logged-in viewer whose session email is in the
|
|
49
|
-
* authorized set — with no valid token and no per-gate code. This is the
|
|
50
|
-
* platform default for EVERY gated resource: any application surface with
|
|
51
|
-
* authentication gets "a signed-in, authorized viewer is never re-asked for
|
|
52
|
-
* their email/OTP" for free, just by exposing its authorized-email set (the
|
|
53
|
-
* same resolver its AccessConfig uses). Not called when the session is
|
|
54
|
-
* anonymous. Omit only for open-capture gates that have no authorized set.
|
|
55
|
-
*/
|
|
56
20
|
resolveAuthorizedEmails?(resourceId: string): Promise<Set<string>>;
|
|
57
|
-
/**
|
|
58
|
-
* OPTIONAL OVERRIDE of the baked-in session check above — for the rare gate
|
|
59
|
-
* whose session→identity rule isn't simply "session email ∈ authorized set".
|
|
60
|
-
* When present it wins; otherwise the engine uses `resolveAuthorizedEmails`.
|
|
61
|
-
* Called only when no valid access token is present.
|
|
62
|
-
*/
|
|
63
21
|
sessionAuthorize?(
|
|
64
22
|
resolved: Resolved,
|
|
65
23
|
resourceId: string,
|
|
@@ -67,62 +25,23 @@ export interface ContentGateConfig {
|
|
|
67
25
|
| { email: string; name?: string }
|
|
68
26
|
| null
|
|
69
27
|
| Promise<{ email: string; name?: string } | null>;
|
|
70
|
-
/** Reveal + seed the verified viewer's identity/session (token was valid). */
|
|
71
28
|
onAuthorized(
|
|
72
29
|
resolved: Resolved,
|
|
73
30
|
resourceId: string,
|
|
74
31
|
identity: { email: string; name?: string; phone?: string; segmentation?: GateSegmentation },
|
|
75
32
|
): void | Promise<void>;
|
|
76
|
-
/** Withhold content (strip/inject the gate) — token absent/invalid. Async-
|
|
77
|
-
* capable so a gate can load its display/behaviour settings from a registry
|
|
78
|
-
* row (DB-driven, not hardcoded); sync implementations remain valid. */
|
|
79
33
|
onUnauthorized(resolved: Resolved, resourceId: string): void | Promise<void>;
|
|
80
|
-
/**
|
|
81
|
-
* OPT-IN REQUIRED-GATE-ENTRY. When set, a valid token authorizes ONLY on a
|
|
82
|
-
* request that also carries this marker cookie — set by the access endpoint
|
|
83
|
-
* alongside the token and consumed (cleared) by the render it authorizes, so
|
|
84
|
-
* the marker lives exactly one view: the submit's own redirect releases the
|
|
85
|
-
* content, and every later load (a refresh) is unauthorized, showing the
|
|
86
|
-
* gate again. Omit for gates whose token is session-scoped by design.
|
|
87
|
-
*/
|
|
88
34
|
entryCookieName?: string;
|
|
89
35
|
}
|
|
90
36
|
|
|
91
|
-
/** Drives the public access endpoint (access.ts / attachContentGateAccess). */
|
|
92
37
|
export interface AccessConfig {
|
|
93
|
-
/** Route to mount, e.g. "/api/proposal-access". */
|
|
94
38
|
path: string;
|
|
95
|
-
/** Cookie to set on a match. */
|
|
96
39
|
cookieName: string;
|
|
97
|
-
/** Cookie lifetime in ms (Max-Age). */
|
|
98
40
|
ttlMs: number;
|
|
99
|
-
/**
|
|
100
|
-
* Marker cookie set alongside the token on a match. The gate requires it
|
|
101
|
-
* (ContentGateConfig.entryCookieName) and the render it authorizes clears
|
|
102
|
-
* it, so it authorizes exactly the submit's own redirect.
|
|
103
|
-
*/
|
|
104
41
|
entryCookieName?: string;
|
|
105
|
-
/** Form field carrying the resource id, e.g. "proposalId". */
|
|
106
42
|
recordIdField: string;
|
|
107
|
-
/**
|
|
108
|
-
* Open lead-capture gate: ANY non-empty email is accepted (no allow-list).
|
|
109
|
-
* For surfaces whose "gate" captures a visitor rather than authorizing one
|
|
110
|
-
* against a fixed set (the public GPT). When true, `resolveAuthorizedEmails`
|
|
111
|
-
* is not called. Default/omitted = allow-list mode (proposals, agreements).
|
|
112
|
-
*/
|
|
113
43
|
openCapture?: boolean;
|
|
114
|
-
/**
|
|
115
|
-
* The authorized email set for a resource (lowercased), checked server-side.
|
|
116
|
-
* Not called when `openCapture` is true.
|
|
117
|
-
*/
|
|
118
44
|
resolveAuthorizedEmails(resourceId: string): Promise<Set<string>>;
|
|
119
|
-
/**
|
|
120
|
-
* Mint the access token for a matched email (token-agnostic per gate).
|
|
121
|
-
* `name` and `phone` are the optional captured identity, `segmentation` the
|
|
122
|
-
* optional captured prospect fields (open-capture gates carry them in the
|
|
123
|
-
* token; allow-list gates ignore them). They exist so a capture gate needs
|
|
124
|
-
* no browser storage to remember who this is.
|
|
125
|
-
*/
|
|
126
45
|
signToken(
|
|
127
46
|
resourceId: string,
|
|
128
47
|
email: string,
|
package/src/content-gate/gate.ts
CHANGED
|
@@ -3,20 +3,6 @@
|
|
|
3
3
|
* @status handwritten
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
* What a gate decided, so a caller can act on it without re-deriving it.
|
|
8
|
-
*
|
|
9
|
-
* THE ASSISTANT MUST NOT OUTRUN THE GATE. Its runtime context is the whole
|
|
10
|
-
* document serialised, so injecting it for an unauthorized viewer would hand
|
|
11
|
-
* the gated bytes to the chat -- the exact thing the gate exists to withhold.
|
|
12
|
-
* The gate already knows the answer; returning it means the page-route
|
|
13
|
-
* assistant setting reads ONE decision instead of inventing a second notion of
|
|
14
|
-
* "is this viewer allowed" that could drift from this one.
|
|
15
|
-
*
|
|
16
|
-
* `applies: false` means the gate did not govern this page at all (a public
|
|
17
|
-
* page), which is different from an unauthorized viewer and must not be
|
|
18
|
-
* confused with one.
|
|
19
|
-
*/
|
|
20
6
|
|
|
21
7
|
import type { ContentGateConfig } from "./config-types.ts";
|
|
22
8
|
import { parseCookie } from "./parse-cookie.ts";
|
|
@@ -81,12 +67,6 @@ export async function runContentGate(
|
|
|
81
67
|
return { applies: true, authorized: false };
|
|
82
68
|
}
|
|
83
69
|
|
|
84
|
-
/**
|
|
85
|
-
* The signed-in viewer's authorized identity for this resource, or null. The
|
|
86
|
-
* default (used by every gate) is "session email ∈ the gate's authorized set";
|
|
87
|
-
* a gate may supply a custom sessionAuthorize to override. Anonymous sessions
|
|
88
|
-
* and gates with no authorized-email set return null (stay token-only).
|
|
89
|
-
*/
|
|
90
70
|
async function resolveSessionIdentity(
|
|
91
71
|
resolved: Record<string, unknown>,
|
|
92
72
|
resourceId: string,
|
|
@@ -4,11 +4,6 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { PrimaryResourceMissingError } from "./resource-not-found.ts";
|
|
6
6
|
|
|
7
|
-
/**
|
|
8
|
-
* A page ROW exists but its body has zero content blocks — a content gap, not a
|
|
9
|
-
* dead link. True only when the row is present yet `puck_data` is absent or its
|
|
10
|
-
* `content` array is empty. Handles string (raw) and object (normalized) puck_data.
|
|
11
|
-
*/
|
|
12
7
|
export function pageBodyIsEmpty(page: unknown): boolean {
|
|
13
8
|
if (!page || typeof page !== "object") return false;
|
|
14
9
|
const obj = page as Record<string, unknown>;
|
|
@@ -27,12 +22,6 @@ export function pageBodyIsEmpty(page: unknown): boolean {
|
|
|
27
22
|
return !Array.isArray(data?.content) || (data.content as unknown[]).length === 0;
|
|
28
23
|
}
|
|
29
24
|
|
|
30
|
-
/**
|
|
31
|
-
* Decide whether a content route should fall to the branded not-found floor.
|
|
32
|
-
* Returns the error to throw (mapped to the floor by bind-page-handler), or null
|
|
33
|
-
* to render normally. `hasResourceType` routes are handled by the existing
|
|
34
|
-
* resource_type missing check (caller-side) — they return null here.
|
|
35
|
-
*/
|
|
36
25
|
export function decideContentPageFloor(opts: {
|
|
37
26
|
hasResourceType: boolean;
|
|
38
27
|
hasPageContentSource: boolean;
|
package/src/error-page-logo.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { registerCacheInvalidation } from "@teamscala/db/notify-listener/cache-i
|
|
|
9
9
|
import { getServerClient } from "@teamscala/orpc/server-client-registry";
|
|
10
10
|
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
11
11
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
12
|
-
import {
|
|
12
|
+
import { ServiceInstallBootSchema } from "./load-service-install-row.ts";
|
|
13
13
|
|
|
14
14
|
const logoCache = createCache<string | null>("service-boot:error-page-logo", {
|
|
15
15
|
ttlMs: 5 * 60_000,
|
|
@@ -25,7 +25,7 @@ export async function resolveErrorPageLogo(
|
|
|
25
25
|
const cfg = await loadRegistryConfig(
|
|
26
26
|
"service_install",
|
|
27
27
|
serviceSlug,
|
|
28
|
-
|
|
28
|
+
ServiceInstallBootSchema,
|
|
29
29
|
);
|
|
30
30
|
const org = (cfg as { brand_org?: string }).brand_org;
|
|
31
31
|
if (!org) {
|
|
@@ -72,20 +72,10 @@ export async function resolveErrorPageLogo(
|
|
|
72
72
|
});
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
/** Above this byte size, inlining bloats the HTML more than it helps — fall
|
|
76
|
-
* back to a logo-less page. Typical org logos are 5–30 KB, so this only
|
|
77
|
-
* catches a genuinely oversized upload. */
|
|
78
75
|
const MAX_INLINE_LOGO_BYTES = 100 * 1024;
|
|
79
76
|
|
|
80
77
|
const ASSETS_PREFIX = "/api/assets/";
|
|
81
78
|
|
|
82
|
-
/**
|
|
83
|
-
* Inline a stored logo value as a `data:<mime>;base64,…` URI by fetching its
|
|
84
|
-
* bytes from S3 (the same store `/api/assets/` proxies to). The stored value
|
|
85
|
-
* IS the servable form — `/api/assets/<key>` — so the bucket key is the path
|
|
86
|
-
* segment after that prefix (single-logo-pathway). Skew-tolerant: any miss →
|
|
87
|
-
* null.
|
|
88
|
-
*/
|
|
89
79
|
type LogoStorage = {
|
|
90
80
|
file(key: string): {
|
|
91
81
|
stat(): Promise<unknown>;
|
package/src/expand-roster.ts
CHANGED
|
@@ -31,7 +31,6 @@ function formatDate(iso: string | null): string {
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
/** Resolve a `{{field}}` token for one roster item. */
|
|
35
34
|
function tokenValue(
|
|
36
35
|
field: string,
|
|
37
36
|
item: RosterItem,
|
|
@@ -55,7 +54,6 @@ function tokenValue(
|
|
|
55
54
|
}
|
|
56
55
|
}
|
|
57
56
|
|
|
58
|
-
/** Replace every `{{field}}` token in a string with the item's value. */
|
|
59
57
|
function substitute(
|
|
60
58
|
value: string,
|
|
61
59
|
item: RosterItem,
|
|
@@ -66,7 +64,6 @@ function substitute(
|
|
|
66
64
|
);
|
|
67
65
|
}
|
|
68
66
|
|
|
69
|
-
/** The `content` slot arrays a block exposes (Container.props.content, …). */
|
|
70
67
|
function slotArrays(block: Block): unknown[][] {
|
|
71
68
|
const out: unknown[][] = [];
|
|
72
69
|
const props = block.props;
|
|
@@ -79,7 +76,6 @@ function slotArrays(block: Block): unknown[][] {
|
|
|
79
76
|
return out;
|
|
80
77
|
}
|
|
81
78
|
|
|
82
|
-
/** Deep-clone a template block, binding it to one roster item. */
|
|
83
79
|
function cloneForItem(template: Block, item: RosterItem): Block {
|
|
84
80
|
const labels = {
|
|
85
81
|
signed: String(template.props?.signedLabel ?? ""),
|
|
@@ -110,7 +106,6 @@ function cloneForItem(template: Block, item: RosterItem): Block {
|
|
|
110
106
|
return clone;
|
|
111
107
|
}
|
|
112
108
|
|
|
113
|
-
/** Is this block a per-party template to expand? */
|
|
114
109
|
function isPartyTemplate(block: Block): boolean {
|
|
115
110
|
// Generic marker (any block that declares `dataRepeat`)…
|
|
116
111
|
if (block?.props?.dataRepeat === "parties") return true;
|
|
@@ -124,7 +119,6 @@ function isPartyTemplate(block: Block): boolean {
|
|
|
124
119
|
return false;
|
|
125
120
|
}
|
|
126
121
|
|
|
127
|
-
/** Expand every per-party template in a content array, recursing into slots. */
|
|
128
122
|
function expandArray(content: unknown[], parties: RosterItem[]): unknown[] {
|
|
129
123
|
const out: unknown[] = [];
|
|
130
124
|
for (const raw of content) {
|
|
@@ -144,12 +138,10 @@ function expandArray(content: unknown[], parties: RosterItem[]): unknown[] {
|
|
|
144
138
|
return out;
|
|
145
139
|
}
|
|
146
140
|
|
|
147
|
-
/** Agreement-level tokens (one substitution, not per-party). */
|
|
148
141
|
export interface RosterMeta {
|
|
149
142
|
[token: string]: string;
|
|
150
143
|
}
|
|
151
144
|
|
|
152
|
-
/** Substitute `{{agreement.token}}` placeholders in every string prop, in place. */
|
|
153
145
|
function substituteMeta(node: unknown, meta: RosterMeta): unknown {
|
|
154
146
|
if (typeof node === "string") {
|
|
155
147
|
return node.replace(/\{\{\s*agreement\.(\w+)\s*\}\}/g, (_m, t) =>
|
|
@@ -166,7 +158,6 @@ function substituteMeta(node: unknown, meta: RosterMeta): unknown {
|
|
|
166
158
|
return node;
|
|
167
159
|
}
|
|
168
160
|
|
|
169
|
-
/** Expand roster templates + bind agreement-meta placeholders across regions. */
|
|
170
161
|
export function expandRosterTemplates(
|
|
171
162
|
resolved: Record<string, unknown>,
|
|
172
163
|
parties: RosterItem[],
|
package/src/gpt-gate.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { AccessConfig, ContentGateConfig } from "./content-gate/config-types.ts";
|
|
7
|
-
import { signAccessToken, verifyAccessTokenIdentity } from "
|
|
7
|
+
import { signAccessToken, verifyAccessTokenIdentity } from "@teamscala/agreement-generation/content-gate-token";
|
|
8
8
|
import { gptTtlMs } from "./gate-ttls.ts";
|
|
9
9
|
|
|
10
10
|
const GPT_COOKIE = "gpt_token";
|
|
@@ -8,10 +8,6 @@ import { buildErrorReport } from "@teamscala/os/errors/error-report";
|
|
|
8
8
|
import type { Context } from "hono";
|
|
9
9
|
import { HTTPException } from "hono/http-exception";
|
|
10
10
|
|
|
11
|
-
/**
|
|
12
|
-
* Returns an `app.onError` handler tagged with `label` (used in the log line so
|
|
13
|
-
* the boot site that surfaced the error is identifiable).
|
|
14
|
-
*/
|
|
15
11
|
export function createRequestErrorHandler(label: string) {
|
|
16
12
|
return (err: unknown, c: Context): Response => {
|
|
17
13
|
// Errors must never be swallowed (errors-always-logged): every unhandled
|
package/src/install-logging.ts
CHANGED
|
@@ -17,16 +17,9 @@ import { loadRegistryConfig as dbLoadRegistryConfig } from "@teamscala/db/regist
|
|
|
17
17
|
import { configure as httpConfigure } from "@teamscala/http/configure";
|
|
18
18
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
19
19
|
import { initSentry } from "@teamscala/logger/init-sentry";
|
|
20
|
-
import { attachRequestLogger } from "@teamscala/logger/install-logging";
|
|
21
20
|
import { getRequestContext } from "@teamscala/logger/request-context-async";
|
|
22
21
|
import { configure as orpcConfigure } from "@teamscala/orpc/configure";
|
|
23
22
|
|
|
24
|
-
// Re-exported from @teamscala/logger (the canonical home, per
|
|
25
|
-
// vertical-dependency-only). service-boot consumers that previously
|
|
26
|
-
// imported from service-boot/install-logging continue to work; the
|
|
27
|
-
// implementation lives once in @teamscala/logger.
|
|
28
|
-
export { attachRequestLogger };
|
|
29
|
-
|
|
30
23
|
const CONSOLE_METHODS = [
|
|
31
24
|
"log",
|
|
32
25
|
"info",
|
|
@@ -51,29 +44,13 @@ export function installLogging(opts: InstallLoggingOptions): void {
|
|
|
51
44
|
installConsoleShim(serviceSlug);
|
|
52
45
|
installProcessHandlers(serviceSlug);
|
|
53
46
|
|
|
54
|
-
// Bootloader injection per `bootloader-injection-contract`. After
|
|
55
|
-
// initSentry + the console redirect, the logger surface is live; inject it
|
|
56
|
-
// into every configurable primitive that defaults to noop. The list is
|
|
57
|
-
// enumerated here; it becomes registry-driven once #57 lands.
|
|
58
47
|
const log = getAppLogger();
|
|
59
|
-
// Persisted last-known-good tier for registry config reads — created here
|
|
60
|
-
// (runtime tier) so host-path resolution stays out of the foundation-tier
|
|
61
|
-
// db package, which cannot import @teamscala/cache or resolve userHome itself.
|
|
62
|
-
// load-config serves this on a transient DB failure instead of crash-looping.
|
|
63
|
-
// The root is stateRoot(), not userHome(): a container that declares a
|
|
64
|
-
// read-only HOME (scala-sign prod: HOME=/var/empty) made this cache EACCES on
|
|
65
|
-
// every request — the tier died in exactly the outage class it exists for.
|
|
66
48
|
const registryConfigDiskCache = createDiskCache("db:registry-config", {
|
|
67
49
|
dir: join(stateRoot(), ".scala-db-cache", "registry-config"),
|
|
68
50
|
ttlMs: 24 * 60 * 60 * 1000,
|
|
69
51
|
});
|
|
70
52
|
dbConfigure({
|
|
71
53
|
logger: log,
|
|
72
|
-
// `as never` bridges the configured-primitives type gap: db holds
|
|
73
|
-
// structural approximations of these foundation factories (it cannot
|
|
74
|
-
// import @teamscala/cache/timing for the real CacheOptions/TimerOptions
|
|
75
|
-
// without re-creating the same-tier edge). The runtime values are the
|
|
76
|
-
// real factories; the cast only reconciles the approximate signatures.
|
|
77
54
|
ManagedCache: ManagedCache as never,
|
|
78
55
|
createCache: createCache as never,
|
|
79
56
|
createTimer: createTimer as never,
|
|
@@ -92,10 +69,6 @@ export function installLogging(opts: InstallLoggingOptions): void {
|
|
|
92
69
|
logger: log,
|
|
93
70
|
requestContextProvider: () => getRequestContext(),
|
|
94
71
|
});
|
|
95
|
-
// @teamscala/mcp-multi-session and @teamscala/tool-executor are MCP-only;
|
|
96
|
-
// their configure() calls moved to @teamscala/mcp-tool-runtime/mount.ts
|
|
97
|
-
// per `runtime-deps-universal-only`. MCP services declare mcp-tool-runtime
|
|
98
|
-
// in service_install.server_packages and pick up the configures there.
|
|
99
72
|
}
|
|
100
73
|
|
|
101
74
|
function installConsoleShim(serviceSlug: string): void {
|
|
@@ -149,8 +122,3 @@ function safeStringify(v: unknown): string {
|
|
|
149
122
|
return String(v);
|
|
150
123
|
}
|
|
151
124
|
}
|
|
152
|
-
// No event-driven invalidation subscription here: this module caches nothing.
|
|
153
|
-
// The removed block opened a dedicated LISTEN connection whose handler was
|
|
154
|
-
// empty `() => {}` — a connection held for the process lifetime to run no
|
|
155
|
-
// code. Registering a no-op against the shared listener would be the same
|
|
156
|
-
// defect one layer down, so it is deleted rather than migrated.
|
|
@@ -10,12 +10,6 @@ interface PageManifestModule {
|
|
|
10
10
|
components?: Record<string, RendererComponentEntry>;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
/**
|
|
14
|
-
* Reads the generated manifest's `components` export. Throws when the file is
|
|
15
|
-
* absent or exports nothing — an empty manifest means codegen has not run for
|
|
16
|
-
* this service, which is a build problem the operator must see named, not a
|
|
17
|
-
* degraded runtime state to paper over.
|
|
18
|
-
*/
|
|
19
13
|
export async function loadPageManifest(
|
|
20
14
|
serviceSlug: string,
|
|
21
15
|
): Promise<Record<string, RendererComponentEntry>> {
|
|
@@ -9,7 +9,6 @@ import {
|
|
|
9
9
|
RegistryValidationError,
|
|
10
10
|
} from "@teamscala/db/registry/require";
|
|
11
11
|
import { normalizeRegistryConfigObject } from "@teamscala/db/registry/validation";
|
|
12
|
-
import type { ServiceInstallConfig } from "@teamscala/db-validation/registry-schemas/service-install";
|
|
13
12
|
import {
|
|
14
13
|
enforceExplicitRenderMode,
|
|
15
14
|
ServiceInstallConfigSchema,
|
|
@@ -17,8 +16,32 @@ import {
|
|
|
17
16
|
} from "@teamscala/db-validation/registry-schemas/service-install";
|
|
18
17
|
import * as v from "valibot";
|
|
19
18
|
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
const BOOT_ROW_KEYS = [
|
|
20
|
+
"access",
|
|
21
|
+
"api_routes",
|
|
22
|
+
"app_port",
|
|
23
|
+
"auth",
|
|
24
|
+
"block_packages",
|
|
25
|
+
"boot_hooks",
|
|
26
|
+
"brand_org",
|
|
27
|
+
"home_path",
|
|
28
|
+
"kind",
|
|
29
|
+
"orpc_router",
|
|
30
|
+
"portal_scoped",
|
|
31
|
+
"port",
|
|
32
|
+
"providers",
|
|
33
|
+
"render_mode",
|
|
34
|
+
"server_packages",
|
|
35
|
+
"websocket_routes",
|
|
36
|
+
] as const;
|
|
37
|
+
|
|
38
|
+
export const ServiceInstallBootSchema = v.object(
|
|
39
|
+
v.pick(ServiceInstallConfigSchema.pipe[0], BOOT_ROW_KEYS).entries,
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
export type ServiceInstallConfig = v.InferOutput<
|
|
43
|
+
typeof ServiceInstallBootSchema
|
|
44
|
+
>;
|
|
22
45
|
|
|
23
46
|
export interface LoadServiceInstallRowResult {
|
|
24
47
|
success: true;
|
|
@@ -49,9 +72,23 @@ export async function loadServiceInstallRow(
|
|
|
49
72
|
`service_install:${serviceSlug}`,
|
|
50
73
|
) as Record<string, unknown>;
|
|
51
74
|
enforceExplicitRenderMode(serviceSlug, rawConfig);
|
|
52
|
-
const config = v.parse(
|
|
75
|
+
const config = v.parse(ServiceInstallBootSchema, rawConfig);
|
|
53
76
|
return { success: true, config };
|
|
54
77
|
} catch (err) {
|
|
78
|
+
if (err instanceof v.ValiError) {
|
|
79
|
+
const issues = err.issues
|
|
80
|
+
.map((issue) => {
|
|
81
|
+
const path = v.getDotPath(issue);
|
|
82
|
+
return path ? `${path}: ${issue.message}` : issue.message;
|
|
83
|
+
})
|
|
84
|
+
.join("; ");
|
|
85
|
+
return {
|
|
86
|
+
success: false,
|
|
87
|
+
error: "validation_failed",
|
|
88
|
+
slug: serviceSlug,
|
|
89
|
+
message: `service_install row for "${serviceSlug}" has a key the boot reads missing or malformed: ${issues}`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
55
92
|
if (err instanceof RegistryMissingError) {
|
|
56
93
|
const sql = `INSERT INTO registry_entries (type, slug, config, is_active) VALUES ('service_install', '${serviceSlug}', '{"kind":"ui","render_mode":"public","port":null,"page_routes":[],"providers":[],"api_routes":[],"block_packages":[],"teamgbg_packages":[],"env_vars":[]}'::jsonb, true);`;
|
|
57
94
|
return {
|
package/src/module-not-found.ts
CHANGED
|
@@ -3,22 +3,11 @@
|
|
|
3
3
|
* @status handwritten
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
/** `@scope/name/sub/path.ts` -> `@scope/name`; `name/sub` -> `name`. */
|
|
7
6
|
export function barePackageOf(specifier: string): string {
|
|
8
7
|
const parts = specifier.split("/");
|
|
9
8
|
return specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
|
|
10
9
|
}
|
|
11
10
|
|
|
12
|
-
/**
|
|
13
|
-
* True when the import failed because `requestedPath` itself is absent.
|
|
14
|
-
*
|
|
15
|
-
* Bun reports an unexported SUBPATH as `Cannot find package '<bare>'`, naming
|
|
16
|
-
* the package rather than the subpath, so the bare name of THIS candidate
|
|
17
|
-
* counts as not-found. Without that the first candidate's miss is
|
|
18
|
-
* misclassified as fatal and the fallbacks never run (measured 2026-08-24:
|
|
19
|
-
* scala-agents-ui crash-looped because cli-session exports ./boot/mount.ts and
|
|
20
|
-
* not ./mount.ts). A transitive miss naming a DIFFERENT module still surfaces.
|
|
21
|
-
*/
|
|
22
11
|
export function isModuleNotFound(err: unknown, requestedPath: string): boolean {
|
|
23
12
|
const msg =
|
|
24
13
|
(err as { message?: string })?.message ??
|