@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/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",
|
|
@@ -246,8 +240,6 @@
|
|
|
246
240
|
"./resolvers/portal-route-paths.ts": "./src/resolvers/portal-route-paths.ts",
|
|
247
241
|
"./resolvers/website-by-host-map": "./src/resolvers/website-by-host-map.ts",
|
|
248
242
|
"./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
243
|
"./resource-not-found": "./src/resource-not-found.ts",
|
|
252
244
|
"./resource-not-found.ts": "./src/resource-not-found.ts",
|
|
253
245
|
"./run-boot-hook-list": "./src/run-boot-hook-list.ts",
|
|
@@ -302,5 +294,5 @@
|
|
|
302
294
|
"repository": "https://github.com/teamgbg/service-boot",
|
|
303
295
|
"sideEffects": false,
|
|
304
296
|
"type": "module",
|
|
305
|
-
"version": "0.23.
|
|
297
|
+
"version": "0.23.525"
|
|
306
298
|
}
|
|
@@ -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(
|
package/src/agreement-gate.ts
CHANGED
|
@@ -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 "
|
|
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 "
|
|
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";
|
package/src/auth/sign-out.ts
CHANGED
|
@@ -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)
|
package/src/bind-page-handler.ts
CHANGED
|
@@ -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
|
}
|