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.
- package/CHANGELOG.md +96 -0
- package/LICENSE +201 -0
- package/README.md +161 -0
- package/package.json +76 -0
- package/src/agent-card.ts +39 -0
- package/src/agent.ts +283 -0
- package/src/agentmail-client.ts +138 -0
- package/src/augments/bash/index.ts +463 -0
- package/src/augments/bash/skill/SKILL.md +156 -0
- package/src/augments/budgets/budget-store.ts +513 -0
- package/src/augments/budgets/index.ts +134 -0
- package/src/augments/budgets/preamble.ts +93 -0
- package/src/augments/budgets/types.ts +89 -0
- package/src/augments/file-memory/index.ts +71 -0
- package/src/augments/filesystem/index.ts +533 -0
- package/src/augments/filesystem/skill/SKILL.md +142 -0
- package/src/augments/filesystem/skill/references/mount-permissions.md +81 -0
- package/src/augments/layered-memory/extractor/buffer.ts +56 -0
- package/src/augments/layered-memory/extractor/frequency.ts +79 -0
- package/src/augments/layered-memory/extractor/inject-handler.ts +103 -0
- package/src/augments/layered-memory/extractor/parse.ts +75 -0
- package/src/augments/layered-memory/extractor/prompt.md +26 -0
- package/src/augments/layered-memory/index.ts +757 -0
- package/src/augments/layered-memory/skill/SKILL.md +153 -0
- package/src/augments/layered-memory/storage/migrations/README.md +16 -0
- package/src/augments/layered-memory/storage/migrations/supabase-add-fact-fields.sql +9 -0
- package/src/augments/layered-memory/storage/sqlite-store.ts +352 -0
- package/src/augments/layered-memory/storage/supabase-store.ts +263 -0
- package/src/augments/layered-memory/storage/types.ts +98 -0
- package/src/augments/link/index.ts +489 -0
- package/src/augments/link/translate.ts +261 -0
- package/src/augments/notify/adapters/agentmail.ts +70 -0
- package/src/augments/notify/adapters/telegram.ts +60 -0
- package/src/augments/notify/adapters/webhook.ts +55 -0
- package/src/augments/notify/index.ts +284 -0
- package/src/augments/notify/skill/SKILL.md +150 -0
- package/src/augments/org-context/index.ts +721 -0
- package/src/augments/org-context/skill/SKILL.md +96 -0
- package/src/augments/skills/index.ts +103 -0
- package/src/augments/supabase-memory/index.ts +151 -0
- package/src/augments/telegram-transport/index.ts +312 -0
- package/src/augments/telegram-transport/polling.ts +55 -0
- package/src/augments/telegram-transport/webhook.ts +56 -0
- package/src/augments/turn-control/index.ts +61 -0
- package/src/augments/turn-control/skill/SKILL.md +155 -0
- package/src/augments/visitor-auth/email-validation.ts +66 -0
- package/src/augments/visitor-auth/index.ts +779 -0
- package/src/augments/visitor-auth/rate-limiter.ts +90 -0
- package/src/augments/visitor-auth/skill/SKILL.md +55 -0
- package/src/augments/visitor-auth/storage/sqlite-store.ts +398 -0
- package/src/augments/visitor-auth/storage/types.ts +164 -0
- package/src/augments/visitor-auth/types.ts +123 -0
- package/src/augments/visitor-auth/verify-page.ts +179 -0
- package/src/augments/web-fetch/index.ts +331 -0
- package/src/augments/web-fetch/skill/SKILL.md +100 -0
- package/src/cli/agent-index.ts +289 -0
- package/src/cli/augment-catalog.ts +320 -0
- package/src/cli/augment-resolver.ts +597 -0
- package/src/cli/commands/add-skill.ts +194 -0
- package/src/cli/commands/add.ts +87 -0
- package/src/cli/commands/chat.ts +207 -0
- package/src/cli/commands/create.ts +462 -0
- package/src/cli/commands/dev.ts +139 -0
- package/src/cli/commands/eval.ts +180 -0
- package/src/cli/commands/ls.ts +66 -0
- package/src/cli/commands/remove.ts +95 -0
- package/src/cli/commands/restart.ts +40 -0
- package/src/cli/commands/start.ts +123 -0
- package/src/cli/commands/status.ts +104 -0
- package/src/cli/commands/stop.ts +84 -0
- package/src/cli/commands/visitors-revoke.ts +155 -0
- package/src/cli/commands/visitors.ts +101 -0
- package/src/cli/config-parser.ts +1034 -0
- package/src/cli/engine-resolver.ts +68 -0
- package/src/cli/index.ts +178 -0
- package/src/cli/model-picker.ts +89 -0
- package/src/cli/pid-registry.ts +146 -0
- package/src/cli/plist-generator.ts +117 -0
- package/src/cli/resolve-config.ts +56 -0
- package/src/cli/scaffold-skills.ts +158 -0
- package/src/cli/scaffold.ts +291 -0
- package/src/cli/skill-frontmatter.ts +51 -0
- package/src/cli/skill-validator.ts +151 -0
- package/src/cli/types.ts +228 -0
- package/src/cli/yaml-helpers.ts +66 -0
- package/src/engines/_shared/cost.ts +55 -0
- package/src/engines/_shared/schema-normalize.ts +75 -0
- package/src/engines/anthropic/pricing.ts +117 -0
- package/src/engines/anthropic.ts +483 -0
- package/src/engines/openai/pricing.ts +67 -0
- package/src/engines/openai.ts +446 -0
- package/src/engines/openrouter/pricing.ts +83 -0
- package/src/engines/openrouter.ts +185 -0
- package/src/helpers.ts +24 -0
- package/src/http.ts +387 -0
- package/src/index.ts +165 -0
- package/src/kernel/capability-table.ts +172 -0
- package/src/kernel/context-allocator.ts +161 -0
- package/src/kernel/history-manager.ts +198 -0
- package/src/kernel/lifecycle-manager.ts +106 -0
- package/src/kernel/output-validator.ts +35 -0
- package/src/kernel/preamble.ts +23 -0
- package/src/kernel/route-collector.ts +97 -0
- package/src/kernel/timeout.ts +21 -0
- package/src/kernel/tool-selector.ts +47 -0
- package/src/kernel/trace-emitter.ts +66 -0
- package/src/kernel/transport-queue.ts +147 -0
- package/src/kernel/turn-loop.ts +1148 -0
- package/src/memory/context-synthesis.ts +83 -0
- package/src/memory/memory-bus.ts +61 -0
- package/src/memory/registry.ts +80 -0
- package/src/memory/tools.ts +320 -0
- package/src/memory/types.ts +8 -0
- package/src/parts.ts +30 -0
- package/src/scaffold-templates/identity.md +31 -0
- package/src/telegram-client.ts +145 -0
- package/src/tokenizer.ts +14 -0
- package/src/transports/ag-ui-events.ts +253 -0
- package/src/transports/visitor-token.ts +82 -0
- package/src/transports/web-transport.ts +948 -0
- 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
|
+
}
|