@teamscala/service-boot 0.23.419 → 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 CHANGED
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "name": "@teamscala/service-boot",
8
8
  "type": "module",
9
- "version": "0.23.419",
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 @indivisible-unit
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
- const SIGN_IN_PATHS = new Set([
37
- "/login",
38
- "/sign-in",
39
- "/sign-up",
40
- "/forgot-password",
41
- "/reset-password",
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
- /// A STATIC ASSET IS NEVER A PROTECTED RESOURCE, so it is decided by EXTENSION
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 @indivisible-unit
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
- /** channel_data discriminator carrying the agent this thread belongs to. */
40
- const assistantType = (agentId: string) => `chat:${agentId}`;
41
-
42
- /**
43
- * How many recent messages createOrRestore returns. The full history was
44
- * previously loaded (unbounded) — a heavy thread paid ~2.5s + ~460KB on every
45
- * open/prefetch. Older messages paginate via `loadEarlier`. Adjustable; when a
46
- * tunable, lift to a registry config row (config-constants-db-driven).
47
- */
48
- const RECENT_MESSAGE_LIMIT = 50;
49
-
50
- const createOrRestoreSchema = v.object({
51
- agentId: v.string(),
52
- organisationId: v.string(),
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