@lightupai/polaris 0.0.76 → 0.0.78
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/.github/workflows/cd.yml +77 -0
- package/docs/architecture.md +203 -0
- package/docs/product-critique-2026-06.md +136 -0
- package/docs/readiness-review-2026-06.md +130 -0
- package/hooks/capture-prompt.ts +60 -0
- package/hooks/capture-stop.ts +10 -3
- package/hooks/capture.sh +12 -0
- package/hooks/statusline.sh +8 -2
- package/package.json +1 -1
- package/scripts/backup.sh +71 -0
- package/src/bridge-discover-org.ts +30 -4
- package/src/cli/cli.ts +304 -34
- package/src/client/client.ts +44 -11
- package/src/daemon/daemon.ts +262 -38
- package/src/service/auth.ts +10 -1
- package/src/service/db.ts +338 -63
- package/src/service/server.ts +267 -11
- package/src/slack/bridge.ts +172 -34
- package/src/types.ts +22 -0
- package/src/web/app.ts +373 -2
- package/src/web/layout.ts +5 -1
- package/src/web/views.ts +420 -6
- package/tests/acl.test.ts +100 -0
- package/tests/annotations.test.ts +209 -0
- package/tests/bridge-discover.test.ts +70 -0
- package/tests/daemon-auth.test.ts +123 -0
- package/tests/event-lookup.test.ts +113 -0
- package/tests/label.test.ts +90 -0
- package/tests/pagination.test.ts +165 -0
- package/tests/recover.test.ts +83 -0
- package/tests/reply-attribution.test.ts +103 -0
- package/tests/search-acl.test.ts +117 -0
- package/tests/search.test.ts +145 -0
package/src/service/server.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
568
|
+
sender,
|
|
405
569
|
payload: {
|
|
406
570
|
type: "inject" as const,
|
|
407
571
|
content: parsed.data.content,
|
|
408
|
-
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");
|