@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
@@ -7,19 +7,61 @@ import {
7
7
  createUser,
8
8
  deleteUser,
9
9
  getAllUsers,
10
+ getDb,
10
11
  getTaskById,
11
12
  getUserById,
12
13
  initDb,
13
- resolveUser,
14
14
  updateUser,
15
15
  } from "../be/db";
16
+ import {
17
+ findOrCreateUserByEmail,
18
+ findUserByEmail,
19
+ findUserByExternalId,
20
+ findUserById,
21
+ fingerprintApiKey,
22
+ getUserIdentities,
23
+ type IdentityActor,
24
+ linkIdentity,
25
+ mintToken,
26
+ recordIdentityEvent,
27
+ resolveUserByToken,
28
+ revokeToken,
29
+ unlinkIdentity,
30
+ } from "../be/users";
16
31
 
17
32
  const TEST_DB_PATH = "./test-user-identity.sqlite";
18
33
 
19
34
  let leadAgent: ReturnType<typeof createAgent>;
20
35
  let workerAgent: ReturnType<typeof createAgent>;
21
36
 
37
+ const SYSTEM_ACTOR: IdentityActor = { kind: "system", id: "test-suite" };
38
+ const OPERATOR_ACTOR: IdentityActor = { kind: "operator", id: "op:0000000000000000" };
39
+
40
+ function eventsFor(userId: string): Array<{
41
+ eventType: string;
42
+ actor: string;
43
+ beforeJson: string | null;
44
+ afterJson: string | null;
45
+ }> {
46
+ // Order by createdAt then rowid — events emitted within the same
47
+ // millisecond (synchronous bursts in tests) need a stable tiebreaker.
48
+ return getDb()
49
+ .prepare<
50
+ { eventType: string; actor: string; beforeJson: string | null; afterJson: string | null },
51
+ string
52
+ >(
53
+ "SELECT eventType, actor, beforeJson, afterJson FROM user_identity_events WHERE userId = ? ORDER BY createdAt ASC, rowid ASC",
54
+ )
55
+ .all(userId);
56
+ }
57
+
22
58
  beforeAll(() => {
59
+ // Best-effort cleanup of any lingering test DB from a previous crashed run.
60
+ for (const suffix of ["", "-wal", "-shm"]) {
61
+ try {
62
+ unlinkSync(`${TEST_DB_PATH}${suffix}`);
63
+ } catch {}
64
+ }
23
65
  initDb(TEST_DB_PATH);
24
66
  leadAgent = createAgent({ name: "TestLead", isLead: true, status: "idle" });
25
67
  workerAgent = createAgent({ name: "TestWorker", isLead: false, status: "idle" });
@@ -27,19 +69,19 @@ beforeAll(() => {
27
69
 
28
70
  afterAll(() => {
29
71
  closeDb();
30
- try {
31
- unlinkSync(TEST_DB_PATH);
32
- unlinkSync(`${TEST_DB_PATH}-wal`);
33
- unlinkSync(`${TEST_DB_PATH}-shm`);
34
- } catch {
35
- // ignore
72
+ for (const suffix of ["", "-wal", "-shm"]) {
73
+ try {
74
+ unlinkSync(`${TEST_DB_PATH}${suffix}`);
75
+ } catch {
76
+ // ignore
77
+ }
36
78
  }
37
79
  });
38
80
 
39
81
  // ─── User CRUD ────────────────────────────────────────────────────────────────
40
82
 
41
83
  describe("createUser", () => {
42
- test("creates a user with required fields only", () => {
84
+ test("creates a user with required fields only — no identities yet", () => {
43
85
  const user = createUser({ name: "Alice" });
44
86
  expect(user.id).toBeDefined();
45
87
  expect(user.name).toBe("Alice");
@@ -47,49 +89,100 @@ describe("createUser", () => {
47
89
  expect(user.role).toBeUndefined();
48
90
  expect(user.emailAliases).toEqual([]);
49
91
  expect(user.preferredChannel).toBe("slack");
92
+ expect(user.status).toBe("active");
93
+ expect(user.dailyBudgetUsd).toBeNull();
50
94
  expect(user.createdAt).toBeDefined();
51
95
  expect(user.lastUpdatedAt).toBeDefined();
96
+ expect(getUserIdentities(user.id)).toEqual([]);
52
97
  });
53
98
 
54
- test("creates a user with all fields", () => {
99
+ test("links identities one-by-one via linkIdentity", () => {
55
100
  const user = createUser({
56
101
  name: "Bob",
57
102
  email: "bob@example.com",
58
103
  role: "engineer",
59
104
  notes: "Test user",
60
- slackUserId: "U_BOB",
61
- linearUserId: "lin-bob-uuid",
62
- githubUsername: "bob-gh",
63
- gitlabUsername: "bob-gl",
64
105
  emailAliases: ["bob2@example.com", "robert@example.com"],
65
106
  preferredChannel: "email",
66
107
  timezone: "America/New_York",
67
108
  });
68
109
  expect(user.name).toBe("Bob");
69
110
  expect(user.email).toBe("bob@example.com");
70
- expect(user.role).toBe("engineer");
71
- expect(user.notes).toBe("Test user");
72
- expect(user.slackUserId).toBe("U_BOB");
73
- expect(user.linearUserId).toBe("lin-bob-uuid");
74
- expect(user.githubUsername).toBe("bob-gh");
75
- expect(user.gitlabUsername).toBe("bob-gl");
76
111
  expect(user.emailAliases).toEqual(["bob2@example.com", "robert@example.com"]);
77
- expect(user.preferredChannel).toBe("email");
78
- expect(user.timezone).toBe("America/New_York");
112
+
113
+ linkIdentity(user.id, "slack", "U_BOB", SYSTEM_ACTOR);
114
+ linkIdentity(user.id, "linear", "lin-bob-uuid", SYSTEM_ACTOR);
115
+ linkIdentity(user.id, "github", "bob-gh", SYSTEM_ACTOR);
116
+ linkIdentity(user.id, "gitlab", "bob-gl", SYSTEM_ACTOR);
117
+
118
+ const ids = getUserIdentities(user.id);
119
+ expect(ids).toContainEqual({ kind: "slack", externalId: "U_BOB" });
120
+ expect(ids).toContainEqual({ kind: "linear", externalId: "lin-bob-uuid" });
121
+ expect(ids).toContainEqual({ kind: "github", externalId: "bob-gh" });
122
+ expect(ids).toContainEqual({ kind: "gitlab", externalId: "bob-gl" });
79
123
  });
80
124
 
81
- test("rejects duplicate slackUserId", () => {
82
- createUser({ name: "First", slackUserId: "U_UNIQUE" });
83
- expect(() => createUser({ name: "Second", slackUserId: "U_UNIQUE" })).toThrow();
125
+ test("supports new Phase 064 fields", () => {
126
+ const user = createUser({
127
+ name: "Budgeted",
128
+ dailyBudgetUsd: 12.5,
129
+ status: "invited",
130
+ metadata: { hint: "test" },
131
+ });
132
+ expect(user.dailyBudgetUsd).toBe(12.5);
133
+ expect(user.status).toBe("invited");
134
+ expect(user.metadata).toEqual({ hint: "test" });
135
+ });
136
+ });
137
+
138
+ describe("linkIdentity", () => {
139
+ test("rejects duplicate (kind, externalId) — PK collision", () => {
140
+ const u1 = createUser({ name: "Dup1" });
141
+ const u2 = createUser({ name: "Dup2" });
142
+ linkIdentity(u1.id, "slack", "U_DUP", SYSTEM_ACTOR);
143
+ expect(() => linkIdentity(u2.id, "slack", "U_DUP", SYSTEM_ACTOR)).toThrow();
84
144
  });
85
145
 
86
- test("rejects duplicate githubUsername", () => {
87
- createUser({ name: "GH1", githubUsername: "unique-gh" });
88
- expect(() => createUser({ name: "GH2", githubUsername: "unique-gh" })).toThrow();
146
+ test("rejects duplicate (kind, externalId) — same user, second call", () => {
147
+ const u = createUser({ name: "SelfDup" });
148
+ linkIdentity(u.id, "github", "self-dup-gh", SYSTEM_ACTOR);
149
+ expect(() => linkIdentity(u.id, "github", "self-dup-gh", SYSTEM_ACTOR)).toThrow();
150
+ });
151
+
152
+ test("emits identity_added event in the same transaction", () => {
153
+ const u = createUser({ name: "EventLink" });
154
+ linkIdentity(u.id, "slack", "U_EVENTLINK", SYSTEM_ACTOR);
155
+ const events = eventsFor(u.id);
156
+ expect(events.length).toBe(1);
157
+ expect(events[0]!.eventType).toBe("identity_added");
158
+ expect(events[0]!.actor).toBe("system:test-suite");
159
+ expect(JSON.parse(events[0]!.afterJson!)).toEqual({
160
+ kind: "slack",
161
+ externalId: "U_EVENTLINK",
162
+ });
163
+ });
164
+ });
165
+
166
+ describe("unlinkIdentity", () => {
167
+ test("removes the mapping and emits identity_removed", () => {
168
+ const u = createUser({ name: "Unlink" });
169
+ linkIdentity(u.id, "slack", "U_UNLINK", SYSTEM_ACTOR);
170
+ expect(findUserByExternalId("slack", "U_UNLINK")).not.toBeNull();
171
+
172
+ unlinkIdentity(u.id, "slack", "U_UNLINK", SYSTEM_ACTOR);
173
+ expect(findUserByExternalId("slack", "U_UNLINK")).toBeNull();
174
+
175
+ const events = eventsFor(u.id);
176
+ expect(events.map((e) => e.eventType)).toEqual(["identity_added", "identity_removed"]);
177
+ expect(JSON.parse(events[1]!.beforeJson!)).toEqual({
178
+ kind: "slack",
179
+ externalId: "U_UNLINK",
180
+ });
181
+ expect(events[1]!.afterJson).toBeNull();
89
182
  });
90
183
  });
91
184
 
92
- describe("getUserById", () => {
185
+ describe("getUserById / getUserIdentities", () => {
93
186
  test("returns user by ID", () => {
94
187
  const created = createUser({ name: "GetById" });
95
188
  const fetched = getUserById(created.id);
@@ -98,8 +191,20 @@ describe("getUserById", () => {
98
191
  expect(fetched!.id).toBe(created.id);
99
192
  });
100
193
 
101
- test("returns null for non-existent ID", () => {
102
- expect(getUserById("nonexistent")).toBeNull();
194
+ test("findUserById returns null for non-existent ID", () => {
195
+ expect(findUserById("nonexistent")).toBeNull();
196
+ });
197
+
198
+ test("getUserIdentities returns sorted (kind, externalId) tuples", () => {
199
+ const u = createUser({ name: "IdList" });
200
+ linkIdentity(u.id, "slack", "U_LIST", SYSTEM_ACTOR);
201
+ linkIdentity(u.id, "github", "list-gh", SYSTEM_ACTOR);
202
+ const list = getUserIdentities(u.id);
203
+ expect(list.length).toBe(2);
204
+ expect(list).toEqual([
205
+ { kind: "github", externalId: "list-gh" },
206
+ { kind: "slack", externalId: "U_LIST" },
207
+ ]);
103
208
  });
104
209
  });
105
210
 
@@ -126,6 +231,18 @@ describe("updateUser", () => {
126
231
  expect(updated!.emailAliases).toEqual(["alias1@test.com", "alias2@test.com"]);
127
232
  });
128
233
 
234
+ test("updates new Phase 064 fields", () => {
235
+ const user = createUser({ name: "BudgetUser" });
236
+ const updated = updateUser(user.id, {
237
+ dailyBudgetUsd: 25.0,
238
+ status: "suspended",
239
+ metadata: { reason: "test" },
240
+ });
241
+ expect(updated!.dailyBudgetUsd).toBe(25.0);
242
+ expect(updated!.status).toBe("suspended");
243
+ expect(updated!.metadata).toEqual({ reason: "test" });
244
+ });
245
+
129
246
  test("returns null for non-existent user", () => {
130
247
  expect(updateUser("nonexistent", { name: "Nope" })).toBeNull();
131
248
  });
@@ -149,8 +266,11 @@ describe("deleteUser", () => {
149
266
  expect(deleteUser("nonexistent")).toBe(false);
150
267
  });
151
268
 
152
- test("clears requestedByUserId on tasks when user is deleted", () => {
153
- const user = createUser({ name: "TaskOwner", slackUserId: "U_TASKOWNER" });
269
+ test("clears requestedByUserId on tasks AND cascades user_external_ids", () => {
270
+ const user = createUser({ name: "TaskOwner" });
271
+ linkIdentity(user.id, "slack", "U_TASKOWNER", SYSTEM_ACTOR);
272
+ expect(findUserByExternalId("slack", "U_TASKOWNER")).not.toBeNull();
273
+
154
274
  const task = createTaskExtended("test task with user", {
155
275
  agentId: workerAgent.id,
156
276
  source: "slack",
@@ -160,92 +280,242 @@ describe("deleteUser", () => {
160
280
 
161
281
  deleteUser(user.id);
162
282
  expect(getTaskById(task.id)!.requestedByUserId).toBeUndefined();
283
+ // ON DELETE CASCADE on user_external_ids.userId should clear the mapping.
284
+ expect(findUserByExternalId("slack", "U_TASKOWNER")).toBeNull();
163
285
  });
164
286
  });
165
287
 
166
- // ─── resolveUser ──────────────────────────────────────────────────────────────
288
+ // ─── findUserByExternalId ─────────────────────────────────────────────────────
167
289
 
168
- describe("resolveUser", () => {
290
+ describe("findUserByExternalId", () => {
169
291
  let testUser: ReturnType<typeof createUser>;
170
292
 
171
293
  beforeAll(() => {
172
294
  testUser = createUser({
173
295
  name: "Resolve TestUser",
174
296
  email: "resolve-test@example.com",
175
- slackUserId: "U_RESOLVE_SLACK",
176
- linearUserId: "lin-resolve-uuid",
177
- githubUsername: "resolve-gh",
178
- gitlabUsername: "resolve-gl",
179
- emailAliases: ["resolve-alias@example.com"],
180
297
  });
298
+ linkIdentity(testUser.id, "slack", "U_RESOLVE_SLACK", SYSTEM_ACTOR);
299
+ linkIdentity(testUser.id, "linear", "lin-resolve-uuid", SYSTEM_ACTOR);
300
+ linkIdentity(testUser.id, "github", "resolve-gh", SYSTEM_ACTOR);
301
+ linkIdentity(testUser.id, "gitlab", "resolve-gl", SYSTEM_ACTOR);
181
302
  });
182
303
 
183
- test("resolves by slackUserId", () => {
184
- const user = resolveUser({ slackUserId: "U_RESOLVE_SLACK" });
304
+ test("resolves by slack identity", () => {
305
+ const user = findUserByExternalId("slack", "U_RESOLVE_SLACK");
185
306
  expect(user).toBeDefined();
186
307
  expect(user!.id).toBe(testUser.id);
187
308
  });
188
309
 
189
- test("resolves by linearUserId", () => {
190
- const user = resolveUser({ linearUserId: "lin-resolve-uuid" });
191
- expect(user).toBeDefined();
310
+ test("resolves by linear identity", () => {
311
+ const user = findUserByExternalId("linear", "lin-resolve-uuid");
192
312
  expect(user!.id).toBe(testUser.id);
193
313
  });
194
314
 
195
- test("resolves by githubUsername", () => {
196
- const user = resolveUser({ githubUsername: "resolve-gh" });
197
- expect(user).toBeDefined();
315
+ test("resolves by github identity", () => {
316
+ const user = findUserByExternalId("github", "resolve-gh");
198
317
  expect(user!.id).toBe(testUser.id);
199
318
  });
200
319
 
201
- test("resolves by gitlabUsername", () => {
202
- const user = resolveUser({ gitlabUsername: "resolve-gl" });
203
- expect(user).toBeDefined();
320
+ test("resolves by gitlab identity", () => {
321
+ const user = findUserByExternalId("gitlab", "resolve-gl");
204
322
  expect(user!.id).toBe(testUser.id);
205
323
  });
206
324
 
207
- test("resolves by primary email", () => {
208
- const user = resolveUser({ email: "resolve-test@example.com" });
209
- expect(user).toBeDefined();
210
- expect(user!.id).toBe(testUser.id);
325
+ test("returns null for unknown externalId", () => {
326
+ expect(findUserByExternalId("slack", "U_NONEXISTENT")).toBeNull();
327
+ expect(findUserByExternalId("github", "no-such-account")).toBeNull();
211
328
  });
212
329
 
213
- test("resolves by email alias (case-insensitive)", () => {
214
- const user = resolveUser({ email: "RESOLVE-ALIAS@EXAMPLE.COM" });
215
- expect(user).toBeDefined();
216
- expect(user!.id).toBe(testUser.id);
330
+ test("kind is exact slack externalId does not resolve under github", () => {
331
+ expect(findUserByExternalId("github", "U_RESOLVE_SLACK")).toBeNull();
217
332
  });
333
+ });
218
334
 
219
- test("resolves by name substring (case-insensitive)", () => {
220
- const user = resolveUser({ name: "resolve testuser" });
221
- expect(user).toBeDefined();
222
- expect(user!.id).toBe(testUser.id);
223
- });
335
+ // ─── findUserByEmail ──────────────────────────────────────────────────────────
224
336
 
225
- test("returns null for no match", () => {
226
- expect(resolveUser({ slackUserId: "U_NONEXISTENT" })).toBeNull();
227
- expect(resolveUser({ email: "nobody@nowhere.com" })).toBeNull();
228
- expect(resolveUser({ name: "ZZZNoSuchPerson" })).toBeNull();
337
+ describe("findUserByEmail", () => {
338
+ test("matches primary email (case-insensitive)", () => {
339
+ const user = createUser({
340
+ name: "EmailPrimary",
341
+ email: "primary@example.com",
342
+ });
343
+ expect(findUserByEmail("primary@example.com")!.id).toBe(user.id);
344
+ expect(findUserByEmail("PRIMARY@example.com")!.id).toBe(user.id);
229
345
  });
230
346
 
231
- test("prioritizes platform ID over email", () => {
232
- // Create a second user with different slack ID
233
- const user2 = createUser({
234
- name: "PriorityUser",
235
- slackUserId: "U_OTHER_PRIORITY",
236
- });
237
- // When resolving with both slackUserId and email, slackUserId wins
238
- const resolved = resolveUser({
239
- slackUserId: "U_OTHER_PRIORITY",
240
- email: "resolve-test@example.com", // belongs to testUser
347
+ test("matches an emailAlias (case-insensitive)", () => {
348
+ const user = createUser({
349
+ name: "EmailAlias",
350
+ email: "main@example.com",
351
+ emailAliases: ["alt@example.com", "other@example.com"],
241
352
  });
242
- expect(resolved!.id).toBe(user2.id);
243
- // Cleanup
244
- deleteUser(user2.id);
353
+ expect(findUserByEmail("alt@example.com")!.id).toBe(user.id);
354
+ expect(findUserByEmail("OTHER@EXAMPLE.COM")!.id).toBe(user.id);
355
+ });
356
+
357
+ test("returns null on no match", () => {
358
+ expect(findUserByEmail("nobody@nowhere.com")).toBeNull();
359
+ });
360
+ });
361
+
362
+ // ─── findOrCreateUserByEmail ──────────────────────────────────────────────────
363
+
364
+ describe("findOrCreateUserByEmail", () => {
365
+ test("creates a fresh user + emits identity_added when no match", () => {
366
+ const result = findOrCreateUserByEmail(
367
+ "new-foc@example.com",
368
+ { name: "FocNew" },
369
+ { kind: "system", id: "webhook:test" },
370
+ );
371
+ expect(result.created).toBe(true);
372
+ expect(result.user.email).toBe("new-foc@example.com");
373
+ expect(result.user.name).toBe("FocNew");
374
+ const events = eventsFor(result.user.id);
375
+ expect(events.length).toBe(1);
376
+ expect(events[0]!.eventType).toBe("identity_added");
377
+ expect(events[0]!.actor).toBe("system:webhook:test");
378
+ });
379
+
380
+ test("returns the existing user + emits auto_merge on a second call", () => {
381
+ const first = findOrCreateUserByEmail(
382
+ "merge-foc@example.com",
383
+ { name: "FocMerge" },
384
+ SYSTEM_ACTOR,
385
+ );
386
+ expect(first.created).toBe(true);
387
+
388
+ const second = findOrCreateUserByEmail(
389
+ "merge-foc@example.com",
390
+ { name: "FocMergeRetry" },
391
+ SYSTEM_ACTOR,
392
+ );
393
+ expect(second.created).toBe(false);
394
+ expect(second.user.id).toBe(first.user.id);
395
+
396
+ const events = eventsFor(first.user.id);
397
+ // identity_added (initial) + auto_merge (second call) = 2 events.
398
+ expect(events.map((e) => e.eventType)).toEqual(["identity_added", "auto_merge"]);
399
+ });
400
+
401
+ test("falls back to email local-part when no name hint provided", () => {
402
+ const result = findOrCreateUserByEmail("auto-name@example.com", {}, SYSTEM_ACTOR);
403
+ expect(result.created).toBe(true);
404
+ expect(result.user.name).toBe("auto-name");
405
+ });
406
+ });
407
+
408
+ // ─── Tokens ───────────────────────────────────────────────────────────────────
409
+
410
+ describe("mintToken / revokeToken / resolveUserByToken", () => {
411
+ test("mintToken returns aswt_-prefixed plaintext and stores hash + 4-char preview", () => {
412
+ const user = createUser({ name: "TokenUser" });
413
+ const { tokenId, plaintext } = mintToken(user.id, "CI test", OPERATOR_ACTOR);
414
+
415
+ expect(plaintext.startsWith("aswt_")).toBe(true);
416
+ expect(plaintext.length).toBeGreaterThanOrEqual(25);
417
+ expect(tokenId).toBeDefined();
418
+
419
+ // Stored row should have hash != plaintext and preview = last 4 chars.
420
+ const row = getDb()
421
+ .prepare<{ tokenHash: string; tokenPreview: string }, string>(
422
+ "SELECT tokenHash, tokenPreview FROM user_tokens WHERE id = ?",
423
+ )
424
+ .get(tokenId);
425
+ expect(row).toBeDefined();
426
+ expect(row!.tokenHash).not.toBe(plaintext);
427
+ expect(row!.tokenHash.length).toBe(64); // sha256 hex
428
+ expect(row!.tokenPreview).toBe(plaintext.slice(-4));
429
+
430
+ // token_minted event landed with operator actor.
431
+ const events = eventsFor(user.id);
432
+ expect(events.find((e) => e.eventType === "token_minted")).toBeDefined();
433
+ expect(events.find((e) => e.eventType === "token_minted")!.actor).toBe(
434
+ "operator:op:0000000000000000",
435
+ );
436
+ });
437
+
438
+ test("resolveUserByToken returns the owning user and bumps lastUsedAt", () => {
439
+ const user = createUser({ name: "ResolveTokenUser" });
440
+ const { tokenId, plaintext } = mintToken(user.id, null, OPERATOR_ACTOR);
441
+
442
+ const resolved = resolveUserByToken(plaintext);
443
+ expect(resolved).not.toBeNull();
444
+ expect(resolved!.id).toBe(user.id);
445
+
446
+ const row = getDb()
447
+ .prepare<{ lastUsedAt: string | null }, string>(
448
+ "SELECT lastUsedAt FROM user_tokens WHERE id = ?",
449
+ )
450
+ .get(tokenId);
451
+ expect(row!.lastUsedAt).not.toBeNull();
452
+ });
453
+
454
+ test("revokeToken sets revokedAt + emits token_revoked + resolveUserByToken returns null", () => {
455
+ const user = createUser({ name: "RevokeUser" });
456
+ const { tokenId, plaintext } = mintToken(user.id, "to-revoke", OPERATOR_ACTOR);
457
+ revokeToken(tokenId, OPERATOR_ACTOR);
458
+
459
+ const row = getDb()
460
+ .prepare<{ revokedAt: string | null }, string>(
461
+ "SELECT revokedAt FROM user_tokens WHERE id = ?",
462
+ )
463
+ .get(tokenId);
464
+ expect(row!.revokedAt).not.toBeNull();
465
+
466
+ expect(resolveUserByToken(plaintext)).toBeNull();
467
+
468
+ const events = eventsFor(user.id);
469
+ expect(events.map((e) => e.eventType)).toContain("token_revoked");
470
+ });
471
+
472
+ test("resolveUserByToken returns null for unknown plaintext", () => {
473
+ expect(resolveUserByToken("aswt_unknown000000000000000000000")).toBeNull();
474
+ });
475
+ });
476
+
477
+ // ─── fingerprintApiKey ────────────────────────────────────────────────────────
478
+
479
+ describe("fingerprintApiKey", () => {
480
+ test("returns op:<sha256-16-hex> format", () => {
481
+ expect(fingerprintApiKey("some-key")).toMatch(/^op:[0-9a-f]{16}$/);
482
+ expect(fingerprintApiKey("")).toMatch(/^op:[0-9a-f]{16}$/);
483
+ });
484
+
485
+ test("is deterministic", () => {
486
+ expect(fingerprintApiKey("same-input")).toBe(fingerprintApiKey("same-input"));
487
+ });
488
+ });
489
+
490
+ // ─── recordIdentityEvent (direct API) ─────────────────────────────────────────
491
+
492
+ describe("recordIdentityEvent", () => {
493
+ test("can emit budget_changed / status_changed / email_* directly", () => {
494
+ const user = createUser({ name: "EventDirect" });
495
+ recordIdentityEvent(user.id, "budget_changed", OPERATOR_ACTOR, null, { dailyBudgetUsd: 10 });
496
+ recordIdentityEvent(
497
+ user.id,
498
+ "status_changed",
499
+ OPERATOR_ACTOR,
500
+ { status: "active" },
501
+ {
502
+ status: "suspended",
503
+ },
504
+ );
505
+ recordIdentityEvent(user.id, "email_added", OPERATOR_ACTOR, null, { email: "x@y.com" });
506
+ recordIdentityEvent(user.id, "email_removed", OPERATOR_ACTOR, { email: "x@y.com" }, null);
507
+
508
+ const events = eventsFor(user.id);
509
+ expect(events.map((e) => e.eventType)).toEqual([
510
+ "budget_changed",
511
+ "status_changed",
512
+ "email_added",
513
+ "email_removed",
514
+ ]);
245
515
  });
246
516
  });
247
517
 
248
- // ─── requestedByUserId on tasks ─────────────────────────────────────────────
518
+ // ─── requestedByUserId on tasks ───────────────────────────────────────────────
249
519
 
250
520
  describe("requestedByUserId in tasks", () => {
251
521
  test("createTaskExtended stores requestedByUserId", () => {