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,779 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* visitorAuth augment — email magic-link verification flow.
|
|
3
|
+
*
|
|
4
|
+
* Owns: SQLite store (visitor-auth.db), `request_auth` tool, /visitor-auth/verify
|
|
5
|
+
* HTTP route, per-peer rate limiter, per-peer recent-message buffer (for the
|
|
6
|
+
* email-in-recent-message check), and a context block summarizing verification
|
|
7
|
+
* state for the active peer.
|
|
8
|
+
*
|
|
9
|
+
* This module is intentionally bottom-of-stack: it imports types, storage,
|
|
10
|
+
* helpers, and the shared agentmail-client. It does NOT import notify or any
|
|
11
|
+
* notify adapter — see plan §"Spec deviation".
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync } from "node:fs";
|
|
15
|
+
import { Database } from "bun:sqlite";
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
import { defineTool } from "../../helpers";
|
|
18
|
+
import { createAgentMailClient, type AgentMailClient } from "../../agentmail-client";
|
|
19
|
+
import { createVisitorToken, deriveSigningKey } from "../../transports/visitor-token";
|
|
20
|
+
import type { Augment, ContextBlock, ToolExecuteContext, TurnState } from "../../types";
|
|
21
|
+
import type {
|
|
22
|
+
RecentVisitorMessage,
|
|
23
|
+
RequestAuthResult,
|
|
24
|
+
VisitorAuthAugmentExtras,
|
|
25
|
+
VisitorAuthOptions,
|
|
26
|
+
} from "./types";
|
|
27
|
+
import {
|
|
28
|
+
createSqliteVisitorAuthStore,
|
|
29
|
+
type SqliteVisitorAuthStoreConfig,
|
|
30
|
+
} from "./storage/sqlite-store";
|
|
31
|
+
import type { VisitorAuthStore } from "./storage/types";
|
|
32
|
+
import { emailAppearsInRecentMessages, isWellFormedEmail } from "./email-validation";
|
|
33
|
+
import { createVisitorAuthRateLimiter, type VisitorAuthRateLimiter } from "./rate-limiter";
|
|
34
|
+
import {
|
|
35
|
+
buildVerifyConfirmPage,
|
|
36
|
+
buildVerifyFailurePage,
|
|
37
|
+
buildVerifySuccessPage,
|
|
38
|
+
} from "./verify-page";
|
|
39
|
+
|
|
40
|
+
const DEFAULT_TOKEN_TTL_MIN = 15;
|
|
41
|
+
const DEFAULT_REVERIFY_DAYS = 90;
|
|
42
|
+
const DEFAULT_RATE_LIMIT = { perHour: 1, perDay: 3 };
|
|
43
|
+
const VERIFY_PATH = "/visitor-auth/verify";
|
|
44
|
+
|
|
45
|
+
/** Internal options exposed for tests — production callers do not pass these. */
|
|
46
|
+
export interface VisitorAuthInternalOptions extends VisitorAuthOptions {
|
|
47
|
+
/** Test-only AgentMail client override. Production constructs from agentMail.apiKey. */
|
|
48
|
+
_agentMailClient?: AgentMailClient;
|
|
49
|
+
/** Test-only clock injection. Production uses Date.now. */
|
|
50
|
+
_now?: () => number;
|
|
51
|
+
/**
|
|
52
|
+
* Test-only override for the F11 rate-limit sweep cadence (default 1h).
|
|
53
|
+
* Tests use a tiny value (e.g., 30ms) to exercise the actual setInterval
|
|
54
|
+
* wiring rather than just the sweep() unit.
|
|
55
|
+
*/
|
|
56
|
+
_rateLimitSweepIntervalMs?: number;
|
|
57
|
+
/**
|
|
58
|
+
* Test-only callback fired after each rate-limiter sweep tick. Receives
|
|
59
|
+
* the eviction count returned by sweep(). Lets tests assert that the
|
|
60
|
+
* setInterval is wired AND that clearInterval stops the cadence on
|
|
61
|
+
* onShutdown.
|
|
62
|
+
*/
|
|
63
|
+
_onRateLimitSweep?: (evicted: number, now: number) => void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function validateOptions(opts: VisitorAuthInternalOptions): void {
|
|
67
|
+
if (!opts.publicUrl || typeof opts.publicUrl !== "string") {
|
|
68
|
+
throw new Error("visitorAuth: publicUrl is required");
|
|
69
|
+
}
|
|
70
|
+
let parsedPublicUrl: URL;
|
|
71
|
+
try {
|
|
72
|
+
parsedPublicUrl = new URL(opts.publicUrl);
|
|
73
|
+
} catch {
|
|
74
|
+
throw new Error(`visitorAuth: publicUrl "${opts.publicUrl}" is not a valid URL`);
|
|
75
|
+
}
|
|
76
|
+
if (!/^https?:$/.test(parsedPublicUrl.protocol)) {
|
|
77
|
+
throw new Error("visitorAuth: publicUrl must use http:// or https://");
|
|
78
|
+
}
|
|
79
|
+
if (!opts.agentMail?.apiKey || !opts.agentMail?.inboxId) {
|
|
80
|
+
throw new Error("visitorAuth: agentMail.apiKey and agentMail.inboxId are required");
|
|
81
|
+
}
|
|
82
|
+
if (!opts.signingKey || typeof opts.signingKey !== "string") {
|
|
83
|
+
throw new Error("visitorAuth: signingKey is required (set VISITOR_SIGNING_KEY in .env)");
|
|
84
|
+
}
|
|
85
|
+
if (!opts.dbPath) {
|
|
86
|
+
throw new Error("visitorAuth: dbPath is required");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function looksLikePlaceholder(value: string): boolean {
|
|
91
|
+
return /^\$\{[A-Z0-9_]+\}$/.test(value);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function visitorAuth(opts: VisitorAuthInternalOptions): Augment & VisitorAuthAugmentExtras {
|
|
95
|
+
validateOptions(opts);
|
|
96
|
+
|
|
97
|
+
const now = opts._now ?? (() => Date.now());
|
|
98
|
+
const tokenTtlMin = opts.tokenTtlMinutes ?? DEFAULT_TOKEN_TTL_MIN;
|
|
99
|
+
const reverifyDays = opts.reverifyAfterDays ?? DEFAULT_REVERIFY_DAYS;
|
|
100
|
+
const rateLimitCaps = opts.rateLimit ?? DEFAULT_RATE_LIMIT;
|
|
101
|
+
const subjectPrefix = opts.agentMail.subjectPrefix ?? "[Verify] ";
|
|
102
|
+
const agentBinding = opts.agentBinding ?? "auggy";
|
|
103
|
+
|
|
104
|
+
const storeConfig: SqliteVisitorAuthStoreConfig = { dbPath: opts.dbPath };
|
|
105
|
+
const store: VisitorAuthStore = createSqliteVisitorAuthStore(storeConfig);
|
|
106
|
+
const rateLimiter: VisitorAuthRateLimiter = createVisitorAuthRateLimiter(rateLimitCaps);
|
|
107
|
+
|
|
108
|
+
const agentMail: AgentMailClient =
|
|
109
|
+
opts._agentMailClient ??
|
|
110
|
+
createAgentMailClient({
|
|
111
|
+
apiKey: opts.agentMail.apiKey,
|
|
112
|
+
apiBaseUrl: opts.agentMail.apiBaseUrl,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// Per-peer recent-message buffer for email-in-recent-message validation.
|
|
116
|
+
// Holds up to RECENT_MESSAGES per peerId. Populated by onTurnStart from the
|
|
117
|
+
// turn's inbound message (Task 7).
|
|
118
|
+
const RECENT_MESSAGES = 4;
|
|
119
|
+
const recentByPeer = new Map<string, RecentVisitorMessage[]>();
|
|
120
|
+
|
|
121
|
+
// F10: bound recentByPeer growth under high peer churn (e.g. anon-* ids
|
|
122
|
+
// change per thread; long-running agents accumulate stale entries forever).
|
|
123
|
+
// We track the last onTurnStart timestamp per peer, and every
|
|
124
|
+
// RECENT_PEER_SWEEP_EVERY turns we evict entries older than
|
|
125
|
+
// RECENT_PEER_TTL_MS. Sweep is amortized — no setInterval needed.
|
|
126
|
+
const RECENT_PEER_TTL_MS = 24 * 60 * 60_000; // 24h
|
|
127
|
+
const RECENT_PEER_SWEEP_EVERY = 50; // amortized cost: 1/50 turns
|
|
128
|
+
const lastSeenByPeer = new Map<string, number>();
|
|
129
|
+
let onTurnStartCounter = 0;
|
|
130
|
+
|
|
131
|
+
// Cached HMAC signing key — derived once at boot.
|
|
132
|
+
let signingCryptoKey: CryptoKey | null = null;
|
|
133
|
+
|
|
134
|
+
// Bootflag — context() and the route handler must noop until onBoot completed.
|
|
135
|
+
let booted = false;
|
|
136
|
+
|
|
137
|
+
// F11: rate-limiter background sweep handle. Set in onBoot; cleared in
|
|
138
|
+
// onShutdown.
|
|
139
|
+
let rateLimiterSweepHandle: ReturnType<typeof setInterval> | null = null;
|
|
140
|
+
|
|
141
|
+
function buildVerifyUrl(token: string): string {
|
|
142
|
+
const url = new URL(VERIFY_PATH, opts.publicUrl);
|
|
143
|
+
url.searchParams.set("token", token);
|
|
144
|
+
return url.href;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function buildEmailBody(
|
|
148
|
+
verifyUrl: string,
|
|
149
|
+
ttlMinutes: number,
|
|
150
|
+
): { subject: string; text: string } {
|
|
151
|
+
const subject = `${subjectPrefix}Verify your email`;
|
|
152
|
+
const text =
|
|
153
|
+
`Click the link below to verify your email.\n\n` +
|
|
154
|
+
`${verifyUrl}\n\n` +
|
|
155
|
+
`The link expires in ${ttlMinutes} minutes and may only be used once. ` +
|
|
156
|
+
`If you didn't request this, ignore this email.`;
|
|
157
|
+
return { subject, text };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const requestAuthTool = defineTool({
|
|
161
|
+
name: "request_auth",
|
|
162
|
+
description:
|
|
163
|
+
"Send a verification email to a visitor's claimed address. Use this to promote an anonymous visitor to recognized identity. method: 'email' is the only supported value at v1. Returns status: 'sent' | 'rejected' | 'failed'.",
|
|
164
|
+
category: "communication",
|
|
165
|
+
input: z.object({
|
|
166
|
+
method: z.literal("email"),
|
|
167
|
+
email: z.string(),
|
|
168
|
+
}),
|
|
169
|
+
execute: async (
|
|
170
|
+
input: { method: "email"; email: string },
|
|
171
|
+
ctx?: ToolExecuteContext,
|
|
172
|
+
): Promise<string> => {
|
|
173
|
+
const fail = (status: "rejected" | "failed", message: string): string =>
|
|
174
|
+
JSON.stringify({ status, message } satisfies RequestAuthResult);
|
|
175
|
+
|
|
176
|
+
if (!booted) {
|
|
177
|
+
return fail("failed", "visitorAuth has not finished booting; try again shortly.");
|
|
178
|
+
}
|
|
179
|
+
if (!ctx?.peer) {
|
|
180
|
+
return fail("failed", "request_auth requires turn context with a peer identity.");
|
|
181
|
+
}
|
|
182
|
+
if (input.method !== "email") {
|
|
183
|
+
return fail(
|
|
184
|
+
"rejected",
|
|
185
|
+
`method "${input.method}" not supported in this build; only "email" is available.`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
const email = input.email.trim().toLowerCase();
|
|
189
|
+
if (!isWellFormedEmail(email)) {
|
|
190
|
+
return fail("rejected", "Email address is malformed.");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Email-in-recent-message validation (fix #4).
|
|
194
|
+
const recent = recentByPeer.get(ctx.peer.id) ?? [];
|
|
195
|
+
const match = emailAppearsInRecentMessages(email, recent);
|
|
196
|
+
if (!match.matched) {
|
|
197
|
+
return fail(
|
|
198
|
+
"rejected",
|
|
199
|
+
"Email was not found in the visitor's recent messages. Refusing to send to an address the visitor did not type.",
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Per-email rate limit (fix H1: threadId rotation bypass).
|
|
204
|
+
// Keying on email prevents an attacker from rotating peer.id / threadId
|
|
205
|
+
// to escape the rate limit. The "email:" prefix namespaces the key so
|
|
206
|
+
// future per-IP keying (e.g. "ip:...") can coexist without collision.
|
|
207
|
+
const t = now();
|
|
208
|
+
const rlKey = `email:${email}`;
|
|
209
|
+
const rl = rateLimiter.check(rlKey, t);
|
|
210
|
+
if (!rl.allowed) {
|
|
211
|
+
const wait = Math.ceil(rl.retryAfterSec / 60);
|
|
212
|
+
return fail(
|
|
213
|
+
"rejected",
|
|
214
|
+
`Verification rate limit reached for this visitor (${rl.reason}). Try again in ~${wait} minute(s).`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Invalidate any prior open token for this peer; only one open at a time.
|
|
219
|
+
store.invalidateOpenTokensForPeer(ctx.peer.id, t);
|
|
220
|
+
|
|
221
|
+
// Mint a fresh token + write the row + build URL.
|
|
222
|
+
const token = crypto.randomUUID();
|
|
223
|
+
const ttlMs = tokenTtlMin * 60_000;
|
|
224
|
+
store.issueToken({
|
|
225
|
+
token,
|
|
226
|
+
email,
|
|
227
|
+
peerId: ctx.peer.id,
|
|
228
|
+
threadId: ctx.threadId,
|
|
229
|
+
expiresAt: t + ttlMs,
|
|
230
|
+
// `||` (not `??`) so empty-string from emailAppearsInRecentMessages
|
|
231
|
+
// (when the recent message had no messageId) becomes null, not "".
|
|
232
|
+
sourceMessageId: match.messageId || null,
|
|
233
|
+
});
|
|
234
|
+
const verifyUrl = buildVerifyUrl(token);
|
|
235
|
+
const { subject, text } = buildEmailBody(verifyUrl, tokenTtlMin);
|
|
236
|
+
|
|
237
|
+
// Send via agentmail-client.ts (direct — see plan §"Spec deviation").
|
|
238
|
+
const sendResult = await agentMail.send({
|
|
239
|
+
inboxId: opts.agentMail.inboxId,
|
|
240
|
+
to: [email],
|
|
241
|
+
subject,
|
|
242
|
+
text,
|
|
243
|
+
labels: ["visitor-auth", "verify"],
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
if (sendResult.status !== "sent") {
|
|
247
|
+
// Mark THIS token consumed so it can't be redeemed despite the visitor
|
|
248
|
+
// never receiving it. Use the token-scoped variant (F3): a peer-wide
|
|
249
|
+
// invalidate would steamroll a sibling concurrent request_auth call's
|
|
250
|
+
// token if one was issued in between (the line-192 pre-invalidate kills
|
|
251
|
+
// OUR pending token from the sibling's perspective; OUR failure cleanup
|
|
252
|
+
// must not return the favor).
|
|
253
|
+
store.invalidateTokenIfStillOpen(token, t + 1);
|
|
254
|
+
return fail(
|
|
255
|
+
"failed",
|
|
256
|
+
`Failed to send verification email: ${sendResult.detail ?? "unknown error"}`,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Record the rate-limit tick AFTER successful send (keyed to email, not peer).
|
|
261
|
+
rateLimiter.record(rlKey, t);
|
|
262
|
+
|
|
263
|
+
return JSON.stringify({
|
|
264
|
+
status: "sent",
|
|
265
|
+
message: `Verification email sent to ${email}. The link expires in ${tokenTtlMin} minutes.`,
|
|
266
|
+
expiresInSec: Math.floor(ttlMs / 1000),
|
|
267
|
+
} satisfies RequestAuthResult);
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Real-time revocation check for webTransport integration (fix C1).
|
|
273
|
+
*
|
|
274
|
+
* Returns `true` if the visitorId is known AND its row is marked revoked.
|
|
275
|
+
* webTransport calls this after HMAC verification succeeds; a `true` return
|
|
276
|
+
* causes the request to be treated as anonymous, rendering old tokens inert
|
|
277
|
+
* without waiting for their TTL to expire.
|
|
278
|
+
*
|
|
279
|
+
* Exposed as a plain function on the augment object so the augment resolver
|
|
280
|
+
* can wire it into webTransport's `visitorTokens.revocationCheck` option.
|
|
281
|
+
* Not part of the `Augment` interface — resolved via type assertion in the
|
|
282
|
+
* resolver.
|
|
283
|
+
*/
|
|
284
|
+
function isVisitorRevoked(visitorId: string): boolean {
|
|
285
|
+
if (!booted) return false; // store not initialized; fail-open to avoid boot-order deadlock
|
|
286
|
+
// Check the permanent denylist first — this catches OLD vis_ids that have
|
|
287
|
+
// been rotated away by unrevokeAndRotate (their row no longer exists under
|
|
288
|
+
// that id, so findVisitorById would return null and we'd incorrectly admit
|
|
289
|
+
// them as "not revoked").
|
|
290
|
+
if (store.isVisitorIdRevoked(visitorId)) return true;
|
|
291
|
+
// Then check the current verified_visitors row state.
|
|
292
|
+
const row = store.findVisitorById(visitorId);
|
|
293
|
+
return !!row?.revoked;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const augment: Augment & { isVisitorRevoked: (visitorId: string) => boolean } = {
|
|
297
|
+
name: "visitor-auth",
|
|
298
|
+
capabilities: ["tools", "context"],
|
|
299
|
+
tools: [requestAuthTool],
|
|
300
|
+
isVisitorRevoked,
|
|
301
|
+
httpRoutes: [
|
|
302
|
+
// -----------------------------------------------------------------------
|
|
303
|
+
// GET /visitor-auth/verify?token=<uuid>
|
|
304
|
+
//
|
|
305
|
+
// Returns a CONFIRMATION page — does NOT consume the token.
|
|
306
|
+
// Mail-scanner AV bots follow GET links passively; returning a confirm
|
|
307
|
+
// page here (rather than consuming) means prefetch is harmless. The human
|
|
308
|
+
// clicks "Verify my email" which submits a form POST that atomically
|
|
309
|
+
// consumes the token. Design: fix H2 (GET prefetch burns tokens).
|
|
310
|
+
// -----------------------------------------------------------------------
|
|
311
|
+
{
|
|
312
|
+
method: "GET",
|
|
313
|
+
path: VERIFY_PATH,
|
|
314
|
+
auth: "none",
|
|
315
|
+
rateLimit: { maxPerMinute: 60 },
|
|
316
|
+
handler: async (req, _opts) => {
|
|
317
|
+
if (!booted || !signingCryptoKey) {
|
|
318
|
+
return new Response(buildVerifyFailurePage({ reason: "unknown" }), {
|
|
319
|
+
status: 503,
|
|
320
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
const url = new URL(req.url);
|
|
324
|
+
const token = url.searchParams.get("token");
|
|
325
|
+
// UUID-shape validation — still reject malformed tokens early so the
|
|
326
|
+
// confirm page is never shown for obviously wrong inputs.
|
|
327
|
+
if (
|
|
328
|
+
!token ||
|
|
329
|
+
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(token)
|
|
330
|
+
) {
|
|
331
|
+
return new Response(buildVerifyFailurePage({ reason: "malformed" }), {
|
|
332
|
+
status: 400,
|
|
333
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
// Do NOT touch the store — token is consumed only by POST.
|
|
337
|
+
return new Response(buildVerifyConfirmPage({ token, publicUrl: opts.publicUrl }), {
|
|
338
|
+
status: 200,
|
|
339
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
340
|
+
});
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
// -----------------------------------------------------------------------
|
|
344
|
+
// POST /visitor-auth/verify
|
|
345
|
+
//
|
|
346
|
+
// Consumes the token and mints a vis_<uuid> visitor token.
|
|
347
|
+
// Accepts token either as a form-encoded body field or as JSON.
|
|
348
|
+
// Mail scanners do not auto-submit POSTs, so this path is human-only.
|
|
349
|
+
// -----------------------------------------------------------------------
|
|
350
|
+
{
|
|
351
|
+
method: "POST",
|
|
352
|
+
path: VERIFY_PATH,
|
|
353
|
+
auth: "none",
|
|
354
|
+
rateLimit: { maxPerMinute: 60 },
|
|
355
|
+
handler: async (req, _opts) => {
|
|
356
|
+
if (!booted || !signingCryptoKey) {
|
|
357
|
+
return new Response(buildVerifyFailurePage({ reason: "unknown" }), {
|
|
358
|
+
status: 503,
|
|
359
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Read token from form-encoded body or JSON body.
|
|
364
|
+
let token: string | null = null;
|
|
365
|
+
let bodyParseFailed = false;
|
|
366
|
+
try {
|
|
367
|
+
const ct = req.headers.get("content-type") ?? "";
|
|
368
|
+
if (ct.includes("application/x-www-form-urlencoded")) {
|
|
369
|
+
const text = await req.text();
|
|
370
|
+
const params = new URLSearchParams(text);
|
|
371
|
+
token = params.get("token");
|
|
372
|
+
} else {
|
|
373
|
+
// Treat anything else as JSON (including application/json and
|
|
374
|
+
// the default browser fetch content-type).
|
|
375
|
+
const body = (await req.json()) as Record<string, unknown>;
|
|
376
|
+
token = typeof body.token === "string" ? body.token : null;
|
|
377
|
+
}
|
|
378
|
+
} catch {
|
|
379
|
+
// Body could not be parsed (non-JSON, binary, etc.).
|
|
380
|
+
bodyParseFailed = true;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// UUID-shape validation — distinguish parse failure from malformed UUID.
|
|
384
|
+
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
385
|
+
const postReason = bodyParseFailed
|
|
386
|
+
? "bad-body"
|
|
387
|
+
: !token || !uuidRegex.test(token)
|
|
388
|
+
? "malformed"
|
|
389
|
+
: null;
|
|
390
|
+
if (postReason) {
|
|
391
|
+
return new Response(buildVerifyFailurePage({ reason: postReason }), {
|
|
392
|
+
status: 400,
|
|
393
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
// postReason is null ⟹ parse succeeded AND token is a valid UUID string.
|
|
397
|
+
const validToken = token!;
|
|
398
|
+
|
|
399
|
+
const t = now();
|
|
400
|
+
const consume = store.consumeToken(validToken, t);
|
|
401
|
+
if (!consume.consumed) {
|
|
402
|
+
const status = store.tokenStatus(validToken, t);
|
|
403
|
+
const reason: "unknown" | "expired" | "consumed" =
|
|
404
|
+
status === "unknown" ? "unknown" : status === "expired" ? "expired" : "consumed";
|
|
405
|
+
const httpStatus = status === "unknown" ? 404 : 410;
|
|
406
|
+
return new Response(buildVerifyFailurePage({ reason }), {
|
|
407
|
+
status: httpStatus,
|
|
408
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Mint a visitor token bound to the verified email's peer.
|
|
413
|
+
// Uses the SAME signing key webTransport derives from VISITOR_SIGNING_KEY,
|
|
414
|
+
// so the token will verify cleanly on the next /agent/run request.
|
|
415
|
+
//
|
|
416
|
+
// CRITICAL: on re-verification of an already-known email, reuse the
|
|
417
|
+
// EXISTING visitorId so peer-scoped state in layered-memory remains
|
|
418
|
+
// continuous. Minting a fresh visitorId here would orphan the
|
|
419
|
+
// visitor's prior conversation history under the old id.
|
|
420
|
+
//
|
|
421
|
+
// EXCEPTION: if the row is revoked, the operator destroyed that identity.
|
|
422
|
+
// Re-verify must establish a NEW identity (fresh vis_<uuid>); the revoked
|
|
423
|
+
// row is un-revoked and rotated to the new id via unrevokeAndRotate so
|
|
424
|
+
// the UNIQUE-email constraint is not violated by a second INSERT.
|
|
425
|
+
const ttlSec = reverifyDays * 86_400;
|
|
426
|
+
const existing = store.findVerifiedByEmail(consume.email!);
|
|
427
|
+
// Revoked rows must NOT reuse the old visitorId — that identity was destroyed.
|
|
428
|
+
const reuseVisitorId = existing && !existing.revoked ? existing.visitorId : undefined;
|
|
429
|
+
// Use `let` so the race-loser path can reassign minted to carry the
|
|
430
|
+
// winner's visitorId (see F4 UNIQUE catch below).
|
|
431
|
+
let minted = await createVisitorToken(
|
|
432
|
+
signingCryptoKey,
|
|
433
|
+
agentBinding,
|
|
434
|
+
ttlSec,
|
|
435
|
+
reuseVisitorId,
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
// Record / touch the verified-visitor row:
|
|
439
|
+
// - Active (non-revoked) row: touch lastSeenAt, preserve visitorId.
|
|
440
|
+
// - Revoked row: un-revoke + rotate to new visitorId (avoids INSERT
|
|
441
|
+
// UNIQUE-constraint collision on email).
|
|
442
|
+
// - No row: fresh INSERT, with UNIQUE-race guard (see F4 fix below).
|
|
443
|
+
if (existing && !existing.revoked) {
|
|
444
|
+
store.touchVerifiedVisitor(consume.email!, t);
|
|
445
|
+
} else if (existing?.revoked) {
|
|
446
|
+
store.unrevokeAndRotate(consume.email!, minted.payload.visitorId, t, t + ttlSec * 1000);
|
|
447
|
+
} else {
|
|
448
|
+
try {
|
|
449
|
+
store.recordVerifiedVisitor({
|
|
450
|
+
visitorId: minted.payload.visitorId,
|
|
451
|
+
email: consume.email!,
|
|
452
|
+
verifiedAt: t,
|
|
453
|
+
lastSeenAt: t,
|
|
454
|
+
reverifyDueAt: t + ttlSec * 1000,
|
|
455
|
+
revoked: false,
|
|
456
|
+
revokedAt: null,
|
|
457
|
+
revokedReason: null,
|
|
458
|
+
});
|
|
459
|
+
} catch (err) {
|
|
460
|
+
// F4: concurrent first-verifies UNIQUE race — two requests for the
|
|
461
|
+
// same email both pass `existing === null` and both attempt INSERT.
|
|
462
|
+
// The second throws a SQLite UNIQUE constraint error on the email
|
|
463
|
+
// column. Re-fetch the WINNER's row and RE-MINT a token carrying
|
|
464
|
+
// the winner's visitorId. Without this, the loser's user receives a
|
|
465
|
+
// token with a vis_id not in the verified_visitors table, which
|
|
466
|
+
// causes webTransport's revocation check (findVisitorById) to return
|
|
467
|
+
// null and silently admit the loser as a different identity — breaking
|
|
468
|
+
// identity-continuity with layered-memory's peer-scoped storage.
|
|
469
|
+
const isUniqueViolation =
|
|
470
|
+
err instanceof Error && /UNIQUE constraint failed/i.test(err.message);
|
|
471
|
+
if (!isUniqueViolation) throw err;
|
|
472
|
+
const winner = store.findVerifiedByEmail(consume.email!);
|
|
473
|
+
if (winner && !winner.revoked) {
|
|
474
|
+
store.touchVerifiedVisitor(consume.email!, t);
|
|
475
|
+
// RE-MINT with the winner's visitorId so the token payload matches
|
|
476
|
+
// the row in verified_visitors — identity-continuity preserved.
|
|
477
|
+
minted = await createVisitorToken(
|
|
478
|
+
signingCryptoKey,
|
|
479
|
+
agentBinding,
|
|
480
|
+
ttlSec,
|
|
481
|
+
winner.visitorId,
|
|
482
|
+
);
|
|
483
|
+
} else {
|
|
484
|
+
// Defensive: winner row vanished or is revoked between INSERT failure
|
|
485
|
+
// and re-fetch. Surface the original error rather than issue a token
|
|
486
|
+
// that nobody in the table will recognize.
|
|
487
|
+
throw err;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Anonymous→recognized peer-id migration. The verify route knows the
|
|
493
|
+
// OLD peer-id (consume.peerId from the token row) and the NEW vis_<uuid>
|
|
494
|
+
// (minted above; reuses existing visitorId on re-verify). Best-effort;
|
|
495
|
+
// failures are logged and don't block success.
|
|
496
|
+
migratePeerIdOnVerify(
|
|
497
|
+
opts.layeredMemoryDbPath === undefined ? "./memory.db" : opts.layeredMemoryDbPath,
|
|
498
|
+
consume.peerId!,
|
|
499
|
+
minted.payload.visitorId,
|
|
500
|
+
);
|
|
501
|
+
|
|
502
|
+
// Operator notification on first verify per email (optional).
|
|
503
|
+
if (opts.notifyOnFirstVerify) {
|
|
504
|
+
const cfg = opts.notifyOnFirstVerify;
|
|
505
|
+
if (!store.hasNotifiedFirstVerifyFor(consume.email!)) {
|
|
506
|
+
// F5: mark-after-send — only record the ledger entry when the send
|
|
507
|
+
// actually succeeds. Trade-off: if the agent crashes between
|
|
508
|
+
// send-success and markNotifiedFirstVerifyFor, the operator gets a
|
|
509
|
+
// duplicate notification on the next verify retry. Accepted —
|
|
510
|
+
// a duplicate ops note is preferable to a permanently dropped one.
|
|
511
|
+
const subject = `${cfg.subjectPrefix ?? "[New verified visitor] "}${consume.email}`;
|
|
512
|
+
const text = `A new visitor verified their email: ${consume.email!} (vis_id: ${minted.payload.visitorId}).`;
|
|
513
|
+
try {
|
|
514
|
+
const notifyResult = await agentMail.send({
|
|
515
|
+
inboxId: opts.agentMail.inboxId,
|
|
516
|
+
to: [cfg.to],
|
|
517
|
+
subject,
|
|
518
|
+
text,
|
|
519
|
+
labels: ["visitor-auth", "first-verify-operator-note"],
|
|
520
|
+
});
|
|
521
|
+
if (notifyResult.status === "sent") {
|
|
522
|
+
store.markNotifiedFirstVerifyFor(consume.email!, t);
|
|
523
|
+
} else {
|
|
524
|
+
console.warn(
|
|
525
|
+
`[visitor-auth] first-verify operator notification failed (destination redacted): ${(notifyResult as { detail?: string }).detail ?? "unknown"}. Will retry on next verify.`,
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
} catch (err) {
|
|
529
|
+
console.warn(
|
|
530
|
+
`[visitor-auth] first-verify operator notification failed: ${(err as Error).message}`,
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return new Response(
|
|
537
|
+
buildVerifySuccessPage({ visitorToken: minted.token, email: consume.email! }),
|
|
538
|
+
{
|
|
539
|
+
status: 200,
|
|
540
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
541
|
+
},
|
|
542
|
+
);
|
|
543
|
+
},
|
|
544
|
+
},
|
|
545
|
+
],
|
|
546
|
+
async onBoot() {
|
|
547
|
+
// Fail-fast on placeholder env-var leakage (operator forgot to set .env).
|
|
548
|
+
if (looksLikePlaceholder(opts.agentMail.apiKey)) {
|
|
549
|
+
throw new Error(
|
|
550
|
+
`visitorAuth: AGENTMAIL_API_KEY is unresolved (got "${opts.agentMail.apiKey}"). Set it in .env and restart.`,
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
if (looksLikePlaceholder(opts.agentMail.inboxId)) {
|
|
554
|
+
throw new Error(
|
|
555
|
+
`visitorAuth: AGENTMAIL_INBOX_ID is unresolved. Set it in .env and restart.`,
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
if (looksLikePlaceholder(opts.signingKey)) {
|
|
559
|
+
throw new Error(
|
|
560
|
+
"visitorAuth: VISITOR_SIGNING_KEY is unresolved. Set it in .env and restart (the same value webTransport uses).",
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
// F12: an unresolved agentBinding silently degrades token-payload checks
|
|
564
|
+
// (every minted token's `agent` field becomes the literal "${AGENT_BINDING}",
|
|
565
|
+
// which still self-consistently verifies — masking the misconfig).
|
|
566
|
+
// Fail loud at boot instead.
|
|
567
|
+
if (looksLikePlaceholder(agentBinding)) {
|
|
568
|
+
throw new Error(
|
|
569
|
+
`visitorAuth: agentBinding is unresolved (got "${agentBinding}"). Set the referenced env var in .env and restart, or remove the agentBinding option to use the "auggy" default.`,
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
store.initialize();
|
|
574
|
+
signingCryptoKey = await deriveSigningKey(opts.signingKey);
|
|
575
|
+
|
|
576
|
+
// AgentMail healthcheck. Severity branches on httpStatus (F9):
|
|
577
|
+
// 401 / 403 / 404 → operator misconfig (bad API key, missing inbox).
|
|
578
|
+
// Throw at boot so the operator notices before the first visitor
|
|
579
|
+
// hits a silent send-failure.
|
|
580
|
+
// 5xx / network errors → transient. Warn and continue; the first
|
|
581
|
+
// real send will surface the same error if it persists.
|
|
582
|
+
const health = await agentMail.getInbox(opts.agentMail.inboxId);
|
|
583
|
+
if (health.status !== "ok") {
|
|
584
|
+
const httpStatus = health.httpStatus;
|
|
585
|
+
if (httpStatus === 401 || httpStatus === 403 || httpStatus === 404) {
|
|
586
|
+
throw new Error(
|
|
587
|
+
`visitorAuth: AgentMail inbox "${opts.agentMail.inboxId}" healthcheck failed with HTTP ${httpStatus}: ${health.detail}. ` +
|
|
588
|
+
`Check AGENTMAIL_API_KEY and AGENTMAIL_INBOX_ID in .env and restart.`,
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
console.warn(
|
|
592
|
+
`[visitor-auth] AgentMail inbox "${opts.agentMail.inboxId}" healthcheck failed: ${health.detail}. ` +
|
|
593
|
+
`First send will surface the real error.`,
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// F11: periodic rate-limiter sweep. Hourly cadence is well below the
|
|
598
|
+
// 24h window so inactive entries are evicted within a window of their
|
|
599
|
+
// last activity. unref() so the timer doesn't hold the event loop open
|
|
600
|
+
// (mirrors how launchd-managed processes shut down on SIGTERM).
|
|
601
|
+
const RATE_LIMIT_SWEEP_INTERVAL_MS = opts._rateLimitSweepIntervalMs ?? 60 * 60_000; // 1h
|
|
602
|
+
rateLimiterSweepHandle = setInterval(() => {
|
|
603
|
+
const t = now();
|
|
604
|
+
const evicted = rateLimiter.sweep(t);
|
|
605
|
+
opts._onRateLimitSweep?.(evicted, t);
|
|
606
|
+
}, RATE_LIMIT_SWEEP_INTERVAL_MS);
|
|
607
|
+
if (typeof rateLimiterSweepHandle.unref === "function") {
|
|
608
|
+
rateLimiterSweepHandle.unref();
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
booted = true;
|
|
612
|
+
},
|
|
613
|
+
async onTurnStart(turn: TurnState) {
|
|
614
|
+
if (!turn.peer) return;
|
|
615
|
+
const peerId = turn.peer.id;
|
|
616
|
+
// F10: amortized eviction of stale recentByPeer / lastSeenByPeer entries.
|
|
617
|
+
// Runs once every RECENT_PEER_SWEEP_EVERY turns to keep the per-turn cost
|
|
618
|
+
// O(1) on average.
|
|
619
|
+
onTurnStartCounter++;
|
|
620
|
+
if (onTurnStartCounter % RECENT_PEER_SWEEP_EVERY === 0) {
|
|
621
|
+
const cutoff = now() - RECENT_PEER_TTL_MS;
|
|
622
|
+
for (const [id, lastSeen] of lastSeenByPeer) {
|
|
623
|
+
if (lastSeen < cutoff) {
|
|
624
|
+
lastSeenByPeer.delete(id);
|
|
625
|
+
recentByPeer.delete(id);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
// Track liveness for the next sweep. Always update — even when there's
|
|
630
|
+
// no inbound text, the peer is active.
|
|
631
|
+
lastSeenByPeer.set(peerId, now());
|
|
632
|
+
// Pull the visitor's text from the inbound message payload.
|
|
633
|
+
const payload = turn.trigger.payload as
|
|
634
|
+
| { parts?: Array<{ kind: string; text?: string }> }
|
|
635
|
+
| undefined;
|
|
636
|
+
const text = (payload?.parts ?? [])
|
|
637
|
+
.filter((p) => p.kind === "text" && typeof p.text === "string")
|
|
638
|
+
.map((p) => p.text!)
|
|
639
|
+
.join("\n");
|
|
640
|
+
if (!text) return;
|
|
641
|
+
const messageId = (turn.trigger.payload as { metadata?: { messageId?: string } })?.metadata
|
|
642
|
+
?.messageId;
|
|
643
|
+
const list = recentByPeer.get(peerId) ?? [];
|
|
644
|
+
list.push({ text, messageId });
|
|
645
|
+
while (list.length > RECENT_MESSAGES) list.shift();
|
|
646
|
+
recentByPeer.set(peerId, list);
|
|
647
|
+
},
|
|
648
|
+
async onShutdown() {
|
|
649
|
+
if (rateLimiterSweepHandle !== null) {
|
|
650
|
+
clearInterval(rateLimiterSweepHandle);
|
|
651
|
+
rateLimiterSweepHandle = null;
|
|
652
|
+
}
|
|
653
|
+
if (booted) {
|
|
654
|
+
store.close();
|
|
655
|
+
booted = false;
|
|
656
|
+
}
|
|
657
|
+
},
|
|
658
|
+
async context(turn: TurnState): Promise<ContextBlock[]> {
|
|
659
|
+
if (!booted) return [];
|
|
660
|
+
if (!turn.peer) return [];
|
|
661
|
+
|
|
662
|
+
const t = now();
|
|
663
|
+
|
|
664
|
+
// Verified-by-id branch: peer.id starts with vis_ → look up by visitor id.
|
|
665
|
+
// Walk listVerifiedVisitors (small at operator scale) to find the row.
|
|
666
|
+
if (turn.peer.id.startsWith("vis_")) {
|
|
667
|
+
const all = store.listVerifiedVisitors();
|
|
668
|
+
const row = all.find((r) => r.visitorId === turn.peer!.id);
|
|
669
|
+
if (!row || row.revoked) return [];
|
|
670
|
+
store.touchVerifiedVisitor(row.email, t);
|
|
671
|
+
const verifiedAgo = humanRelativeMs(t - row.verifiedAt);
|
|
672
|
+
if (row.reverifyDueAt <= t) {
|
|
673
|
+
return [
|
|
674
|
+
block(`Verified email: ${row.email} — reverification due. Visitor should reverify.`),
|
|
675
|
+
];
|
|
676
|
+
}
|
|
677
|
+
return [block(`Verified email: ${row.email} (verified ${verifiedAgo}).`)];
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Anonymous branch: peer.id ~ anon-<threadId> → look up by token.
|
|
681
|
+
const recent = store.findMostRecentTokenForPeer(turn.peer.id, t);
|
|
682
|
+
if (!recent) return [];
|
|
683
|
+
if (recent.consumed) {
|
|
684
|
+
// Edge case: peer.id is still anon-* but token was consumed —
|
|
685
|
+
// verification happened but the chat tab hasn't applied the new
|
|
686
|
+
// token yet. No block; the next request will arrive as vis_*.
|
|
687
|
+
return [];
|
|
688
|
+
}
|
|
689
|
+
if (recent.expiresAt <= t) {
|
|
690
|
+
return [
|
|
691
|
+
block(`Verification email to ${recent.email} expired. Visitor may request a new one.`),
|
|
692
|
+
];
|
|
693
|
+
}
|
|
694
|
+
const sentMin = Math.max(0, Math.floor((t - recent.issuedAt) / 60_000));
|
|
695
|
+
const expiresMin = Math.max(1, Math.ceil((recent.expiresAt - t) / 60_000));
|
|
696
|
+
return [
|
|
697
|
+
block(
|
|
698
|
+
`Verification email sent to ${recent.email} (sent ${sentMin}m ago, expires in ${expiresMin}m). Awaiting click.`,
|
|
699
|
+
),
|
|
700
|
+
];
|
|
701
|
+
},
|
|
702
|
+
};
|
|
703
|
+
|
|
704
|
+
return augment;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function block(content: string): ContextBlock {
|
|
708
|
+
return {
|
|
709
|
+
source: "visitor-auth",
|
|
710
|
+
content,
|
|
711
|
+
placement: "preamble",
|
|
712
|
+
provenance: "augment",
|
|
713
|
+
priority: "normal",
|
|
714
|
+
eviction: "drop",
|
|
715
|
+
origin: "system",
|
|
716
|
+
ttl: "session",
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function humanRelativeMs(ms: number): string {
|
|
721
|
+
const min = Math.floor(ms / 60_000);
|
|
722
|
+
if (min < 60) return `${min}m ago`;
|
|
723
|
+
const hr = Math.floor(min / 60);
|
|
724
|
+
if (hr < 24) return `${hr}h ago`;
|
|
725
|
+
const day = Math.floor(hr / 24);
|
|
726
|
+
return `${day}d ago`;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Best-effort anonymous→recognized peer-id migration on the layeredMemory
|
|
731
|
+
* SQLite file. Runs ONE UPDATE statement; logs + continues on any error so
|
|
732
|
+
* verify-success is not blocked by an unrelated DB issue. Skipped when
|
|
733
|
+
* `dbPath` is null/undefined or the file does not exist.
|
|
734
|
+
*/
|
|
735
|
+
function migratePeerIdOnVerify(
|
|
736
|
+
dbPath: string | null | undefined,
|
|
737
|
+
oldPeerId: string,
|
|
738
|
+
newPeerId: string,
|
|
739
|
+
): void {
|
|
740
|
+
if (!dbPath) return;
|
|
741
|
+
if (!existsSync(dbPath)) {
|
|
742
|
+
console.warn(
|
|
743
|
+
`[visitor-auth] layeredMemory db "${dbPath}" not found; skipping peer-id migration for ${oldPeerId}`,
|
|
744
|
+
);
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
// No-op when ids are identical (re-verify after token-expiry where peer
|
|
748
|
+
// already arrives as vis_*; nothing to migrate).
|
|
749
|
+
if (oldPeerId === newPeerId) return;
|
|
750
|
+
let db: Database | null = null;
|
|
751
|
+
try {
|
|
752
|
+
db = new Database(dbPath, { readwrite: true });
|
|
753
|
+
db.run("PRAGMA journal_mode = WAL");
|
|
754
|
+
const result = db
|
|
755
|
+
.prepare(`UPDATE entries SET peer_id = ? WHERE peer_id = ?`)
|
|
756
|
+
.run(newPeerId, oldPeerId);
|
|
757
|
+
if (result.changes > 0) {
|
|
758
|
+
console.info(
|
|
759
|
+
`[visitor-auth] migrated ${result.changes} memory row(s) ${oldPeerId} → ${newPeerId}`,
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
} catch (err) {
|
|
763
|
+
console.warn(
|
|
764
|
+
`[visitor-auth] peer-id migration failed for ${oldPeerId} → ${newPeerId}: ${(err as Error).message}`,
|
|
765
|
+
);
|
|
766
|
+
} finally {
|
|
767
|
+
// Guarantee close on every path (success, mid-statement throw, anything).
|
|
768
|
+
if (db) {
|
|
769
|
+
try {
|
|
770
|
+
db.close();
|
|
771
|
+
} catch {
|
|
772
|
+
// Ignore close errors — db is already in an unrecoverable state.
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// Internal-only re-exports for Task 7+ (avoid duplicating types in tests).
|
|
779
|
+
export type { VisitorAuthOptions };
|