@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/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,
|
|
8861
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
|
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
|
+
}
|