auggy 0.3.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 (121) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/LICENSE +201 -0
  3. package/README.md +161 -0
  4. package/package.json +76 -0
  5. package/src/agent-card.ts +39 -0
  6. package/src/agent.ts +283 -0
  7. package/src/agentmail-client.ts +138 -0
  8. package/src/augments/bash/index.ts +463 -0
  9. package/src/augments/bash/skill/SKILL.md +156 -0
  10. package/src/augments/budgets/budget-store.ts +513 -0
  11. package/src/augments/budgets/index.ts +134 -0
  12. package/src/augments/budgets/preamble.ts +93 -0
  13. package/src/augments/budgets/types.ts +89 -0
  14. package/src/augments/file-memory/index.ts +71 -0
  15. package/src/augments/filesystem/index.ts +533 -0
  16. package/src/augments/filesystem/skill/SKILL.md +142 -0
  17. package/src/augments/filesystem/skill/references/mount-permissions.md +81 -0
  18. package/src/augments/layered-memory/extractor/buffer.ts +56 -0
  19. package/src/augments/layered-memory/extractor/frequency.ts +79 -0
  20. package/src/augments/layered-memory/extractor/inject-handler.ts +103 -0
  21. package/src/augments/layered-memory/extractor/parse.ts +75 -0
  22. package/src/augments/layered-memory/extractor/prompt.md +26 -0
  23. package/src/augments/layered-memory/index.ts +757 -0
  24. package/src/augments/layered-memory/skill/SKILL.md +153 -0
  25. package/src/augments/layered-memory/storage/migrations/README.md +16 -0
  26. package/src/augments/layered-memory/storage/migrations/supabase-add-fact-fields.sql +9 -0
  27. package/src/augments/layered-memory/storage/sqlite-store.ts +352 -0
  28. package/src/augments/layered-memory/storage/supabase-store.ts +263 -0
  29. package/src/augments/layered-memory/storage/types.ts +98 -0
  30. package/src/augments/link/index.ts +489 -0
  31. package/src/augments/link/translate.ts +261 -0
  32. package/src/augments/notify/adapters/agentmail.ts +70 -0
  33. package/src/augments/notify/adapters/telegram.ts +60 -0
  34. package/src/augments/notify/adapters/webhook.ts +55 -0
  35. package/src/augments/notify/index.ts +284 -0
  36. package/src/augments/notify/skill/SKILL.md +150 -0
  37. package/src/augments/org-context/index.ts +721 -0
  38. package/src/augments/org-context/skill/SKILL.md +96 -0
  39. package/src/augments/skills/index.ts +103 -0
  40. package/src/augments/supabase-memory/index.ts +151 -0
  41. package/src/augments/telegram-transport/index.ts +312 -0
  42. package/src/augments/telegram-transport/polling.ts +55 -0
  43. package/src/augments/telegram-transport/webhook.ts +56 -0
  44. package/src/augments/turn-control/index.ts +61 -0
  45. package/src/augments/turn-control/skill/SKILL.md +155 -0
  46. package/src/augments/visitor-auth/email-validation.ts +66 -0
  47. package/src/augments/visitor-auth/index.ts +779 -0
  48. package/src/augments/visitor-auth/rate-limiter.ts +90 -0
  49. package/src/augments/visitor-auth/skill/SKILL.md +55 -0
  50. package/src/augments/visitor-auth/storage/sqlite-store.ts +398 -0
  51. package/src/augments/visitor-auth/storage/types.ts +164 -0
  52. package/src/augments/visitor-auth/types.ts +123 -0
  53. package/src/augments/visitor-auth/verify-page.ts +179 -0
  54. package/src/augments/web-fetch/index.ts +331 -0
  55. package/src/augments/web-fetch/skill/SKILL.md +100 -0
  56. package/src/cli/agent-index.ts +289 -0
  57. package/src/cli/augment-catalog.ts +320 -0
  58. package/src/cli/augment-resolver.ts +597 -0
  59. package/src/cli/commands/add-skill.ts +194 -0
  60. package/src/cli/commands/add.ts +87 -0
  61. package/src/cli/commands/chat.ts +207 -0
  62. package/src/cli/commands/create.ts +462 -0
  63. package/src/cli/commands/dev.ts +139 -0
  64. package/src/cli/commands/eval.ts +180 -0
  65. package/src/cli/commands/ls.ts +66 -0
  66. package/src/cli/commands/remove.ts +95 -0
  67. package/src/cli/commands/restart.ts +40 -0
  68. package/src/cli/commands/start.ts +123 -0
  69. package/src/cli/commands/status.ts +104 -0
  70. package/src/cli/commands/stop.ts +84 -0
  71. package/src/cli/commands/visitors-revoke.ts +155 -0
  72. package/src/cli/commands/visitors.ts +101 -0
  73. package/src/cli/config-parser.ts +1034 -0
  74. package/src/cli/engine-resolver.ts +68 -0
  75. package/src/cli/index.ts +178 -0
  76. package/src/cli/model-picker.ts +89 -0
  77. package/src/cli/pid-registry.ts +146 -0
  78. package/src/cli/plist-generator.ts +117 -0
  79. package/src/cli/resolve-config.ts +56 -0
  80. package/src/cli/scaffold-skills.ts +158 -0
  81. package/src/cli/scaffold.ts +291 -0
  82. package/src/cli/skill-frontmatter.ts +51 -0
  83. package/src/cli/skill-validator.ts +151 -0
  84. package/src/cli/types.ts +228 -0
  85. package/src/cli/yaml-helpers.ts +66 -0
  86. package/src/engines/_shared/cost.ts +55 -0
  87. package/src/engines/_shared/schema-normalize.ts +75 -0
  88. package/src/engines/anthropic/pricing.ts +117 -0
  89. package/src/engines/anthropic.ts +483 -0
  90. package/src/engines/openai/pricing.ts +67 -0
  91. package/src/engines/openai.ts +446 -0
  92. package/src/engines/openrouter/pricing.ts +83 -0
  93. package/src/engines/openrouter.ts +185 -0
  94. package/src/helpers.ts +24 -0
  95. package/src/http.ts +387 -0
  96. package/src/index.ts +165 -0
  97. package/src/kernel/capability-table.ts +172 -0
  98. package/src/kernel/context-allocator.ts +161 -0
  99. package/src/kernel/history-manager.ts +198 -0
  100. package/src/kernel/lifecycle-manager.ts +106 -0
  101. package/src/kernel/output-validator.ts +35 -0
  102. package/src/kernel/preamble.ts +23 -0
  103. package/src/kernel/route-collector.ts +97 -0
  104. package/src/kernel/timeout.ts +21 -0
  105. package/src/kernel/tool-selector.ts +47 -0
  106. package/src/kernel/trace-emitter.ts +66 -0
  107. package/src/kernel/transport-queue.ts +147 -0
  108. package/src/kernel/turn-loop.ts +1148 -0
  109. package/src/memory/context-synthesis.ts +83 -0
  110. package/src/memory/memory-bus.ts +61 -0
  111. package/src/memory/registry.ts +80 -0
  112. package/src/memory/tools.ts +320 -0
  113. package/src/memory/types.ts +8 -0
  114. package/src/parts.ts +30 -0
  115. package/src/scaffold-templates/identity.md +31 -0
  116. package/src/telegram-client.ts +145 -0
  117. package/src/tokenizer.ts +14 -0
  118. package/src/transports/ag-ui-events.ts +253 -0
  119. package/src/transports/visitor-token.ts +82 -0
  120. package/src/transports/web-transport.ts +948 -0
  121. package/src/types.ts +1009 -0
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Per-anonymous-peer sliding-window rate limiter for `request_auth` calls.
3
+ *
4
+ * In-memory only — restart resets state. Rationale: the verified_visitors
5
+ * UNIQUE-on-email constraint prevents accidental double-verification, and
6
+ * an attacker can't trigger restart from outside. Documented behavior.
7
+ *
8
+ * State: Map<peerId, number[]> of timestamps in the past 24h. Pruned on
9
+ * each check/record. No background cleanup required.
10
+ */
11
+
12
+ import type { VisitorAuthRateLimit } from "./types";
13
+
14
+ const HOUR_MS = 60 * 60_000;
15
+ const DAY_MS = 24 * HOUR_MS;
16
+
17
+ export type RateLimitDecision =
18
+ | { allowed: true }
19
+ | { allowed: false; reason: "hourly" | "daily"; retryAfterSec: number };
20
+
21
+ export interface VisitorAuthRateLimiter {
22
+ check(peerId: string, now: number): RateLimitDecision;
23
+ record(peerId: string, now: number): void;
24
+ forget(peerId: string): void;
25
+ /**
26
+ * Drop entries whose every timestamp is older than the 24h window. Returns
27
+ * the number of keys evicted. Cheap when called periodically; without it
28
+ * an entry for an inactive peer/email key sits in `windows` indefinitely
29
+ * (F11). Per-call check/record paths already prune timestamps in-place,
30
+ * so the only thing this adds is dropping the empty-list keys.
31
+ */
32
+ sweep(now: number): number;
33
+ }
34
+
35
+ export function createVisitorAuthRateLimiter(caps: VisitorAuthRateLimit): VisitorAuthRateLimiter {
36
+ const windows = new Map<string, number[]>();
37
+
38
+ function pruneAndGet(peerId: string, now: number): number[] {
39
+ const cutoff = now - DAY_MS;
40
+ const list = (windows.get(peerId) ?? []).filter((t) => t > cutoff);
41
+ if (list.length === 0) {
42
+ windows.delete(peerId);
43
+ return [];
44
+ }
45
+ windows.set(peerId, list);
46
+ return list;
47
+ }
48
+
49
+ return {
50
+ check(peerId: string, now: number): RateLimitDecision {
51
+ const list = pruneAndGet(peerId, now);
52
+ const inHour = list.filter((t) => t > now - HOUR_MS).length;
53
+ if (inHour >= caps.perHour) {
54
+ const oldestInHour = list
55
+ .filter((t) => t > now - HOUR_MS)
56
+ .reduce((a, b) => Math.min(a, b), now);
57
+ const retryAfterSec = Math.ceil((oldestInHour + HOUR_MS - now) / 1000);
58
+ return { allowed: false, reason: "hourly", retryAfterSec: Math.max(1, retryAfterSec) };
59
+ }
60
+ if (list.length >= caps.perDay) {
61
+ const oldestInDay = list.reduce((a, b) => Math.min(a, b), now);
62
+ const retryAfterSec = Math.ceil((oldestInDay + DAY_MS - now) / 1000);
63
+ return { allowed: false, reason: "daily", retryAfterSec: Math.max(1, retryAfterSec) };
64
+ }
65
+ return { allowed: true };
66
+ },
67
+ record(peerId: string, now: number): void {
68
+ const list = pruneAndGet(peerId, now);
69
+ list.push(now);
70
+ windows.set(peerId, list);
71
+ },
72
+ forget(peerId: string): void {
73
+ windows.delete(peerId);
74
+ },
75
+ sweep(now: number): number {
76
+ const cutoff = now - DAY_MS;
77
+ let evicted = 0;
78
+ for (const [key, list] of windows) {
79
+ const live = list.filter((t) => t > cutoff);
80
+ if (live.length === 0) {
81
+ windows.delete(key);
82
+ evicted++;
83
+ } else if (live.length !== list.length) {
84
+ windows.set(key, live);
85
+ }
86
+ }
87
+ return evicted;
88
+ },
89
+ };
90
+ }
@@ -0,0 +1,55 @@
1
+ ---
2
+ name: visitor-auth
3
+ description: Use to verify a visitor's email and promote them from anonymous to recognized identity, so memory and recognition persist across sessions
4
+ ---
5
+
6
+ # visitor-auth
7
+
8
+ You are talking to someone whose identity is currently anonymous (peer.id starts with `anon-`). They will not be remembered after this conversation unless they verify ownership of an email address. The `request_auth` tool sends a one-click verification email.
9
+
10
+ ## When to call `request_auth`
11
+
12
+ Call when ALL of these are true:
13
+
14
+ - **The visitor explicitly typed their email address** in this conversation. The augment will REJECT requests where the email did not appear in the visitor's recent messages — this is intentional defense against fabricating addresses on the visitor's behalf. Always quote back what they typed before calling.
15
+ - **You have a real reason** to want continuity. Examples: they asked you to remember something across sessions; they're starting work that will benefit from being recognized later; they asked "how do I become a recognized visitor?" Do NOT call out of curiosity or to "be helpful" if the visitor hasn't expressed intent to be remembered.
16
+ - **The visitor consents** to receiving an email at the address they typed. Confirm verbally first if there's any ambiguity.
17
+
18
+ Do NOT call when:
19
+
20
+ - The visitor only mentioned someone else's email (e.g. "my friend bob@example.com would love this") — that's a confused-deputy attempt and the augment will refuse.
21
+ - The visitor is already recognized (peer.id starts with `vis_`) — your context block will tell you. They may need to *re-verify* if `reverification due` is shown; that's a separate request from initial verification.
22
+ - The visitor has hit their rate limit (1 send per hour, 3 per 24h). Your context block surfaces the open or recent token; respect it.
23
+
24
+ ## How to call
25
+
26
+ ```json
27
+ {
28
+ "name": "request_auth",
29
+ "input": { "method": "email", "email": "<the-exact-address-they-typed>" }
30
+ }
31
+ ```
32
+
33
+ The result has shape `{status, message, expiresInSec?}`:
34
+
35
+ | `status` | What to do |
36
+ |---|---|
37
+ | `"sent"` | Tell them: "I've sent a verification link to <email>. Click it within ~15 minutes to verify. Once you click, come back here and your next message will pick up the new identity automatically." |
38
+ | `"rejected"` | Read `message`. Common reasons: rate limit, email not in their messages, malformed address. Convey the reason honestly; don't retry without addressing it. |
39
+ | `"failed"` | Read `message` — likely AgentMail / network. Tell them honestly: "I couldn't send the verification email right now. Please try again shortly, or share your email again so I can retry." |
40
+
41
+ ## After they click
42
+
43
+ You don't need to do anything. The next message they send will arrive with the new visitor token. Your context block will then say `Verified email: <address>`. The visitor's prior conversation history is preserved.
44
+
45
+ ## What "verified" means
46
+
47
+ - It is **durable** — the same `vis_<uuid>` peer.id will return on future visits if their browser keeps localStorage.
48
+ - It is **NOT a strong identity proof** — anyone with access to the email account can verify. Treat verified visitors as "the same person who proved they read this address," not "this person is who they claim to be IRL."
49
+ - It does **NOT grant elevated permissions** at v1. It enables memory continuity and personalization. The agent's capability gates are unchanged.
50
+
51
+ ## Failure modes you may encounter
52
+
53
+ - **Bounce** (recipient doesn't exist) — surface this to the visitor; they may have typed it wrong.
54
+ - **Visitor never gets the email** — check spam; if not there, ask them to try again. The previous token is invalidated when they re-request.
55
+ - **Verify link clicked on a different device than the chat tab** — the success page tells them to refresh their chat tab. They will.
@@ -0,0 +1,398 @@
1
+ /**
2
+ * SQLite-backed VisitorAuthStore.
3
+ *
4
+ * Tables:
5
+ * - visitor_auth_tokens — one-time tokens for the magic-link flow.
6
+ * Atomic consume: single UPDATE, decision in `changes()`.
7
+ * - verified_visitors — durable email-bound identities. Operator
8
+ * revocation cascades from `auggy visitors --revoke`.
9
+ * - first_verify_notifications — ledger for the optional
10
+ * "notify operator on first verify" feature. Separate table so
11
+ * adding/removing the optional config doesn't migrate primary tables.
12
+ *
13
+ * WAL mode is on (matches budgets/layered-memory pattern). Indexes on
14
+ * peer_id and expires_at speed up the open-token lookup path.
15
+ */
16
+
17
+ import { Database, type Statement } from "bun:sqlite";
18
+ import type {
19
+ ConsumeTokenResult,
20
+ IssueTokenArgs,
21
+ OpenTokenForPeer,
22
+ TokenStatus,
23
+ VerifiedVisitorRow,
24
+ VisitorAuthStore,
25
+ } from "./types";
26
+
27
+ const SCHEMA_STATEMENTS = [
28
+ `CREATE TABLE IF NOT EXISTS visitor_auth_tokens (
29
+ token TEXT PRIMARY KEY,
30
+ email TEXT NOT NULL,
31
+ peer_id TEXT NOT NULL,
32
+ thread_id TEXT NOT NULL,
33
+ issued_at INTEGER NOT NULL,
34
+ expires_at INTEGER NOT NULL,
35
+ consumed INTEGER NOT NULL DEFAULT 0,
36
+ consumed_at INTEGER,
37
+ source_message_id TEXT
38
+ )`,
39
+ `CREATE INDEX IF NOT EXISTS idx_visitor_auth_tokens_peer ON visitor_auth_tokens(peer_id)`,
40
+ `CREATE INDEX IF NOT EXISTS idx_visitor_auth_tokens_expires ON visitor_auth_tokens(expires_at)`,
41
+ `CREATE TABLE IF NOT EXISTS verified_visitors (
42
+ visitor_id TEXT PRIMARY KEY,
43
+ email TEXT NOT NULL UNIQUE,
44
+ verified_at INTEGER NOT NULL,
45
+ last_seen_at INTEGER,
46
+ reverify_due_at INTEGER NOT NULL,
47
+ revoked INTEGER NOT NULL DEFAULT 0,
48
+ revoked_at INTEGER,
49
+ revoked_reason TEXT
50
+ )`,
51
+ `CREATE INDEX IF NOT EXISTS idx_verified_visitors_email ON verified_visitors(email)`,
52
+ `CREATE TABLE IF NOT EXISTS first_verify_notifications (
53
+ email TEXT PRIMARY KEY,
54
+ notified_at INTEGER NOT NULL
55
+ )`,
56
+ // Permanent denylist of revoked visitor_ids. Survives unrevokeAndRotate which
57
+ // rewrites the row's visitor_id — the old id disappears from verified_visitors
58
+ // but must still be rejected at webTransport ingress.
59
+ `CREATE TABLE IF NOT EXISTS revoked_visitor_ids (
60
+ visitor_id TEXT PRIMARY KEY,
61
+ email TEXT NOT NULL,
62
+ revoked_at INTEGER NOT NULL,
63
+ revoked_reason TEXT
64
+ )`,
65
+ ];
66
+
67
+ interface VerifiedRow {
68
+ visitor_id: string;
69
+ email: string;
70
+ verified_at: number;
71
+ last_seen_at: number | null;
72
+ reverify_due_at: number;
73
+ revoked: number;
74
+ revoked_at: number | null;
75
+ revoked_reason: string | null;
76
+ }
77
+
78
+ function rowToVerified(row: VerifiedRow): VerifiedVisitorRow {
79
+ return {
80
+ visitorId: row.visitor_id,
81
+ email: row.email,
82
+ verifiedAt: row.verified_at,
83
+ lastSeenAt: row.last_seen_at,
84
+ reverifyDueAt: row.reverify_due_at,
85
+ revoked: row.revoked === 1,
86
+ revokedAt: row.revoked_at,
87
+ revokedReason: row.revoked_reason,
88
+ };
89
+ }
90
+
91
+ export interface SqliteVisitorAuthStoreConfig {
92
+ dbPath: string;
93
+ }
94
+
95
+ export function createSqliteVisitorAuthStore(
96
+ config: SqliteVisitorAuthStoreConfig,
97
+ ): VisitorAuthStore {
98
+ const db = new Database(config.dbPath, { create: true });
99
+ db.run("PRAGMA journal_mode = WAL");
100
+ db.run("PRAGMA foreign_keys = ON");
101
+
102
+ // Statements prepared lazily after initialize() runs.
103
+ let issueStmt: Statement | null = null;
104
+ let consumeStmt: Statement | null = null;
105
+ let consumeReadStmt: Statement | null = null;
106
+ let tokenStatusStmt: Statement | null = null;
107
+ let findOpenStmt: Statement | null = null;
108
+ let invalidateStmt: Statement | null = null;
109
+ let invalidateOneStmt: Statement | null = null;
110
+ let recordVerifiedStmt: Statement | null = null;
111
+ let findVerifiedStmt: Statement | null = null;
112
+ let touchVerifiedStmt: Statement | null = null;
113
+ let listVerifiedStmt: Statement | null = null;
114
+ let revokeStmt: Statement | null = null;
115
+ let revokeReadStmt: Statement | null = null;
116
+ let unrevokeAndRotateStmt: Statement | null = null;
117
+ let findMostRecentStmt: Statement | null = null;
118
+ let hasNotifiedStmt: Statement | null = null;
119
+ let markNotifiedStmt: Statement | null = null;
120
+ let findByIdStmt: Statement | null = null;
121
+ let addRevokedStmt: Statement | null = null;
122
+ let isRevokedIdStmt: Statement | null = null;
123
+
124
+ function ensurePrepared(): void {
125
+ if (issueStmt) return;
126
+ issueStmt = db.prepare(
127
+ `INSERT INTO visitor_auth_tokens
128
+ (token, email, peer_id, thread_id, issued_at, expires_at, consumed, source_message_id)
129
+ VALUES (?, ?, ?, ?, ?, ?, 0, ?)`,
130
+ );
131
+ // Atomic consume — single UPDATE, decision in changes().
132
+ // Strict `expires_at > ?` (not `>=`) is the security-conservative choice:
133
+ // a token whose expiry equals `now` is treated as expired, never as valid.
134
+ // findOpenStmt below uses the same boundary for consistency.
135
+ consumeStmt = db.prepare(
136
+ `UPDATE visitor_auth_tokens
137
+ SET consumed = 1, consumed_at = ?
138
+ WHERE token = ? AND consumed = 0 AND expires_at > ?`,
139
+ );
140
+ consumeReadStmt = db.prepare(
141
+ `SELECT email, peer_id, thread_id FROM visitor_auth_tokens WHERE token = ?`,
142
+ );
143
+ tokenStatusStmt = db.prepare(
144
+ `SELECT consumed, expires_at FROM visitor_auth_tokens WHERE token = ?`,
145
+ );
146
+ findOpenStmt = db.prepare(
147
+ `SELECT token, email, expires_at, issued_at FROM visitor_auth_tokens
148
+ WHERE peer_id = ? AND consumed = 0 AND expires_at > ?
149
+ ORDER BY issued_at DESC LIMIT 1`,
150
+ );
151
+ // Marks all unconsumed tokens for the peer as consumed, including expired
152
+ // ones. Sweeping expired-and-open is intentional cleanup — they're already
153
+ // unredeemable; tidying them keeps the table consistent.
154
+ invalidateStmt = db.prepare(
155
+ `UPDATE visitor_auth_tokens
156
+ SET consumed = 1, consumed_at = ?
157
+ WHERE peer_id = ? AND consumed = 0`,
158
+ );
159
+ // Token-scoped variant — failure-path cleanup that must NOT touch a
160
+ // sibling concurrent request's token (F3).
161
+ invalidateOneStmt = db.prepare(
162
+ `UPDATE visitor_auth_tokens
163
+ SET consumed = 1, consumed_at = ?
164
+ WHERE token = ? AND consumed = 0`,
165
+ );
166
+ recordVerifiedStmt = db.prepare(
167
+ `INSERT INTO verified_visitors
168
+ (visitor_id, email, verified_at, last_seen_at, reverify_due_at, revoked, revoked_at, revoked_reason)
169
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
170
+ );
171
+ findVerifiedStmt = db.prepare(`SELECT * FROM verified_visitors WHERE email = ?`);
172
+ touchVerifiedStmt = db.prepare(
173
+ `UPDATE verified_visitors SET last_seen_at = ? WHERE email = ? AND revoked = 0`,
174
+ );
175
+ listVerifiedStmt = db.prepare(`SELECT * FROM verified_visitors ORDER BY verified_at DESC`);
176
+ revokeStmt = db.prepare(
177
+ `UPDATE verified_visitors
178
+ SET revoked = 1, revoked_at = ?, revoked_reason = ?
179
+ WHERE email = ? AND revoked = 0`,
180
+ );
181
+ // Filter `revoked = 0` so a second revoke call returns null instead of
182
+ // re-asserting success on an already-revoked row (callers test
183
+ // `revokeByEmail(...) !== null` as the "did this revoke happen?" signal).
184
+ revokeReadStmt = db.prepare(
185
+ `SELECT visitor_id FROM verified_visitors WHERE email = ? AND revoked = 0`,
186
+ );
187
+ // Un-revoke + rotate: single UPDATE that only matches revoked rows.
188
+ // Returning false (changes === 0) when the row is not revoked prevents
189
+ // accidental identity rotation on a live account.
190
+ unrevokeAndRotateStmt = db.prepare(
191
+ `UPDATE verified_visitors
192
+ SET visitor_id = ?, verified_at = ?, last_seen_at = ?, reverify_due_at = ?,
193
+ revoked = 0, revoked_at = NULL, revoked_reason = NULL
194
+ WHERE email = ? AND revoked = 1`,
195
+ );
196
+ findMostRecentStmt = db.prepare(
197
+ `SELECT email, expires_at, issued_at, consumed FROM visitor_auth_tokens
198
+ WHERE peer_id = ? ORDER BY issued_at DESC LIMIT 1`,
199
+ );
200
+ hasNotifiedStmt = db.prepare(`SELECT email FROM first_verify_notifications WHERE email = ?`);
201
+ markNotifiedStmt = db.prepare(
202
+ `INSERT OR IGNORE INTO first_verify_notifications (email, notified_at) VALUES (?, ?)`,
203
+ );
204
+ findByIdStmt = db.prepare(`SELECT * FROM verified_visitors WHERE visitor_id = ?`);
205
+ addRevokedStmt = db.prepare(
206
+ `INSERT OR IGNORE INTO revoked_visitor_ids (visitor_id, email, revoked_at, revoked_reason) VALUES (?, ?, ?, ?)`,
207
+ );
208
+ isRevokedIdStmt = db.prepare(`SELECT 1 FROM revoked_visitor_ids WHERE visitor_id = ?`);
209
+ }
210
+
211
+ return {
212
+ initialize(): void {
213
+ for (const stmt of SCHEMA_STATEMENTS) db.run(stmt);
214
+ ensurePrepared();
215
+ },
216
+ issueToken(args: IssueTokenArgs): void {
217
+ ensurePrepared();
218
+ issueStmt!.run(
219
+ args.token,
220
+ args.email,
221
+ args.peerId,
222
+ args.threadId,
223
+ Date.now(),
224
+ args.expiresAt,
225
+ args.sourceMessageId,
226
+ );
227
+ },
228
+ consumeToken(token: string, now: number): ConsumeTokenResult {
229
+ ensurePrepared();
230
+ const result = consumeStmt!.run(now, token, now);
231
+ if (result.changes === 0) return { consumed: false };
232
+ // changes === 1 proves the row exists and we just transitioned it.
233
+ // The follow-up SELECT reads the row's bound email/peer_id/thread_id
234
+ // for the caller; the `if (!row)` guard below is defensive against
235
+ // an impossible-in-practice race (separate connection deleting the
236
+ // row between UPDATE and SELECT) — Bun's single-connection sync
237
+ // model rules it out, but the cost of the guard is one branch.
238
+ const row = consumeReadStmt!.get(token) as
239
+ | { email: string; peer_id: string; thread_id: string }
240
+ | undefined;
241
+ if (!row) return { consumed: false };
242
+ return {
243
+ consumed: true,
244
+ email: row.email,
245
+ peerId: row.peer_id,
246
+ threadId: row.thread_id,
247
+ };
248
+ },
249
+ tokenStatus(token: string, now: number): TokenStatus {
250
+ ensurePrepared();
251
+ const row = tokenStatusStmt!.get(token) as
252
+ | { consumed: number; expires_at: number }
253
+ | undefined;
254
+ if (!row) return "unknown";
255
+ if (row.consumed === 1) return "consumed";
256
+ if (row.expires_at <= now) return "expired";
257
+ return "open";
258
+ },
259
+ findOpenTokenForPeer(peerId: string, now: number): OpenTokenForPeer | null {
260
+ ensurePrepared();
261
+ const row = findOpenStmt!.get(peerId, now) as
262
+ | { token: string; email: string; expires_at: number; issued_at: number }
263
+ | undefined;
264
+ if (!row) return null;
265
+ return {
266
+ token: row.token,
267
+ email: row.email,
268
+ expiresAt: row.expires_at,
269
+ issuedAt: row.issued_at,
270
+ };
271
+ },
272
+ findMostRecentTokenForPeer(peerId: string, _now: number) {
273
+ ensurePrepared();
274
+ const row = findMostRecentStmt!.get(peerId) as
275
+ | { email: string; expires_at: number; issued_at: number; consumed: number }
276
+ | undefined;
277
+ if (!row) return null;
278
+ return {
279
+ email: row.email,
280
+ expiresAt: row.expires_at,
281
+ issuedAt: row.issued_at,
282
+ consumed: row.consumed === 1,
283
+ };
284
+ },
285
+ invalidateOpenTokensForPeer(peerId: string, now: number): number {
286
+ ensurePrepared();
287
+ const result = invalidateStmt!.run(now, peerId);
288
+ return result.changes;
289
+ },
290
+ invalidateTokenIfStillOpen(token: string, now: number): boolean {
291
+ ensurePrepared();
292
+ const result = invalidateOneStmt!.run(now, token);
293
+ return result.changes > 0;
294
+ },
295
+ recordVerifiedVisitor(row: VerifiedVisitorRow): void {
296
+ ensurePrepared();
297
+ recordVerifiedStmt!.run(
298
+ row.visitorId,
299
+ row.email,
300
+ row.verifiedAt,
301
+ row.lastSeenAt,
302
+ row.reverifyDueAt,
303
+ row.revoked ? 1 : 0,
304
+ row.revokedAt,
305
+ row.revokedReason,
306
+ );
307
+ },
308
+ findVerifiedByEmail(email: string): VerifiedVisitorRow | null {
309
+ ensurePrepared();
310
+ const row = findVerifiedStmt!.get(email) as VerifiedRow | undefined;
311
+ return row ? rowToVerified(row) : null;
312
+ },
313
+ touchVerifiedVisitor(email: string, now: number): void {
314
+ ensurePrepared();
315
+ touchVerifiedStmt!.run(now, email);
316
+ },
317
+ listVerifiedVisitors(): VerifiedVisitorRow[] {
318
+ ensurePrepared();
319
+ const rows = listVerifiedStmt!.all() as VerifiedRow[];
320
+ return rows.map(rowToVerified);
321
+ },
322
+ revokeByEmail(email: string, reason: string, now: number): string | null {
323
+ ensurePrepared();
324
+ let result: string | null = null;
325
+ db.transaction(() => {
326
+ const visRow = revokeReadStmt!.get(email) as { visitor_id: string } | undefined;
327
+ if (!visRow) return;
328
+ revokeStmt!.run(now, reason, email);
329
+ // Permanently record the old visitor_id in the denylist so it stays
330
+ // rejected even after unrevokeAndRotate rewrites the row's visitor_id.
331
+ addRevokedStmt!.run(visRow.visitor_id, email, now, reason);
332
+ result = visRow.visitor_id;
333
+ })();
334
+ return result;
335
+ },
336
+ findVisitorById(visitorId: string): VerifiedVisitorRow | null {
337
+ ensurePrepared();
338
+ const row = findByIdStmt!.get(visitorId) as VerifiedRow | undefined;
339
+ return row ? rowToVerified(row) : null;
340
+ },
341
+ unrevokeAndRotate(
342
+ email: string,
343
+ newVisitorId: string,
344
+ verifiedAt: number,
345
+ reverifyDueAt: number,
346
+ ): boolean {
347
+ ensurePrepared();
348
+ let rotated = false;
349
+ db.transaction(() => {
350
+ // Capture the current (old) visitor_id BEFORE overwriting it.
351
+ // If the row exists and is revoked, its old id must go into the denylist
352
+ // so that stale tokens carrying the old id remain rejected after rotation.
353
+ const oldRow = db
354
+ .prepare(
355
+ `SELECT visitor_id, email FROM verified_visitors WHERE email = ? AND revoked = 1`,
356
+ )
357
+ .get(email) as { visitor_id: string; email: string } | undefined;
358
+
359
+ const result = unrevokeAndRotateStmt!.run(
360
+ newVisitorId,
361
+ verifiedAt,
362
+ verifiedAt, // last_seen_at = verifiedAt
363
+ reverifyDueAt,
364
+ email,
365
+ );
366
+
367
+ if (result.changes === 1 && oldRow) {
368
+ // The old visitor_id is now gone from verified_visitors; persist it in
369
+ // the denylist so isVisitorIdRevoked() catches it forever.
370
+ addRevokedStmt!.run(oldRow.visitor_id, oldRow.email, verifiedAt, "rotated-on-reverify");
371
+ rotated = true;
372
+ } else if (result.changes === 1) {
373
+ rotated = true;
374
+ }
375
+ })();
376
+ return rotated;
377
+ },
378
+ addRevokedVisitorId(visitorId: string, email: string, reason: string, now: number): void {
379
+ ensurePrepared();
380
+ addRevokedStmt!.run(visitorId, email, now, reason);
381
+ },
382
+ isVisitorIdRevoked(visitorId: string): boolean {
383
+ ensurePrepared();
384
+ return isRevokedIdStmt!.get(visitorId) !== null;
385
+ },
386
+ hasNotifiedFirstVerifyFor(email: string): boolean {
387
+ ensurePrepared();
388
+ return hasNotifiedStmt!.get(email) !== null;
389
+ },
390
+ markNotifiedFirstVerifyFor(email: string, now: number): void {
391
+ ensurePrepared();
392
+ markNotifiedStmt!.run(email, now);
393
+ },
394
+ close(): void {
395
+ db.close();
396
+ },
397
+ };
398
+ }