@schlessera/brain-ui-server 0.34.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/README.md +28 -0
  2. package/dist/activity/notify.d.ts.map +1 -1
  3. package/dist/activity/notify.js +18 -8
  4. package/dist/activity/notify.js.map +1 -1
  5. package/dist/activity/push-sender.d.ts +7 -3
  6. package/dist/activity/push-sender.d.ts.map +1 -1
  7. package/dist/activity/push-sender.js +45 -11
  8. package/dist/activity/push-sender.js.map +1 -1
  9. package/dist/activity/query.d.ts.map +1 -1
  10. package/dist/activity/query.js +8 -0
  11. package/dist/activity/query.js.map +1 -1
  12. package/dist/activity/recorder.d.ts +11 -1
  13. package/dist/activity/recorder.d.ts.map +1 -1
  14. package/dist/activity/recorder.js +52 -2
  15. package/dist/activity/recorder.js.map +1 -1
  16. package/dist/activity/runtime.d.ts.map +1 -1
  17. package/dist/activity/runtime.js +2 -5
  18. package/dist/activity/runtime.js.map +1 -1
  19. package/dist/activity/sql.d.ts +34 -0
  20. package/dist/activity/sql.d.ts.map +1 -0
  21. package/dist/activity/sql.js +114 -0
  22. package/dist/activity/sql.js.map +1 -0
  23. package/dist/activity/store.d.ts +7 -0
  24. package/dist/activity/store.d.ts.map +1 -1
  25. package/dist/activity/store.js +48 -113
  26. package/dist/activity/store.js.map +1 -1
  27. package/dist/activity/stream.d.ts +3 -1
  28. package/dist/activity/stream.d.ts.map +1 -1
  29. package/dist/activity/stream.js +32 -17
  30. package/dist/activity/stream.js.map +1 -1
  31. package/dist/app-env.d.ts +8 -0
  32. package/dist/app-env.d.ts.map +1 -0
  33. package/dist/app-env.js +2 -0
  34. package/dist/app-env.js.map +1 -0
  35. package/dist/app.d.ts.map +1 -1
  36. package/dist/app.js +28 -3
  37. package/dist/app.js.map +1 -1
  38. package/dist/brain/client.d.ts +4 -2
  39. package/dist/brain/client.d.ts.map +1 -1
  40. package/dist/brain/client.js +60 -20
  41. package/dist/brain/client.js.map +1 -1
  42. package/dist/brain/types.d.ts +9 -0
  43. package/dist/brain/types.d.ts.map +1 -1
  44. package/dist/cron/run-job.d.ts.map +1 -1
  45. package/dist/cron/run-job.js +60 -10
  46. package/dist/cron/run-job.js.map +1 -1
  47. package/dist/db/principals.d.ts +72 -0
  48. package/dist/db/principals.d.ts.map +1 -0
  49. package/dist/db/principals.js +294 -0
  50. package/dist/db/principals.js.map +1 -0
  51. package/dist/files/walker.d.ts.map +1 -1
  52. package/dist/files/walker.js +6 -1
  53. package/dist/files/walker.js.map +1 -1
  54. package/dist/index.d.ts +4 -1
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +1 -1
  57. package/dist/index.js.map +1 -1
  58. package/dist/middleware/auth.d.ts +37 -9
  59. package/dist/middleware/auth.d.ts.map +1 -1
  60. package/dist/middleware/auth.js +148 -74
  61. package/dist/middleware/auth.js.map +1 -1
  62. package/dist/middleware/passkeys.d.ts +9 -7
  63. package/dist/middleware/passkeys.d.ts.map +1 -1
  64. package/dist/middleware/passkeys.js +25 -7
  65. package/dist/middleware/passkeys.js.map +1 -1
  66. package/dist/middleware/principals.d.ts +18 -0
  67. package/dist/middleware/principals.d.ts.map +1 -0
  68. package/dist/middleware/principals.js +138 -0
  69. package/dist/middleware/principals.js.map +1 -0
  70. package/dist/middleware/require-owner.d.ts +5 -0
  71. package/dist/middleware/require-owner.d.ts.map +1 -0
  72. package/dist/middleware/require-owner.js +10 -0
  73. package/dist/middleware/require-owner.js.map +1 -0
  74. package/dist/observability/loggers.js +1 -1
  75. package/dist/observability/loggers.js.map +1 -1
  76. package/dist/routes/activity.d.ts.map +1 -1
  77. package/dist/routes/activity.js +3 -0
  78. package/dist/routes/activity.js.map +1 -1
  79. package/dist/routes/brain.d.ts.map +1 -1
  80. package/dist/routes/brain.js +102 -63
  81. package/dist/routes/brain.js.map +1 -1
  82. package/dist/routes/files.d.ts.map +1 -1
  83. package/dist/routes/files.js +7 -2
  84. package/dist/routes/files.js.map +1 -1
  85. package/dist/routes/push.d.ts +61 -2
  86. package/dist/routes/push.d.ts.map +1 -1
  87. package/dist/routes/push.js +8 -3
  88. package/dist/routes/push.js.map +1 -1
  89. package/dist/routes/sessions.d.ts.map +1 -1
  90. package/dist/routes/sessions.js +30 -4
  91. package/dist/routes/sessions.js.map +1 -1
  92. package/dist/share/staging.d.ts.map +1 -1
  93. package/dist/share/staging.js +11 -4
  94. package/dist/share/staging.js.map +1 -1
  95. package/dist/ws/bridge.d.ts.map +1 -1
  96. package/dist/ws/bridge.js +6 -2
  97. package/dist/ws/bridge.js.map +1 -1
  98. package/dist/ws/clients.d.ts +9 -2
  99. package/dist/ws/clients.d.ts.map +1 -1
  100. package/dist/ws/clients.js +44 -6
  101. package/dist/ws/clients.js.map +1 -1
  102. package/dist/ws/connection.d.ts +7 -4
  103. package/dist/ws/connection.d.ts.map +1 -1
  104. package/dist/ws/connection.js +83 -12
  105. package/dist/ws/connection.js.map +1 -1
  106. package/dist/ws/dispatch.d.ts +12 -1
  107. package/dist/ws/dispatch.d.ts.map +1 -1
  108. package/dist/ws/dispatch.js +46 -6
  109. package/dist/ws/dispatch.js.map +1 -1
  110. package/dist/ws/history.d.ts.map +1 -1
  111. package/dist/ws/history.js +7 -5
  112. package/dist/ws/history.js.map +1 -1
  113. package/dist/ws/host.d.ts +22 -0
  114. package/dist/ws/host.d.ts.map +1 -1
  115. package/dist/ws/host.js +39 -1
  116. package/dist/ws/host.js.map +1 -1
  117. package/dist/ws/run-session.d.ts +7 -7
  118. package/dist/ws/run-session.d.ts.map +1 -1
  119. package/dist/ws/run-session.js +208 -52
  120. package/dist/ws/run-session.js.map +1 -1
  121. package/dist/ws/shrink.d.ts +3 -1
  122. package/dist/ws/shrink.d.ts.map +1 -1
  123. package/dist/ws/shrink.js +46 -16
  124. package/dist/ws/shrink.js.map +1 -1
  125. package/dist/ws/turns.d.ts +58 -3
  126. package/dist/ws/turns.d.ts.map +1 -1
  127. package/dist/ws/turns.js +101 -2
  128. package/dist/ws/turns.js.map +1 -1
  129. package/migrations/011_principals.sql +13 -0
  130. package/migrations/012_activity_principals.sql +12 -0
  131. package/migrations/013_push_subscription_principals.sql +7 -0
  132. package/migrations/014_invalidate_legacy_sessions.sql +14 -0
  133. package/package.json +3 -3
  134. package/src/activity/notify.ts +16 -7
  135. package/src/activity/push-sender.ts +79 -16
  136. package/src/activity/query.ts +8 -0
  137. package/src/activity/recorder.ts +68 -3
  138. package/src/activity/runtime.ts +2 -5
  139. package/src/activity/sql.ts +122 -0
  140. package/src/activity/store.ts +73 -155
  141. package/src/activity/stream.ts +37 -17
  142. package/src/app-env.ts +8 -0
  143. package/src/app.ts +33 -4
  144. package/src/brain/client.ts +61 -20
  145. package/src/brain/types.ts +10 -0
  146. package/src/cron/run-job.ts +41 -17
  147. package/src/db/principals.ts +452 -0
  148. package/src/files/walker.ts +5 -1
  149. package/src/index.ts +6 -1
  150. package/src/middleware/auth.ts +241 -81
  151. package/src/middleware/passkeys.ts +35 -14
  152. package/src/middleware/principals.ts +189 -0
  153. package/src/middleware/require-owner.ts +13 -0
  154. package/src/observability/loggers.ts +1 -1
  155. package/src/routes/activity.ts +3 -0
  156. package/src/routes/brain.ts +107 -70
  157. package/src/routes/files.ts +10 -5
  158. package/src/routes/push.ts +11 -5
  159. package/src/routes/sessions.ts +45 -20
  160. package/src/share/staging.ts +11 -4
  161. package/src/ws/bridge.ts +14 -2
  162. package/src/ws/clients.ts +52 -7
  163. package/src/ws/connection.ts +98 -20
  164. package/src/ws/dispatch.ts +66 -6
  165. package/src/ws/history.ts +7 -5
  166. package/src/ws/host.ts +59 -1
  167. package/src/ws/run-session.ts +247 -74
  168. package/src/ws/shrink.ts +48 -14
  169. package/src/ws/turns.ts +136 -5
package/src/ws/turns.ts CHANGED
@@ -11,12 +11,31 @@ import type {
11
11
  ChatImageAttachment,
12
12
  ClientEnvironment,
13
13
  } from "@schlessera/brain-ui-sdk/protocol";
14
+ import type { TurnRecorder } from "../activity/recorder.js";
15
+
16
+ const authorizationContextBrand: unique symbol = Symbol("AuthorizationContext");
17
+
18
+ /** Mutable admission decision shared by every asynchronous path from one socket. */
19
+ export interface AuthorizationContext {
20
+ readonly [authorizationContextBrand]: true;
21
+ readonly principalId: string;
22
+ readonly expiresAt: number;
23
+ valid: boolean;
24
+ /** Keep this context discoverable until the returned release is called. */
25
+ retain(): () => void;
26
+ /** Release the reference acquired when the coordinator opened this context. */
27
+ release(): void;
28
+ }
14
29
 
15
30
  export interface QueuedFollowUp {
31
+ principalId: string;
32
+ authorization: AuthorizationContext;
16
33
  text: string;
17
34
  attachments: ChatImageAttachment[];
18
35
  /** Device snapshot taken when the message was sent, not when it runs. */
19
36
  client?: ClientEnvironment;
37
+ /** Queue-owned authorization lease, transferred to the runner on dequeue. */
38
+ releaseAuthorization: () => void;
20
39
  }
21
40
 
22
41
  /**
@@ -30,7 +49,9 @@ export interface QueuedFollowUp {
30
49
  * on the wire.) The client snapshot is a handful of short fields and is not
31
50
  * worth walking.
32
51
  */
33
- export function queuedFollowUpBytes(entry: QueuedFollowUp): number {
52
+ export function queuedFollowUpBytes(
53
+ entry: Pick<QueuedFollowUp, "text" | "attachments">
54
+ ): number {
34
55
  let bytes = Buffer.byteLength(entry.text, "utf-8");
35
56
  for (const attachment of entry.attachments) {
36
57
  bytes += attachment.data.length;
@@ -39,7 +60,7 @@ export function queuedFollowUpBytes(entry: QueuedFollowUp): number {
39
60
  }
40
61
 
41
62
  /** Total bytes currently parked in a session's follow-up queue. */
42
- export function queuedBytes(turn: RunningTurn): number {
63
+ export function queuedBytes(turn: Pick<RunningTurn, "queue">): number {
43
64
  let bytes = 0;
44
65
  for (const entry of turn.queue) bytes += queuedFollowUpBytes(entry);
45
66
  return bytes;
@@ -51,6 +72,16 @@ export function queuedBytes(turn: RunningTurn): number {
51
72
  * same slot (same sessionId) after the current one resolves.
52
73
  */
53
74
  export interface RunningTurn {
75
+ /** Principal responsible for the CURRENT turn in this session slot. */
76
+ principalId: string;
77
+ authorization: AuthorizationContext;
78
+ /** Activity recorder for the CURRENT turn, once startup reaches the backend. */
79
+ recorder?: TurnRecorder;
80
+ /**
81
+ * Cancelling actors accepted before the recorder exists (while billing is
82
+ * resolving). Drained into immutable activity events at recorder creation.
83
+ */
84
+ pendingCancellationPrincipalIds: string[];
54
85
  sessionId: string | null; // null until session_info resolves it (new session)
55
86
  /** Host-minted id of the CURRENT turn in this slot; re-minted per queued follow-up. */
56
87
  turnId: string;
@@ -88,7 +119,10 @@ export interface PendingApproval {
88
119
  * locks; an approval must survive that and reappear, not silently die.
89
120
  */
90
121
  request: PermissionRequest;
91
- resolve: (decision: PermissionDecision) => void;
122
+ resolve: (
123
+ decision: PermissionDecision,
124
+ response?: { principalId: string; always?: boolean }
125
+ ) => void;
92
126
  }
93
127
 
94
128
  export interface PendingAskUser {
@@ -132,12 +166,17 @@ export class TurnCoordinator {
132
166
  readonly running = new Set<RunningTurn>();
133
167
  readonly bySession = new Map<string, RunningTurn>();
134
168
  startingSessions = 0;
169
+ /** Reserve a known session before asynchronous backend routing. */
170
+ readonly startingBySession = new Map<string, { queue: QueuedFollowUp[]; cancelled: boolean }>();
135
171
 
136
172
  readonly pendingApprovals = new Map<string, PendingApproval>();
137
173
  readonly pendingAskUser = new Map<string, PendingAskUser>();
138
174
  readonly pendingLocation = new Map<string, PendingLocation>();
139
175
  readonly pendingMask = new Map<string, PendingMask>();
140
176
 
177
+ /** Every authorization context with at least one live owner or async lease. */
178
+ readonly authorizationRegistry = new Map<AuthorizationContext, number>();
179
+
141
180
  private locationCounter = 0;
142
181
  private maskCounter = 0;
143
182
 
@@ -154,9 +193,89 @@ export class TurnCoordinator {
154
193
  return this.running.size > 0;
155
194
  }
156
195
 
196
+ /** Construct and synchronously register a context with its initial reference. */
197
+ openAuthorization(input: {
198
+ principalId: string;
199
+ expiresAt: number;
200
+ valid: boolean;
201
+ }): AuthorizationContext {
202
+ let initialReleased = false;
203
+ const releaseReference = () => {
204
+ const count = this.authorizationRegistry.get(authorization);
205
+ if (count === undefined) return;
206
+ if (count === 1) this.authorizationRegistry.delete(authorization);
207
+ else this.authorizationRegistry.set(authorization, count - 1);
208
+ };
209
+ const authorization: AuthorizationContext = {
210
+ [authorizationContextBrand]: true,
211
+ ...input,
212
+ retain: () => {
213
+ const count = this.authorizationRegistry.get(authorization);
214
+ if (count === undefined) {
215
+ throw new Error("Cannot retain a released authorization context");
216
+ }
217
+ this.authorizationRegistry.set(authorization, count + 1);
218
+ let released = false;
219
+ return () => {
220
+ if (released) return;
221
+ released = true;
222
+ releaseReference();
223
+ };
224
+ },
225
+ release: () => {
226
+ if (initialReleased) return;
227
+ initialReleased = true;
228
+ releaseReference();
229
+ },
230
+ };
231
+ this.authorizationRegistry.set(authorization, 1);
232
+ return authorization;
233
+ }
234
+
235
+ /** Add principals whose registered authorization context has expired. */
236
+ collectExpiredPrincipalIds(now: number, ids: Set<string>): void {
237
+ for (const authorization of this.authorizationRegistry.keys()) {
238
+ if (authorization.valid && authorization.expiresAt <= now) {
239
+ ids.add(authorization.principalId);
240
+ }
241
+ }
242
+ }
243
+
244
+ /** Invalidate every registered context belonging to one of these principals. */
245
+ invalidateAuthorizations(principalIds: ReadonlySet<string>): void {
246
+ for (const authorization of this.authorizationRegistry.keys()) {
247
+ if (principalIds.has(authorization.principalId)) authorization.valid = false;
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Record revocation without aborting running work. Unstarted queued work is
253
+ * removed per sender, while a current turn merely carries the invalid marker.
254
+ */
255
+ revokePrincipals(principalIds: ReadonlySet<string>): RunningTurn[] {
256
+ const affectedRunning: RunningTurn[] = [];
257
+ this.invalidateAuthorizations(principalIds);
258
+ for (const turn of this.running) {
259
+ if (principalIds.has(turn.principalId)) {
260
+ affectedRunning.push(turn);
261
+ }
262
+ turn.queue = turn.queue.filter((entry) => {
263
+ if (!principalIds.has(entry.principalId)) return true;
264
+ entry.releaseAuthorization();
265
+ return false;
266
+ });
267
+ }
268
+ return affectedRunning;
269
+ }
270
+
157
271
  /** Cancel every running turn (used on shutdown). */
158
272
  cancelAll(reason: string): boolean {
159
- if (this.running.size === 0) return false;
273
+ const hadWork = this.running.size > 0 || this.startingBySession.size > 0;
274
+ for (const starting of this.startingBySession.values()) {
275
+ starting.cancelled = true;
276
+ starting.queue.length = 0;
277
+ }
278
+ if (!hadWork) return false;
160
279
  for (const turn of [...this.running]) this.cancelTurn(turn, reason);
161
280
  return true;
162
281
  }
@@ -164,12 +283,18 @@ export class TurnCoordinator {
164
283
  /** Cancel a session's current turn and drop its queued follow-ups. */
165
284
  cancelTurn(turn: RunningTurn, reason: string): void {
166
285
  turn.cancelled = true;
167
- turn.queue.length = 0;
286
+ for (const entry of turn.queue.splice(0)) entry.releaseAuthorization();
168
287
  clearTimeout(turn.timeoutHandle);
169
288
  turn.abortController.abort();
170
289
  this.drainPendingForTurn(turn, reason);
171
290
  }
172
291
 
292
+ /** Record now, or retain the actor across the pre-recorder startup window. */
293
+ recordCancellation(turn: RunningTurn, principalId: string): void {
294
+ if (turn.recorder) turn.recorder.recordCancellation(principalId);
295
+ else turn.pendingCancellationPrincipalIds.push(principalId);
296
+ }
297
+
173
298
  /** Resolve/reject every pending interactive request belonging to one turn. */
174
299
  drainPendingForTurn(turn: RunningTurn, reason: string): void {
175
300
  for (const [id, p] of this.pendingApprovals) {
@@ -238,7 +363,13 @@ export class TurnCoordinator {
238
363
  for (const turn of [...this.running]) {
239
364
  clearTimeout(turn.timeoutHandle);
240
365
  turn.abortController.abort();
366
+ for (const entry of turn.queue.splice(0)) entry.releaseAuthorization();
367
+ }
368
+ for (const starting of this.startingBySession.values()) {
369
+ starting.cancelled = true;
370
+ starting.queue.length = 0;
241
371
  }
372
+ this.startingBySession.clear();
242
373
  this.running.clear();
243
374
  this.bySession.clear();
244
375
  this.pendingApprovals.clear();