@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
package/src/be/db.ts CHANGED
@@ -8733,13 +8733,13 @@ type UserRow = {
8733
8733
  email: string | null;
8734
8734
  role: string | null;
8735
8735
  notes: string | null;
8736
- slackUserId: string | null;
8737
- linearUserId: string | null;
8738
- githubUsername: string | null;
8739
- gitlabUsername: string | null;
8740
8736
  emailAliases: string | null;
8741
8737
  preferredChannel: string | null;
8742
8738
  timezone: string | null;
8739
+ // Phase 064 columns
8740
+ metadata: string | null;
8741
+ dailyBudgetUsd: number | null;
8742
+ status: string;
8743
8743
  createdAt: string;
8744
8744
  lastUpdatedAt: string;
8745
8745
  };
@@ -8751,86 +8751,17 @@ function rowToUser(row: UserRow): User {
8751
8751
  email: row.email ?? undefined,
8752
8752
  role: row.role ?? undefined,
8753
8753
  notes: row.notes ?? undefined,
8754
- slackUserId: row.slackUserId ?? undefined,
8755
- linearUserId: row.linearUserId ?? undefined,
8756
- githubUsername: row.githubUsername ?? undefined,
8757
- gitlabUsername: row.gitlabUsername ?? undefined,
8758
8754
  emailAliases: row.emailAliases ? JSON.parse(row.emailAliases) : [],
8759
8755
  preferredChannel: row.preferredChannel ?? "slack",
8760
8756
  timezone: row.timezone ?? undefined,
8757
+ metadata: row.metadata ? (JSON.parse(row.metadata) as Record<string, unknown>) : undefined,
8758
+ dailyBudgetUsd: row.dailyBudgetUsd ?? null,
8759
+ status: (row.status as "invited" | "active" | "suspended") ?? "active",
8761
8760
  createdAt: row.createdAt,
8762
8761
  lastUpdatedAt: row.lastUpdatedAt,
8763
8762
  };
8764
8763
  }
8765
8764
 
8766
- /**
8767
- * Resolve a user by any platform-specific identifier.
8768
- * Priority: exact match on platform ID, then email (including aliases), then name substring.
8769
- */
8770
- export function resolveUser(opts: {
8771
- slackUserId?: string;
8772
- linearUserId?: string;
8773
- githubUsername?: string;
8774
- gitlabUsername?: string;
8775
- email?: string;
8776
- name?: string;
8777
- }): User | null {
8778
- const db = getDb();
8779
-
8780
- // Try exact platform ID matches first
8781
- if (opts.slackUserId) {
8782
- const row = db
8783
- .prepare<UserRow, string>("SELECT * FROM users WHERE slackUserId = ?")
8784
- .get(opts.slackUserId);
8785
- if (row) return rowToUser(row);
8786
- }
8787
- if (opts.linearUserId) {
8788
- const row = db
8789
- .prepare<UserRow, string>("SELECT * FROM users WHERE linearUserId = ?")
8790
- .get(opts.linearUserId);
8791
- if (row) return rowToUser(row);
8792
- }
8793
- if (opts.githubUsername) {
8794
- const row = db
8795
- .prepare<UserRow, string>("SELECT * FROM users WHERE githubUsername = ?")
8796
- .get(opts.githubUsername);
8797
- if (row) return rowToUser(row);
8798
- }
8799
- if (opts.gitlabUsername) {
8800
- const row = db
8801
- .prepare<UserRow, string>("SELECT * FROM users WHERE gitlabUsername = ?")
8802
- .get(opts.gitlabUsername);
8803
- if (row) return rowToUser(row);
8804
- }
8805
-
8806
- // Try email match (primary email)
8807
- if (opts.email) {
8808
- const row = db.prepare<UserRow, string>("SELECT * FROM users WHERE email = ?").get(opts.email);
8809
- if (row) return rowToUser(row);
8810
-
8811
- // Check emailAliases (JSON array search)
8812
- const aliasRows = db
8813
- .prepare<UserRow, []>("SELECT * FROM users WHERE emailAliases != '[]'")
8814
- .all();
8815
- for (const r of aliasRows) {
8816
- const aliases: string[] = r.emailAliases ? JSON.parse(r.emailAliases) : [];
8817
- if (aliases.some((a) => a.toLowerCase() === opts.email!.toLowerCase())) {
8818
- return rowToUser(r);
8819
- }
8820
- }
8821
- }
8822
-
8823
- // Try name substring match (case-insensitive)
8824
- if (opts.name) {
8825
- const row = db
8826
- .prepare<UserRow, string>("SELECT * FROM users WHERE LOWER(name) LIKE '%' || LOWER(?) || '%'")
8827
- .get(opts.name);
8828
- if (row) return rowToUser(row);
8829
- }
8830
-
8831
- return null;
8832
- }
8833
-
8834
8765
  export function getUserById(id: string): User | null {
8835
8766
  const row = getDb().prepare<UserRow, string>("SELECT * FROM users WHERE id = ?").get(id);
8836
8767
  return row ? rowToUser(row) : null;
@@ -8845,20 +8776,19 @@ export function createUser(data: {
8845
8776
  email?: string;
8846
8777
  role?: string;
8847
8778
  notes?: string;
8848
- slackUserId?: string;
8849
- linearUserId?: string;
8850
- githubUsername?: string;
8851
- gitlabUsername?: string;
8852
8779
  emailAliases?: string[];
8853
8780
  preferredChannel?: string;
8854
8781
  timezone?: string;
8782
+ metadata?: Record<string, unknown>;
8783
+ dailyBudgetUsd?: number | null;
8784
+ status?: "invited" | "active" | "suspended";
8855
8785
  }): User {
8856
8786
  const id = crypto.randomUUID().replace(/-/g, "");
8857
8787
  const now = new Date().toISOString();
8858
8788
  const row = getDb()
8859
- .prepare<UserRow, (string | null)[]>(
8860
- `INSERT INTO users (id, name, email, role, notes, slackUserId, linearUserId, githubUsername, gitlabUsername, emailAliases, preferredChannel, timezone, createdAt, lastUpdatedAt)
8861
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
8789
+ .prepare<UserRow, (string | number | null)[]>(
8790
+ `INSERT INTO users (id, name, email, role, notes, emailAliases, preferredChannel, timezone, metadata, dailyBudgetUsd, status, createdAt, lastUpdatedAt)
8791
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
8862
8792
  )
8863
8793
  .get(
8864
8794
  id,
@@ -8866,13 +8796,12 @@ export function createUser(data: {
8866
8796
  data.email ?? null,
8867
8797
  data.role ?? null,
8868
8798
  data.notes ?? null,
8869
- data.slackUserId ?? null,
8870
- data.linearUserId ?? null,
8871
- data.githubUsername ?? null,
8872
- data.gitlabUsername ?? null,
8873
8799
  JSON.stringify(data.emailAliases ?? []),
8874
8800
  data.preferredChannel ?? "slack",
8875
8801
  data.timezone ?? null,
8802
+ data.metadata !== undefined ? JSON.stringify(data.metadata) : null,
8803
+ data.dailyBudgetUsd ?? null,
8804
+ data.status ?? "active",
8876
8805
  now,
8877
8806
  now,
8878
8807
  );
@@ -8887,17 +8816,16 @@ export function updateUser(
8887
8816
  email: string;
8888
8817
  role: string;
8889
8818
  notes: string;
8890
- slackUserId: string;
8891
- linearUserId: string;
8892
- githubUsername: string;
8893
- gitlabUsername: string;
8894
8819
  emailAliases: string[];
8895
8820
  preferredChannel: string;
8896
8821
  timezone: string;
8822
+ metadata: Record<string, unknown> | null;
8823
+ dailyBudgetUsd: number | null;
8824
+ status: "invited" | "active" | "suspended";
8897
8825
  }>,
8898
8826
  ): User | null {
8899
8827
  const sets: string[] = [];
8900
- const params: (string | null)[] = [];
8828
+ const params: (string | number | null)[] = [];
8901
8829
 
8902
8830
  if (data.name !== undefined) {
8903
8831
  sets.push("name = ?");
@@ -8915,22 +8843,6 @@ export function updateUser(
8915
8843
  sets.push("notes = ?");
8916
8844
  params.push(data.notes);
8917
8845
  }
8918
- if (data.slackUserId !== undefined) {
8919
- sets.push("slackUserId = ?");
8920
- params.push(data.slackUserId);
8921
- }
8922
- if (data.linearUserId !== undefined) {
8923
- sets.push("linearUserId = ?");
8924
- params.push(data.linearUserId);
8925
- }
8926
- if (data.githubUsername !== undefined) {
8927
- sets.push("githubUsername = ?");
8928
- params.push(data.githubUsername);
8929
- }
8930
- if (data.gitlabUsername !== undefined) {
8931
- sets.push("gitlabUsername = ?");
8932
- params.push(data.gitlabUsername);
8933
- }
8934
8846
  if (data.emailAliases !== undefined) {
8935
8847
  sets.push("emailAliases = ?");
8936
8848
  params.push(JSON.stringify(data.emailAliases));
@@ -8943,6 +8855,18 @@ export function updateUser(
8943
8855
  sets.push("timezone = ?");
8944
8856
  params.push(data.timezone);
8945
8857
  }
8858
+ if (data.metadata !== undefined) {
8859
+ sets.push("metadata = ?");
8860
+ params.push(data.metadata === null ? null : JSON.stringify(data.metadata));
8861
+ }
8862
+ if (data.dailyBudgetUsd !== undefined) {
8863
+ sets.push("dailyBudgetUsd = ?");
8864
+ params.push(data.dailyBudgetUsd);
8865
+ }
8866
+ if (data.status !== undefined) {
8867
+ sets.push("status = ?");
8868
+ params.push(data.status);
8869
+ }
8946
8870
 
8947
8871
  if (sets.length === 0) return getUserById(id);
8948
8872
 
@@ -8951,7 +8875,7 @@ export function updateUser(
8951
8875
  params.push(id);
8952
8876
 
8953
8877
  const row = getDb()
8954
- .prepare<UserRow, (string | null)[]>(
8878
+ .prepare<UserRow, (string | number | null)[]>(
8955
8879
  `UPDATE users SET ${sets.join(", ")} WHERE id = ? RETURNING *`,
8956
8880
  )
8957
8881
  .get(...params);
@@ -0,0 +1,185 @@
1
+ -- 064_users_first_class.sql
2
+ -- "Humans as first-class users" — foundation migration.
3
+ --
4
+ -- Normalizes platform identities into a join table and lands the supporting
5
+ -- tables/columns the rest of the refactor (plan: 2026-05-18-users-first-class-refactor)
6
+ -- needs. The four previously-denormalized identity columns on `users`
7
+ -- (slackUserId / linearUserId / githubUsername / gitlabUsername — added in
8
+ -- migration 031) are backfilled into `user_external_ids` and then dropped.
9
+ --
10
+ -- Q-research refs (brainstorm 2026-05-18-humans-as-first-class-users):
11
+ -- * Q8 — `user_external_ids` schema (PK = (kind, externalId))
12
+ -- * Q15 — no-soak, same-PR DROP COLUMNs
13
+ -- * Q17.D — `unmapped` integration kv shape lives in kv_entries (no schema needed)
14
+ -- * Q19 — full event-type CHECK enum (10 types)
15
+ -- * Q20 — `user_tokens.tokenPreview` (last 4 chars of plaintext)
16
+ --
17
+ -- ORDER MATTERS:
18
+ -- 1) DDL: new tables + new user columns (no FKs from drop targets)
19
+ -- 2) Backfill: copy the four identity columns into user_external_ids
20
+ -- 3) DROP COLUMN: remove the four identity columns from users
21
+ --
22
+ -- SQLite drops dependent UNIQUE indexes automatically when their parent
23
+ -- column is dropped (verified in CI by Automated Verification §1 in step-1).
24
+
25
+ -- ---------------------------------------------------------------------------
26
+ -- 1. user_external_ids — canonical join table for platform identities.
27
+ -- ---------------------------------------------------------------------------
28
+ --
29
+ -- PK is (kind, externalId): one external account maps to at most one swarm
30
+ -- user. Multiple identities of the same kind (e.g. two Slack workspaces) are
31
+ -- handled by the externalId prefix being workspace-scoped at the caller.
32
+ --
33
+ -- userId FK has ON DELETE CASCADE so removing a user cleans up their identity
34
+ -- mappings without manual fan-out. Mirrors `user_tokens.userId` behaviour.
35
+
36
+ CREATE TABLE IF NOT EXISTS user_external_ids (
37
+ kind TEXT NOT NULL,
38
+ externalId TEXT NOT NULL,
39
+ userId TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
40
+ createdAt TEXT NOT NULL DEFAULT (datetime('now')),
41
+ PRIMARY KEY (kind, externalId)
42
+ );
43
+
44
+ CREATE INDEX IF NOT EXISTS idx_user_external_ids_userId
45
+ ON user_external_ids(userId);
46
+
47
+ -- ---------------------------------------------------------------------------
48
+ -- 2. users — new columns.
49
+ -- ---------------------------------------------------------------------------
50
+ -- metadata : free-form JSON (operator notes, integration hints, …)
51
+ -- dailyBudgetUsd : NULL = unlimited, REAL value = soft cap in USD/day
52
+ -- status : invited → active → suspended (operator lifecycle)
53
+
54
+ ALTER TABLE users ADD COLUMN metadata TEXT;
55
+ ALTER TABLE users ADD COLUMN dailyBudgetUsd REAL;
56
+ ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'active'
57
+ CHECK (status IN ('invited', 'active', 'suspended'));
58
+
59
+ -- ---------------------------------------------------------------------------
60
+ -- 3. user_tokens — schema lands here (Q20). Mint/revoke endpoints + UI dialog
61
+ -- ship with a separate MCP-token plan; this table is created so the scrubber
62
+ -- rule + future endpoints have a place to write.
63
+ -- ---------------------------------------------------------------------------
64
+ -- tokenHash : sha256 of the plaintext (only thing we can search by).
65
+ -- tokenPreview : last 4 chars of the plaintext for UI display ("…ax7b").
66
+
67
+ CREATE TABLE IF NOT EXISTS user_tokens (
68
+ id TEXT PRIMARY KEY,
69
+ userId TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
70
+ label TEXT,
71
+ tokenHash TEXT NOT NULL UNIQUE,
72
+ tokenPreview TEXT NOT NULL,
73
+ createdAt TEXT NOT NULL DEFAULT (datetime('now')),
74
+ lastUsedAt TEXT,
75
+ revokedAt TEXT
76
+ );
77
+
78
+ CREATE INDEX IF NOT EXISTS idx_user_tokens_userId
79
+ ON user_tokens(userId);
80
+
81
+ -- ---------------------------------------------------------------------------
82
+ -- 4. user_identity_events — append-only audit log of identity mutations.
83
+ -- ---------------------------------------------------------------------------
84
+ -- CHECK enum mirrors `IdentityEventTypeSchema` in src/types.ts (Q19).
85
+ -- Keep these in lockstep — drift breaks helper INSERTs at runtime.
86
+ --
87
+ -- actor : free-form identifier of the caller, e.g.
88
+ -- 'system:slack-webhook' — automatic webhook path
89
+ -- 'op:<sha256-16>' — operator (fingerprintApiKey output)
90
+ -- 'user:<userId>' — end-user action (future MCP token)
91
+
92
+ CREATE TABLE IF NOT EXISTS user_identity_events (
93
+ id TEXT PRIMARY KEY,
94
+ userId TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
95
+ eventType TEXT NOT NULL CHECK (eventType IN (
96
+ 'auto_merge',
97
+ 'manual_merge',
98
+ 'identity_added',
99
+ 'identity_removed',
100
+ 'email_added',
101
+ 'email_removed',
102
+ 'token_minted',
103
+ 'token_revoked',
104
+ 'budget_changed',
105
+ 'status_changed'
106
+ )),
107
+ actor TEXT NOT NULL,
108
+ beforeJson TEXT,
109
+ afterJson TEXT,
110
+ createdAt TEXT NOT NULL DEFAULT (datetime('now'))
111
+ );
112
+
113
+ CREATE INDEX IF NOT EXISTS idx_user_identity_events_userId_createdAt
114
+ ON user_identity_events(userId, createdAt DESC);
115
+
116
+ -- ---------------------------------------------------------------------------
117
+ -- 5. Backfill: copy the four existing identity columns into user_external_ids.
118
+ -- Must run BEFORE the DROP COLUMNs below.
119
+ -- ---------------------------------------------------------------------------
120
+
121
+ INSERT OR IGNORE INTO user_external_ids (userId, kind, externalId)
122
+ SELECT id, 'slack', slackUserId FROM users WHERE slackUserId IS NOT NULL
123
+ UNION ALL
124
+ SELECT id, 'linear', linearUserId FROM users WHERE linearUserId IS NOT NULL
125
+ UNION ALL
126
+ SELECT id, 'github', githubUsername FROM users WHERE githubUsername IS NOT NULL
127
+ UNION ALL
128
+ SELECT id, 'gitlab', gitlabUsername FROM users WHERE gitlabUsername IS NOT NULL;
129
+
130
+ -- ---------------------------------------------------------------------------
131
+ -- 6. Drop the four deprecated identity columns.
132
+ --
133
+ -- Migration 031 declared each of these with inline `UNIQUE` on the column
134
+ -- itself (separate from the partial unique indexes). SQLite refuses to
135
+ -- `DROP COLUMN` when an inline UNIQUE constraint is attached, so we do the
136
+ -- standard create-new / copy / drop / rename dance — same pattern as
137
+ -- migration 063's `pricing` and `session_costs` rewrites.
138
+ --
139
+ -- We also explicitly drop the four partial unique indexes (`idx_users_*`)
140
+ -- first; SQLite would auto-drop them with the table swap, but listing them
141
+ -- here keeps the intent obvious.
142
+ -- ---------------------------------------------------------------------------
143
+
144
+ DROP INDEX IF EXISTS idx_users_slack;
145
+ DROP INDEX IF EXISTS idx_users_linear;
146
+ DROP INDEX IF EXISTS idx_users_github;
147
+ DROP INDEX IF EXISTS idx_users_gitlab;
148
+
149
+ CREATE TABLE users_new (
150
+ id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
151
+ name TEXT NOT NULL,
152
+ email TEXT,
153
+ role TEXT,
154
+ notes TEXT,
155
+ emailAliases TEXT DEFAULT '[]',
156
+ preferredChannel TEXT DEFAULT 'slack',
157
+ timezone TEXT,
158
+ metadata TEXT,
159
+ dailyBudgetUsd REAL,
160
+ status TEXT NOT NULL DEFAULT 'active'
161
+ CHECK (status IN ('invited', 'active', 'suspended')),
162
+ createdAt TEXT NOT NULL DEFAULT (datetime('now')),
163
+ lastUpdatedAt TEXT NOT NULL DEFAULT (datetime('now'))
164
+ );
165
+
166
+ INSERT INTO users_new (
167
+ id, name, email, role, notes,
168
+ emailAliases, preferredChannel, timezone,
169
+ metadata, dailyBudgetUsd, status,
170
+ createdAt, lastUpdatedAt
171
+ )
172
+ SELECT
173
+ id, name, email, role, notes,
174
+ emailAliases, preferredChannel, timezone,
175
+ metadata, dailyBudgetUsd, status,
176
+ createdAt, lastUpdatedAt
177
+ FROM users;
178
+
179
+ DROP TABLE users;
180
+ ALTER TABLE users_new RENAME TO users;
181
+
182
+ -- Recreate the email index that migration 031 set up (still relevant —
183
+ -- aliases-lookup goes through the JSON path).
184
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email
185
+ ON users(email) WHERE email IS NOT NULL;
@@ -0,0 +1,56 @@
1
+ -- 065_profile_changed_event_type.sql
2
+ -- Widen the CHECK constraint on `user_identity_events.eventType` to include
3
+ -- `profile_changed` — emitted by PATCH /api/users/:id for non-budget,
4
+ -- non-status, non-identity, non-email-alias field edits (name, email, role,
5
+ -- emailAliases-as-a-whole already covered by email_added/removed, timezone,
6
+ -- preferredChannel, notes, metadata).
7
+ --
8
+ -- SQLite cannot ALTER a CHECK constraint in place — we follow the table-rebuild
9
+ -- recipe from migration 056_drop_agent_tasks_source_check.sql verbatim:
10
+ -- 1) PRAGMA foreign_keys=off
11
+ -- 2) CREATE TABLE user_identity_events_new (… new CHECK …)
12
+ -- 3) INSERT … SELECT (explicit column list)
13
+ -- 4) DROP old, RENAME new
14
+ -- 5) Recreate the (userId, createdAt DESC) index from migration 064
15
+ -- 6) PRAGMA foreign_keys=on
16
+ --
17
+ -- Keep `IdentityEventTypeSchema` in src/types.ts in lockstep with this CHECK.
18
+
19
+ PRAGMA foreign_keys=off;
20
+
21
+ CREATE TABLE user_identity_events_new (
22
+ id TEXT PRIMARY KEY,
23
+ userId TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
24
+ eventType TEXT NOT NULL CHECK (eventType IN (
25
+ 'auto_merge',
26
+ 'manual_merge',
27
+ 'identity_added',
28
+ 'identity_removed',
29
+ 'email_added',
30
+ 'email_removed',
31
+ 'token_minted',
32
+ 'token_revoked',
33
+ 'budget_changed',
34
+ 'status_changed',
35
+ 'profile_changed'
36
+ )),
37
+ actor TEXT NOT NULL,
38
+ beforeJson TEXT,
39
+ afterJson TEXT,
40
+ createdAt TEXT NOT NULL DEFAULT (datetime('now'))
41
+ );
42
+
43
+ INSERT INTO user_identity_events_new (
44
+ id, userId, eventType, actor, beforeJson, afterJson, createdAt
45
+ )
46
+ SELECT
47
+ id, userId, eventType, actor, beforeJson, afterJson, createdAt
48
+ FROM user_identity_events;
49
+
50
+ DROP TABLE user_identity_events;
51
+ ALTER TABLE user_identity_events_new RENAME TO user_identity_events;
52
+
53
+ CREATE INDEX IF NOT EXISTS idx_user_identity_events_userId_createdAt
54
+ ON user_identity_events(userId, createdAt DESC);
55
+
56
+ PRAGMA foreign_keys=on;
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Kv-backed tracker for external identities that hit a webhook handler but
3
+ * couldn't be auto-linked to a `users` row (no email available + no existing
4
+ * mapping in `user_external_ids`). Operators triage these via the People-page
5
+ * Unmapped tab (Q17.D / Q14).
6
+ *
7
+ * Storage layout — two rows per `(kind, externalId)`:
8
+ * * `<externalId>:meta` → JSON `UnmappedMeta`, upserted on each sighting
9
+ * with a refreshed 30-day TTL.
10
+ * * `<externalId>:count` → integer, atomically incremented via `incrKv`.
11
+ *
12
+ * Namespace shape: `integration:unmapped:<kind>` (e.g. `integration:unmapped:slack`).
13
+ *
14
+ * TTL: The `:meta` row carries the canonical 30-day expiry, refreshed on every
15
+ * sighting. The `:count` row inherits the same TTL when first minted; the
16
+ * operator UI reads `:meta` rows and joins to `:count`, so a stale `:count`
17
+ * without a matching `:meta` is treated as gone (and naturally expires after
18
+ * 30 days of inactivity).
19
+ *
20
+ * API-side only — uses `getDb`-backed helpers via `src/be/db`. The DB-boundary
21
+ * checker enforces that worker-side paths don't import this file.
22
+ */
23
+
24
+ import { getKv, incrKv, upsertKv } from "./db";
25
+
26
+ const TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
27
+
28
+ /** Metadata stored under `<externalId>:meta`. */
29
+ export interface UnmappedMeta {
30
+ /** ISO timestamp of the most recent sighting. */
31
+ lastSeenAt: string;
32
+ /** Coarse event-type label (e.g. `message`, `assistant_message`, `block_actions`). */
33
+ sampleEventType: string;
34
+ /** ≤100 char excerpt of the trigger payload so operators have triage context. */
35
+ sampleContext: string;
36
+ }
37
+
38
+ function namespace(kind: string): string {
39
+ return `integration:unmapped:${kind}`;
40
+ }
41
+
42
+ /**
43
+ * Record one sighting of an unmapped external identity. Emits exactly two
44
+ * kv writes:
45
+ * 1. `<externalId>:meta` — JSON upsert with a refreshed 30-day TTL.
46
+ * 2. `<externalId>:count` — atomic integer increment. On the first sighting
47
+ * the counter row is created without a TTL by `incrKv`, so we patch it
48
+ * to inherit the 30-day window. Concurrent increments are safe — the
49
+ * patch is a no-op once the row already has a TTL.
50
+ *
51
+ * The writes are NOT bundled in a single transaction — the tracker is
52
+ * best-effort audit, not a primary store. A partial failure is acceptable;
53
+ * the next sighting reconciles.
54
+ */
55
+ export function recordUnmappedIdentity(
56
+ kind: string,
57
+ externalId: string,
58
+ meta: { sampleEventType: string; sampleContext: string },
59
+ ): void {
60
+ const ns = namespace(kind);
61
+ const now = new Date().toISOString();
62
+ const expiresAt = Date.now() + TTL_MS;
63
+ const countKey = `${externalId}:count`;
64
+
65
+ // Snapshot count-row existence BEFORE incrementing so we know whether to
66
+ // patch the TTL. Reads are race-tolerant: worst case two callers both see
67
+ // "no row" and both patch — the second patch is idempotent.
68
+ const countBefore = getKv(ns, countKey);
69
+
70
+ upsertKv({
71
+ namespace: ns,
72
+ key: `${externalId}:meta`,
73
+ value: {
74
+ lastSeenAt: now,
75
+ sampleEventType: meta.sampleEventType,
76
+ sampleContext: meta.sampleContext.slice(0, 100),
77
+ } satisfies UnmappedMeta,
78
+ valueType: "json",
79
+ expiresAt,
80
+ });
81
+
82
+ const incremented = incrKv(ns, countKey, 1);
83
+
84
+ // First-mint TTL patch. Only patch when:
85
+ // * The pre-incr snapshot showed no row (or expired)
86
+ // * AND the post-incr value is the count we just established
87
+ // Reading the post-incr value back keeps us from clobbering a concurrent
88
+ // increment that already bumped past 1.
89
+ if (countBefore === null) {
90
+ upsertKv({
91
+ namespace: ns,
92
+ key: countKey,
93
+ value: Number(incremented.value),
94
+ valueType: "integer",
95
+ expiresAt,
96
+ });
97
+ }
98
+ }