@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.
Files changed (83) hide show
  1. package/package.json +1 -11
  2. package/src/agreement-gate/authorized-emails.ts +0 -7
  3. package/src/agreement-gate/placeholder-strip.ts +0 -2
  4. package/src/agreement-gate/settings.ts +0 -15
  5. package/src/agreement-gate.ts +0 -34
  6. package/src/attach-proposal-accept.ts +1 -1
  7. package/src/attach-proposal-magic.ts +1 -1
  8. package/src/auth/sign-out.ts +0 -2
  9. package/src/auth-gate/public-paths.ts +0 -86
  10. package/src/auth-gate.ts +0 -19
  11. package/src/bind-page-handler.ts +0 -76
  12. package/src/build-client-assets/hydrate-bundle.ts +0 -126
  13. package/src/build-client-assets/manifest.ts +0 -18
  14. package/src/build-client-assets/source-candidates.ts +0 -34
  15. package/src/build-client-assets/sourcemaps-upload.ts +0 -42
  16. package/src/build-client-assets/styles-build.ts +0 -50
  17. package/src/capability-slug.ts +0 -10
  18. package/src/compose-page-title.ts +0 -9
  19. package/src/content-gate/access.ts +0 -26
  20. package/src/content-gate/catalog.ts +0 -29
  21. package/src/content-gate/config-types.ts +3 -84
  22. package/src/content-gate/gate.ts +0 -20
  23. package/src/content-page-floor.ts +0 -11
  24. package/src/error-page-logo.ts +2 -12
  25. package/src/expand-roster.ts +0 -9
  26. package/src/gpt-gate.ts +1 -1
  27. package/src/hono-error-handler.ts +0 -4
  28. package/src/install-logging.ts +0 -32
  29. package/src/load-page-manifest.ts +0 -6
  30. package/src/load-service-install-row.ts +41 -4
  31. package/src/module-not-found.ts +0 -11
  32. package/src/mount-orpc-router.ts +1 -88
  33. package/src/mount-server-packages.ts +0 -29
  34. package/src/mutable-app-ref.ts +0 -11
  35. package/src/orpc-contract-rows.json +4102 -61
  36. package/src/orpc-openapi-document.ts +0 -6
  37. package/src/orpc-openapi-gate.ts +0 -10
  38. package/src/pg-boss-handler-registry.ts +0 -12
  39. package/src/pg-boss-mount.ts +0 -101
  40. package/src/proposal-gate.ts +1 -41
  41. package/src/quiz-gate.ts +1 -9
  42. package/src/register-page-server-client.ts +0 -53
  43. package/src/register-registry-page-route/ai-chat-prefetch.ts +0 -18
  44. package/src/register-registry-page-route/apply-page-branding.ts +0 -90
  45. package/src/register-registry-page-route/captured-loader-config.ts +0 -1
  46. package/src/register-registry-page-route/live-loader-config.ts +0 -16
  47. package/src/register-registry-page-route/meta-title.ts +0 -24
  48. package/src/register-registry-page-route/page-data-shared.ts +0 -6
  49. package/src/register-registry-page-route/prefetch-org-id.ts +0 -17
  50. package/src/register-registry-page-route/proposal-approval-email-token.ts +0 -11
  51. package/src/register-registry-page-route/proposal-brand-stamping.ts +0 -20
  52. package/src/register-registry-page-route/public-shell-page-data.ts +0 -26
  53. package/src/register-registry-page-route/resolve-request-graph.ts +2 -155
  54. package/src/register-registry-page-route/run-content-gates.ts +2 -54
  55. package/src/register-registry-page-route/warn-uncovered-blocks.ts +0 -10
  56. package/src/register-registry-page-route.ts +0 -85
  57. package/src/register-static-assets.ts +0 -45
  58. package/src/resolve-brand-favicon.ts +0 -9
  59. package/src/resolve-service-port.ts +0 -11
  60. package/src/resolve-theme-mode.ts +0 -8
  61. package/src/resolvers/app-header.ts +0 -1
  62. package/src/resolvers/fetch-org-brand.ts +0 -1
  63. package/src/resolvers/portal-route-paths.ts +0 -17
  64. package/src/resource-not-found.ts +0 -24
  65. package/src/select-service-routes.ts +0 -14
  66. package/src/theme-fonts-link.ts +0 -5
  67. package/src/two-phase-full-app.ts +0 -76
  68. package/src/two-phase-minimal-app.ts +1 -105
  69. package/src/types.ts +0 -14
  70. package/src/ui-service.ts +1 -42
  71. package/src/content-gate/gate-entry-marker.test.ts +0 -111
  72. package/src/content-gate/token.segmentation.test.ts +0 -76
  73. package/src/content-gate/token.test.ts +0 -65
  74. package/src/content-gate/token.ts +0 -197
  75. package/src/is-react-element-type.test.ts +0 -45
  76. package/src/is-react-element-type.ts +0 -10
  77. package/src/page-server-orpc/hub-mcp-client.ts +0 -14
  78. package/src/pg-boss-mount.test.ts +0 -101
  79. package/src/register-registry-page-route/graph-cache.test.ts +0 -81
  80. package/src/register-registry-page-route/graph-cache.ts +0 -71
  81. package/src/resource-chat-inject.page-assistant.test.ts +0 -145
  82. package/src/resource-chat-inject.test.ts +0 -240
  83. package/src/resource-chat-inject.ts +0 -357
@@ -3,12 +3,6 @@
3
3
  * @status handwritten
4
4
  */
5
5
 
6
- /**
7
- * Both fallbacks are read through FALSYNESS, not `??`: a header that is
8
- * present but empty named nothing, and `"" ?? "http"` would splice an
9
- * empty scheme into the URL as `://host` — a document that serves and
10
- * cannot be called from (caught by the row passing empty positions).
11
- */
12
6
  import type { OpenApiDocumentOptions } from "@teamscala/orpc/openapi";
13
7
 
14
8
  const FALLBACK_HOST = "localhost";
@@ -3,23 +3,13 @@
3
3
  * @status handwritten
4
4
  */
5
5
 
6
- /**
7
- * The four request facts the gate reads, all optional: a fact the caller
8
- * did not send is absent, never a value to trust. Truthiness reads absence
9
- * and null identically, so a header is an assertion, not a hint.
10
- */
11
6
  export interface OpenApiContractRequest {
12
- /** The raw `authorization` header, absent when the caller sent none. */
13
7
  authorization?: string | null;
14
- /** The configured `SCALA_DEV_KEY`, absent when the service has none. */
15
8
  key?: string | null;
16
- /** `x-forwarded-proto`. Its presence means the request was proxied. */
17
9
  forwardedProto?: string | null;
18
- /** `x-forwarded-for`. Same meaning, checked so neither alone is load-bearing. */
19
10
  forwardedFor?: string | null;
20
11
  }
21
12
 
22
- /** True only for an unproxied request presenting exactly the configured key. */
23
13
  export function openApiContractAuthorised(request: OpenApiContractRequest): boolean {
24
14
  if (!request.key) return false;
25
15
  if (request.forwardedProto || request.forwardedFor) return false;
@@ -7,21 +7,9 @@ import { createRegistry } from "@teamscala/generic-registry/create-registry";
7
7
 
8
8
  export type PgBossJobHandler = (ctx: {
9
9
  config: ScheduledJobConfig;
10
- /** Per-job data payload from a one-shot `boss.send()` — absent for scheduled jobs. */
11
10
  jobData?: unknown;
12
11
  }) => Promise<unknown> | unknown;
13
12
 
14
13
  export const pgBossHandlerRegistry = createRegistry<PgBossJobHandler>(
15
14
  "pg-boss-handlers",
16
15
  );
17
-
18
- /**
19
- * Register a handler for a `scheduled_job.handler_slug`. Idempotent on re-import
20
- * (createRegistry.register overwrites silently — last registration wins).
21
- */
22
- export function registerScheduledJobHandler(
23
- slug: string,
24
- handler: PgBossJobHandler,
25
- ): void {
26
- pgBossHandlerRegistry.register(slug, handler);
27
- }
@@ -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
  });
@@ -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 "./content-gate/token.ts";
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 "./content-gate/token.ts";
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(
@@ -3,7 +3,6 @@
3
3
  * @status handwritten
4
4
  */
5
5
 
6
- /** Structural slice of the ResourceLoaderConfigSchema output this unit reads. */
7
6
  interface LoaderConfigSlice {
8
7
  sources?: { key: string }[];
9
8
  region_slots?: string[];