@lightupai/polaris 0.0.77 → 0.0.79

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.
@@ -14,14 +14,27 @@ import {
14
14
  setDriver,
15
15
  clearDriver,
16
16
  pushEvent,
17
+ getEventById,
17
18
  getProjectEvents,
18
19
  getSessionEvents,
20
+ getSessionEventsPage,
19
21
  getEventsSince,
22
+ searchEvents,
23
+ setSessionLabel,
24
+ addAnnotation,
25
+ listSessionAnnotations,
26
+ listDecisions,
27
+ deleteAnnotation,
28
+ setProjectVisibility,
29
+ addProjectMember,
30
+ removeProjectMember,
31
+ listProjectMembers,
32
+ userCanAccessProject,
20
33
  type Sql,
21
34
  } from "./db";
22
- import { verifyToken, type TokenPayload } from "./auth";
35
+ import { verifyToken, assertSecretConfigured, type TokenPayload } from "./auth";
23
36
  import type { PolarisEvent, ParticipantId } from "../types";
24
- import { HookPayload, ParticipantId as ParticipantIdSchema } from "../types";
37
+ import { AnnotationKind, HookPayload, ParticipantId as ParticipantIdSchema } from "../types";
25
38
 
26
39
  // --- WebSocket subscriber management ---
27
40
 
@@ -106,6 +119,44 @@ function broadcastSse(event: PolarisEvent) {
106
119
  }
107
120
  }
108
121
 
122
+ // --- Realtime backbone: LISTEN/NOTIFY de-dup ---
123
+ // Events broadcast inline by a POST handler record their id here so the echoed NOTIFY
124
+ // (delivered via the LISTEN connection) is skipped. Module-level so in-process test
125
+ // servers share one de-dup horizon.
126
+ const recentlyBroadcast = new Map<string, number>(); // event id -> recorded-at ms
127
+ const RECENTLY_BROADCAST_TTL_MS = 30_000;
128
+
129
+ function markBroadcast(id: string) {
130
+ const now = Date.now();
131
+ recentlyBroadcast.set(id, now);
132
+ for (const [k, ts] of recentlyBroadcast) {
133
+ if (now - ts > RECENTLY_BROADCAST_TTL_MS) recentlyBroadcast.delete(k);
134
+ }
135
+ }
136
+
137
+ // --- Rate limiting (tiny in-memory fixed window; lenient by design) ---
138
+
139
+ const RATE_WINDOW_MS = 60_000;
140
+ const INJECT_LIMIT_PER_WINDOW = 60; // per token (or per IP when anonymous)
141
+ const WRITE_LIMIT_PER_WINDOW = 600;
142
+ const rateBuckets = new Map<string, { windowStart: number; count: number }>();
143
+
144
+ function rateLimited(key: string, limit: number): boolean {
145
+ const now = Date.now();
146
+ let bucket = rateBuckets.get(key);
147
+ if (!bucket || now - bucket.windowStart >= RATE_WINDOW_MS) {
148
+ bucket = { windowStart: now, count: 0 };
149
+ rateBuckets.set(key, bucket);
150
+ if (rateBuckets.size > 10_000) {
151
+ for (const [k, b] of rateBuckets) {
152
+ if (now - b.windowStart >= RATE_WINDOW_MS) rateBuckets.delete(k);
153
+ }
154
+ }
155
+ }
156
+ bucket.count++;
157
+ return bucket.count > limit;
158
+ }
159
+
109
160
  // --- Route matching ---
110
161
 
111
162
  type RouteParams = Record<string, string>;
@@ -149,14 +200,18 @@ function error(message: string, status: number): Response {
149
200
 
150
201
  // --- Auth helper ---
151
202
 
152
- async function resolveOrgId(req: Request, defaultOrgId: string): Promise<string> {
203
+ async function authOrgId(req: Request, defaultOrgId: string): Promise<{ orgId: string; participantId: string | null } | Response> {
153
204
  const auth = req.headers.get("Authorization");
154
205
  if (auth?.startsWith("Bearer ")) {
155
206
  const token = auth.slice(7);
156
207
  const payload = await verifyToken(token);
157
- if (payload) return payload.org_id;
208
+ if (payload) return { orgId: payload.org_id, participantId: payload.participant_id };
209
+ }
210
+ // No/invalid token: fall back to the default org outside production
211
+ if (process.env.NODE_ENV !== "production") {
212
+ return { orgId: defaultOrgId, participantId: null };
158
213
  }
159
- return defaultOrgId;
214
+ return error("Unauthorized", 401);
160
215
  }
161
216
 
162
217
  // --- Server factory ---
@@ -171,6 +226,7 @@ export async function startServer(opts: {
171
226
  defaultOrgId: string;
172
227
  stop: () => Promise<void>;
173
228
  }> {
229
+ assertSecretConfigured();
174
230
  const sql = await createDb(opts.databaseUrl);
175
231
  const port = opts.port ?? Number(process.env.PORT ?? 4321);
176
232
  const defaultOrgId = opts.defaultOrgId ?? "default";
@@ -182,6 +238,23 @@ export async function startServer(opts: {
182
238
  // Already exists
183
239
  }
184
240
 
241
+ // One dedicated LISTEN connection per process. Delivers events not already broadcast
242
+ // inline (e.g. bridge-origin Slack injects) to WS/SSE subscribers, and enables
243
+ // multi-replica fan-out.
244
+ await sql.listen("polaris_event", (id) => {
245
+ if (!id || recentlyBroadcast.has(id)) return;
246
+ markBroadcast(id);
247
+ (async () => {
248
+ const found = await getEventById(sql, id);
249
+ if (!found) return;
250
+ const { org_id: _orgId, ...event } = found;
251
+ broadcastEvent(event);
252
+ broadcastSse(event);
253
+ })().catch((e) => {
254
+ console.error("[server] polaris_event listener failed:", e);
255
+ });
256
+ });
257
+
185
258
  const server = Bun.serve<WsData>({
186
259
  port,
187
260
  hostname: "0.0.0.0",
@@ -198,9 +271,6 @@ export async function startServer(opts: {
198
271
  const method = req.method;
199
272
  let params: RouteParams | null;
200
273
 
201
- // Resolve org from auth token or use default
202
- const orgId = await resolveOrgId(req, defaultOrgId);
203
-
204
274
  // --- WebSocket upgrade ---
205
275
  params = matchRoute(method, pathname, "/projects/:proj/ws", "GET");
206
276
  if (params) {
@@ -213,6 +283,43 @@ export async function startServer(opts: {
213
283
  return upgraded ? undefined : error("WebSocket upgrade failed", 400);
214
284
  }
215
285
 
286
+ if (method === "GET" && pathname === "/status") {
287
+ return json({ ok: true, version: "0.0.1" });
288
+ }
289
+
290
+ // Resolve org from auth token; falls back to default org outside production,
291
+ // returns 401 in production without a valid token
292
+ const a = await authOrgId(req, defaultOrgId);
293
+ if (a instanceof Response) return a;
294
+ const orgId = a.orgId;
295
+ const participantId = a.participantId;
296
+
297
+ // --- Rate limiting (write endpoints; per token, per IP when anonymous) ---
298
+ if (method !== "GET") {
299
+ const rateKey = participantId ?? `ip:${server.requestIP(req)?.address ?? "unknown"}`;
300
+ const isInject = /^\/projects\/[^/]+\/sessions\/[^/]+\/inject$/.test(pathname);
301
+ if (isInject) {
302
+ if (rateLimited(`inject:${rateKey}`, INJECT_LIMIT_PER_WINDOW)) {
303
+ return error("Rate limit exceeded: too many injects", 429);
304
+ }
305
+ } else if (rateLimited(`write:${rateKey}`, WRITE_LIMIT_PER_WINDOW)) {
306
+ return error("Rate limit exceeded", 429);
307
+ }
308
+ }
309
+
310
+ // --- Per-project ACL ---
311
+ // 403 non-members on project-scoped endpoints. Anonymous callers (null participant,
312
+ // dev/tests) and the ACL-admin endpoints (visibility/members) are exempt — so you
313
+ // can still add members after flipping a project to 'members'.
314
+ const aclMatch = pathname.match(/^\/projects\/([^/]+)(\/.*)?$/);
315
+ if (aclMatch && participantId) {
316
+ const aclRest = aclMatch[2] ?? "";
317
+ const isAclAdmin = aclRest === "/visibility" || aclRest === "/members" || aclRest.startsWith("/members/");
318
+ if (!isAclAdmin && !(await userCanAccessProject(sql, orgId, aclMatch[1], participantId))) {
319
+ return error("Forbidden: project is restricted to members", 403);
320
+ }
321
+ }
322
+
216
323
  // --- Project endpoints ---
217
324
 
218
325
  if (method === "GET" && pathname === "/projects") {
@@ -271,6 +378,49 @@ export async function startServer(opts: {
271
378
  return json({ status: "renamed", oldName: params.proj, newName: body.name });
272
379
  }
273
380
 
381
+ // --- Project ACL endpoints (visibility & members) ---
382
+
383
+ params = matchRoute(method, pathname, "/projects/:proj/visibility", "POST");
384
+ if (params) {
385
+ const project = await getProject(sql, orgId, params.proj);
386
+ if (!project) return error("Project not found", 404);
387
+ const body = await jsonBody(req);
388
+ const parsed = z.object({ visibility: z.enum(["org", "members"]) }).safeParse(body);
389
+ if (!parsed.success) return error("Invalid body: visibility must be 'org' or 'members'", 400);
390
+ await setProjectVisibility(sql, orgId, params.proj, parsed.data.visibility);
391
+ return json({ ok: true, visibility: parsed.data.visibility });
392
+ }
393
+
394
+ params = matchRoute(method, pathname, "/projects/:proj/members", "POST");
395
+ if (params) {
396
+ const project = await getProject(sql, orgId, params.proj);
397
+ if (!project) return error("Project not found", 404);
398
+ const body = await jsonBody(req);
399
+ const parsed = z
400
+ .object({ participant_id: z.string().min(1), role: z.string().optional() })
401
+ .safeParse(body);
402
+ if (!parsed.success) return error("Invalid body: participant_id is required", 400);
403
+ await addProjectMember(sql, orgId, params.proj, parsed.data.participant_id, parsed.data.role);
404
+ return json({ ok: true });
405
+ }
406
+
407
+ params = matchRoute(method, pathname, "/projects/:proj/members", "GET");
408
+ if (params) {
409
+ const project = await getProject(sql, orgId, params.proj);
410
+ if (!project) return error("Project not found", 404);
411
+ const members = await listProjectMembers(sql, orgId, params.proj);
412
+ return json({ members });
413
+ }
414
+
415
+ params = matchRoute(method, pathname, "/projects/:proj/members/:pid", "DELETE");
416
+ if (params) {
417
+ const project = await getProject(sql, orgId, params.proj);
418
+ if (!project) return error("Project not found", 404);
419
+ // Participant ids contain ':' which clients often percent-encode in paths
420
+ await removeProjectMember(sql, orgId, params.proj, decodeURIComponent(params.pid));
421
+ return json({ ok: true });
422
+ }
423
+
274
424
  params = matchRoute(method, pathname, "/projects/:proj/messages", "GET");
275
425
  if (params) {
276
426
  const project = await getProject(sql, orgId, params.proj);
@@ -346,6 +496,8 @@ export async function startServer(opts: {
346
496
  sender: parsed.data.sender,
347
497
  payload: parsed.data.payload,
348
498
  };
499
+ // Record before pushEvent so our own NOTIFY echo is de-duped (we broadcast inline below).
500
+ markBroadcast(event.id);
349
501
  await pushEvent(sql, orgId, event);
350
502
  broadcastEvent(event);
351
503
  broadcastSse(event);
@@ -374,6 +526,15 @@ export async function startServer(opts: {
374
526
  if (params) {
375
527
  const session = await getSession(sql, orgId, params.proj, params.sess);
376
528
  if (!session) return error("Session not found", 404);
529
+ // Paginated event history when limit/before is present; SSE stream otherwise
530
+ if (url.searchParams.has("limit") || url.searchParams.has("before")) {
531
+ const limitParam = url.searchParams.get("limit");
532
+ const parsedLimit = limitParam ? Number.parseInt(limitParam, 10) : NaN;
533
+ const limit = Number.isFinite(parsedLimit) ? parsedLimit : undefined;
534
+ const before = url.searchParams.get("before") ?? undefined;
535
+ const { events, nextCursor } = await getSessionEventsPage(sql, orgId, params.proj, params.sess, { limit, before });
536
+ return json({ events, nextCursor });
537
+ }
377
538
  const proj = params.proj;
378
539
  const sess = params.sess;
379
540
  let controller: SseController;
@@ -392,23 +553,28 @@ export async function startServer(opts: {
392
553
  if (!session) return error("Session not found", 404);
393
554
  const body = await jsonBody(req);
394
555
  const parsed = z
395
- .object({ content: z.string().min(1), sender: ParticipantIdSchema })
556
+ .object({ content: z.string().min(1), sender: ParticipantIdSchema.optional() })
396
557
  .safeParse(body);
397
558
  if (!parsed.success) return error(`Invalid body: ${parsed.error.message}`, 400);
559
+ // Authenticated callers always inject as themselves; anonymous (non-prod) callers must supply a sender
560
+ const sender = participantId ?? parsed.data.sender;
561
+ if (!sender) return error("Invalid body: sender is required", 400);
398
562
  const event: PolarisEvent = {
399
563
  id: crypto.randomUUID(),
400
564
  project: params.proj,
401
565
  session: params.sess,
402
566
  timestamp: new Date().toISOString(),
403
567
  source: "inject",
404
- sender: parsed.data.sender,
568
+ sender,
405
569
  payload: {
406
570
  type: "inject" as const,
407
571
  content: parsed.data.content,
408
- sender: parsed.data.sender,
572
+ sender,
409
573
  target: params.sess,
410
574
  },
411
575
  };
576
+ // Record before pushEvent so our own NOTIFY echo is de-duped (we broadcast inline below).
577
+ markBroadcast(event.id);
412
578
  await pushEvent(sql, orgId, event);
413
579
  broadcastEvent(event);
414
580
  broadcastSse(event);
@@ -444,6 +610,96 @@ export async function startServer(opts: {
444
610
  return json({ status: "claimed", driver: parsed.data.driver, session: params.sess });
445
611
  }
446
612
 
613
+ params = matchRoute(method, pathname, "/projects/:proj/sessions/:sess/label", "POST");
614
+ if (params) {
615
+ const session = await getSession(sql, orgId, params.proj, params.sess);
616
+ if (!session) return error("Session not found", 404);
617
+ const body = await jsonBody(req);
618
+ const parsed = z.object({ label: z.string() }).safeParse(body);
619
+ if (!parsed.success) return error("Invalid body: label is required", 400);
620
+ await setSessionLabel(sql, orgId, params.proj, params.sess, parsed.data.label);
621
+ return json({ ok: true });
622
+ }
623
+
624
+ // --- Annotations (curation: stars, tags, decisions) ---
625
+
626
+ params = matchRoute(method, pathname, "/projects/:proj/sessions/:sess/annotations", "POST");
627
+ if (params) {
628
+ const session = await getSession(sql, orgId, params.proj, params.sess);
629
+ if (!session) return error("Session not found", 404);
630
+ const body = await jsonBody(req);
631
+ const parsed = z
632
+ .object({
633
+ event_id: z.string().uuid().optional(),
634
+ kind: AnnotationKind,
635
+ value: z.string().optional(),
636
+ })
637
+ .safeParse(body);
638
+ if (!parsed.success) return error(`Invalid body: ${parsed.error.message}`, 400);
639
+ const { id } = await addAnnotation(sql, orgId, {
640
+ event_id: parsed.data.event_id ?? null,
641
+ project: params.proj,
642
+ session: params.sess,
643
+ participant_id: participantId,
644
+ kind: parsed.data.kind,
645
+ value: parsed.data.value ?? null,
646
+ });
647
+ return json({ id }, 201);
648
+ }
649
+
650
+ params = matchRoute(method, pathname, "/projects/:proj/sessions/:sess/annotations", "GET");
651
+ if (params) {
652
+ const session = await getSession(sql, orgId, params.proj, params.sess);
653
+ if (!session) return error("Session not found", 404);
654
+ const annotations = await listSessionAnnotations(sql, orgId, params.proj, params.sess);
655
+ return json({ annotations });
656
+ }
657
+
658
+ params = matchRoute(method, pathname, "/annotations/:id", "DELETE");
659
+ if (params) {
660
+ await deleteAnnotation(sql, orgId, params.id);
661
+ return json({ ok: true });
662
+ }
663
+
664
+ if (method === "GET" && pathname === "/decisions") {
665
+ const project = url.searchParams.get("project") ?? undefined;
666
+ if (project && !(await userCanAccessProject(sql, orgId, project, participantId))) {
667
+ return error("Forbidden: project is restricted to members", 403);
668
+ }
669
+ const limitParam = url.searchParams.get("limit");
670
+ const parsedLimit = limitParam ? Number.parseInt(limitParam, 10) : NaN;
671
+ const decisions = await listDecisions(sql, orgId, {
672
+ project,
673
+ limit: Number.isFinite(parsedLimit) ? parsedLimit : undefined,
674
+ participantId,
675
+ });
676
+ return json({ decisions });
677
+ }
678
+
679
+ // --- Search ---
680
+
681
+ if (method === "GET" && pathname === "/search") {
682
+ const q = url.searchParams.get("q");
683
+ if (!q || !q.trim()) return error("q is required", 400);
684
+ const project = url.searchParams.get("project") ?? undefined;
685
+ if (project && !(await userCanAccessProject(sql, orgId, project, participantId))) {
686
+ return error("Forbidden: project is restricted to members", 403);
687
+ }
688
+ const limitParam = url.searchParams.get("limit");
689
+ const parsedLimit = limitParam ? Number.parseInt(limitParam, 10) : NaN;
690
+ const { results } = await searchEvents(sql, orgId, {
691
+ q,
692
+ project,
693
+ session: url.searchParams.get("session") ?? undefined,
694
+ sender: url.searchParams.get("sender") ?? undefined,
695
+ source: url.searchParams.get("source") ?? undefined,
696
+ tag: url.searchParams.get("tag") ?? undefined,
697
+ limit: Number.isFinite(parsedLimit) ? parsedLimit : undefined,
698
+ participantId,
699
+ });
700
+ return json({ results });
701
+ }
702
+
447
703
  // GET /team — list org members with Slack identities
448
704
  if (method === "GET" && pathname === "/team") {
449
705
  const { listUsers, getOrg: getOrgFn } = await import("./db");