@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
package/package.json CHANGED
@@ -118,8 +118,6 @@
118
118
  "./content-gate/gate-entry-clears.ts": "./src/content-gate/gate-entry-clears.ts",
119
119
  "./content-gate/parse-cookie": "./src/content-gate/parse-cookie.ts",
120
120
  "./content-gate/parse-cookie.ts": "./src/content-gate/parse-cookie.ts",
121
- "./content-gate/token": "./src/content-gate/token.ts",
122
- "./content-gate/token.ts": "./src/content-gate/token.ts",
123
121
  "./content-page-floor": "./src/content-page-floor.ts",
124
122
  "./content-page-floor.ts": "./src/content-page-floor.ts",
125
123
  "./csrf-protection": "./src/csrf-protection.ts",
@@ -138,8 +136,6 @@
138
136
  "./install-harness.ts": "./src/install-harness.ts",
139
137
  "./install-logging": "./src/install-logging.ts",
140
138
  "./install-logging.ts": "./src/install-logging.ts",
141
- "./is-react-element-type": "./src/is-react-element-type.ts",
142
- "./is-react-element-type.ts": "./src/is-react-element-type.ts",
143
139
  "./load-page-manifest": "./src/load-page-manifest.ts",
144
140
  "./load-page-manifest.ts": "./src/load-page-manifest.ts",
145
141
  "./load-service-install-row": "./src/load-service-install-row.ts",
@@ -162,8 +158,6 @@
162
158
  "./page-route-shell-types.ts": "./src/page-route-shell-types.ts",
163
159
  "./page-server-loader-graph": "./src/page-server-loader-graph.ts",
164
160
  "./page-server-loader-graph.ts": "./src/page-server-loader-graph.ts",
165
- "./page-server-orpc/hub-mcp-client": "./src/page-server-orpc/hub-mcp-client.ts",
166
- "./page-server-orpc/hub-mcp-client.ts": "./src/page-server-orpc/hub-mcp-client.ts",
167
161
  "./page-server-orpc/server-client": "./src/page-server-orpc/server-client.ts",
168
162
  "./page-server-orpc/server-client.ts": "./src/page-server-orpc/server-client.ts",
169
163
  "./pg-boss-handler-registry": "./src/pg-boss-handler-registry.ts",
@@ -196,8 +190,6 @@
196
190
  "./register-registry-page-route/apply-page-branding.ts": "./src/register-registry-page-route/apply-page-branding.ts",
197
191
  "./register-registry-page-route/captured-loader-config": "./src/register-registry-page-route/captured-loader-config.ts",
198
192
  "./register-registry-page-route/captured-loader-config.ts": "./src/register-registry-page-route/captured-loader-config.ts",
199
- "./register-registry-page-route/graph-cache": "./src/register-registry-page-route/graph-cache.ts",
200
- "./register-registry-page-route/graph-cache.ts": "./src/register-registry-page-route/graph-cache.ts",
201
193
  "./register-registry-page-route/live-loader-config": "./src/register-registry-page-route/live-loader-config.ts",
202
194
  "./register-registry-page-route/live-loader-config.ts": "./src/register-registry-page-route/live-loader-config.ts",
203
195
  "./register-registry-page-route/meta-title": "./src/register-registry-page-route/meta-title.ts",
@@ -246,8 +238,6 @@
246
238
  "./resolvers/portal-route-paths.ts": "./src/resolvers/portal-route-paths.ts",
247
239
  "./resolvers/website-by-host-map": "./src/resolvers/website-by-host-map.ts",
248
240
  "./resolvers/website-by-host-map.ts": "./src/resolvers/website-by-host-map.ts",
249
- "./resource-chat-inject": "./src/resource-chat-inject.ts",
250
- "./resource-chat-inject.ts": "./src/resource-chat-inject.ts",
251
241
  "./resource-not-found": "./src/resource-not-found.ts",
252
242
  "./resource-not-found.ts": "./src/resource-not-found.ts",
253
243
  "./run-boot-hook-list": "./src/run-boot-hook-list.ts",
@@ -302,5 +292,5 @@
302
292
  "repository": "https://github.com/teamgbg/service-boot",
303
293
  "sideEffects": false,
304
294
  "type": "module",
305
- "version": "0.23.524"
295
+ "version": "0.23.526"
306
296
  }
@@ -8,7 +8,6 @@ import { parseAgreementParties } from "@teamscala/document-signing/agreement-par
8
8
  import { findFirstAgreement } from "./agreement-lookup.ts";
9
9
  import { resolveAgreementViewerDirectory } from "./resolution-caches.ts";
10
10
 
11
- /** The agreement's authorized email set (its signer roster) for the access endpoint. */
12
11
  export async function resolveAgreementSignerEmails(
13
12
  agreementId: string,
14
13
  ): Promise<Set<string>> {
@@ -40,12 +39,6 @@ export async function resolveAgreementSignerEmails(
40
39
  }
41
40
  }
42
41
 
43
- /**
44
- * Every email allowed PAST the gate: the signer roster (who may sign) UNION every
45
- * member of the owning org (who may VIEW). Signing authority stays roster-only —
46
- * buildSigningSession gives a non-party org member a view-only session. This is
47
- * what the access endpoint and session-authorize check.
48
- */
49
42
  export async function resolveAgreementAuthorizedEmails(
50
43
  agreementId: string,
51
44
  ): Promise<Set<string>> {
@@ -5,7 +5,6 @@
5
5
 
6
6
  import type { ContentBlock } from "./signing-session.ts";
7
7
 
8
- /** True if any of the block's OWN string props carries an {{agreement.*}} token. */
9
8
  function blockHasAgreementPlaceholder(block: ContentBlock): boolean {
10
9
  const props = block.props;
11
10
  if (!props) return false;
@@ -15,7 +14,6 @@ function blockHasAgreementPlaceholder(block: ContentBlock): boolean {
15
14
  return false;
16
15
  }
17
16
 
18
- /** Drop blocks whose own text still holds an {{agreement.*}} token, recursively. */
19
17
  export function stripAgreementPlaceholderBlocks(content: ContentBlock[]): ContentBlock[] {
20
18
  const out: ContentBlock[] = [];
21
19
  for (const block of content) {
@@ -8,13 +8,6 @@ import * as v from "valibot";
8
8
 
9
9
  export const AG_COOKIE = "ag_token";
10
10
 
11
- /**
12
- * Agreement gate display + behaviour, DB-driven (config/content-gate-agreement),
13
- * never hardcoded — mirrors proposal-gate's loadProposalGateSettings. requireOtp
14
- * is the per-gate OTP switch (agreements default ON: a signer verifies identity
15
- * before signing). title is the fallback used when no agreement name resolves. A
16
- * missing/invalid row falls back to these defaults so the gate UI ALWAYS renders.
17
- */
18
11
  export const AGREEMENT_GATE_DEFAULTS = {
19
12
  title: "Private document",
20
13
  subtitle: "Verify your email to view and sign this document.",
@@ -29,16 +22,8 @@ const AgreementGateSettingsSchema = v.object({
29
22
  requireOtp: v.optional(v.boolean(), AGREEMENT_GATE_DEFAULTS.requireOtp),
30
23
  });
31
24
 
32
- /**
33
- * The settings type is the SCHEMA'S output, never a hand mirror
34
- * (`autosave-single-surface` rule 2): the interface re-spelled the schema one
35
- * block away and drifts the first time the schema grows a field.
36
- */
37
25
  export type AgreementGateSettings = v.InferOutput<typeof AgreementGateSettingsSchema>;
38
26
 
39
- /** Gate copy/behaviour, or null when the config row is absent/broken. Null is
40
- * the absent signal — callers default explicitly so a missing row neither 500s
41
- * the page nor masks as a populated value (protective-layers-fail-explicitly). */
42
27
  export async function loadAgreementGateSettings(): Promise<AgreementGateSettings | null> {
43
28
  try {
44
29
  return await loadRegistryConfig(
@@ -9,7 +9,6 @@ import {
9
9
  verifyAgreementAccessToken,
10
10
  } from "@teamscala/document-signing/agreement-access-token";
11
11
  import { expandRosterTemplates } from "./expand-roster.ts";
12
- import { injectResourceChatLauncher } from "./resource-chat-inject.ts";
13
12
  import { agTtlMs } from "./gate-ttls.ts";
14
13
  import { getAppLogger } from "@teamscala/logger/app-loggers";
15
14
  import { AG_COOKIE, AGREEMENT_GATE_DEFAULTS, loadAgreementGateSettings } from "./agreement-gate/settings.ts";
@@ -36,7 +35,6 @@ import { resolveAgreementAuthorizedEmails } from "./agreement-gate/authorized-em
36
35
  export const agreementGateConfig: ContentGateConfig = {
37
36
  cookieName: AG_COOKIE,
38
37
  async getResourceId(resolved) {
39
- // Old-flow pages: a pre-placed SignIn gate block carries the UUID.
40
38
  const gate = findAgreementGate(resolved);
41
39
  if (gate.agreementUuid) {
42
40
  const gateRegion = gate.gateRegions[0];
@@ -47,7 +45,6 @@ export const agreementGateConfig: ContentGateConfig = {
47
45
  return gate.agreementUuid;
48
46
  }
49
47
 
50
- // New-flow pages: resolve UUID from the page→agreement association.
51
48
  const pageId = (resolved.page as { id?: string } | undefined)?.id;
52
49
  if (!pageId) return "";
53
50
 
@@ -70,7 +67,6 @@ export const agreementGateConfig: ContentGateConfig = {
70
67
  stashAgreementMeta(resolved, agreement.id, agreement.name);
71
68
  return agreement.id;
72
69
  }
73
- // Cache negative so non-agreement pages never re-query.
74
70
  pageAgreementCache.set(pageId, { id: "", name: "" });
75
71
  return "";
76
72
  } catch (err) {
@@ -80,10 +76,6 @@ export const agreementGateConfig: ContentGateConfig = {
80
76
  return "";
81
77
  }
82
78
  },
83
- // Baked-in unified auth (engine default): a logged-in viewer whose session
84
- // email is in the authorized set (signer roster ∪ owning/party-org members)
85
- // is released with no per-gate OTP — session verify is local, works on
86
- // Railway. Just exposing the resolver opts into the platform-wide behaviour.
87
79
  resolveAuthorizedEmails: resolveAgreementAuthorizedEmails,
88
80
  verifyToken(token, resourceId, nowMs) {
89
81
  const email = verifyAgreementAccessToken(token, resourceId, nowMs);
@@ -92,7 +84,6 @@ export const agreementGateConfig: ContentGateConfig = {
92
84
  async onAuthorized(resolved, agreementUuid, { email }) {
93
85
  const session = await buildSigningSession(agreementUuid, email);
94
86
  if (!session) {
95
- // Session build failed — withhold content as if unauthorized.
96
87
  if (!hasPrePlacedGate(resolved)) {
97
88
  const content = getBodyContentArray(resolved);
98
89
  if (content) {
@@ -117,9 +108,6 @@ export const agreementGateConfig: ContentGateConfig = {
117
108
  })
118
109
  : "";
119
110
 
120
- // New-flow pages: inject the signing shell BEFORE roster expansion so
121
- // the SignatureField template is expanded per party. Old-flow pages
122
- // already have pre-placed blocks — skip injection.
123
111
  if (!hasPrePlacedGate(resolved)) {
124
112
  const content = getBodyContentArray(resolved);
125
113
  if (content) {
@@ -130,37 +118,18 @@ export const agreementGateConfig: ContentGateConfig = {
130
118
  }
131
119
  }
132
120
 
133
- // Expand roster templates + bind agreement-meta placeholders (both flows).
134
121
  expandRosterTemplates(resolved, session.parties, {
135
122
  name: session.agreementName,
136
123
  ref: session.agreementRef,
137
124
  org: session.orgName ?? "",
138
125
  signer: session.authedName,
139
- // Role-aware verb for the header status line. authedRole is a party key
140
- // for a designated signer, or the literal "viewer" for a view-only
141
- // recipient (an org member who isn't on the signer roster). So the
142
- // header reads "Viewing as <name>" for a viewer, "Signing as <name>"
143
- // for a signer — never implying a viewer can sign.
144
126
  signerVerb: session.authedRole === "viewer" ? "Viewing" : "Signing",
145
127
  signedCount: String(signedCount),
146
128
  total: String(session.parties.length),
147
129
  completedDate,
148
130
  });
149
- // Seed the verified session into every SignatureField (both flows).
150
131
  seedSigningBlocks(resolved, session);
151
132
 
152
- // Floating "Ask about this agreement" GPT bubble — the generic
153
- // ResourceChatLauncher (one-ai-chat-system), strictly behind the email
154
- // gate. The agreement's clause text becomes the chat's runtime context.
155
- await injectResourceChatLauncher(resolved, email, {
156
- resourceKey: "agreement",
157
- agentConfigSlug: "agreement-assistant-agent",
158
- organisationId: session.organisationId ?? undefined,
159
- launchLabel: "Ask about this agreement",
160
- placeholder: "Ask about this agreement…",
161
- });
162
-
163
- // Old-flow only: drop the pre-placed gate block (new flow has none).
164
133
  if (hasPrePlacedGate(resolved)) {
165
134
  const { gateRegions } = findAgreementGate(resolved);
166
135
  for (const { pd, content, gateIdx } of gateRegions) {
@@ -170,13 +139,11 @@ export const agreementGateConfig: ContentGateConfig = {
170
139
  },
171
140
  async onUnauthorized(resolved, agreementUuid) {
172
141
  if (hasPrePlacedGate(resolved)) {
173
- // Old flow: strip region to gate-only.
174
142
  const { gateRegions } = findAgreementGate(resolved);
175
143
  for (const { pd, content, gateIdx } of gateRegions) {
176
144
  pd.content = [content[gateIdx]];
177
145
  }
178
146
  } else {
179
- // New flow: inject gate, withhold all body content.
180
147
  const meta = resolved[AGREEMENT_META_KEY] as
181
148
  | { name?: string }
182
149
  | undefined;
@@ -195,7 +162,6 @@ export const agreementGateConfig: ContentGateConfig = {
195
162
  }
196
163
  }
197
164
 
198
- // Common: strip agreement-placeholder blocks + neutralize page meta.
199
165
  for (const value of Object.values(resolved)) {
200
166
  const pd = (
201
167
  value as { puck_data?: { content?: unknown[] } } | null
@@ -7,7 +7,7 @@ import type { Hono } from "hono";
7
7
  import { getAppLogger } from "@teamscala/logger/app-loggers";
8
8
  import { signAgreementAccessToken } from "@teamscala/document-signing/agreement-access-token";
9
9
  import { parseCookie } from "./content-gate/parse-cookie.ts";
10
- import { verifyAccessToken } from "./content-gate/token.ts";
10
+ import { verifyAccessToken } from "@teamscala/agreement-generation/content-gate-token";
11
11
  import { agTtlMs } from "./gate-ttls.ts";
12
12
  import { createAgreementFromProposal } from "@teamscala/agreement-generation/create-agreement-from-proposal";
13
13
  import { getCookieDomain } from "@teamscala/db/registry/cookie-domain";
@@ -5,7 +5,7 @@
5
5
 
6
6
  import type { Hono } from "hono";
7
7
  import { getAppLogger } from "@teamscala/logger/app-loggers";
8
- import { verifyAccessToken } from "./content-gate/token.ts";
8
+ import { verifyAccessToken } from "@teamscala/agreement-generation/content-gate-token";
9
9
  import { propTtlMs } from "./gate-ttls.ts";
10
10
 
11
11
  const PROP_COOKIE = "prop_token";
@@ -5,9 +5,7 @@
5
5
  import { getAppLogger } from "@teamscala/logger/app-loggers";
6
6
 
7
7
  export interface SignOutResult {
8
- /** True once the local session is revoked (and the central one, when centralised). */
9
8
  ok: boolean;
10
- /** True when the sign-out was forwarded to scala-otp. */
11
9
  forwarded: boolean;
12
10
  }
13
11
 
@@ -13,16 +13,6 @@ export const SIGN_IN_PATHS = new Set([
13
13
  "/magic-link",
14
14
  ]);
15
15
 
16
- /// A STATIC ASSET IS NEVER A PROTECTED RESOURCE, so it is decided by EXTENSION / rather than by remembering to list it.
17
- /// / The allowlist below enumerates PREFIXES, and every path outside it is gated. / That makes a new public asset directory private BY DEFAULT, and the failure
18
- /// lands exactly where it hurts most: on the SIGNED-OUT screen, which is the one / page guaranteed to need branding. `/assets/` was listed; `/logos/` never was,
19
- /// so `/logos/cropped.png` answered 302 → /sign-in and the browser rendered a / blank logo under a "starting up" splash. Favicons are on the list ONE FILE AT
20
- /// A TIME, which is the same defect already visible. / / This is why the operator reports it "keeps regressing": nothing is broken by
21
- /// a code change, the allowlist simply never covered the path, and any build that / moves images re-creates it. Enumerating what is public is `prevention-over-
22
- /// detection` inverted — the cure is a predicate, not a longer list. /
23
- /// Redirecting an <img> to a login page is never useful behaviour either: the
24
- /// browser cannot act on the redirect, so it silently paints nothing. Failing
25
- /// closed on a page is protection; failing closed on an image is just a blank.
26
16
  const STATIC_ASSET_EXTENSIONS = [
27
17
  ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg", ".ico", ".bmp",
28
18
  ".woff", ".woff2", ".ttf", ".otf", ".eot",
@@ -38,17 +28,7 @@ export const PUBLIC_INFRA_PREFIXES = [
38
28
  "/assets/",
39
29
  "/api/assets/",
40
30
  "/api/auth/",
41
- // fn.signInFlow procedures serve the unauthenticated sign-in flow (config
42
- // load, session check, portal list) — anonymous-allowed by design (return {}
43
- // for no session). They replaced the former /api/data/sign-in-flow-config,
44
- // /check-session, /portals HANDLER_MAP entries (handler-map-retirement).
45
31
  "/api/rpc/fn/signInFlow/",
46
- // scala-enroll device-onboarding: the machine-facing installer + register
47
- // routes are TOKEN-authenticated (a single-use enrollment token issued by a
48
- // logged-in, dev_access-approved operator), fetched by the onboardee via
49
- // `irm`/`curl` with NO session cookie — so they must skip the page login
50
- // gate, exactly like /mcp (Bearer) and /api/rpc/ws self-authenticate. The
51
- // TOKEN is the delegated capability; issuance (/issue) stays login-gated.
52
32
  "/provision/",
53
33
  ];
54
34
 
@@ -58,81 +38,15 @@ export const PUBLIC_INFRA_PATHS = new Set([
58
38
  "/favicon.svg",
59
39
  "/api/session",
60
40
  "/api/rpc/ws",
61
- // The MCP endpoint is a service-to-service surface with its OWN Bearer auth
62
- // (the gateway sends `Authorization: Bearer SCALA_DEV_KEY`, validated by the
63
- // MCP route handler). It must never be session-page-gated: on a kind:ui
64
- // service that also mounts an MCP server (scala-agents — the fleet
65
- // orchestration + cross-pane channel), the auth-gate would 302 /mcp to
66
- // /sign-in, so the gateway sees toolCount=0 and the whole channel goes
67
- // degraded. Backend-only MCP hosts have no auth-gate, so this only bit the
68
- // one UI MCP host once the localhost super-admin bypass was removed
69
- // (the bypass had been masking it). Exempt alongside /api/rpc/ws and
70
- // /api/auth/, which self-authenticate the same way.
71
41
  "/mcp",
72
- // build-id is polled by the client self-heal; it must always return JSON
73
- // (a tiny bundle hash) regardless of session, never a /sign-in redirect —
74
- // otherwise an expired-session tab would compare a redirect body to its
75
- // booted build and never heal.
76
42
  "/api/build-id",
77
- // /_heap is the localhost-gated heap-attribution probe (register-static-assets);
78
- // like /health it is infra, not a page, so it must never 302 to /sign-in on an
79
- // auth-gated service. The probe's own localhost Host check is the real gate.
80
43
  "/_heap",
81
- // /api/fleet/events is the SSE fleet event stream consumed self-contained by
82
- // the generated opencode scala-hooks plugin (and any future client without
83
- // an @orpc/client dep) for native cross-pane channel delivery. The plugin
84
- // runs in the opencode process with no scala-agents session cookie, so the
85
- // route must be public; the stream is keyed by the orchestratorSessionId
86
- // query param (the subscriber's own tmux pane id) and carries no sensitive
87
- // data, and dispatch only emits events targeted at that identity.
88
44
  "/api/fleet/events",
89
- // /api/fleet/claude-sessions is a non-sensitive lane-identity read (active
90
- // Claude lane sessionIDs + cwds) consumed by the session-picker daemon to
91
- // RESTORE transcript tails after its own restart. The picker runs with no
92
- // scala-agents session cookie; the data is less sensitive than the
93
- // /api/fleet/events stream above (no message content), so it is public like
94
- // the other dev-host infra reads.
95
45
  "/api/fleet/claude-sessions",
96
- // /api/fleet/ingest-event receives parsed lane-action events
97
- // (message.part.updated etc.) from the cli-session transcript-tail — a
98
- // dev-host INTERNAL machine caller that POSTs with no session cookie, exactly
99
- // like the lane-event hook. Without this exemption the page auth-gate 302s
100
- // every POST and the lane's tool/text action stream never reaches the
101
- // ingestEvent pipeline. A first-class
102
- // api_route (sibling to lane-event); security is the handler's loopback gate
103
- // on the platform's x-caller-remote-address (set by register-api-routes).
104
46
  "/api/fleet/ingest-event",
105
- // /api/fleet/lane-event receives Claude Code http lifecycle hooks
106
- // (Stop/UserPromptSubmit/Pre*ToolUse) for live-lane visibility. Like /mcp it
107
- // is a service-to-service surface with its OWN Bearer auth (the hook sends
108
- // `Authorization: Bearer SCALA_DEV_KEY`, validated by the handler); the hook
109
- // runs in the Claude process with no scala-agents session cookie, so it must
110
- // not be session-page-gated. The handler filters to fleet lanes by workspace
111
- // cwd + resolves the lane via the session-picker, so an unauth'd caller
112
- // writes no lane state.
113
47
  "/api/fleet/lane-event",
114
- // /api/fleet/sweep-executor receives the loga monitor engine's sweep dispatch
115
- // (POST {slug}) -- a service-to-service surface like /api/fleet/lane-event /
116
- // /api/fleet/ingest-event (loga -> scala-agents, localhost). Security is the
117
- // handler's loopback gate (x-caller-remote-address), identical to its siblings.
118
48
  "/api/fleet/sweep-executor",
119
- // /api/terminal-ws is the in-browser terminal WebSocket bridge (a real PTY
120
- // login shell). It is a dev-tool surface analogous to /api/fleet/events:
121
- // the operator's own shell, no sensitive data beyond what the operator
122
- // already has in their terminal. Must not be session-gated — the test
123
- // harness and the terminal block both connect without a session cookie.
124
49
  "/api/terminal-ws",
125
- // /unauthorised is the access-gate's rejection target (the PageGuard access
126
- // page). It MUST be exempt: an authed-but-not-allowed viewer is redirected
127
- // here, so gating it would re-reject and loop. It renders no sensitive data.
128
50
  "/unauthorised",
129
- // The OpenAPI contract for the ORPC router — a service-to-service surface
130
- // with its OWN Bearer auth (`Authorization: Bearer SCALA_DEV_KEY`,
131
- // validated by the mount-orpc-router handler, fail-closed), exactly like
132
- // /mcp and /api/fleet/lane-event above. The MCP gateway fetches it at boot
133
- // (SCALA_OPENAPI_SPEC_URL) with no session cookie; without this exemption
134
- // the page auth-gate 302s the fetch to /sign-in and the gateway reads an
135
- // HTML sign-in page where the JSON document belongs (observed probing
136
- // scala-portal.internal:5200/api/rpc/openapi.json on 2026-09-30).
137
51
  "/api/rpc/openapi.json",
138
52
  ]);
package/src/auth-gate.ts CHANGED
@@ -3,25 +3,6 @@
3
3
  * @status handwritten
4
4
  */
5
5
 
6
- /**
7
- * Point the event-log primitive at the process's Prisma singleton.
8
- *
9
- * Driver-agnostic `write` callback backed by the Prisma singleton
10
- * (`one-db-surface` — no second postgres). Lazy, and it takes precedence over
11
- * any `prisma:` configure, so this writer is authoritative for authed services.
12
- * The previous form passed the ORPC server client (`getServerClient()`) cast
13
- * `as never` as `prisma:` — that client has no `$executeRaw`, so `recordEvent`
14
- * silently failed. Routing it through ORPC would reintroduce exactly that.
15
- * Retention is owned by the event-log-sweep pg-boss job.
16
- *
17
- * EXTRACTED so its name states what it is. This is a `configured-primitives`
18
- * INJECTION — handing a client to a primitive's configure() — not a query, and
19
- * `no-direct-prisma-outside-orpc` cannot tell the two apart by AST. It exempts
20
- * initialization, which is precisely what this is; buried inside
21
- * `attachAuthGate` the call read as an arbitrary getPrisma() and blocked every
22
- * unrelated edit to this file. Naming the boot step honestly is the fix. The
23
- * registry refused a path exemption for it, correctly — `guard-exemptions-to-zero`.
24
- */
25
6
 
26
7
  // Eager-warm the local BetterAuth runtime at boot whenever a request path
27
8
  // will build it: the session-READ path (getSession, better-auth-cookie mode)
@@ -3,17 +3,6 @@
3
3
  * @status handwritten
4
4
  */
5
5
 
6
- // Per-page root.props.maxWidth (the canonical mechanism) centers any page at the chosen width via inline styles — already hydration-safe
7
- // and page-scoped. Bind-page-handler MUST NOT inject unscoped CSS selectors: a global `.puck-root-slot { max-width }` rule shrinks
8
- // every portal pane that hosts a Puck root (the 2026-06-17 dashboard sliver bug — `.puck-root-slot` matches every Puck page in every
9
- // region, including the dashboard center pane). The `no-bind-page-handler-css-injection` guard enforces this; this comment is the load-bearing rationale.
10
- // Live dashboards must never be served from a stale cache (intermediary, browser back-forward, mobile Safari aggressive caching). Force every
11
- // SSR HTML response to be revalidated. Static assets (CSS/JS bundles) keep their hash-busted caching — those are content-addressed.
12
- // no-transform: intermediaries must not mutate SSR HTML — Cloudflare's Email Address Obfuscation rewrites mailto: hrefs at the edge, which
13
- // breaks the server-serializes-client-reads-verbatim hydration contract (React recoverable-error on every page with a mailto link). Per CF
14
- // docs, Email Obfuscation does not apply when no-transform is present. NOTE: a shared-edge-caching (CDN max-age) variant shipped briefly on 2026-06-10
15
- // and was REMOVED by operator ruling the same day ("the origin should be so fast we don't care") — the origin-speed work (SWR caches,
16
- // wave-parallel loader, brotli) is the sanctioned path; re-adding shared-cache headers needs a fresh operator ruling.
17
6
 
18
7
  import { getAppLogger } from "@teamscala/logger/app-loggers";
19
8
  import { withGateEntryClears } from "./content-gate/gate-entry-clears.ts";
@@ -42,34 +31,12 @@ export async function bindPageHandler(
42
31
  let orgId: string | undefined;
43
32
  let visitor: { id: string; name: string; email: string } | null = null;
44
33
 
45
- // NO localhost super-admin bypass: identity comes ONLY from the session
46
- // (the auth-gate already forces sign-in for authed services on every
47
- // host, 127.0.0.1 included). The old `if (isLocalhost) userId="super-admin"`
48
- // rendered authed pages as a privileged user on localhost regardless of
49
- // who (if anyone) was signed in — so agent/tool verification on 127.0.0.1
50
- // saw a view that diverged from the real signed-in user's. Tools that need
51
- // an authed render sign in and carry the cookie (getSuperAdminSessionCookie).
52
34
 
53
- // The .scala.business session cookie reaches EVERY app on the domain,
54
- // so a signed-in platform user is identifiable at SSR regardless of
55
- // this service's own auth mode. Resolving it per-request (never via
56
- // the cross-request loader graph) lets identity-gated blocks
57
- // (PublicGptBlock's SignInBlock gate) first-paint the real experience
58
- // instead of a client-side "checking who you are" loading pass
59
- // (ssr-then-live-handoff). authMode still governs AUTHORIZATION;
60
- // this is read-only identity convenience.
61
35
  const hasSessionCookie = /better-auth\.session_token=/.test(
62
36
  c.req.header("cookie") ?? "",
63
37
  );
64
38
 
65
39
  if (authMode === "better-auth-cookie" || hasSessionCookie) {
66
- // Visitor identity is an ENHANCEMENT: on auth:none services
67
- // (scala-public et al.) no auth factory is registered, and a
68
- // signed-in operator's domain-wide cookie reaches every
69
- // *.scala.business site — getSession throwing "Auth factory not
70
- // registered" 500'd every page for signed-in visitors
71
- // (public-dev outage, 2026-06-11). Degrade to anonymous, never
72
- // fail the render (block-and-provider-error-isolation).
73
40
  try {
74
41
  const { getSession } = await import("@teamscala/auth/get-session.ts");
75
42
  const session = await getSession(c.req.raw.headers);
@@ -86,27 +53,11 @@ export async function bindPageHandler(
86
53
  }
87
54
  } catch (err) {
88
55
  if (authMode === "better-auth-cookie") throw err;
89
- // cookie-only enhancement path: anonymous render proceeds
90
56
  }
91
57
  }
92
58
 
93
- // Anonymous dynamic routes (proposal/form/quiz/agent) resolve a
94
- // resource whose owning org is known from the loader. That org owns
95
- // the brand the block-level serverLoaders read — override the
96
- // viewer's org (if any) so the SSR injects the resource owner's
97
- // logo/colours, not the viewer's.
98
59
  if (resourceOrgId) orgId = resourceOrgId;
99
60
 
100
- // Unauthed /sign-in (no session, no resource) would otherwise resolve no org, and the brand resolver falls back to the super-admin org (GBG Indo)
101
- // — so a link shared to org A (e.g. GMG APAC) previews as org B's brand on WhatsApp/crawlers, because the link 302→/sign-in for the unauthed crawler.
102
- // The callbackURL carries the org the visitor is signing in to; derive orgId from it so the sign-in page's brand/title/og reflect the destination org.
103
- // The canonical portal shape is /<websiteId>/<orgId>/<path> — the org is segment 1, NOT segment 0. Reading segment 0 (the website id) fed a website
104
- // id into brand resolution, which resolved no org and fell back to the super-admin org — the exact cross-tenant mis-branding this block exists to
105
- // fix, still live until 2026-07-21; violates portal-branding-resolves-page-org-first.
106
- // scala-projects routes /<orgId>/… (single UUID) and proposal/form/quiz hosts
107
- // route /<resourceId>/… (a non-org first segment) — both keep segment 0 via
108
- // the helper's single-UUID fallback (resolves to no brand, falls back as
109
- // before). The helper is the single authority shared with the route resolver.
110
61
  if (!orgId) {
111
62
  const cb = c.req.query("callbackURL") ?? "";
112
63
  const resolved = extractOrgIdFromCallbackPath(cb);
@@ -126,10 +77,6 @@ export async function bindPageHandler(
126
77
  }),
127
78
  );
128
79
  const headers = new Headers(resp.headers as HeadersInit);
129
- // Gate-entry markers are ONE-VIEW state: the render that consumed one
130
- // clears it here, so the next load (a refresh) is unauthorized and shows
131
- // the gate again. Without this the marker would outlive the view that
132
- // used it and every load would skip the gate.
133
80
  for (const name of gateEntryClears) {
134
81
  headers.append(
135
82
  "Set-Cookie",
@@ -142,26 +89,9 @@ export async function bindPageHandler(
142
89
  );
143
90
  headers.set("Pragma", "no-cache");
144
91
  headers.set("Expires", "0");
145
- // Viewport-aware slot loading varies the response by these headers.
146
- // Even with Cache-Control: no-store, browsers and intermediaries
147
- // honour Vary on back/forward navigation and BFCache; Accept-CH
148
- // asks the browser to send Sec-CH-UA-Mobile on subsequent
149
- // navigations (Chromium-family auto-sends, others opt in).
150
92
  headers.set("Vary", "Sec-CH-UA-Mobile, User-Agent");
151
93
  headers.set("Accept-CH", "Sec-CH-UA-Mobile");
152
- // Origin-side gzip. Cache-Control: no-transform (load-bearing above —
153
- // it disables Cloudflare HTML rewrites that corrupt hydration) ALSO
154
- // makes hono's compress() middleware skip this response, which shipped
155
- // 1.2MB of raw SSR HTML per page load through the dev tunnel (~3s of
156
- // pure transfer; the operator's tablet timed out — 2026-06-06).
157
- // no-transform forbids INTERMEDIARY transforms; origin-applied
158
- // content-coding is part of the representation (RFC 9110 §8.4.1), so
159
- // compressing here honors both. Bun.gzipSync on ~1.2MB costs ~10ms.
160
94
  const acceptEnc = c.req.header("Accept-Encoding") ?? "";
161
- // Brotli preferred when offered (every modern browser): ~15-20%
162
- // smaller than gzip on SSR HTML. Quality 4, NOT the library default
163
- // 11 — q11 costs hundreds of ms on a ~1MB page (defeats the point);
164
- // q4 compresses in single-digit ms and still beats gzip.
165
95
  if (/\bbr\b/.test(acceptEnc) && resp.body) {
166
96
  const { brotliCompressSync, constants } = await import("node:zlib");
167
97
  const raw = new Uint8Array(await new Response(resp.body as never).arrayBuffer());
@@ -181,8 +111,6 @@ export async function bindPageHandler(
181
111
  }
182
112
  return new Response(resp.body as never, { status: resp.status, headers });
183
113
  } catch (err) {
184
- // Dead entity link (wrong id, deleted row) → the branded not-found
185
- // page with a real 404 — never a 500, never default-content leakage.
186
114
  if (err instanceof PrimaryResourceMissingError) {
187
115
  log.warn(
188
116
  `[scala-install:${serviceSlug}] 404 ${err.resourceType} not found (${label}) for ${new URL(c.req.url).pathname}`,
@@ -191,10 +119,6 @@ export async function bindPageHandler(
191
119
  err.resourceType,
192
120
  await resolveErrorPageLogo(serviceSlug),
193
121
  err.httpStatus,
194
- // "Take me home" → the host root (relative "/" resolves to the
195
- // current site's home; the portal redirects / to its dashboard).
196
- // empty → the page-row-exists-but-zero-blocks "This page is empty"
197
- // surface, distinct from a dead link (operator ruling 2026-07-21).
198
122
  { homeUrl: "/", empty: err.empty },
199
123
  );
200
124
  }