@desplega.ai/agent-swarm 1.80.2 → 1.81.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 (40) hide show
  1. package/README.md +3 -0
  2. package/openapi.json +486 -29
  3. package/package.json +3 -3
  4. package/plugin/commands/user-management.md +85 -46
  5. package/plugin/pi-skills/user-management/SKILL.md +85 -46
  6. package/src/agentmail/handlers.ts +25 -3
  7. package/src/agentmail/types.ts +1 -0
  8. package/src/be/db.ts +33 -109
  9. package/src/be/migrations/067_users_first_class.sql +185 -0
  10. package/src/be/migrations/068_profile_changed_event_type.sql +56 -0
  11. package/src/be/unmapped-identities.ts +98 -0
  12. package/src/be/users.ts +531 -0
  13. package/src/github/handlers.ts +67 -7
  14. package/src/gitlab/handlers.ts +73 -5
  15. package/src/http/operator-actor.ts +59 -0
  16. package/src/http/users.ts +611 -21
  17. package/src/http/webhooks.ts +9 -0
  18. package/src/http/workflows.ts +2 -15
  19. package/src/linear/oauth.ts +61 -1
  20. package/src/linear/sync.ts +134 -21
  21. package/src/slack/actions.ts +8 -2
  22. package/src/slack/assistant.ts +12 -9
  23. package/src/slack/enrich.ts +162 -0
  24. package/src/slack/handlers.ts +11 -19
  25. package/src/tests/agentmail-handlers.test.ts +166 -0
  26. package/src/tests/github-handlers.test.ts +290 -0
  27. package/src/tests/gitlab-handlers.test.ts +293 -1
  28. package/src/tests/http-api-integration.test.ts +8 -4
  29. package/src/tests/http-users.test.ts +605 -0
  30. package/src/tests/linear-sync-identity.test.ts +427 -0
  31. package/src/tests/mcp-tools-user.test.ts +292 -0
  32. package/src/tests/slack-identity-resolution.test.ts +349 -0
  33. package/src/tests/user-identity.test.ts +351 -81
  34. package/src/tests/workflow-triggers-v2.test.ts +261 -20
  35. package/src/tools/manage-user.ts +119 -24
  36. package/src/tools/resolve-user.ts +43 -29
  37. package/src/types.ts +26 -4
  38. package/src/utils/secret-scrubber.ts +5 -0
  39. package/src/workflows/input.ts +7 -2
  40. package/src/workflows/triggers.ts +89 -9
@@ -0,0 +1,531 @@
1
+ /**
2
+ * Canonical API-side identity surface — the only path that mutates identity
3
+ * tables. Every mutating helper wraps row mutation + event emission in a
4
+ * single `db.transaction()` so the invariant "every identity mutation has a
5
+ * matching event row" (Q9) holds even on partial failures.
6
+ *
7
+ * This module is API-side ONLY. The DB-boundary checker (`scripts/check-db-boundary.sh`)
8
+ * enforces that worker-side code paths (`src/commands/`, `src/hooks/`,
9
+ * `src/providers/`, `src/prompts/`, `src/cli.tsx`, `src/claude.ts`) do not
10
+ * import from `src/be/*`. This file follows that convention.
11
+ *
12
+ * Q-research refs (brainstorm 2026-05-18-humans-as-first-class-users):
13
+ * * Q10 — helper surface (find/findOrCreate/link/unlink/mint/revoke/resolve)
14
+ * * Q17.G — `getUserIdentities` for People-page response composition
15
+ * * Q19 — full event-type CHECK enum (mirrored in `src/types.ts` + migration 064)
16
+ * * Q20 — `mintToken` returns `aswt_<base62>`, stores hash + 4-char preview
17
+ * * Q12 — `findUserByEmail` checks BOTH primary email AND emailAliases
18
+ * * Q14 — PK collision on duplicate `(kind, externalId)` throws (no UNIQUE fallback)
19
+ * * Q16 — `fingerprintApiKey` returns `op:<sha256-16>` for operator audit
20
+ */
21
+
22
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
23
+ import type { User } from "../types";
24
+ import { getDb } from "./db";
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Types
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /**
31
+ * Caller identity for event auditing. Embedded into `user_identity_events.actor`
32
+ * as `<kind>:<id>`. The migration's CHECK constraint does NOT validate `actor`
33
+ * shape — it's free-form per Q19 — but helpers stick to this convention so
34
+ * UI filters can carve by kind cheaply.
35
+ */
36
+ export type IdentityActor = {
37
+ kind: "system" | "operator" | "user";
38
+ id: string;
39
+ };
40
+
41
+ function actorString(actor: IdentityActor): string {
42
+ return `${actor.kind}:${actor.id}`;
43
+ }
44
+
45
+ /** Internal row shape — superset of `User` plus columns added in migration 064. */
46
+ type UserRow = {
47
+ id: string;
48
+ name: string;
49
+ email: string | null;
50
+ role: string | null;
51
+ notes: string | null;
52
+ emailAliases: string | null;
53
+ preferredChannel: string | null;
54
+ timezone: string | null;
55
+ metadata: string | null;
56
+ dailyBudgetUsd: number | null;
57
+ status: string;
58
+ createdAt: string;
59
+ lastUpdatedAt: string;
60
+ };
61
+
62
+ function rowToUser(row: UserRow): User {
63
+ return {
64
+ id: row.id,
65
+ name: row.name,
66
+ email: row.email ?? undefined,
67
+ role: row.role ?? undefined,
68
+ notes: row.notes ?? undefined,
69
+ emailAliases: row.emailAliases ? (JSON.parse(row.emailAliases) as string[]) : [],
70
+ preferredChannel: row.preferredChannel ?? "slack",
71
+ timezone: row.timezone ?? undefined,
72
+ metadata: row.metadata ? (JSON.parse(row.metadata) as Record<string, unknown>) : undefined,
73
+ dailyBudgetUsd: row.dailyBudgetUsd ?? null,
74
+ status: (row.status as "invited" | "active" | "suspended") ?? "active",
75
+ createdAt: row.createdAt,
76
+ lastUpdatedAt: row.lastUpdatedAt,
77
+ };
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Read helpers
82
+ // ---------------------------------------------------------------------------
83
+
84
+ /** Single SELECT by primary key. */
85
+ export function findUserById(id: string): User | null {
86
+ const row = getDb().prepare<UserRow, string>("SELECT * FROM users WHERE id = ?").get(id);
87
+ return row ? rowToUser(row) : null;
88
+ }
89
+
90
+ /**
91
+ * Look up a user by an `(kind, externalId)` pair via `user_external_ids`.
92
+ * Returns null if no mapping exists. Use this for webhook auto-link paths.
93
+ */
94
+ export function findUserByExternalId(kind: string, externalId: string): User | null {
95
+ const row = getDb()
96
+ .prepare<UserRow, [string, string]>(
97
+ `SELECT u.* FROM users u
98
+ INNER JOIN user_external_ids x ON x.userId = u.id
99
+ WHERE x.kind = ? AND x.externalId = ?`,
100
+ )
101
+ .get(kind, externalId);
102
+ return row ? rowToUser(row) : null;
103
+ }
104
+
105
+ /**
106
+ * Find by email — primary `users.email` OR a member of `emailAliases` (JSON
107
+ * array, case-insensitive). Q12 invariant: aliases are first-class.
108
+ *
109
+ * NOTE: SQLite's `json_each` requires a non-null source; we filter on
110
+ * `emailAliases != '[]'` in the alias branch so the rare row with NULL
111
+ * aliases doesn't blow up the JOIN.
112
+ */
113
+ export function findUserByEmail(email: string): User | null {
114
+ const lower = email.toLowerCase();
115
+ const db = getDb();
116
+
117
+ // Primary email (case-insensitive)
118
+ const primary = db
119
+ .prepare<UserRow, string>("SELECT * FROM users WHERE LOWER(email) = LOWER(?)")
120
+ .get(email);
121
+ if (primary) return rowToUser(primary);
122
+
123
+ // Alias array
124
+ const aliasRows = db
125
+ .prepare<UserRow, []>(
126
+ "SELECT * FROM users WHERE emailAliases IS NOT NULL AND emailAliases != '[]'",
127
+ )
128
+ .all();
129
+ for (const r of aliasRows) {
130
+ const aliases: string[] = r.emailAliases ? (JSON.parse(r.emailAliases) as string[]) : [];
131
+ if (aliases.some((a) => a.toLowerCase() === lower)) {
132
+ return rowToUser(r);
133
+ }
134
+ }
135
+ return null;
136
+ }
137
+
138
+ /**
139
+ * Return all `(kind, externalId)` mappings for a user — used by the People
140
+ * page detail view to render identity badges in one request.
141
+ */
142
+ export function getUserIdentities(userId: string): Array<{ kind: string; externalId: string }> {
143
+ return getDb()
144
+ .prepare<{ kind: string; externalId: string }, string>(
145
+ "SELECT kind, externalId FROM user_external_ids WHERE userId = ? ORDER BY kind, externalId",
146
+ )
147
+ .all(userId);
148
+ }
149
+
150
+ /**
151
+ * Identity-event row shape — what the People page timeline consumes. Mirrors
152
+ * the columns on `user_identity_events`; `beforeJson`/`afterJson` are decoded
153
+ * here so callers don't repeat the parse.
154
+ */
155
+ export type IdentityEvent = {
156
+ id: string;
157
+ userId: string;
158
+ eventType: string;
159
+ actor: string;
160
+ before: unknown | null;
161
+ after: unknown | null;
162
+ createdAt: string;
163
+ };
164
+
165
+ type IdentityEventRow = {
166
+ id: string;
167
+ userId: string;
168
+ eventType: string;
169
+ actor: string;
170
+ beforeJson: string | null;
171
+ afterJson: string | null;
172
+ createdAt: string;
173
+ };
174
+
175
+ function rowToEvent(row: IdentityEventRow): IdentityEvent {
176
+ return {
177
+ id: row.id,
178
+ userId: row.userId,
179
+ eventType: row.eventType,
180
+ actor: row.actor,
181
+ before: row.beforeJson == null ? null : (JSON.parse(row.beforeJson) as unknown),
182
+ after: row.afterJson == null ? null : (JSON.parse(row.afterJson) as unknown),
183
+ createdAt: row.createdAt,
184
+ };
185
+ }
186
+
187
+ /**
188
+ * Paginated event timeline for a user. `limit` is hard-capped at 200; `before`
189
+ * is a cursor on `createdAt` (ISO string) so the caller can keep paging by
190
+ * passing back the last event's `createdAt`.
191
+ */
192
+ export function listUserEvents(
193
+ userId: string,
194
+ opts: { limit?: number; before?: string } = {},
195
+ ): IdentityEvent[] {
196
+ const limit = Math.max(1, Math.min(opts.limit ?? 50, 200));
197
+ const before = opts.before;
198
+ const db = getDb();
199
+ if (before) {
200
+ return db
201
+ .prepare<IdentityEventRow, [string, string, number]>(
202
+ `SELECT id, userId, eventType, actor, beforeJson, afterJson, createdAt
203
+ FROM user_identity_events
204
+ WHERE userId = ? AND createdAt < ?
205
+ ORDER BY createdAt DESC, rowid DESC
206
+ LIMIT ?`,
207
+ )
208
+ .all(userId, before, limit)
209
+ .map(rowToEvent);
210
+ }
211
+ return db
212
+ .prepare<IdentityEventRow, [string, number]>(
213
+ `SELECT id, userId, eventType, actor, beforeJson, afterJson, createdAt
214
+ FROM user_identity_events
215
+ WHERE userId = ?
216
+ ORDER BY createdAt DESC, rowid DESC
217
+ LIMIT ?`,
218
+ )
219
+ .all(userId, limit)
220
+ .map(rowToEvent);
221
+ }
222
+
223
+ /**
224
+ * Token row shape returned to operators — `tokenHash` is never exposed,
225
+ * `tokenPreview` is the last 4 chars of the plaintext.
226
+ */
227
+ export type UserTokenSummary = {
228
+ id: string;
229
+ userId: string;
230
+ label: string | null;
231
+ tokenPreview: string;
232
+ createdAt: string;
233
+ lastUsedAt: string | null;
234
+ revokedAt: string | null;
235
+ };
236
+
237
+ type UserTokenRow = UserTokenSummary;
238
+
239
+ /**
240
+ * List a user's MCP tokens (without the hash). Used to render the People
241
+ * page token panel — the mint/revoke endpoints + UI dialog ship with the
242
+ * MCP-token plan, this helper lands here so step-8's `GET /users` response
243
+ * can include token summaries.
244
+ */
245
+ export function listUserTokens(userId: string): UserTokenSummary[] {
246
+ return getDb()
247
+ .prepare<UserTokenRow, string>(
248
+ `SELECT id, userId, label, tokenPreview, createdAt, lastUsedAt, revokedAt
249
+ FROM user_tokens
250
+ WHERE userId = ?
251
+ ORDER BY createdAt DESC`,
252
+ )
253
+ .all(userId);
254
+ }
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // Event audit
258
+ // ---------------------------------------------------------------------------
259
+
260
+ /**
261
+ * Append a row to `user_identity_events`. Exported so the manage-user MCP
262
+ * tool / HTTP endpoint can emit `email_added` / `email_removed` /
263
+ * `budget_changed` / `status_changed` directly (the mutating helpers below
264
+ * already emit their own events in-transaction).
265
+ */
266
+ export function recordIdentityEvent(
267
+ userId: string,
268
+ eventType:
269
+ | "auto_merge"
270
+ | "manual_merge"
271
+ | "identity_added"
272
+ | "identity_removed"
273
+ | "email_added"
274
+ | "email_removed"
275
+ | "token_minted"
276
+ | "token_revoked"
277
+ | "budget_changed"
278
+ | "status_changed"
279
+ | "profile_changed",
280
+ actor: IdentityActor,
281
+ before: unknown | null,
282
+ after: unknown | null,
283
+ ): void {
284
+ getDb()
285
+ .prepare(
286
+ `INSERT INTO user_identity_events (id, userId, eventType, actor, beforeJson, afterJson, createdAt)
287
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
288
+ )
289
+ .run(
290
+ randomUUID().replace(/-/g, ""),
291
+ userId,
292
+ eventType,
293
+ actorString(actor),
294
+ before == null ? null : JSON.stringify(before),
295
+ after == null ? null : JSON.stringify(after),
296
+ new Date().toISOString(),
297
+ );
298
+ }
299
+
300
+ // ---------------------------------------------------------------------------
301
+ // findOrCreate
302
+ // ---------------------------------------------------------------------------
303
+
304
+ /**
305
+ * Q4/Q5 auto-merge or auto-create by email.
306
+ *
307
+ * - If a user with this email (primary OR alias) exists, return it with
308
+ * `created: false` and emit an `auto_merge` event tagged with `hints`.
309
+ * - Otherwise create a new row with `name` from hints (or the email
310
+ * local-part) and emit `identity_added` with the new row in `afterJson`.
311
+ *
312
+ * Wrapped in `db.transaction()` so create + event land together.
313
+ */
314
+ export function findOrCreateUserByEmail(
315
+ email: string,
316
+ hints: { name?: string; role?: string; notes?: string; preferredChannel?: string },
317
+ actor: IdentityActor,
318
+ ): { user: User; created: boolean } {
319
+ const existing = findUserByEmail(email);
320
+ if (existing) {
321
+ recordIdentityEvent(existing.id, "auto_merge", actor, null, { email, hints });
322
+ return { user: existing, created: false };
323
+ }
324
+
325
+ const id = randomUUID().replace(/-/g, "");
326
+ const now = new Date().toISOString();
327
+ const name = hints.name?.trim() || email.split("@")[0] || email;
328
+ const db = getDb();
329
+
330
+ const created = db.transaction(() => {
331
+ db.prepare(
332
+ `INSERT INTO users (id, name, email, role, notes, emailAliases, preferredChannel, timezone, createdAt, lastUpdatedAt, status)
333
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active')`,
334
+ ).run(
335
+ id,
336
+ name,
337
+ email,
338
+ hints.role ?? null,
339
+ hints.notes ?? null,
340
+ "[]",
341
+ hints.preferredChannel ?? "slack",
342
+ null,
343
+ now,
344
+ now,
345
+ );
346
+ const row = db.prepare<UserRow, string>("SELECT * FROM users WHERE id = ?").get(id);
347
+ if (!row) throw new Error("Failed to create user");
348
+ recordIdentityEvent(id, "identity_added", actor, null, { email, name });
349
+ return rowToUser(row);
350
+ })();
351
+
352
+ return { user: created, created: true };
353
+ }
354
+
355
+ // ---------------------------------------------------------------------------
356
+ // Identity link/unlink
357
+ // ---------------------------------------------------------------------------
358
+
359
+ /**
360
+ * Map an external identity to a user. PK collision on `(kind, externalId)`
361
+ * throws (Q14 — replaces old UNIQUE-constraint behaviour). Caller decides
362
+ * whether to surface that as a merge prompt.
363
+ *
364
+ * Atomic: INSERT + event in one transaction.
365
+ */
366
+ export function linkIdentity(
367
+ userId: string,
368
+ kind: string,
369
+ externalId: string,
370
+ actor: IdentityActor,
371
+ ): void {
372
+ const db = getDb();
373
+ db.transaction(() => {
374
+ db.prepare("INSERT INTO user_external_ids (userId, kind, externalId) VALUES (?, ?, ?)").run(
375
+ userId,
376
+ kind,
377
+ externalId,
378
+ );
379
+ recordIdentityEvent(userId, "identity_added", actor, null, { kind, externalId });
380
+ })();
381
+ }
382
+
383
+ /**
384
+ * Remove an `(kind, externalId)` mapping. No-op if no row matched — but we
385
+ * still emit the event with the same before/after for the audit trail.
386
+ * Atomic: DELETE + event in one transaction.
387
+ */
388
+ export function unlinkIdentity(
389
+ userId: string,
390
+ kind: string,
391
+ externalId: string,
392
+ actor: IdentityActor,
393
+ ): void {
394
+ const db = getDb();
395
+ db.transaction(() => {
396
+ db.prepare(
397
+ "DELETE FROM user_external_ids WHERE userId = ? AND kind = ? AND externalId = ?",
398
+ ).run(userId, kind, externalId);
399
+ recordIdentityEvent(userId, "identity_removed", actor, { kind, externalId }, null);
400
+ })();
401
+ }
402
+
403
+ // ---------------------------------------------------------------------------
404
+ // Tokens — schema + helpers land here (Q20). Mint/revoke endpoints + UI
405
+ // dialog ship with the separate MCP-token plan.
406
+ // ---------------------------------------------------------------------------
407
+
408
+ const TOKEN_PREFIX = "aswt_"; // agent-swarm-token
409
+
410
+ function base62(bytes: Uint8Array): string {
411
+ // Map random bytes into base62 alphabet for URL-safe, unambiguous tokens.
412
+ const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
413
+ let out = "";
414
+ for (const b of bytes) {
415
+ out += alphabet[b % 62];
416
+ }
417
+ return out;
418
+ }
419
+
420
+ function sha256Hex(input: string): string {
421
+ return createHash("sha256").update(input).digest("hex");
422
+ }
423
+
424
+ /**
425
+ * Generate an `aswt_<base62-24>` plaintext token, store its sha256 hash
426
+ * plus the last-4-char preview, and emit `token_minted`. Returns the
427
+ * plaintext ONCE; future reads only ever see the hash + preview.
428
+ *
429
+ * Token shape: `aswt_` + 24 base62 chars = >140 bits of entropy.
430
+ */
431
+ export function mintToken(
432
+ userId: string,
433
+ label: string | null,
434
+ actor: IdentityActor,
435
+ ): { tokenId: string; plaintext: string } {
436
+ // 24 base62 chars from 24 random bytes (~143 bits of entropy).
437
+ const plaintext = `${TOKEN_PREFIX}${base62(randomBytes(24))}`;
438
+ const tokenId = randomUUID().replace(/-/g, "");
439
+ const hash = sha256Hex(plaintext);
440
+ const preview = plaintext.slice(-4);
441
+ const now = new Date().toISOString();
442
+
443
+ const db = getDb();
444
+ db.transaction(() => {
445
+ db.prepare(
446
+ `INSERT INTO user_tokens (id, userId, label, tokenHash, tokenPreview, createdAt)
447
+ VALUES (?, ?, ?, ?, ?, ?)`,
448
+ ).run(tokenId, userId, label, hash, preview, now);
449
+ recordIdentityEvent(userId, "token_minted", actor, null, { tokenId, label, preview });
450
+ })();
451
+
452
+ return { tokenId, plaintext };
453
+ }
454
+
455
+ /**
456
+ * Revoke a previously-minted token. Sets `revokedAt = now` and emits
457
+ * `token_revoked`. Subsequent `resolveUserByToken(plaintext)` returns null.
458
+ */
459
+ export function revokeToken(tokenId: string, actor: IdentityActor): void {
460
+ const db = getDb();
461
+ db.transaction(() => {
462
+ const row = db
463
+ .prepare<{ userId: string; label: string | null; tokenPreview: string }, string>(
464
+ "SELECT userId, label, tokenPreview FROM user_tokens WHERE id = ?",
465
+ )
466
+ .get(tokenId);
467
+ if (!row) {
468
+ throw new Error(`Token not found: ${tokenId}`);
469
+ }
470
+ db.prepare("UPDATE user_tokens SET revokedAt = ? WHERE id = ?").run(
471
+ new Date().toISOString(),
472
+ tokenId,
473
+ );
474
+ recordIdentityEvent(
475
+ row.userId,
476
+ "token_revoked",
477
+ actor,
478
+ { tokenId, label: row.label, preview: row.tokenPreview },
479
+ null,
480
+ );
481
+ })();
482
+ }
483
+
484
+ /**
485
+ * Resolve a plaintext token to its owning user. Returns null if the token
486
+ * is unknown or revoked. On a successful hit, fires-and-forgets a
487
+ * `lastUsedAt` update so the People page can surface "last seen" without
488
+ * blocking the request path.
489
+ */
490
+ export function resolveUserByToken(plaintext: string): User | null {
491
+ const hash = sha256Hex(plaintext);
492
+ const db = getDb();
493
+
494
+ const row = db
495
+ .prepare<{ id: string; userId: string; revokedAt: string | null }, string>(
496
+ "SELECT id, userId, revokedAt FROM user_tokens WHERE tokenHash = ?",
497
+ )
498
+ .get(hash);
499
+ if (!row || row.revokedAt !== null) return null;
500
+
501
+ // Fire-and-forget lastUsedAt update. Synchronous bun:sqlite write is fast
502
+ // enough that we don't need to defer to a microtask; treating it as
503
+ // best-effort keeps the call-site clean.
504
+ try {
505
+ db.prepare("UPDATE user_tokens SET lastUsedAt = ? WHERE id = ?").run(
506
+ new Date().toISOString(),
507
+ row.id,
508
+ );
509
+ } catch {
510
+ // Never let a `lastUsedAt` update failure leak to the caller.
511
+ }
512
+
513
+ return findUserById(row.userId);
514
+ }
515
+
516
+ // ---------------------------------------------------------------------------
517
+ // API-key fingerprint (operator audit)
518
+ // ---------------------------------------------------------------------------
519
+
520
+ /**
521
+ * Q16: produce a short fingerprint of a raw operator API key for the
522
+ * `user_identity_events.actor` column. Format: `op:<sha256(rawKey).slice(0, 16)>`.
523
+ *
524
+ * Step-1 only defines this helper; the operator auth middleware in step-8
525
+ * will pass the raw key through `getApiKey()` from `src/utils/api-key.ts`
526
+ * and then call this. Step-1 itself does NOT read the env directly, so the
527
+ * api-key boundary check stays green.
528
+ */
529
+ export function fingerprintApiKey(rawKey: string): string {
530
+ return `op:${sha256Hex(rawKey).slice(0, 16)}`;
531
+ }
@@ -1,4 +1,5 @@
1
- import { failTask, findTaskByVcs, getAllAgents, resolveUser } from "../be/db";
1
+ import { failTask, findTaskByVcs, getAllAgents, incrKv, upsertKv } from "../be/db";
2
+ import { findUserByExternalId } from "../be/users";
2
3
  import { resolveTemplate } from "../prompts/resolver";
3
4
  import { githubContextKey } from "../tasks/context-key";
4
5
  import { createTaskWithSiblingAwareness } from "../tasks/sibling-awareness";
@@ -139,6 +140,47 @@ function findLeadAgent() {
139
140
  return agents.find((a) => a.isLead) ?? null;
140
141
  }
141
142
 
143
+ // ── Identity resolution ──
144
+
145
+ const UNMAPPED_NAMESPACE = "integration:unmapped:github";
146
+ const UNMAPPED_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
147
+
148
+ /**
149
+ * Resolve a GitHub webhook sender to a `users.id`.
150
+ *
151
+ * Per Q17.A: GitHub never exposes email reliably via webhook or App-installation
152
+ * token, so there is NO email auto-link cascade. The only paths are:
153
+ * 1. Fast path — `findUserByExternalId('github', sender.login)`.
154
+ * 2. Miss — record an unmapped tracker entry (kv) for operator triage on
155
+ * the People → Unmapped tab.
156
+ *
157
+ * Returns `undefined` when no mapping exists — callers pass that straight to
158
+ * `requestedByUserId`.
159
+ */
160
+ function resolveGitHubSender(
161
+ login: string,
162
+ sampleEventType: string,
163
+ sampleContext: string,
164
+ ): string | undefined {
165
+ const existing = findUserByExternalId("github", login);
166
+ if (existing) return existing.id;
167
+
168
+ // No mapping → unmapped tracker.
169
+ upsertKv({
170
+ namespace: UNMAPPED_NAMESPACE,
171
+ key: `${login}:meta`,
172
+ value: {
173
+ lastSeenAt: new Date().toISOString(),
174
+ sampleEventType,
175
+ sampleContext: sampleContext.slice(0, 100),
176
+ },
177
+ valueType: "json",
178
+ expiresAt: Date.now() + UNMAPPED_TTL_MS,
179
+ });
180
+ incrKv(UNMAPPED_NAMESPACE, `${login}:count`, 1);
181
+ return undefined;
182
+ }
183
+
142
184
  /**
143
185
  * Handle pull_request events (opened, edited)
144
186
  */
@@ -156,7 +198,11 @@ export async function handlePullRequest(
156
198
  } = event;
157
199
 
158
200
  // Resolve canonical user from GitHub sender
159
- const requestedByUserId = resolveUser({ githubUsername: sender.login })?.id;
201
+ const requestedByUserId = resolveGitHubSender(
202
+ sender.login,
203
+ "pull_request",
204
+ `PR #${pr.number}: ${pr.title}`,
205
+ );
160
206
 
161
207
  // Handle assigned action - bot was assigned to PR
162
208
  if (action === "assigned") {
@@ -514,7 +560,11 @@ export async function handleIssue(
514
560
  const { action, issue, repository, sender, installation, assignee } = event;
515
561
 
516
562
  // Resolve canonical user from GitHub sender
517
- const requestedByUserId = resolveUser({ githubUsername: sender.login })?.id;
563
+ const requestedByUserId = resolveGitHubSender(
564
+ sender.login,
565
+ "issues",
566
+ `Issue #${issue.number}: ${issue.title}`,
567
+ );
518
568
 
519
569
  // Handle assigned action - bot was assigned to issue
520
570
  if (action === "assigned") {
@@ -748,8 +798,13 @@ export async function handleComment(
748
798
  ): Promise<{ created: boolean; taskId?: string }> {
749
799
  const { action, comment, repository, sender, issue, pull_request, installation } = event;
750
800
 
751
- // Resolve canonical user from GitHub sender
752
- const _requestedByUserId = resolveUser({ githubUsername: sender.login })?.id;
801
+ // Resolve canonical user from GitHub sender (currently unused, but the
802
+ // unmapped-tracker side effect is still useful for operator triage).
803
+ const _requestedByUserId = resolveGitHubSender(
804
+ sender.login,
805
+ "issue_comment",
806
+ comment.body.slice(0, 100),
807
+ );
753
808
 
754
809
  // Only handle created action
755
810
  if (action !== "created") {
@@ -856,8 +911,13 @@ export async function handlePullRequestReview(
856
911
  ): Promise<{ created: boolean; taskId?: string }> {
857
912
  const { action, review, pull_request: pr, repository, sender, installation } = event;
858
913
 
859
- // Resolve canonical user from GitHub sender
860
- const _requestedByUserId = resolveUser({ githubUsername: sender.login })?.id;
914
+ // Resolve canonical user from GitHub sender (currently unused, but the
915
+ // unmapped-tracker side effect is still useful for operator triage).
916
+ const _requestedByUserId = resolveGitHubSender(
917
+ sender.login,
918
+ "pull_request_review",
919
+ `Review on PR #${pr.number}: ${review.state}`,
920
+ );
861
921
 
862
922
  // Only handle submitted reviews (the most important action)
863
923
  // Edited reviews are less common and dismissed is handled by the state