@teamscala/service-boot 0.23.418 → 0.23.420
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 +17 -1
- package/src/auth-gate/public-paths.ts +147 -0
- package/src/auth-gate.ts +8 -133
- package/src/fn-routers/chat-session-router-shared.ts +130 -0
- package/src/fn-routers/chat-session-router.ts +15 -116
- package/src/handlers/platform-fault-escalation/config.ts +54 -0
- package/src/handlers/platform-fault-escalation/delivery.ts +173 -0
- package/src/handlers/platform-fault-escalation/event-log-queries.ts +122 -0
- package/src/handlers/platform-fault-escalation/orpc-client.ts +97 -0
- package/src/handlers/platform-fault-escalation.ts +15 -387
package/package.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
},
|
|
7
7
|
"name": "@teamscala/service-boot",
|
|
8
8
|
"type": "module",
|
|
9
|
-
"version": "0.23.
|
|
9
|
+
"version": "0.23.420",
|
|
10
10
|
"sideEffects": false,
|
|
11
11
|
"imports": {
|
|
12
12
|
"#service-boot/*": "./src/*",
|
|
@@ -1253,6 +1253,22 @@
|
|
|
1253
1253
|
"./register-registry-page-route/single-page-data.ts": {
|
|
1254
1254
|
"types": "./src/register-registry-page-route/single-page-data.d.ts",
|
|
1255
1255
|
"default": "./src/register-registry-page-route/single-page-data.ts"
|
|
1256
|
+
},
|
|
1257
|
+
"./auth-gate/public-paths": {
|
|
1258
|
+
"types": "./src/auth-gate/public-paths.d.ts",
|
|
1259
|
+
"default": "./src/auth-gate/public-paths.ts"
|
|
1260
|
+
},
|
|
1261
|
+
"./auth-gate/public-paths.ts": {
|
|
1262
|
+
"types": "./src/auth-gate/public-paths.d.ts",
|
|
1263
|
+
"default": "./src/auth-gate/public-paths.ts"
|
|
1264
|
+
},
|
|
1265
|
+
"./fn-routers/chat-session-router-shared": {
|
|
1266
|
+
"types": "./src/fn-routers/chat-session-router-shared.d.ts",
|
|
1267
|
+
"default": "./src/fn-routers/chat-session-router-shared.ts"
|
|
1268
|
+
},
|
|
1269
|
+
"./fn-routers/chat-session-router-shared.ts": {
|
|
1270
|
+
"types": "./src/fn-routers/chat-session-router-shared.d.ts",
|
|
1271
|
+
"default": "./src/fn-routers/chat-session-router-shared.ts"
|
|
1256
1272
|
}
|
|
1257
1273
|
},
|
|
1258
1274
|
"repository": "https://github.com/teamgbg/service-boot",
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system server-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* The auth gate's public-path surface: sign-in paths, the static-asset
|
|
7
|
+
* extension predicate, and the public infra prefixes/paths (self-authenticating
|
|
8
|
+
* service-to-service surfaces, dev-host infra reads, and the access-gate
|
|
9
|
+
* rejection target).
|
|
10
|
+
*
|
|
11
|
+
* Split out of auth-gate.ts (436 lines) — byte-for-byte move; import paths
|
|
12
|
+
* adjusted only.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const SIGN_IN_PATHS = new Set([
|
|
16
|
+
"/login",
|
|
17
|
+
"/sign-in",
|
|
18
|
+
"/sign-up",
|
|
19
|
+
"/forgot-password",
|
|
20
|
+
"/reset-password",
|
|
21
|
+
"/email-otp",
|
|
22
|
+
"/magic-link",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
/// A STATIC ASSET IS NEVER A PROTECTED RESOURCE, so it is decided by EXTENSION
|
|
26
|
+
/// rather than by remembering to list it.
|
|
27
|
+
///
|
|
28
|
+
/// The allowlist below enumerates PREFIXES, and every path outside it is gated.
|
|
29
|
+
/// That makes a new public asset directory private BY DEFAULT, and the failure
|
|
30
|
+
/// lands exactly where it hurts most: on the SIGNED-OUT screen, which is the one
|
|
31
|
+
/// page guaranteed to need branding. `/assets/` was listed; `/logos/` never was,
|
|
32
|
+
/// so `/logos/cropped.png` answered 302 → /sign-in and the browser rendered a
|
|
33
|
+
/// blank logo under a "starting up" splash. Favicons are on the list ONE FILE AT
|
|
34
|
+
/// A TIME, which is the same defect already visible.
|
|
35
|
+
///
|
|
36
|
+
/// This is why the operator reports it "keeps regressing": nothing is broken by
|
|
37
|
+
/// a code change, the allowlist simply never covered the path, and any build that
|
|
38
|
+
/// moves images re-creates it. Enumerating what is public is `prevention-over-
|
|
39
|
+
/// detection` inverted — the cure is a predicate, not a longer list.
|
|
40
|
+
///
|
|
41
|
+
/// Redirecting an <img> to a login page is never useful behaviour either: the
|
|
42
|
+
/// browser cannot act on the redirect, so it silently paints nothing. Failing
|
|
43
|
+
/// closed on a page is protection; failing closed on an image is just a blank.
|
|
44
|
+
const STATIC_ASSET_EXTENSIONS = [
|
|
45
|
+
".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg", ".ico", ".bmp",
|
|
46
|
+
".woff", ".woff2", ".ttf", ".otf", ".eot",
|
|
47
|
+
".css", ".js", ".mjs", ".map",
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
export function isStaticAssetPath(pathname: string): boolean {
|
|
51
|
+
const lower = pathname.toLowerCase();
|
|
52
|
+
return STATIC_ASSET_EXTENSIONS.some((ext) => lower.endsWith(ext));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const PUBLIC_INFRA_PREFIXES = [
|
|
56
|
+
"/assets/",
|
|
57
|
+
"/api/assets/",
|
|
58
|
+
"/api/auth/",
|
|
59
|
+
// fn.signInFlow procedures serve the unauthenticated sign-in flow (config
|
|
60
|
+
// load, session check, portal list) — anonymous-allowed by design (return {}
|
|
61
|
+
// for no session). They replaced the former /api/data/sign-in-flow-config,
|
|
62
|
+
// /check-session, /portals HANDLER_MAP entries (handler-map-retirement).
|
|
63
|
+
"/api/rpc/fn/signInFlow/",
|
|
64
|
+
// scala-enroll device-onboarding: the machine-facing installer + register
|
|
65
|
+
// routes are TOKEN-authenticated (a single-use enrollment token issued by a
|
|
66
|
+
// logged-in, dev_access-approved operator), fetched by the onboardee via
|
|
67
|
+
// `irm`/`curl` with NO session cookie — so they must skip the page login
|
|
68
|
+
// gate, exactly like /mcp (Bearer) and /api/rpc/ws self-authenticate. The
|
|
69
|
+
// TOKEN is the delegated capability; issuance (/issue) stays login-gated.
|
|
70
|
+
"/provision/",
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
export const PUBLIC_INFRA_PATHS = new Set([
|
|
74
|
+
"/health",
|
|
75
|
+
"/favicon.ico",
|
|
76
|
+
"/favicon.svg",
|
|
77
|
+
"/api/session",
|
|
78
|
+
"/api/rpc/ws",
|
|
79
|
+
// The MCP endpoint is a service-to-service surface with its OWN Bearer auth
|
|
80
|
+
// (the gateway sends `Authorization: Bearer SCALA_DEV_KEY`, validated by the
|
|
81
|
+
// MCP route handler). It must never be session-page-gated: on a kind:ui
|
|
82
|
+
// service that also mounts an MCP server (scala-agents — the fleet
|
|
83
|
+
// orchestration + cross-pane channel), the auth-gate would 302 /mcp to
|
|
84
|
+
// /sign-in, so the gateway sees toolCount=0 and the whole channel goes
|
|
85
|
+
// degraded. Backend-only MCP hosts have no auth-gate, so this only bit the
|
|
86
|
+
// one UI MCP host once the localhost super-admin bypass was removed
|
|
87
|
+
// (the bypass had been masking it). Exempt alongside /api/rpc/ws and
|
|
88
|
+
// /api/auth/, which self-authenticate the same way.
|
|
89
|
+
"/mcp",
|
|
90
|
+
// build-id is polled by the client self-heal; it must always return JSON
|
|
91
|
+
// (a tiny bundle hash) regardless of session, never a /sign-in redirect —
|
|
92
|
+
// otherwise an expired-session tab would compare a redirect body to its
|
|
93
|
+
// booted build and never heal.
|
|
94
|
+
"/api/build-id",
|
|
95
|
+
// /_heap is the localhost-gated heap-attribution probe (register-static-assets);
|
|
96
|
+
// like /health it is infra, not a page, so it must never 302 to /sign-in on an
|
|
97
|
+
// auth-gated service. The probe's own localhost Host check is the real gate.
|
|
98
|
+
"/_heap",
|
|
99
|
+
// /api/fleet/events is the SSE fleet event stream consumed self-contained by
|
|
100
|
+
// the generated opencode scala-hooks plugin (and any future client without
|
|
101
|
+
// an @orpc/client dep) for native cross-pane channel delivery. The plugin
|
|
102
|
+
// runs in the opencode process with no scala-agents session cookie, so the
|
|
103
|
+
// route must be public; the stream is keyed by the orchestratorSessionId
|
|
104
|
+
// query param (the subscriber's own tmux pane id) and carries no sensitive
|
|
105
|
+
// data, and dispatch only emits events targeted at that identity.
|
|
106
|
+
"/api/fleet/events",
|
|
107
|
+
// /api/fleet/claude-sessions is a non-sensitive lane-identity read (active
|
|
108
|
+
// Claude lane sessionIDs + cwds) consumed by the session-picker daemon to
|
|
109
|
+
// RESTORE transcript tails after its own restart. The picker runs with no
|
|
110
|
+
// scala-agents session cookie; the data is less sensitive than the
|
|
111
|
+
// /api/fleet/events stream above (no message content), so it is public like
|
|
112
|
+
// the other dev-host infra reads.
|
|
113
|
+
"/api/fleet/claude-sessions",
|
|
114
|
+
// /api/fleet/ingest-event receives parsed lane-action events
|
|
115
|
+
// (message.part.updated etc.) from the cli-session transcript-tail — a
|
|
116
|
+
// dev-host INTERNAL machine caller that POSTs with no session cookie, exactly
|
|
117
|
+
// like the lane-event hook. Without this exemption the page auth-gate 302s
|
|
118
|
+
// every POST and the lane's tool/text action stream never reaches the
|
|
119
|
+
// ingestEvent pipeline. A first-class
|
|
120
|
+
// api_route (sibling to lane-event); security is the handler's loopback gate
|
|
121
|
+
// on the platform's x-caller-remote-address (set by register-api-routes).
|
|
122
|
+
"/api/fleet/ingest-event",
|
|
123
|
+
// /api/fleet/lane-event receives Claude Code http lifecycle hooks
|
|
124
|
+
// (Stop/UserPromptSubmit/Pre*ToolUse) for live-lane visibility. Like /mcp it
|
|
125
|
+
// is a service-to-service surface with its OWN Bearer auth (the hook sends
|
|
126
|
+
// `Authorization: Bearer SCALA_DEV_KEY`, validated by the handler); the hook
|
|
127
|
+
// runs in the Claude process with no scala-agents session cookie, so it must
|
|
128
|
+
// not be session-page-gated. The handler filters to fleet lanes by workspace
|
|
129
|
+
// cwd + resolves the lane via the session-picker, so an unauth'd caller
|
|
130
|
+
// writes no lane state.
|
|
131
|
+
"/api/fleet/lane-event",
|
|
132
|
+
// /api/fleet/sweep-executor receives the loga monitor engine's sweep dispatch
|
|
133
|
+
// (POST {slug}) -- a service-to-service surface like /api/fleet/lane-event /
|
|
134
|
+
// /api/fleet/ingest-event (loga -> scala-agents, localhost). Security is the
|
|
135
|
+
// handler's loopback gate (x-caller-remote-address), identical to its siblings.
|
|
136
|
+
"/api/fleet/sweep-executor",
|
|
137
|
+
// /api/terminal-ws is the in-browser terminal WebSocket bridge (a real PTY
|
|
138
|
+
// login shell). It is a dev-tool surface analogous to /api/fleet/events:
|
|
139
|
+
// the operator's own shell, no sensitive data beyond what the operator
|
|
140
|
+
// already has in their terminal. Must not be session-gated — the test
|
|
141
|
+
// harness and the terminal block both connect without a session cookie.
|
|
142
|
+
"/api/terminal-ws",
|
|
143
|
+
// /unauthorised is the access-gate's rejection target (the PageGuard access
|
|
144
|
+
// page). It MUST be exempt: an authed-but-not-allowed viewer is redirected
|
|
145
|
+
// here, so gating it would re-reject and loop. It renders no sensitive data.
|
|
146
|
+
"/unauthorised",
|
|
147
|
+
]);
|
package/src/auth-gate.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @system server-boot
|
|
3
|
-
* @status handwritten
|
|
3
|
+
* @status handwritten
|
|
4
4
|
* @edit edit directly
|
|
5
5
|
*
|
|
6
6
|
* Auth middleware factory. Three modes, same BetterAuth email-OTP mechanism:
|
|
@@ -33,40 +33,14 @@ import { configure as configureEventLog } from "@teamscala/event-log/configure";
|
|
|
33
33
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
34
34
|
import type { Hono } from "hono";
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
"/email-otp",
|
|
43
|
-
"/magic-link",
|
|
44
|
-
]);
|
|
36
|
+
import {
|
|
37
|
+
isStaticAssetPath,
|
|
38
|
+
PUBLIC_INFRA_PATHS,
|
|
39
|
+
PUBLIC_INFRA_PREFIXES,
|
|
40
|
+
SIGN_IN_PATHS,
|
|
41
|
+
} from "./auth-gate/public-paths.ts";
|
|
45
42
|
|
|
46
|
-
|
|
47
|
-
/// rather than by remembering to list it.
|
|
48
|
-
///
|
|
49
|
-
/// The allowlist below enumerates PREFIXES, and every path outside it is gated.
|
|
50
|
-
/// That makes a new public asset directory private BY DEFAULT, and the failure
|
|
51
|
-
/// lands exactly where it hurts most: on the SIGNED-OUT screen, which is the one
|
|
52
|
-
/// page guaranteed to need branding. `/assets/` was listed; `/logos/` never was,
|
|
53
|
-
/// so `/logos/cropped.png` answered 302 → /sign-in and the browser rendered a
|
|
54
|
-
/// blank logo under a "starting up" splash. Favicons are on the list ONE FILE AT
|
|
55
|
-
/// A TIME, which is the same defect already visible.
|
|
56
|
-
///
|
|
57
|
-
/// This is why the operator reports it "keeps regressing": nothing is broken by
|
|
58
|
-
/// a code change, the allowlist simply never covered the path, and any build that
|
|
59
|
-
/// moves images re-creates it. Enumerating what is public is `prevention-over-
|
|
60
|
-
/// detection` inverted — the cure is a predicate, not a longer list.
|
|
61
|
-
///
|
|
62
|
-
/// Redirecting an <img> to a login page is never useful behaviour either: the
|
|
63
|
-
/// browser cannot act on the redirect, so it silently paints nothing. Failing
|
|
64
|
-
/// closed on a page is protection; failing closed on an image is just a blank.
|
|
65
|
-
const STATIC_ASSET_EXTENSIONS = [
|
|
66
|
-
".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg", ".ico", ".bmp",
|
|
67
|
-
".woff", ".woff2", ".ttf", ".otf", ".eot",
|
|
68
|
-
".css", ".js", ".mjs", ".map",
|
|
69
|
-
];
|
|
43
|
+
export { isStaticAssetPath } from "./auth-gate/public-paths.ts";
|
|
70
44
|
|
|
71
45
|
/**
|
|
72
46
|
* Point the event-log primitive at the process's Prisma singleton.
|
|
@@ -93,105 +67,6 @@ function initGateDependencies(): { prisma: ReturnType<typeof getPrisma> } {
|
|
|
93
67
|
return { prisma };
|
|
94
68
|
}
|
|
95
69
|
|
|
96
|
-
export function isStaticAssetPath(pathname: string): boolean {
|
|
97
|
-
const lower = pathname.toLowerCase();
|
|
98
|
-
return STATIC_ASSET_EXTENSIONS.some((ext) => lower.endsWith(ext));
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const PUBLIC_INFRA_PREFIXES = [
|
|
102
|
-
"/assets/",
|
|
103
|
-
"/api/assets/",
|
|
104
|
-
"/api/auth/",
|
|
105
|
-
// fn.signInFlow procedures serve the unauthenticated sign-in flow (config
|
|
106
|
-
// load, session check, portal list) — anonymous-allowed by design (return {}
|
|
107
|
-
// for no session). They replaced the former /api/data/sign-in-flow-config,
|
|
108
|
-
// /check-session, /portals HANDLER_MAP entries (handler-map-retirement).
|
|
109
|
-
"/api/rpc/fn/signInFlow/",
|
|
110
|
-
// scala-enroll device-onboarding: the machine-facing installer + register
|
|
111
|
-
// routes are TOKEN-authenticated (a single-use enrollment token issued by a
|
|
112
|
-
// logged-in, dev_access-approved operator), fetched by the onboardee via
|
|
113
|
-
// `irm`/`curl` with NO session cookie — so they must skip the page login
|
|
114
|
-
// gate, exactly like /mcp (Bearer) and /api/rpc/ws self-authenticate. The
|
|
115
|
-
// TOKEN is the delegated capability; issuance (/issue) stays login-gated.
|
|
116
|
-
"/provision/",
|
|
117
|
-
];
|
|
118
|
-
|
|
119
|
-
const PUBLIC_INFRA_PATHS = new Set([
|
|
120
|
-
"/health",
|
|
121
|
-
"/favicon.ico",
|
|
122
|
-
"/favicon.svg",
|
|
123
|
-
"/api/session",
|
|
124
|
-
"/api/rpc/ws",
|
|
125
|
-
// The MCP endpoint is a service-to-service surface with its OWN Bearer auth
|
|
126
|
-
// (the gateway sends `Authorization: Bearer SCALA_DEV_KEY`, validated by the
|
|
127
|
-
// MCP route handler). It must never be session-page-gated: on a kind:ui
|
|
128
|
-
// service that also mounts an MCP server (scala-agents — the fleet
|
|
129
|
-
// orchestration + cross-pane channel), the auth-gate would 302 /mcp to
|
|
130
|
-
// /sign-in, so the gateway sees toolCount=0 and the whole channel goes
|
|
131
|
-
// degraded. Backend-only MCP hosts have no auth-gate, so this only bit the
|
|
132
|
-
// one UI MCP host once the localhost super-admin bypass was removed
|
|
133
|
-
// (the bypass had been masking it). Exempt alongside /api/rpc/ws and
|
|
134
|
-
// /api/auth/, which self-authenticate the same way.
|
|
135
|
-
"/mcp",
|
|
136
|
-
// build-id is polled by the client self-heal; it must always return JSON
|
|
137
|
-
// (a tiny bundle hash) regardless of session, never a /sign-in redirect —
|
|
138
|
-
// otherwise an expired-session tab would compare a redirect body to its
|
|
139
|
-
// booted build and never heal.
|
|
140
|
-
"/api/build-id",
|
|
141
|
-
// /_heap is the localhost-gated heap-attribution probe (register-static-assets);
|
|
142
|
-
// like /health it is infra, not a page, so it must never 302 to /sign-in on an
|
|
143
|
-
// auth-gated service. The probe's own localhost Host check is the real gate.
|
|
144
|
-
"/_heap",
|
|
145
|
-
// /api/fleet/events is the SSE fleet event stream consumed self-contained by
|
|
146
|
-
// the generated opencode scala-hooks plugin (and any future client without
|
|
147
|
-
// an @orpc/client dep) for native cross-pane channel delivery. The plugin
|
|
148
|
-
// runs in the opencode process with no scala-agents session cookie, so the
|
|
149
|
-
// route must be public; the stream is keyed by the orchestratorSessionId
|
|
150
|
-
// query param (the subscriber's own tmux pane id) and carries no sensitive
|
|
151
|
-
// data, and dispatch only emits events targeted at that identity.
|
|
152
|
-
"/api/fleet/events",
|
|
153
|
-
// /api/fleet/claude-sessions is a non-sensitive lane-identity read (active
|
|
154
|
-
// Claude lane sessionIDs + cwds) consumed by the session-picker daemon to
|
|
155
|
-
// RESTORE transcript tails after its own restart. The picker runs with no
|
|
156
|
-
// scala-agents session cookie; the data is less sensitive than the
|
|
157
|
-
// /api/fleet/events stream above (no message content), so it is public like
|
|
158
|
-
// the other dev-host infra reads.
|
|
159
|
-
"/api/fleet/claude-sessions",
|
|
160
|
-
// /api/fleet/ingest-event receives parsed lane-action events
|
|
161
|
-
// (message.part.updated etc.) from the cli-session transcript-tail — a
|
|
162
|
-
// dev-host INTERNAL machine caller that POSTs with no session cookie, exactly
|
|
163
|
-
// like the lane-event hook. Without this exemption the page auth-gate 302s
|
|
164
|
-
// every POST and the lane's tool/text action stream never reaches the
|
|
165
|
-
// ingestEvent pipeline. A first-class
|
|
166
|
-
// api_route (sibling to lane-event); security is the handler's loopback gate
|
|
167
|
-
// on the platform's x-caller-remote-address (set by register-api-routes).
|
|
168
|
-
"/api/fleet/ingest-event",
|
|
169
|
-
// /api/fleet/lane-event receives Claude Code http lifecycle hooks
|
|
170
|
-
// (Stop/UserPromptSubmit/Pre*ToolUse) for live-lane visibility. Like /mcp it
|
|
171
|
-
// is a service-to-service surface with its OWN Bearer auth (the hook sends
|
|
172
|
-
// `Authorization: Bearer SCALA_DEV_KEY`, validated by the handler); the hook
|
|
173
|
-
// runs in the Claude process with no scala-agents session cookie, so it must
|
|
174
|
-
// not be session-page-gated. The handler filters to fleet lanes by workspace
|
|
175
|
-
// cwd + resolves the lane via the session-picker, so an unauth'd caller
|
|
176
|
-
// writes no lane state.
|
|
177
|
-
"/api/fleet/lane-event",
|
|
178
|
-
// /api/fleet/sweep-executor receives the loga monitor engine's sweep dispatch
|
|
179
|
-
// (POST {slug}) -- a service-to-service surface like /api/fleet/lane-event /
|
|
180
|
-
// /api/fleet/ingest-event (loga -> scala-agents, localhost). Security is the
|
|
181
|
-
// handler's loopback gate (x-caller-remote-address), identical to its siblings.
|
|
182
|
-
"/api/fleet/sweep-executor",
|
|
183
|
-
// /api/terminal-ws is the in-browser terminal WebSocket bridge (a real PTY
|
|
184
|
-
// login shell). It is a dev-tool surface analogous to /api/fleet/events:
|
|
185
|
-
// the operator's own shell, no sensitive data beyond what the operator
|
|
186
|
-
// already has in their terminal. Must not be session-gated — the test
|
|
187
|
-
// harness and the terminal block both connect without a session cookie.
|
|
188
|
-
"/api/terminal-ws",
|
|
189
|
-
// /unauthorised is the access-gate's rejection target (the PageGuard access
|
|
190
|
-
// page). It MUST be exempt: an authed-but-not-allowed viewer is redirected
|
|
191
|
-
// here, so gating it would re-reject and loop. It renders no sensitive data.
|
|
192
|
-
"/unauthorised",
|
|
193
|
-
]);
|
|
194
|
-
|
|
195
70
|
export async function attachAuthGate(
|
|
196
71
|
app: Hono,
|
|
197
72
|
config: ServiceInstallConfig,
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* The valibot schemas + row helpers of the chatSession ORPC router: procedure
|
|
7
|
+
* input schemas, the conversation ownership check, and the stored-row → wire
|
|
8
|
+
* message mapping.
|
|
9
|
+
*
|
|
10
|
+
* Split out of fn-routers/chat-session-router.ts (455 lines) — byte-for-byte
|
|
11
|
+
* move; import paths adjusted only.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as v from "valibot";
|
|
15
|
+
|
|
16
|
+
/** channel_data discriminator carrying the agent this thread belongs to. */
|
|
17
|
+
export const assistantType = (agentId: string) => `chat:${agentId}`;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* How many recent messages createOrRestore returns. The full history was
|
|
21
|
+
* previously loaded (unbounded) — a heavy thread paid ~2.5s + ~460KB on every
|
|
22
|
+
* open/prefetch. Older messages paginate via `loadEarlier`. Adjustable; when a
|
|
23
|
+
* tunable, lift to a registry config row (config-constants-db-driven).
|
|
24
|
+
*/
|
|
25
|
+
export const RECENT_MESSAGE_LIMIT = 50;
|
|
26
|
+
|
|
27
|
+
export const createOrRestoreSchema = v.object({
|
|
28
|
+
agentId: v.string(),
|
|
29
|
+
organisationId: v.string(),
|
|
30
|
+
/**
|
|
31
|
+
* SSR-only: the real user's id, passed by the AiChat serverLoader. The
|
|
32
|
+
* in-process server client is system-authed (context.user.id === "system"),
|
|
33
|
+
* so without this the conversation is created under user_id="system" and
|
|
34
|
+
* the real user's saveMessages hits assertOwns → FORBIDDEN. Only honoured
|
|
35
|
+
* when the caller is the system user (server-side SSR), never from a
|
|
36
|
+
* client-side ORPC call.
|
|
37
|
+
*/
|
|
38
|
+
userId: v.optional(v.string()),
|
|
39
|
+
});
|
|
40
|
+
export const loadSchema = v.object({
|
|
41
|
+
conversationId: v.string(),
|
|
42
|
+
});
|
|
43
|
+
export const saveMessagesSchema = v.object({
|
|
44
|
+
conversationId: v.string(),
|
|
45
|
+
messages: v.array(
|
|
46
|
+
v.object({
|
|
47
|
+
// The SDK UIMessage id — persisted AS the row PK so a reload/retry/
|
|
48
|
+
// dual-write upserts the SAME row instead of inserting a duplicate
|
|
49
|
+
// (chat-message-ids-server-stable). The duplicate truncated-assistant
|
|
50
|
+
// rows came from create-without-id: each save inserted a fresh row the
|
|
51
|
+
// next save could never reconcile against.
|
|
52
|
+
id: v.optional(v.string()),
|
|
53
|
+
role: v.string(),
|
|
54
|
+
content: v.string(),
|
|
55
|
+
// Verbatim UIMessage parts (messages-stored-as-uimessage-verbatim).
|
|
56
|
+
parts: v.optional(v.array(v.any())),
|
|
57
|
+
// UIMessage metadata — per-message token usage (inputTokens/
|
|
58
|
+
// outputTokens/contextWindow) the stream attaches; persisted so the
|
|
59
|
+
// context-usage gauge rehydrates on reload (was dropped before).
|
|
60
|
+
metadata: v.optional(v.record(v.string(), v.any())),
|
|
61
|
+
}),
|
|
62
|
+
),
|
|
63
|
+
});
|
|
64
|
+
export const clearSchema = v.object({
|
|
65
|
+
conversationId: v.string(),
|
|
66
|
+
});
|
|
67
|
+
export const listSchema = v.object({
|
|
68
|
+
agentId: v.string(),
|
|
69
|
+
organisationId: v.string(),
|
|
70
|
+
});
|
|
71
|
+
export const loadEarlierSchema = v.object({
|
|
72
|
+
conversationId: v.string(),
|
|
73
|
+
/** Cursor: the occurred_at (ISO) of the oldest currently-loaded message. */
|
|
74
|
+
before: v.pipe(
|
|
75
|
+
v.string(),
|
|
76
|
+
v.isoTimestamp(),
|
|
77
|
+
),
|
|
78
|
+
/** Cap; defaults to RECENT_MESSAGE_LIMIT, clamped to it. */
|
|
79
|
+
limit: v.optional(v.number()),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
export interface SessionUser {
|
|
83
|
+
id: string;
|
|
84
|
+
name?: string | null;
|
|
85
|
+
email?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function throwForbidden(message: string): Promise<never> {
|
|
89
|
+
const { ORPCError } = await import("@orpc/server");
|
|
90
|
+
throw new ORPCError("FORBIDDEN", { message });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Reject if the conversation does not belong to the calling user. Ownership
|
|
95
|
+
* by user_id is the complete proof (the row was created with this user's id);
|
|
96
|
+
* no org check needed.
|
|
97
|
+
*/
|
|
98
|
+
export async function assertOwns(
|
|
99
|
+
prisma: any,
|
|
100
|
+
conversationId: string,
|
|
101
|
+
user: SessionUser,
|
|
102
|
+
): Promise<any> {
|
|
103
|
+
const conv = await prisma.conversations.findUnique({
|
|
104
|
+
where: { id: conversationId },
|
|
105
|
+
});
|
|
106
|
+
if (!conv || conv.user_id !== user.id) await throwForbidden("Conversation not found.");
|
|
107
|
+
return conv;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Map stored message rows to the wire shape the chat client restores. */
|
|
111
|
+
export function mapMessages(msgs: any[]) {
|
|
112
|
+
return msgs
|
|
113
|
+
.filter((m: any) => m.role === "user" || m.role === "assistant")
|
|
114
|
+
.map((m: any) => ({
|
|
115
|
+
// Restore the stable row id so the client re-seeds useChat with the
|
|
116
|
+
// SAME ids it persisted — a later re-save then upserts instead of
|
|
117
|
+
// duplicating (chat-message-ids-server-stable).
|
|
118
|
+
id: m.id,
|
|
119
|
+
role: m.role,
|
|
120
|
+
content: m.content,
|
|
121
|
+
parts: (m.channel_data as { parts?: unknown[] } | null)?.parts,
|
|
122
|
+
// Restore the per-message token usage so the context-usage gauge can
|
|
123
|
+
// recompute on reload (the stream attaches it as UIMessage metadata).
|
|
124
|
+
metadata: (m.channel_data as { metadata?: Record<string, unknown> } | null)?.metadata,
|
|
125
|
+
created_at: m.created_at,
|
|
126
|
+
// The ordering field — exposed so the client can pass the oldest
|
|
127
|
+
// loaded message's occurred_at as the loadEarlier cursor.
|
|
128
|
+
occurred_at: m.occurred_at,
|
|
129
|
+
}));
|
|
130
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @system service-boot
|
|
3
|
-
* @status handwritten
|
|
3
|
+
* @status handwritten
|
|
4
4
|
* @edit edit directly
|
|
5
5
|
*
|
|
6
6
|
* ORPC procedures for the AUTHENTICATED AI-chat conversation lifecycle —
|
|
@@ -36,121 +36,20 @@ import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
|
36
36
|
import { authed, type Context } from "./auth-router.ts";
|
|
37
37
|
import { persistChatMessages } from "./persist-chat-messages.ts";
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* SSR-only: the real user's id, passed by the AiChat serverLoader. The
|
|
55
|
-
* in-process server client is system-authed (context.user.id === "system"),
|
|
56
|
-
* so without this the conversation is created under user_id="system" and
|
|
57
|
-
* the real user's saveMessages hits assertOwns → FORBIDDEN. Only honoured
|
|
58
|
-
* when the caller is the system user (server-side SSR), never from a
|
|
59
|
-
* client-side ORPC call.
|
|
60
|
-
*/
|
|
61
|
-
userId: v.optional(v.string()),
|
|
62
|
-
});
|
|
63
|
-
const loadSchema = v.object({
|
|
64
|
-
conversationId: v.string(),
|
|
65
|
-
});
|
|
66
|
-
const saveMessagesSchema = v.object({
|
|
67
|
-
conversationId: v.string(),
|
|
68
|
-
messages: v.array(
|
|
69
|
-
v.object({
|
|
70
|
-
// The SDK UIMessage id — persisted AS the row PK so a reload/retry/
|
|
71
|
-
// dual-write upserts the SAME row instead of inserting a duplicate
|
|
72
|
-
// (chat-message-ids-server-stable). The duplicate truncated-assistant
|
|
73
|
-
// rows came from create-without-id: each save inserted a fresh row the
|
|
74
|
-
// next save could never reconcile against.
|
|
75
|
-
id: v.optional(v.string()),
|
|
76
|
-
role: v.string(),
|
|
77
|
-
content: v.string(),
|
|
78
|
-
// Verbatim UIMessage parts (messages-stored-as-uimessage-verbatim).
|
|
79
|
-
parts: v.optional(v.array(v.any())),
|
|
80
|
-
// UIMessage metadata — per-message token usage (inputTokens/
|
|
81
|
-
// outputTokens/contextWindow) the stream attaches; persisted so the
|
|
82
|
-
// context-usage gauge rehydrates on reload (was dropped before).
|
|
83
|
-
metadata: v.optional(v.record(v.string(), v.any())),
|
|
84
|
-
}),
|
|
85
|
-
),
|
|
86
|
-
});
|
|
87
|
-
const clearSchema = v.object({
|
|
88
|
-
conversationId: v.string(),
|
|
89
|
-
});
|
|
90
|
-
const listSchema = v.object({
|
|
91
|
-
agentId: v.string(),
|
|
92
|
-
organisationId: v.string(),
|
|
93
|
-
});
|
|
94
|
-
const loadEarlierSchema = v.object({
|
|
95
|
-
conversationId: v.string(),
|
|
96
|
-
/** Cursor: the occurred_at (ISO) of the oldest currently-loaded message. */
|
|
97
|
-
before: v.pipe(
|
|
98
|
-
v.string(),
|
|
99
|
-
v.isoTimestamp(),
|
|
100
|
-
),
|
|
101
|
-
/** Cap; defaults to RECENT_MESSAGE_LIMIT, clamped to it. */
|
|
102
|
-
limit: v.optional(v.number()),
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
interface SessionUser {
|
|
106
|
-
id: string;
|
|
107
|
-
name?: string | null;
|
|
108
|
-
email?: string;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
async function throwForbidden(message: string): Promise<never> {
|
|
112
|
-
const { ORPCError } = await import("@orpc/server");
|
|
113
|
-
throw new ORPCError("FORBIDDEN", { message });
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Reject if the conversation does not belong to the calling user. Ownership
|
|
118
|
-
* by user_id is the complete proof (the row was created with this user's id);
|
|
119
|
-
* no org check needed.
|
|
120
|
-
*/
|
|
121
|
-
async function assertOwns(
|
|
122
|
-
prisma: any,
|
|
123
|
-
conversationId: string,
|
|
124
|
-
user: SessionUser,
|
|
125
|
-
): Promise<any> {
|
|
126
|
-
const conv = await prisma.conversations.findUnique({
|
|
127
|
-
where: { id: conversationId },
|
|
128
|
-
});
|
|
129
|
-
if (!conv || conv.user_id !== user.id) await throwForbidden("Conversation not found.");
|
|
130
|
-
return conv;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/** Map stored message rows to the wire shape the chat client restores. */
|
|
134
|
-
function mapMessages(msgs: any[]) {
|
|
135
|
-
return msgs
|
|
136
|
-
.filter((m: any) => m.role === "user" || m.role === "assistant")
|
|
137
|
-
.map((m: any) => ({
|
|
138
|
-
// Restore the stable row id so the client re-seeds useChat with the
|
|
139
|
-
// SAME ids it persisted — a later re-save then upserts instead of
|
|
140
|
-
// duplicating (chat-message-ids-server-stable).
|
|
141
|
-
id: m.id,
|
|
142
|
-
role: m.role,
|
|
143
|
-
content: m.content,
|
|
144
|
-
parts: (m.channel_data as { parts?: unknown[] } | null)?.parts,
|
|
145
|
-
// Restore the per-message token usage so the context-usage gauge can
|
|
146
|
-
// recompute on reload (the stream attaches it as UIMessage metadata).
|
|
147
|
-
metadata: (m.channel_data as { metadata?: Record<string, unknown> } | null)?.metadata,
|
|
148
|
-
created_at: m.created_at,
|
|
149
|
-
// The ordering field — exposed so the client can pass the oldest
|
|
150
|
-
// loaded message's occurred_at as the loadEarlier cursor.
|
|
151
|
-
occurred_at: m.occurred_at,
|
|
152
|
-
}));
|
|
153
|
-
}
|
|
39
|
+
import {
|
|
40
|
+
assertOwns,
|
|
41
|
+
assistantType,
|
|
42
|
+
clearSchema,
|
|
43
|
+
createOrRestoreSchema,
|
|
44
|
+
listSchema,
|
|
45
|
+
loadEarlierSchema,
|
|
46
|
+
loadSchema,
|
|
47
|
+
mapMessages,
|
|
48
|
+
RECENT_MESSAGE_LIMIT,
|
|
49
|
+
saveMessagesSchema,
|
|
50
|
+
SessionUser,
|
|
51
|
+
throwForbidden,
|
|
52
|
+
} from "./chat-session-router-shared.ts";
|
|
154
53
|
|
|
155
54
|
export const chatSessionRouter = {
|
|
156
55
|
// createOrRestore — resume the caller's most-recent ACTIVE thread for this
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system server-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* The config row of the platform-fault-escalation pg-boss handler
|
|
7
|
+
* (config/platform-fault-escalation).
|
|
8
|
+
*
|
|
9
|
+
* Split out of handlers/platform-fault-escalation.ts (500 lines) —
|
|
10
|
+
* byte-for-byte move; import paths adjusted only.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
14
|
+
import * as v from "valibot";
|
|
15
|
+
|
|
16
|
+
export const CONFIG_SLUG = "platform-fault-escalation";
|
|
17
|
+
|
|
18
|
+
export const FaultEscalationConfigSchema = v.object({
|
|
19
|
+
/** Postgres regex patterns selecting watched event_log kinds. */
|
|
20
|
+
watched_kind_patterns: v.array(v.string()),
|
|
21
|
+
/** Lookback window for the recurrence/duration aggregate, minutes. */
|
|
22
|
+
window_minutes: v.number(),
|
|
23
|
+
min_count: v.number(),
|
|
24
|
+
min_span_minutes: v.number(),
|
|
25
|
+
/** Per-kind recurrence/duration overrides — low-rate-but-persistent kinds. */
|
|
26
|
+
kind_thresholds: v.optional(
|
|
27
|
+
v.record(
|
|
28
|
+
v.string(),
|
|
29
|
+
v.object({
|
|
30
|
+
min_count: v.optional(v.number()),
|
|
31
|
+
min_span_minutes: v.optional(v.number()),
|
|
32
|
+
}),
|
|
33
|
+
),
|
|
34
|
+
),
|
|
35
|
+
resolve_after_minutes: v.number(),
|
|
36
|
+
/** The orchestrator's work item — escalations are COMMENTS on it, never
|
|
37
|
+
* task rows (the fleet-task-placement CHECK constraint refuses a system
|
|
38
|
+
* task without session identity, and an escalation is a finding, not an
|
|
39
|
+
* assigned unit of work). */
|
|
40
|
+
orchestrator_work_item_id: v.string(),
|
|
41
|
+
/** Per-kind copy for the message (impact + recommendation). Falls back to default_copy. */
|
|
42
|
+
copy: v.record(v.string(), v.object({ impact: v.string(), recommendation: v.string() })),
|
|
43
|
+
default_copy: v.object({ impact: v.string(), recommendation: v.string() }),
|
|
44
|
+
/** Per-kind payload-field whitelist sampled into the message's evidence
|
|
45
|
+
* line — named fields ONLY, never the whole payload: some kinds embed
|
|
46
|
+
* request context, and the escalation channel is WhatsApp. */
|
|
47
|
+
sample_fields: v.optional(v.record(v.string(), v.array(v.string()))),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export type FaultEscalationConfig = v.InferOutput<typeof FaultEscalationConfigSchema>;
|
|
51
|
+
|
|
52
|
+
export function loadFaultEscalationConfig(): Promise<FaultEscalationConfig> {
|
|
53
|
+
return loadRegistryConfig("config", CONFIG_SLUG, FaultEscalationConfigSchema);
|
|
54
|
+
}
|