@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.
- package/README.md +3 -0
- package/openapi.json +486 -29
- package/package.json +3 -3
- package/plugin/commands/user-management.md +85 -46
- package/plugin/pi-skills/user-management/SKILL.md +85 -46
- package/src/agentmail/handlers.ts +25 -3
- package/src/agentmail/types.ts +1 -0
- package/src/be/db.ts +33 -109
- package/src/be/migrations/067_users_first_class.sql +185 -0
- package/src/be/migrations/068_profile_changed_event_type.sql +56 -0
- package/src/be/unmapped-identities.ts +98 -0
- package/src/be/users.ts +531 -0
- package/src/github/handlers.ts +67 -7
- package/src/gitlab/handlers.ts +73 -5
- package/src/http/operator-actor.ts +59 -0
- package/src/http/users.ts +611 -21
- package/src/http/webhooks.ts +9 -0
- package/src/http/workflows.ts +2 -15
- package/src/linear/oauth.ts +61 -1
- package/src/linear/sync.ts +134 -21
- package/src/slack/actions.ts +8 -2
- package/src/slack/assistant.ts +12 -9
- package/src/slack/enrich.ts +162 -0
- package/src/slack/handlers.ts +11 -19
- package/src/tests/agentmail-handlers.test.ts +166 -0
- package/src/tests/github-handlers.test.ts +290 -0
- package/src/tests/gitlab-handlers.test.ts +293 -1
- package/src/tests/http-api-integration.test.ts +8 -4
- package/src/tests/http-users.test.ts +605 -0
- package/src/tests/linear-sync-identity.test.ts +427 -0
- package/src/tests/mcp-tools-user.test.ts +292 -0
- package/src/tests/slack-identity-resolution.test.ts +349 -0
- package/src/tests/user-identity.test.ts +351 -81
- package/src/tests/workflow-triggers-v2.test.ts +261 -20
- package/src/tools/manage-user.ts +119 -24
- package/src/tools/resolve-user.ts +43 -29
- package/src/types.ts +26 -4
- package/src/utils/secret-scrubber.ts +5 -0
- package/src/workflows/input.ts +7 -2
- package/src/workflows/triggers.ts +89 -9
package/src/be/users.ts
ADDED
|
@@ -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
|
+
}
|
package/src/github/handlers.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { failTask, findTaskByVcs, getAllAgents,
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|