@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
package/src/pg-boss-mount.ts
CHANGED
|
@@ -14,25 +14,8 @@ import type { ScheduleOptions } from "pg-boss";
|
|
|
14
14
|
import { PgBoss } from "pg-boss";
|
|
15
15
|
import { pgBossHandlerRegistry } from "./pg-boss-handler-registry.ts";
|
|
16
16
|
|
|
17
|
-
// Handlers register via their package's own boot hook / subscriber import
|
|
18
|
-
// (package-subscriber shape) — no bundled copies. The mount resolves each
|
|
19
|
-
// row's handler_slug against this registry at dispatch time.
|
|
20
17
|
|
|
21
18
|
|
|
22
|
-
/**
|
|
23
|
-
* Module-level reference to the mounted PgBoss instance — set by mountPgBoss on
|
|
24
|
-
* successful start. Exposed via getPgBoss() so ORPC procedures (and other
|
|
25
|
-
* in-process callers) can submit one-shot jobs via boss.send() without a second
|
|
26
|
-
* PgBoss connection. Null before mount or when mountPgBoss found no scheduled
|
|
27
|
-
* jobs for this service (boss never created).
|
|
28
|
-
*/
|
|
29
|
-
let pgBossInstance: PgBoss | null = null;
|
|
30
|
-
|
|
31
|
-
/** Returns the mounted PgBoss instance, or null if pg-boss is not running on this service. */
|
|
32
|
-
export function getPgBoss(): PgBoss | null {
|
|
33
|
-
return pgBossInstance;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
19
|
export interface PgBossMountContext {
|
|
37
20
|
databaseUrl: string;
|
|
38
21
|
serviceSlug: string;
|
|
@@ -59,9 +42,6 @@ function belongsToService(
|
|
|
59
42
|
const config = entry.config as Record<string, unknown>;
|
|
60
43
|
const handlerSlug = config.handler_slug as string | undefined;
|
|
61
44
|
if (!handlerSlug) return false;
|
|
62
|
-
// Explicit row-driven targeting takes precedence — a universal boot-package
|
|
63
|
-
// handler (e.g. one in @teamscala/service-boot, which no service declares in
|
|
64
|
-
// server_packages) cannot be scoped by the server_package prefix match.
|
|
65
45
|
const targetService = config.service_slug as string | undefined;
|
|
66
46
|
if (targetService) return targetService === serviceSlug;
|
|
67
47
|
return packages.some(
|
|
@@ -113,50 +93,13 @@ export async function mountPgBoss(
|
|
|
113
93
|
const log = getAppLogger();
|
|
114
94
|
const jobs: MountedJob[] = [];
|
|
115
95
|
|
|
116
|
-
// pg-boss connects through pg, whose sslmode dialect differs from the
|
|
117
|
-
// shared URL's libpq spelling — route through the canonical rewrite.
|
|
118
|
-
// Config object (not a bare connectionString) so the internal pool is
|
|
119
|
-
// bounded + tagged for the ~220ms cross-internet dev→Railway RTT.
|
|
120
|
-
// POLL-ONLY, EXPLICITLY. pg-boss v12 dispatches by polling (SELECT .. FOR
|
|
121
|
-
// UPDATE SKIP LOCKED inside a transaction; its locks are
|
|
122
|
-
// pg_advisory_xact_lock — transaction-scoped, so a transaction pooler
|
|
123
|
-
// cannot leak them); LISTEN/NOTIFY is a supplier-documented latency hint
|
|
124
|
-
// whose registration dies on a transaction-pooler backend reassignment
|
|
125
|
-
// (measured 2026-09-14: the opted-in sync worker boss logged
|
|
126
|
-
// `LISTEN/NOTIFY channel registration was lost` every log window).
|
|
127
|
-
// attorney.js defaults useListenNotify to false in 12.29.0 (pinned),
|
|
128
|
-
// 12.30.0 (deployed) and 12.31.1 (latest) — all three read directly —
|
|
129
|
-
// and spelling it here keeps that contract explicit so a pg-boss bump
|
|
130
|
-
// cannot silently re-introduce a LISTEN dependency
|
|
131
|
-
// (close-the-session-mode-door, 2026-09-14). The connection dials the
|
|
132
|
-
// injected DATABASE_URL — the transaction pooler — reconciling the pg
|
|
133
|
-
// sslmode dialect; the mount adds no endpoint derivation of its own.
|
|
134
96
|
const boss = new PgBoss({
|
|
135
97
|
useListenNotify: false,
|
|
136
98
|
connectionString: pgSslCompatUrl(ctx.databaseUrl),
|
|
137
99
|
application_name: `pgboss:${ctx.serviceSlug}`,
|
|
138
|
-
// ONE connection, not three: pg-boss is a sibling pool that sits OUTSIDE
|
|
139
|
-
// the Prisma budget, and with ~46 declared holders the fleet saturated
|
|
140
|
-
// at 304/300 (2026-08-27) partly on 3-connection pg-boss pools that had
|
|
141
|
-
// no idle timeout. At max:1 jobs serialize into a queue — the sanctioned
|
|
142
|
-
// background degradation (`a-per-consumer-pool-max-is-not-a-bound-on-the-server`:
|
|
143
|
-
// background work degrades to a queue, interactive work degrades to an
|
|
144
|
-
// outage). The sibling total is declared in
|
|
145
|
-
// config/db-pool-sizing.perServiceOverhead (pg-boss 1; the LISTEN allowance is gone with poll-only).
|
|
146
100
|
max: 1,
|
|
147
|
-
// Generous connection-acquisition budget for the cross-internet link so
|
|
148
|
-
// a slow TLS handshake under sustained load doesn't terminate the conn.
|
|
149
101
|
connectionTimeoutMillis: 30_000,
|
|
150
102
|
});
|
|
151
|
-
// crash-isolation: pg-boss's manager emits 'error' on a worker-connection
|
|
152
|
-
// failure (a proxy-killed idle conn under sustained cross-internet writes).
|
|
153
|
-
// With NO listener, EventEmitter THROWS on emit('error'), the worker's
|
|
154
|
-
// promise rejects unhandled, and Bun exits the process — the scala-extract
|
|
155
|
-
// restart-storm of 2026-07-19. The listener makes it a logged, recovered
|
|
156
|
-
// event. This is the pg-boss-owned connection, the sibling of @teamscala/db's
|
|
157
|
-
// application-pool pool.on('error') handler (the app pool alone did not
|
|
158
|
-
// cover pg-boss's separate pool). Registered before start() so a boot-time
|
|
159
|
-
// error is caught too.
|
|
160
103
|
boss.on("error", (err) => {
|
|
161
104
|
log.warn(`[pg-boss:${ctx.serviceSlug}] recovered error event (process kept alive)`, {
|
|
162
105
|
error: err instanceof Error ? err.message : String(err),
|
|
@@ -165,21 +108,10 @@ export async function mountPgBoss(
|
|
|
165
108
|
boss.on("warning", (w) => {
|
|
166
109
|
log.warn(`[pg-boss:${ctx.serviceSlug}] warning`, { warning: String(w) });
|
|
167
110
|
});
|
|
168
|
-
// start() MUST precede work()/schedule() — it opens the DB connection;
|
|
169
|
-
// calling schedule() before start() crashes the timekeeper with
|
|
170
|
-
// "Database not opened. Call open() before executing SQL." (pg-boss v12)
|
|
171
111
|
await boss.start();
|
|
172
|
-
pgBossInstance = boss;
|
|
173
112
|
|
|
174
113
|
for (const entry of scheduledEntries) {
|
|
175
|
-
// crash-isolation: a malformed scheduled_job row degrades to "this job
|
|
176
|
-
// unavailable" (logged + event-log row), NEVER a boot-crash. A bare
|
|
177
|
-
// pg-boss assert reaching boot is the violation.
|
|
178
114
|
let config: ScheduledJobConfig;
|
|
179
|
-
// validateRow returns a RESULT WRAPPER ({ok:true,value}|{ok:false,issues}),
|
|
180
|
-
// never the config itself — measured 2026-09-16: assigning the wrapper as
|
|
181
|
-
// the config left queue_name/handler_slug undefined and silently skipped
|
|
182
|
-
// EVERY row on services holding db-validation 0.1.289+.
|
|
183
115
|
const validated = validateRow("scheduled_job", entry.config);
|
|
184
116
|
if (!validated.ok) {
|
|
185
117
|
const reason = validated.issues
|
|
@@ -210,19 +142,12 @@ export async function mountPgBoss(
|
|
|
210
142
|
const queueName = config.queue_name;
|
|
211
143
|
|
|
212
144
|
try {
|
|
213
|
-
// pg-boss v12 requires the queue to exist before work()/schedule()
|
|
214
|
-
// dispatch against it ("Queue X does not exist") — createQueue's
|
|
215
|
-
// underlying SQL is ON CONFLICT DO NOTHING, so calling it on every
|
|
216
|
-
// boot is a safe idempotent assert, never a duplicate-creation error.
|
|
217
145
|
await boss.createQueue(queueName);
|
|
218
146
|
await boss.work(queueName, async (jobs) => {
|
|
219
147
|
let result: unknown;
|
|
220
148
|
for (const job of jobs) {
|
|
221
149
|
result = await resolveHandler(config.handler_slug, config, job.data);
|
|
222
150
|
}
|
|
223
|
-
// pg-boss stores the work handler return as job output when
|
|
224
|
-
// exactly one job was processed (manager.js complete()). For
|
|
225
|
-
// batch/scheduled jobs the return is not stored.
|
|
226
151
|
return jobs.length === 1 ? result : undefined;
|
|
227
152
|
});
|
|
228
153
|
|
|
@@ -260,15 +185,6 @@ export async function mountPgBoss(
|
|
|
260
185
|
});
|
|
261
186
|
}
|
|
262
187
|
|
|
263
|
-
// A SUCCESSFUL mount emitted nothing until now, while every failure
|
|
264
|
-
// path above recorded an event — so the one state you most need to
|
|
265
|
-
// confirm ("is this job actually scheduled on this service?") was the
|
|
266
|
-
// only one with no evidence. Combined with resolveHandler THROWING
|
|
267
|
-
// into boss.work() rather than warning, that made "never scheduled"
|
|
268
|
-
// and "fired and threw" externally identical, and cost a
|
|
269
|
-
// scheduled_job relocation that had to be reverted undiagnosed on
|
|
270
|
-
// 2026-07-30. Emitting on success makes the mount set queryable
|
|
271
|
-
// (event-log-is-the-only-durable-event-log).
|
|
272
188
|
const mounted = jobs[jobs.length - 1];
|
|
273
189
|
recordEvent({
|
|
274
190
|
kind: "pgboss.job.mounted",
|
|
@@ -282,10 +198,6 @@ export async function mountPgBoss(
|
|
|
282
198
|
},
|
|
283
199
|
});
|
|
284
200
|
} catch (err) {
|
|
285
|
-
// crash-isolation for the mount path: a work()/schedule() failure
|
|
286
|
-
// (e.g. DB not open, queue conflict) degrades to "this job unavailable"
|
|
287
|
-
// (logged + event-log row), NEVER a boot-crash. Same treatment as the
|
|
288
|
-
// row-validation guard above.
|
|
289
201
|
const reason = err instanceof Error ? err.message : String(err);
|
|
290
202
|
log.error(
|
|
291
203
|
`[pg-boss-mount:${ctx.serviceSlug}] scheduled_job "${entry.slug}" failed to mount (work/schedule) — skipping`,
|
|
@@ -299,16 +211,6 @@ export async function mountPgBoss(
|
|
|
299
211
|
}
|
|
300
212
|
}
|
|
301
213
|
|
|
302
|
-
// Reconcile orphaned cron schedules: unschedule any pg-boss schedule that no longer corresponds to an ACTIVE registry row. Deactivating/deleting a
|
|
303
|
-
// scheduled_job (or sync_job_handler) previously left its cron lingering in pgboss.schedule, firing indefinitely — 2 stale web-extractor schedules
|
|
304
|
-
// found 2026-07-27, still scraping every 3/15min months after the job was meant to be one-off. Deactivation must actually stop the cron. A schedule is
|
|
305
|
-
// "backed" iff ANY active registry row's slug OR queue_name equals its name — this covers scheduled_job AND sync_job_handler (and any future
|
|
306
|
-
// schedule-owning type) without hardcoding the type list, so live sync jobs (zoho_assist_sync, trackabi, …) are never touched. Runs on every pg-boss
|
|
307
|
-
// mount and reconciles GLOBALLY: an orphan is removed regardless of which
|
|
308
|
-
// service created it, since a cron with no active backing row is drift by
|
|
309
|
-
// definition. A service with zero active scheduled jobs early-returns above
|
|
310
|
-
// and never reaches here — scala-loga (7 cron jobs) is the reliable
|
|
311
|
-
// reconciler, but any service-with-jobs that boots cleans the full set.
|
|
312
214
|
try {
|
|
313
215
|
const backedScheduleNames = new Set(
|
|
314
216
|
allEntries.flatMap((e) => {
|
|
@@ -335,9 +237,6 @@ export async function mountPgBoss(
|
|
|
335
237
|
});
|
|
336
238
|
}
|
|
337
239
|
} catch (err) {
|
|
338
|
-
// crash-isolation: a reconciliation failure degrades to "orphans not
|
|
339
|
-
// cleaned this boot" (logged), never a boot-crash — reconciled on the
|
|
340
|
-
// next successful mount.
|
|
341
240
|
log.warn(`[pg-boss:${ctx.serviceSlug}] schedule reconciliation failed`, {
|
|
342
241
|
error: err instanceof Error ? err.message : String(err),
|
|
343
242
|
});
|
package/src/proposal-gate.ts
CHANGED
|
@@ -4,19 +4,13 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { AccessConfig, ContentGateConfig } from "./content-gate/config-types.ts";
|
|
7
|
-
import { signAccessToken, verifyAccessToken } from "
|
|
7
|
+
import { signAccessToken, verifyAccessToken } from "@teamscala/agreement-generation/content-gate-token";
|
|
8
8
|
import { resolveProposalAuthorizedEmails } from "./proposal-authorized-emails.ts";
|
|
9
9
|
import { propTtlMs } from "./gate-ttls.ts";
|
|
10
10
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
11
11
|
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
12
12
|
import * as v from "valibot";
|
|
13
13
|
|
|
14
|
-
/**
|
|
15
|
-
* Proposal gate display + behaviour, DB-driven (config/content-gate-proposal),
|
|
16
|
-
* never hardcoded. requireOtp is the per-gate OTP switch (proposals default
|
|
17
|
-
* off — email-only; agreements opt in). A missing/invalid row falls back to the
|
|
18
|
-
* proposal-appropriate defaults so the gate UI ALWAYS renders.
|
|
19
|
-
*/
|
|
20
14
|
interface ProposalGateSettings {
|
|
21
15
|
title: string;
|
|
22
16
|
subtitle: string;
|
|
@@ -78,8 +72,6 @@ function buildGateBlock(
|
|
|
78
72
|
gateContextField: "proposalId",
|
|
79
73
|
gateContextValue: proposalId,
|
|
80
74
|
gateAction: "/api/proposal-access",
|
|
81
|
-
// Display + behaviour are DB-driven (config/content-gate-proposal),
|
|
82
|
-
// not hardcoded. requireOtp is the per-gate OTP switch.
|
|
83
75
|
title: settings.title,
|
|
84
76
|
subtitle: settings.subtitle,
|
|
85
77
|
gateRequireOtp: settings.requireOtp,
|
|
@@ -100,26 +92,14 @@ export const proposalGateConfig: ContentGateConfig = {
|
|
|
100
92
|
const email = verifyAccessToken(token, PROP_COOKIE, resourceId, nowMs);
|
|
101
93
|
return email ? { email } : null;
|
|
102
94
|
},
|
|
103
|
-
// Baked-in unified auth (engine default): a logged-in viewer whose session
|
|
104
|
-
// email is in this set (issuing-org team ∪ recipient ∪ partner) is released
|
|
105
|
-
// with no email/OTP entry. Just exposing the resolver opts this gate into the
|
|
106
|
-
// platform-wide behaviour — no per-gate session code.
|
|
107
95
|
resolveAuthorizedEmails: resolveProposalAuthorizedEmails,
|
|
108
96
|
async onAuthorized(resolved, _resourceId, { email }) {
|
|
109
|
-
// Seed the verified viewer identity (building-applications initialVisitor).
|
|
110
97
|
(resolved as { initialVisitor?: { email: string } }).initialVisitor = {
|
|
111
98
|
email,
|
|
112
99
|
};
|
|
113
|
-
// THE ASSISTANT IS NO LONGER INJECTED HERE. It is declared on the
|
|
114
|
-
// page_route row (`assistant`) and applied once, centrally, after every
|
|
115
|
-
// gate has run — so the bubble is a setting rather than a line of code
|
|
116
|
-
// inside one resource type's gate. This call was the reason a third
|
|
117
|
-
// resource type could not have an assistant without editing service-boot.
|
|
118
100
|
},
|
|
119
101
|
async onUnauthorized(resolved, resourceId) {
|
|
120
102
|
const settings = await loadProposalGateSettings();
|
|
121
|
-
// Replace the proposal BODY with the gate block — no proposal content
|
|
122
|
-
// reaches the HTML. Header/footer chrome stays branded.
|
|
123
103
|
const page = resolved.page as
|
|
124
104
|
| (PuckHolder & {
|
|
125
105
|
meta_title?: unknown;
|
|
@@ -133,18 +113,11 @@ export const proposalGateConfig: ContentGateConfig = {
|
|
|
133
113
|
buildGateBlock(resourceId, brandPrimaryColor(resolved), settings),
|
|
134
114
|
];
|
|
135
115
|
}
|
|
136
|
-
// Neutralize the PAGE record's meta too: page.meta_title/meta_description
|
|
137
|
-
// feed pageData.meta → the <title>/og tags AND the __PAGE_DATA__ hydration
|
|
138
|
-
// JSON. Without this the proposal topic + partner name leak via meta even
|
|
139
|
-
// though the body is withheld (the 2026-06-13 meta-leak: a gated proposal
|
|
140
|
-
// still exposed "Webinar Campaign · in partnership with <partner>").
|
|
141
116
|
if (page) {
|
|
142
117
|
page.meta_title = "Private proposal";
|
|
143
118
|
page.meta_description = "";
|
|
144
119
|
page.name = "Private proposal";
|
|
145
120
|
}
|
|
146
|
-
// Neutralize the proposal record so the meta_context resolver + any block
|
|
147
|
-
// reading it can't leak name/partner/cover/recipient.
|
|
148
121
|
const prop = resolved.proposal as Record<string, unknown> | undefined;
|
|
149
122
|
if (prop) {
|
|
150
123
|
prop.name = "Private proposal";
|
|
@@ -153,13 +126,6 @@ export const proposalGateConfig: ContentGateConfig = {
|
|
|
153
126
|
prop.prepared_for = "";
|
|
154
127
|
prop.cover_image_url = "";
|
|
155
128
|
}
|
|
156
|
-
// Neutralize the resolved headerData too: it carries the recipient's
|
|
157
|
-
// name (row1Value = prepared_for) and title, resolved during the loader
|
|
158
|
-
// phase BEFORE this gate ran — without this, __PAGE_DATA__ ships the
|
|
159
|
-
// recipient name to every unauthorised visitor while the body is
|
|
160
|
-
// withheld. Labels stay (generic words); values go. Measured 2026-09-03:
|
|
161
|
-
// the gate floor's hydration payload carried the recipient name to
|
|
162
|
-
// anonymous visitors on the exact page this outage was about.
|
|
163
129
|
const headerData = resolved.headerData as Record<string, unknown> | undefined;
|
|
164
130
|
if (headerData) {
|
|
165
131
|
headerData.title = "";
|
|
@@ -167,12 +133,6 @@ export const proposalGateConfig: ContentGateConfig = {
|
|
|
167
133
|
headerData.row2Value = "";
|
|
168
134
|
headerData.row3Value = "";
|
|
169
135
|
}
|
|
170
|
-
// Neutralize the CRM contact record too. The v8 adapter ignores select
|
|
171
|
-
// (the full record returns), so without this the gate floor's hydration
|
|
172
|
-
// payload ships the recipient's whole contact row — first/last/full
|
|
173
|
-
// name, emails, phones, CRM ids — to every unauthorised visitor. The
|
|
174
|
-
// company fields stay: the header's Organisation row shows the company,
|
|
175
|
-
// which is brand chrome, not recipient identity.
|
|
176
136
|
const contact = resolved.contact as Record<string, unknown> | undefined;
|
|
177
137
|
if (contact) {
|
|
178
138
|
const keep = new Set(["id", "company_id", "company_name", "company_website"]);
|
package/src/quiz-gate.ts
CHANGED
|
@@ -4,21 +4,13 @@
|
|
|
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 { quizTtlMs } from "./gate-ttls.ts";
|
|
9
9
|
|
|
10
10
|
const QUIZ_COOKIE = "quiz_token";
|
|
11
|
-
/**
|
|
12
|
-
* The ENTRY MARKER: proves this request is the redirect the identity submit
|
|
13
|
-
* produced, not a later load. Operator ruling 2026-09-14: a refresh must return
|
|
14
|
-
* to the identity gate — the visitor starts at Name/Email on every fresh load,
|
|
15
|
-
* and only the submit's own redirect releases the quiz. The marker is set by
|
|
16
|
-
* the access endpoint with the token and cleared by the render it authorized.
|
|
17
|
-
*/
|
|
18
11
|
const QUIZ_ENTRY_COOKIE = "quiz_token_entered";
|
|
19
12
|
const QUIZ_ACCESS_PATH = "/api/quiz-access";
|
|
20
13
|
|
|
21
|
-
/** The assessment this page is for, from the quiz-with-brand loader's graph. */
|
|
22
14
|
function quizId(resolved: Record<string, unknown>): string {
|
|
23
15
|
const quiz = resolved.quiz as { id?: unknown } | null | undefined;
|
|
24
16
|
return typeof quiz?.id === "string" ? quiz.id : "";
|
|
@@ -3,17 +3,6 @@
|
|
|
3
3
|
* @status handwritten
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
// REFUSE, DO NOT SKIP. `existsSync(routerPath)` being false used to fall straight through with appRouter=null and still register a client + log
|
|
7
|
-
// success. That client carries only the three platform routers below and NO CRUD models, which is indistinguishable from a healthy one until a
|
|
8
|
-
// page asks it for a model — at which point resolve-from-loader-config throws MissingLoaderModelCapabilityError, far from the actual fault.
|
|
9
|
-
//
|
|
10
|
-
// Measured 2026-08-20: scala-quiz and scala-forms served 500s on every data-backed page while /health returned 200 and this line logged "ORPC
|
|
11
|
-
// serverClient registered". The build had produced no generated router (its railpack.json ran registry codegen against an unreachable
|
|
12
|
-
// *.railway.internal host), and nothing anywhere said so.
|
|
13
|
-
//
|
|
14
|
-
// A service whose service_schema DECLARES models and has no generated router is not degraded, it is non-functional — so this fails the boot
|
|
15
|
-
// rather than reporting a state it has not verified (`a-component-may-not-report-a-state-it-has-not-verified`). A service
|
|
16
|
-
// that declares no models (daemon/portal shells owning no DB schema) still passes through cleanly.
|
|
17
6
|
|
|
18
7
|
import { join } from "node:path";
|
|
19
8
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
@@ -31,16 +20,6 @@ export async function registerPageServerClient(
|
|
|
31
20
|
};
|
|
32
21
|
const { createRouterClient } = await import("@orpc/server");
|
|
33
22
|
const { getPrisma } = await import("@teamscala/db/prisma-registry");
|
|
34
|
-
// Load the composed CRUD router via getAppRouter() — the SAME registered
|
|
35
|
-
// instance the /api/rpc mount uses (mount-orpc-router.ts). The previous code
|
|
36
|
-
// did a raw second `import(cwd + "prisma/generated/orpc/routers/index.ts")`,
|
|
37
|
-
// re-loading the generated routers under a different module specifier than
|
|
38
|
-
// the mount already used; in the full boot process that re-execution throws,
|
|
39
|
-
// and the throw was swallowed by a SILENT catch — leaving the page-server
|
|
40
|
-
// client without the CRUD models, so every data-backed page 500s
|
|
41
|
-
// ("client.<model> is undefined", the scala-sign prod outage). existsSync
|
|
42
|
-
// gates the legitimate no-router case (daemon/portal services that own no DB
|
|
43
|
-
// schema) so it stays a clean skip, never a misleading warn.
|
|
44
23
|
const { existsSync } = await import("node:fs");
|
|
45
24
|
const routerPath = join(
|
|
46
25
|
process.cwd(),
|
|
@@ -52,8 +31,6 @@ export async function registerPageServerClient(
|
|
|
52
31
|
const { getAppRouter } = await import("@teamscala/orpc/app-router");
|
|
53
32
|
appRouter = ((await getAppRouter()) as Record<string, unknown>) ?? null;
|
|
54
33
|
} catch (err) {
|
|
55
|
-
// A genuine load failure here means every data-backed page 500s, so it
|
|
56
|
-
// must surface, never be swallowed (protective-layers-fail-explicitly).
|
|
57
34
|
log.warn(
|
|
58
35
|
`[scala-install:${serviceSlug}] appRouter load failed — data-backed pages will 500: ${err instanceof Error ? `${err.message}\n${err.stack ?? ""}` : String(err)}`,
|
|
59
36
|
);
|
|
@@ -61,28 +38,6 @@ export async function registerPageServerClient(
|
|
|
61
38
|
}
|
|
62
39
|
const declaredModels = await (async (): Promise<string[]> => {
|
|
63
40
|
const capSlug = toCapabilitySlug(serviceSlug);
|
|
64
|
-
// LOOK IT UP BY CAPABILITY, NOT BY DEPLOYABLE NAME. A service_schema
|
|
65
|
-
// row describes the APPLICATION's data surface, so there is ONE row
|
|
66
|
-
// for both lanes; the dev lane arrives here as
|
|
67
|
-
// `scala-projects-bun-dev`. Both sides normalize through
|
|
68
|
-
// toCapabilitySlug, so the join is tier-agnostic by construction.
|
|
69
|
-
// See ./capability-slug.ts.
|
|
70
|
-
//
|
|
71
|
-
// READ WITH THE REAL SCHEMA, NOT `undefined`. The previous call passed
|
|
72
|
-
// `undefined as never`, so v.parse(undefined, raw) threw a TypeError
|
|
73
|
-
// (schema is not a schema) on EVERY service, the bare catch below turned
|
|
74
|
-
// that into "declares nothing", and the refusal at the bottom NEVER
|
|
75
|
-
// fired — a service declaring 18 models booted "0 declared model(s)"
|
|
76
|
-
// and served 500s. (measured scala-sign 2026-09-24/25: row declares 18,
|
|
77
|
-
// boot logged 0.) loadOptionalRegistryConfig parses against
|
|
78
|
-
// ServiceSchemaConfigSchema and returns null ONLY for a missing row.
|
|
79
|
-
//
|
|
80
|
-
// A MISSING ROW is legitimately "declares nothing" (daemon/portal
|
|
81
|
-
// shells own no DB models) and passes through clean. A READ FAILURE is
|
|
82
|
-
// NOT "declares nothing" — it fails the boot naming the error, so the
|
|
83
|
-
// database can never quietly become "no models" and let a router-less,
|
|
84
|
-
// data-backed service boot and serve 500s
|
|
85
|
-
// (`a-component-may-not-report-a-state-it-has-not-verified`).
|
|
86
41
|
const { loadOptionalRegistryConfig } = await import(
|
|
87
42
|
"@teamscala/db/registry/load-config"
|
|
88
43
|
);
|
|
@@ -133,8 +88,6 @@ export async function registerPageServerClient(
|
|
|
133
88
|
const { PlatformIdentitySchema } = await import(
|
|
134
89
|
"@teamscala/db-validation/registry-schemas/platform-identity"
|
|
135
90
|
);
|
|
136
|
-
// Platform org id is a registry value (registries-are-source-of-truth),
|
|
137
|
-
// not a hardcoded literal — read from config/platform-identity.
|
|
138
91
|
const platformIdentity = await loadRegistryConfig(
|
|
139
92
|
"config",
|
|
140
93
|
"platform-identity",
|
|
@@ -154,19 +107,13 @@ export async function registerPageServerClient(
|
|
|
154
107
|
} as never,
|
|
155
108
|
);
|
|
156
109
|
mod.setServerClient(client);
|
|
157
|
-
// Record declared-vs-present capability for /health. Property lookups only.
|
|
158
110
|
(mod as unknown as { recordServerClientCapability?: (m: readonly string[]) => void })
|
|
159
111
|
.recordServerClientCapability?.(declaredModels);
|
|
160
|
-
// State the model count. "registered" alone was true in the outage and told
|
|
161
|
-
// nobody anything; a zero here is now visible in the boot log.
|
|
162
112
|
log.info(
|
|
163
113
|
`[scala-install:${serviceSlug}] ORPC serverClient registered (system:super-admin) — ` +
|
|
164
114
|
`${Object.keys(composed).length} router(s), ${declaredModels.length} declared model(s)`,
|
|
165
115
|
);
|
|
166
116
|
} catch (err) {
|
|
167
|
-
// RETHROW. Swallowing this into a warn is what let a service boot, pass its
|
|
168
|
-
// healthcheck and serve 500s on every page. The boot must fail so the
|
|
169
|
-
// supervisor and the deploy healthcheck can see it.
|
|
170
117
|
log.error(
|
|
171
118
|
`[scala-install:${serviceSlug}] serverClient registration FAILED — data-backed pages cannot render: ${err instanceof Error ? `${err.message}\n${err.stack ?? ""}` : String(err)}`,
|
|
172
119
|
);
|
|
@@ -2,27 +2,9 @@
|
|
|
2
2
|
* @system service-boot
|
|
3
3
|
* @status handwritten
|
|
4
4
|
*/
|
|
5
|
-
/**
|
|
6
|
-
* SSR-prefetch the signed-in user's AI-chat conversation so a CLICK-opened AI
|
|
7
|
-
* panel renders the real conversation on first paint — never a loading gate
|
|
8
|
-
* (ssr-then-live-handoff). The AiChat block lives on the /ai PANEL page, which
|
|
9
|
-
* isn't rendered when the panel is closed, so its block serverLoader can't run
|
|
10
|
-
* at page-load; this prefetches server-side (regardless of panel open-state) +
|
|
11
|
-
* stamps pageData.aiChatSession, which AiChat reads on mount. The agent is
|
|
12
|
-
* resolved from the /ai panel page's own AiChat block (single source of truth —
|
|
13
|
-
* no duplicate config to drift). Fired in parallel with the post-processors +
|
|
14
|
-
* bounded by their window; a failure degrades silently to the client-side
|
|
15
|
-
* createOrRestore fallback, never an error page.
|
|
16
|
-
*/
|
|
17
5
|
|
|
18
6
|
import { getServerClient } from "../page-server-orpc/server-client.ts";
|
|
19
7
|
|
|
20
|
-
/**
|
|
21
|
-
* Deep-find the serving `website_id` in the loader graph (resolved). The
|
|
22
|
-
* Utility/Portal shell carries the website id (not a slug) under a loader
|
|
23
|
-
* source key; the client RegionPageSwap reads it from loaderData.*.website_id
|
|
24
|
-
* the same way. Bounded walk (cycle-safe) over the resolved loader results.
|
|
25
|
-
*/
|
|
26
8
|
function findWebsiteId(root: unknown): string | undefined {
|
|
27
9
|
if (!root || typeof root !== "object") return undefined;
|
|
28
10
|
const seen = new Set<unknown>();
|
|
@@ -3,64 +3,8 @@
|
|
|
3
3
|
* @status handwritten
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
* /sign-in brand override: a shared org-scoped link 302→/sign-in for the
|
|
8
|
-
* unauthed crawler (WhatsApp), which saw the service's default website
|
|
9
|
-
* brand (the platform owner). The callbackURL carries the destination org
|
|
10
|
-
* — override resolved.brand (before the title/meta/og are built from it)
|
|
11
|
-
* so the page reflects the org the visitor is signing in to. Scoped to
|
|
12
|
-
* /sign-in; resource pages keep their own brand.
|
|
13
|
-
*
|
|
14
|
-
* The org is segment 1 of the canonical portal shape /<websiteId>/<orgId>/
|
|
15
|
-
* <path>, NOT segment 0 (the website id): reading segment 0 fed a website id
|
|
16
|
-
* into fetchOrganisationPageBranding, which resolved no org and left the
|
|
17
|
-
* default (super-admin) brand — the cross-tenant mis-branding this override
|
|
18
|
-
* exists to prevent (portal-branding-resolves-page-org-first, 2026-07-21).
|
|
19
|
-
* Shared helper so this and bind-page-handler cannot drift on the answer.
|
|
20
|
-
*/
|
|
21
6
|
|
|
22
|
-
/**
|
|
23
|
-
* brand-is-org-owned-full-scheme, reconciled with page-org-first
|
|
24
|
-
* (portal-branding-resolves-page-org-first): the session override now
|
|
25
|
-
* personalizes ONLY platform pages (page org === website owning-org).
|
|
26
|
-
* Tenant pages keep their resolved page-org brand — the page is
|
|
27
|
-
* authoritative, the menu switcher changes which tenant's pages you
|
|
28
|
-
* view. Anonymous/public (no session) keeps the resolved brand. Same
|
|
29
|
-
* fetchOrganisationPageBranding pattern as the /sign-in brand override.
|
|
30
|
-
*
|
|
31
|
-
* A PUBLIC PAGE IS NEVER PERSONALISED TO THE READER'S ORG. Personalising
|
|
32
|
-
* the brand is right for a portal — you are looking at your own
|
|
33
|
-
* workspace. It is wrong for a public document, which belongs to the org
|
|
34
|
-
* that PUBLISHED it and must look identical to every reader.
|
|
35
|
-
*
|
|
36
|
-
* Measured 2026-08-17: the Launcha Authority Blueprint, owned by GMG
|
|
37
|
-
* APAC, rendered correctly for an anonymous customer and in GBG Indo's
|
|
38
|
-
* navy with the scala logo for any signed-in staff member — so the two
|
|
39
|
-
* people checking it before it went to clients were the only two who
|
|
40
|
-
* could not see what a client sees. Silent, and inverted: the reviewers
|
|
41
|
-
* got the wrong page.
|
|
42
|
-
*/
|
|
43
7
|
|
|
44
|
-
/**
|
|
45
|
-
* BRAND FROM THE ORG THAT OWNS THE RESOURCE, not the website's owner.
|
|
46
|
-
* A public resource page (an assessment report, a shared document) is
|
|
47
|
-
* hosted on a shared website but BELONGS to a tenant. resolvePageBrand
|
|
48
|
-
* reads pageData.orgId, which is the WEBSITE's org, so every tenant's
|
|
49
|
-
* report rendered the host org's logo and palette.
|
|
50
|
-
*
|
|
51
|
-
* Measured 2026-08-17: the Launcha Authority Blueprint is owned by GMG
|
|
52
|
-
* APAC (#FF4500, launcha logo) and rendered with GBG Indo's navy and
|
|
53
|
-
* scala mark, because both pages live on the shared "Public Pages"
|
|
54
|
-
* site. The loader had already resolved the right org — nothing
|
|
55
|
-
* consumed it.
|
|
56
|
-
*
|
|
57
|
-
* `brand_org_path` names where the owning org id lives in the resolved
|
|
58
|
-
* loader data (e.g. "quiz.organisation_id"), so which resource owns the
|
|
59
|
-
* brand is a ROW, never a per-service branch
|
|
60
|
-
* (`business-logic-is-rows-plus-executors`). Absent or unresolvable,
|
|
61
|
-
* the website's org stands — this can only ever narrow the brand to a
|
|
62
|
-
* more specific owner, never blank it.
|
|
63
|
-
*/
|
|
64
8
|
|
|
65
9
|
import { fetchOrganisationPageBranding } from "@teamscala/db/fetch-organisation-page-branding";
|
|
66
10
|
import { getPlatformOrgId } from "@teamscala/db/registry/platform";
|
|
@@ -77,9 +21,6 @@ function spreadBranding(
|
|
|
77
21
|
return {
|
|
78
22
|
...existing,
|
|
79
23
|
companyName: branding.company_name,
|
|
80
|
-
// Friendly name is the customer-facing one; falling back to the
|
|
81
|
-
// legal entity fed "GMG APAC" to every consumer that correctly
|
|
82
|
-
// read companyFriendlyName (e.g. logoAlt in the Puck host codegen).
|
|
83
24
|
companyFriendlyName:
|
|
84
25
|
branding.company_friendly_name ?? branding.company_name,
|
|
85
26
|
primaryColor: branding.brand_primary_color ?? existing?.primaryColor,
|
|
@@ -117,7 +58,6 @@ export async function applySignInBrandOverride(
|
|
|
117
58
|
}
|
|
118
59
|
}
|
|
119
60
|
} catch {
|
|
120
|
-
// Branding fetch failed — keep the default brand (no regression).
|
|
121
61
|
}
|
|
122
62
|
}
|
|
123
63
|
|
|
@@ -131,31 +71,9 @@ export async function applySessionOrgBranding(
|
|
|
131
71
|
renderMode !== "public" &&
|
|
132
72
|
sessionOrgId &&
|
|
133
73
|
sessionOrgId !== (pageData.orgId as string | undefined) &&
|
|
134
|
-
// Page-org-first (portal-branding-resolves-page-org-first): the
|
|
135
|
-
// session override personalizes ONLY the shared PLATFORM website
|
|
136
|
-
// (super-admin org). A TENANT-owned site (a customer GPT/quiz/portal)
|
|
137
|
-
// keeps its page-org brand regardless of the viewer session org. The
|
|
138
|
-
// old pageData.orgId===website.org mis-fired for a tenant whose page org equals their own website org.
|
|
139
74
|
platformOrgId ===
|
|
140
75
|
((pageData.website as { organisation_id?: string | null } | undefined)
|
|
141
76
|
?.organisation_id ?? undefined) &&
|
|
142
|
-
// ...AND the PAGE itself must be platform-owned. A platform-owned
|
|
143
|
-
// WEBSITE also hosts tenant DOCUMENTS — an agreement, a proposal, a
|
|
144
|
-
// signed contract — and those belong to the org that published them.
|
|
145
|
-
// Testing only the website let the viewer's own org repaint a tenant
|
|
146
|
-
// document, which is the same defect this rule already fixed once at
|
|
147
|
-
// the website level (2026-08-17, the Launcha Authority Blueprint).
|
|
148
|
-
//
|
|
149
|
-
// It recurred on sign.scala.business, whose website IS owned by the
|
|
150
|
-
// platform org (c503629a), so the website test passed and every
|
|
151
|
-
// signed-in staff viewer saw a GMG APAC agreement painted in their own
|
|
152
|
-
// org's brand — while an anonymous client saw it correctly. That is
|
|
153
|
-
// the worst possible shape: the only people who cannot see what the
|
|
154
|
-
// client sees are the people reviewing it before it is sent.
|
|
155
|
-
//
|
|
156
|
-
// A public document belongs to the org that PUBLISHED it and must look
|
|
157
|
-
// identical to every reader, so a page whose org is not the platform's
|
|
158
|
-
// is never personalised.
|
|
159
77
|
platformOrgId === (pageData.orgId as string | undefined)
|
|
160
78
|
) {
|
|
161
79
|
try {
|
|
@@ -169,7 +87,6 @@ export async function applySessionOrgBranding(
|
|
|
169
87
|
pageData.brand = spreadBranding(existing, branding);
|
|
170
88
|
}
|
|
171
89
|
} catch {
|
|
172
|
-
// Branding fetch failed — keep the default brand (no regression).
|
|
173
90
|
}
|
|
174
91
|
}
|
|
175
92
|
}
|
|
@@ -193,12 +110,6 @@ export async function applyBrandOrgPath(
|
|
|
193
110
|
}
|
|
194
111
|
if (typeof cursor === "string" && cursor.length > 0) {
|
|
195
112
|
pageData.orgId = cursor;
|
|
196
|
-
// orgId alone is not enough. resolvePageBrand fills only the
|
|
197
|
-
// accent/secondary/muted gaps from the org; primaryColor, logo and
|
|
198
|
-
// companyName come from pageData.brand, which was built from the
|
|
199
|
-
// website's org. Replace those too, the same way the session
|
|
200
|
-
// override above does, or the page keeps the host's logo while
|
|
201
|
-
// claiming the tenant's org.
|
|
202
113
|
try {
|
|
203
114
|
const owner = await fetchOrganisationPageBranding(
|
|
204
115
|
getServerClient() as never,
|
|
@@ -209,7 +120,6 @@ export async function applyBrandOrgPath(
|
|
|
209
120
|
pageData.brand = spreadBranding(existing, owner);
|
|
210
121
|
}
|
|
211
122
|
} catch {
|
|
212
|
-
// Branding fetch failed — keep the website brand (no regression).
|
|
213
123
|
}
|
|
214
124
|
} else {
|
|
215
125
|
log.warn(
|
|
@@ -2,23 +2,6 @@
|
|
|
2
2
|
* @system service-boot
|
|
3
3
|
* @status handwritten
|
|
4
4
|
*/
|
|
5
|
-
/**
|
|
6
|
-
* Stale-while-revalidate read of a cached loader graph.
|
|
7
|
-
*
|
|
8
|
-
* - Fresh hit (within ttlMs): return the cached graph, no refresh.
|
|
9
|
-
* - Stale hit (within ttlMs + staleTtlMs): return the cached graph AND fire ONE
|
|
10
|
-
* background refresh via cache.refreshInBackground — single-flight (deduped
|
|
11
|
-
* against the cache's in-flight map), never rejects, leaves the stale entry in
|
|
12
|
-
* place on failure. The caller deep-clones the returned value before use.
|
|
13
|
-
* - Miss (past staleTtlMs, or never cached): return undefined — the caller
|
|
14
|
-
* resolves synchronously and cache.set()s the clone.
|
|
15
|
-
*
|
|
16
|
-
* The refresh callback must return a graph safe to store. To SKIP caching a
|
|
17
|
-
* refresh result (e.g. a primary-missing / dead-link graph that must stay
|
|
18
|
-
* per-request so a just-created record appears immediately), the callback THROWS
|
|
19
|
-
* — refreshInBackground catches it, leaves the stale entry, and logs one line
|
|
20
|
-
* (an observable dead-link signal, not a hard error).
|
|
21
|
-
*/
|
|
22
5
|
|
|
23
6
|
import { createCache } from "@teamscala/cache/create-cache";
|
|
24
7
|
|
|
@@ -2,25 +2,6 @@
|
|
|
2
2
|
* @system service-boot
|
|
3
3
|
* @status handwritten
|
|
4
4
|
*/
|
|
5
|
-
/**
|
|
6
|
-
* THE ONE SUBJECT DERIVATION. What a page is ABOUT — the entity whose name the
|
|
7
|
-
* browser tab should carry.
|
|
8
|
-
*
|
|
9
|
-
* This existed as THREE hand-written `||` chains, one per render branch in
|
|
10
|
-
* resolve-page-data, and they had already diverged: two ordered form before
|
|
11
|
-
* proposal, the third ordered proposal first. So which resource named the page
|
|
12
|
-
* depended on which branch happened to render it, silently.
|
|
13
|
-
*
|
|
14
|
-
* The divergence is the cheap half of the cost. The expensive half is that a
|
|
15
|
-
* correction has to be applied three times to work, and applying it twice looks
|
|
16
|
-
* exactly like applying it correctly — measured 2026-08-17, when a fix to the
|
|
17
|
-
* quiz subject was shipped, installed and verified present in the bundle while
|
|
18
|
-
* the page kept its old title, because the branch that rendered it held the
|
|
19
|
-
* second copy.
|
|
20
|
-
*
|
|
21
|
-
* One chain, one precedence, one place to change. Adding a resource type is an
|
|
22
|
-
* edit here rather than a fourth branch in three files.
|
|
23
|
-
*/
|
|
24
5
|
|
|
25
6
|
import { interpolateMeta } from "@teamscala/resolver-engine/loader-config/resolve-from-loader-config";
|
|
26
7
|
|
|
@@ -32,11 +13,6 @@ export function buildMetaFromContext(
|
|
|
32
13
|
return interpolateMeta(metaContext as Record<string, unknown>, resolved);
|
|
33
14
|
}
|
|
34
15
|
|
|
35
|
-
/**
|
|
36
|
-
* The page's own meta_title row (loaderData.page first, resolved.page second)
|
|
37
|
-
* — used only on /sign-in, where the org's sign-in page title overrides the
|
|
38
|
-
* derived one. Moved from register-registry-page-route.ts.
|
|
39
|
-
*/
|
|
40
16
|
export function resolvePageMetaTitle(
|
|
41
17
|
resolved: Record<string, unknown>,
|
|
42
18
|
): unknown {
|