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,164 @@
1
+ /**
2
+ * Storage record shapes + the abstract VisitorAuthStore interface.
3
+ * Splitting the storage contract from the SQLite impl lets us swap to
4
+ * a Postgres-backed store later without touching the augment.
5
+ */
6
+
7
+ export type TokenStatus = "open" | "consumed" | "expired" | "unknown";
8
+
9
+ export interface IssueTokenArgs {
10
+ token: string;
11
+ email: string;
12
+ peerId: string;
13
+ threadId: string;
14
+ expiresAt: number; // epoch ms
15
+ sourceMessageId: string | null;
16
+ }
17
+
18
+ export interface ConsumeTokenResult {
19
+ /** True iff exactly one row transitioned from consumed=0 to consumed=1. */
20
+ consumed: boolean;
21
+ /** Set when consumed=true. */
22
+ email?: string;
23
+ /** Set when consumed=true. */
24
+ peerId?: string;
25
+ /** Set when consumed=true. */
26
+ threadId?: string;
27
+ }
28
+
29
+ export interface VerifiedVisitorRow {
30
+ visitorId: string; // vis_<uuid>
31
+ email: string;
32
+ verifiedAt: number; // epoch ms
33
+ lastSeenAt: number | null;
34
+ reverifyDueAt: number; // epoch ms
35
+ revoked: boolean;
36
+ revokedAt: number | null;
37
+ revokedReason: string | null;
38
+ }
39
+
40
+ export interface OpenTokenForPeer {
41
+ token: string;
42
+ email: string;
43
+ expiresAt: number; // epoch ms
44
+ issuedAt: number; // epoch ms
45
+ }
46
+
47
+ export interface VisitorAuthStore {
48
+ /**
49
+ * Idempotent schema apply. Safe to call repeatedly; safe on a fresh DB.
50
+ * Called from onBoot before any other operation.
51
+ */
52
+ initialize(): void;
53
+ /**
54
+ * Insert a new token row. Throws on PK collision (caller should generate
55
+ * a fresh UUID — collisions are statistically impossible in normal use).
56
+ */
57
+ issueToken(args: IssueTokenArgs): void;
58
+ /**
59
+ * Atomic consume. Single SQL UPDATE, returns whether exactly one row
60
+ * transitioned. When consumed=true the row's email/peerId/threadId are
61
+ * returned for the caller to mint the visitor token.
62
+ *
63
+ * Per spec fix #8 — the entire decision lives in `changes()`, no race.
64
+ */
65
+ consumeToken(token: string, now: number): ConsumeTokenResult;
66
+ /**
67
+ * Read-only status query. Used by the verify route to disambiguate
68
+ * 410 (consumed/expired) from 404 (unknown) after a failed consumeToken.
69
+ */
70
+ tokenStatus(token: string, now: number): TokenStatus;
71
+ /**
72
+ * The most-recent OPEN (unconsumed, unexpired) token for this peer, if any.
73
+ * Used by `request_auth` to invalidate prior open tokens before issuing a new one.
74
+ */
75
+ findOpenTokenForPeer(peerId: string, now: number): OpenTokenForPeer | null;
76
+ /**
77
+ * The most-recent token for this peer regardless of consumed/expired status.
78
+ * Used by context() to surface "verification expired" state. Returns null
79
+ * if the peer has never had a token issued.
80
+ */
81
+ findMostRecentTokenForPeer(
82
+ peerId: string,
83
+ now: number,
84
+ ): {
85
+ email: string;
86
+ expiresAt: number;
87
+ issuedAt: number;
88
+ consumed: boolean;
89
+ } | null;
90
+ /**
91
+ * Mark every open token for this peer as consumed (without minting a
92
+ * visitor token). Used when a peer requests a new email; the prior code
93
+ * goes dead.
94
+ */
95
+ invalidateOpenTokensForPeer(peerId: string, now: number): number;
96
+ /**
97
+ * Mark exactly one token as consumed if it is still open. Returns true
98
+ * if a row was modified (the token existed and was unconsumed), false
99
+ * otherwise. Used by the request_auth failure-path cleanup so a failed
100
+ * send only invalidates its OWN token — without this, a concurrent
101
+ * request_auth call from the same peer could be invalidated as
102
+ * collateral when the failed call's cleanup runs (F3).
103
+ */
104
+ invalidateTokenIfStillOpen(token: string, now: number): boolean;
105
+ /**
106
+ * Insert a verified-visitor row. Caller has already minted the visitor token.
107
+ * If a row with the same email exists and is not revoked, throws — caller
108
+ * should treat this as "already verified, prefer existing identity"
109
+ * (handled in the verify route).
110
+ */
111
+ recordVerifiedVisitor(row: VerifiedVisitorRow): void;
112
+ /** Returns the row for an email, or null. */
113
+ findVerifiedByEmail(email: string): VerifiedVisitorRow | null;
114
+ /** Update lastSeenAt; no-op if email is unknown or revoked. */
115
+ touchVerifiedVisitor(email: string, now: number): void;
116
+ /** All verified-visitor rows, ordered by verifiedAt DESC. Used by `auggy visitors`. */
117
+ listVerifiedVisitors(): VerifiedVisitorRow[];
118
+ /**
119
+ * Hard-revoke. Sets revoked=1 + reason. Returns the visitorId or null
120
+ * if the email was unknown. Used by `auggy visitors --revoke`.
121
+ */
122
+ revokeByEmail(email: string, reason: string, now: number): string | null;
123
+ /**
124
+ * Lookup a verified-visitor row by visitorId. Returns null if no row with
125
+ * that id exists. Used by `isVisitorRevoked` to enable real-time revocation
126
+ * checks at webTransport ingress (fix C1).
127
+ */
128
+ findVisitorById(visitorId: string): VerifiedVisitorRow | null;
129
+ /**
130
+ * Un-revoke a previously-revoked row and rotate to a fresh visitorId.
131
+ * Called by the verify route when a visitor re-verifies after their identity
132
+ * was revoked. A single UPDATE atomically clears revocation state, writes the
133
+ * new vis_<uuid>, and updates verifiedAt / lastSeenAt / reverifyDueAt.
134
+ *
135
+ * Returns true iff exactly one revoked row was updated. Returns false if the
136
+ * email is unknown or the row is not revoked (no-op guard).
137
+ */
138
+ unrevokeAndRotate(
139
+ email: string,
140
+ newVisitorId: string,
141
+ verifiedAt: number,
142
+ reverifyDueAt: number,
143
+ ): boolean;
144
+ /**
145
+ * Permanently record a visitor_id as revoked. The denylist survives even
146
+ * when the row is rotated by unrevokeAndRotate (which rewrites visitor_id),
147
+ * so old tokens cannot bypass the revocation check by returning a row that
148
+ * no longer carries the old id.
149
+ *
150
+ * INSERT OR IGNORE — idempotent; second call with the same id is a no-op.
151
+ */
152
+ addRevokedVisitorId(visitorId: string, email: string, reason: string, now: number): void;
153
+ /**
154
+ * Returns true iff the given visitor_id appears in the permanent denylist.
155
+ * Used by isVisitorRevoked in index.ts as the first check before falling
156
+ * through to the live row lookup.
157
+ */
158
+ isVisitorIdRevoked(visitorId: string): boolean;
159
+ /** True iff the augment has emitted notifyOnFirstVerify for this email yet. */
160
+ hasNotifiedFirstVerifyFor(email: string): boolean;
161
+ /** Mark notifyOnFirstVerify as fired for this email. Idempotent. */
162
+ markNotifiedFirstVerifyFor(email: string, now: number): void;
163
+ close(): void;
164
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Type definitions for the visitorAuth augment.
3
+ *
4
+ * Exposed to the auggy resolver (consumes VisitorAuthOptions) and to the
5
+ * augment's internal modules. All shapes here are stable contracts; storage
6
+ * record shapes live in storage/types.ts (a deliberate split — operator-facing
7
+ * config is separate from on-disk representation).
8
+ */
9
+
10
+ /**
11
+ * AgentMail delivery configuration. visitorAuth uses src/agentmail-client.ts
12
+ * directly (see Plan §"Spec deviation"). Operator wires apiKey + inboxId via
13
+ * env-var interpolation in agent.yaml.
14
+ */
15
+ export interface AgentMailConfig {
16
+ /** Bearer token (`am_*` prefix). Resolve via `${AGENTMAIL_API_KEY}` in agent.yaml. */
17
+ apiKey: string;
18
+ /** AgentMail inbox the verify email is sent FROM. */
19
+ inboxId: string;
20
+ /** Optional subject prefix prepended to the templated subject. Default: `[Verify] `. */
21
+ subjectPrefix?: string;
22
+ /** Optional override for the AgentMail API base URL (testing/sandbox). */
23
+ apiBaseUrl?: string;
24
+ }
25
+
26
+ /**
27
+ * Per-anonymous-peer rate-limit caps for `request_auth` calls. Defaults:
28
+ * 1 send per hour, 3 sends per 24 hours. State is in-memory (resets on
29
+ * restart — documented behavior; the verified_visitors UNIQUE-on-email
30
+ * constraint catches accidental double-verification).
31
+ */
32
+ export interface VisitorAuthRateLimit {
33
+ perHour: number;
34
+ perDay: number;
35
+ }
36
+
37
+ /**
38
+ * Operator notification fired the FIRST time an email verifies on this agent.
39
+ * Optional; when set, visitorAuth uses agentmail-client to send a one-line
40
+ * note from inboxId TO the operator address. Independent from `notify`.
41
+ */
42
+ export interface NotifyOnFirstVerifyConfig {
43
+ to: string;
44
+ /** Optional subject prefix (default `[New verified visitor] `). */
45
+ subjectPrefix?: string;
46
+ }
47
+
48
+ export interface VisitorAuthOptions {
49
+ /**
50
+ * Public-facing base URL for the magic link, e.g. `https://zip.lorf.dev`.
51
+ * Must be a valid URL with `http://` or `https://` scheme. Required because
52
+ * the magic-link URL embedded in the email is `<publicUrl>/visitor-auth/verify?token=<uuid>`.
53
+ */
54
+ publicUrl: string;
55
+ /** Path to the visitor-auth SQLite database. Default: `./visitor-auth.db` (relative to agent dir). */
56
+ dbPath: string;
57
+ /** AgentMail delivery config. Required. */
58
+ agentMail: AgentMailConfig;
59
+ /**
60
+ * HMAC signing key for minting `vis_<uuid>` visitor tokens after a successful
61
+ * verify. MUST match webTransport's `visitorTokens.signingKey`. Resolve via
62
+ * `${VISITOR_SIGNING_KEY}` in agent.yaml (same env var both augments read).
63
+ */
64
+ signingKey: string;
65
+ /**
66
+ * Stable identifier this agent uses for visitor tokens (fix C2). MUST match
67
+ * webTransport's `visitorTokens.agentBinding`. Default: `"auggy"`.
68
+ *
69
+ * When two agents share the same signing key, setting distinct `agentBinding`
70
+ * values on each prevents cross-agent replay: a token minted for agent A
71
+ * will be rejected by agent B because the embedded agentId will not match
72
+ * agent B's expected binding.
73
+ */
74
+ agentBinding?: string;
75
+ /** Optional rate-limit caps. Defaults: { perHour: 1, perDay: 3 }. */
76
+ rateLimit?: VisitorAuthRateLimit;
77
+ /** Days before reverification is required. Default: 90. */
78
+ reverifyAfterDays?: number;
79
+ /** Token TTL in minutes. Default: 15. */
80
+ tokenTtlMinutes?: number;
81
+ /** Optional operator-notification on first verify per email. */
82
+ notifyOnFirstVerify?: NotifyOnFirstVerifyConfig;
83
+ /**
84
+ * Path to the layeredMemory SQLite database for the anonymous→recognized
85
+ * peer-id migration on successful verify. Default: `./memory.db` (relative
86
+ * to agent dir). Set to `null` to disable migration (anonymous history will
87
+ * be orphaned but still queryable by threadId).
88
+ */
89
+ layeredMemoryDbPath?: string | null;
90
+ }
91
+
92
+ /**
93
+ * Extra surface exposed by the visitorAuth augment beyond the base Augment
94
+ * interface. Consumed by the augment resolver to wire the revocation check
95
+ * into webTransport without requiring a new kernel surface (fix C1).
96
+ */
97
+ export interface VisitorAuthAugmentExtras {
98
+ /**
99
+ * Returns `true` iff the visitor with the given `vis_<uuid>` id has been
100
+ * revoked. Reads directly from the store — no caching, always current.
101
+ * Intended to be wired as `webTransport.visitorTokens.revocationCheck`.
102
+ */
103
+ isVisitorRevoked(visitorId: string): boolean;
104
+ }
105
+
106
+ /** Return shape of `request_auth({...})`. JSON-stringified by the tool. */
107
+ export interface RequestAuthResult {
108
+ status: "sent" | "rejected" | "failed";
109
+ message: string;
110
+ /** Present iff status === "sent". TTL of the issued token. */
111
+ expiresInSec?: number;
112
+ }
113
+
114
+ /**
115
+ * Snapshot of the most-recent visitor message text the augment uses for
116
+ * the email-in-recent-message validation. The transcript itself lives
117
+ * in the kernel; visitorAuth only needs the visitor's recent text.
118
+ */
119
+ export interface RecentVisitorMessage {
120
+ text: string;
121
+ /** Optional message id; recorded with the token for audit. */
122
+ messageId?: string;
123
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Pure HTML builders for the verify-success + verify-failure pages.
3
+ *
4
+ * Security posture (spec fix #5):
5
+ * - <meta name="referrer" content="no-referrer">
6
+ * - Zero external assets (inline CSS, no fonts, no analytics, no images)
7
+ * - history.replaceState fires on load to drop the token from the URL bar
8
+ * before any browser history snapshot
9
+ * - The visitor token is JSON-encoded and </script>-escaped before being
10
+ * written into the inline <script> block
11
+ * - The email is rendered via document.createTextNode (innerText), not innerHTML
12
+ */
13
+
14
+ export interface VerifyConfirmPageInput {
15
+ token: string;
16
+ publicUrl: string;
17
+ }
18
+
19
+ export interface VerifySuccessPageInput {
20
+ visitorToken: string;
21
+ email: string;
22
+ }
23
+
24
+ export interface VerifyFailurePageInput {
25
+ reason: "expired" | "consumed" | "unknown" | "malformed" | string;
26
+ }
27
+
28
+ function htmlEscape(value: string): string {
29
+ return value
30
+ .replace(/&/g, "&amp;")
31
+ .replace(/</g, "&lt;")
32
+ .replace(/>/g, "&gt;")
33
+ .replace(/"/g, "&quot;")
34
+ .replace(/'/g, "&#39;");
35
+ }
36
+
37
+ /**
38
+ * JSON-encode a string for safe interpolation inside an inline <script>.
39
+ * The U+003C escape neutralizes any embedded `</script>` so the script block
40
+ * cannot be terminated by attacker-controlled content.
41
+ */
42
+ function jsStringLiteral(value: string): string {
43
+ return JSON.stringify(value).replace(/</g, "\\u003c");
44
+ }
45
+
46
+ const COMMON_HEAD = `<!DOCTYPE html>
47
+ <html lang="en">
48
+ <head>
49
+ <meta charset="utf-8">
50
+ <meta name="referrer" content="no-referrer">
51
+ <meta name="viewport" content="width=device-width, initial-scale=1">
52
+ <title>Auggy — verification</title>
53
+ <style>
54
+ :root { color-scheme: light dark; font-family: ui-sans-serif, system-ui, sans-serif; }
55
+ body { margin: 0; padding: 3rem 1rem; max-width: 36rem; margin-inline: auto; line-height: 1.5; }
56
+ h1 { font-size: 1.25rem; margin: 0 0 0.5rem; }
57
+ p { margin: 0.5rem 0; color: #555; }
58
+ @media (prefers-color-scheme: dark) {
59
+ body { background: #111; color: #eee; }
60
+ p { color: #aaa; }
61
+ }
62
+ </style>
63
+ </head>`;
64
+
65
+ /**
66
+ * Confirmation page returned by GET /visitor-auth/verify.
67
+ *
68
+ * Mail scanners follow links passively (GET); they do NOT auto-submit forms.
69
+ * Returning this page on GET means the scanner harmlessly receives the
70
+ * confirmation page without consuming the one-time token. The human clicks
71
+ * "Verify my email" which triggers a form POST that actually consumes it.
72
+ *
73
+ * Security notes:
74
+ * - <meta name="referrer" content="no-referrer"> prevents the token from
75
+ * leaking in Referer headers to third parties.
76
+ * - The form POSTs to an absolute URL (publicUrl) to guarantee same-site
77
+ * submission even if the user opened the link on a different device.
78
+ * - Inline JS disables the button after click to prevent accidental double-
79
+ * submit within the same page load.
80
+ * - NO localStorage write, NO history.replaceState — those happen on the
81
+ * success page served after the POST.
82
+ */
83
+ export function buildVerifyConfirmPage(input: VerifyConfirmPageInput): string {
84
+ const base = input.publicUrl.endsWith("/") ? input.publicUrl.slice(0, -1) : input.publicUrl;
85
+ const actionUrl = `${base}/visitor-auth/verify`;
86
+ // Token comes from validated UUID input — safe to embed as a hidden field value.
87
+ // htmlEscape is applied for correctness even though UUIDs are [0-9a-f-] only.
88
+ const safeToken = htmlEscape(input.token);
89
+ return `${COMMON_HEAD}
90
+ <body>
91
+ <h1>Verify your email</h1>
92
+ <p>Click the button below to complete email verification. This link can only be used once.</p>
93
+ <form method="POST" action="${htmlEscape(actionUrl)}" id="vf">
94
+ <input type="hidden" name="token" value="${safeToken}">
95
+ <button type="submit" id="btn" style="font-size:1rem;padding:0.6rem 1.4rem;cursor:pointer;">Verify my email</button>
96
+ </form>
97
+ <script>
98
+ (function(){
99
+ var btn = document.getElementById('btn');
100
+ var form = document.getElementById('vf');
101
+ if (form && btn) {
102
+ form.addEventListener('submit', function(){
103
+ btn.disabled = true;
104
+ btn.textContent = 'Verifying…';
105
+ });
106
+ }
107
+ })();
108
+ </script>
109
+ <noscript><p>Submit the form above to verify your email.</p></noscript>
110
+ </body>
111
+ </html>`;
112
+ }
113
+
114
+ export function buildVerifySuccessPage(input: VerifySuccessPageInput): string {
115
+ const tokenLit = jsStringLiteral(input.visitorToken);
116
+ const emailLit = jsStringLiteral(input.email);
117
+ return `${COMMON_HEAD}
118
+ <body>
119
+ <h1 id="title">Verifying…</h1>
120
+ <p id="msg">Please wait.</p>
121
+ <p id="storage-fallback" style="display:none">
122
+ Verified, but your browser blocked storage access. Copy this token manually to your chat tab:
123
+ <br><code id="manual-token" style="word-break:break-all"></code>
124
+ <br><small>(This may happen in private/incognito mode or sandboxed iframes.)</small>
125
+ </p>
126
+ <script>
127
+ (function () {
128
+ var token = ${tokenLit};
129
+ var email = ${emailLit};
130
+ var storageWorks = false;
131
+ try {
132
+ localStorage.setItem('auggy-visitor-token', token);
133
+ storageWorks = true;
134
+ } catch (_) { /* storage may be denied in private/incognito mode or sandboxed iframes */ }
135
+ try {
136
+ history.replaceState(null, '', './verified');
137
+ } catch (_) { /* older browsers — best-effort */ }
138
+ var titleEl = document.getElementById('title');
139
+ var msgEl = document.getElementById('msg');
140
+ var fallbackEl = document.getElementById('storage-fallback');
141
+ var manualEl = document.getElementById('manual-token');
142
+ if (titleEl) titleEl.textContent = 'Verified.';
143
+ if (storageWorks) {
144
+ if (msgEl) {
145
+ msgEl.textContent = 'Email verified: ' + email + '. You may close this tab; your chat tab will pick up the new identity on its next message. If you opened this link on a different device, refresh your chat tab.';
146
+ }
147
+ } else {
148
+ if (msgEl) msgEl.style.display = 'none';
149
+ if (fallbackEl) fallbackEl.style.display = '';
150
+ if (manualEl) manualEl.textContent = token;
151
+ }
152
+ })();
153
+ </script>
154
+ <noscript>
155
+ <p>Email verified, but JavaScript is required to apply the new identity to your chat tab. Please re-open your chat tab manually.</p>
156
+ </noscript>
157
+ </body>
158
+ </html>`;
159
+ }
160
+
161
+ const FAILURE_COPY: Record<string, string> = {
162
+ expired: "This verification link has expired. Please ask the agent to send a new one.",
163
+ consumed:
164
+ "This verification link has already been used. If you didn't expect this, request a new link.",
165
+ unknown: "We don't recognize this verification link. It may be malformed or out of date.",
166
+ malformed: "This verification link is malformed.",
167
+ "bad-body": "The verification request was malformed (could not parse the request body).",
168
+ };
169
+
170
+ export function buildVerifyFailurePage(input: VerifyFailurePageInput): string {
171
+ const known = FAILURE_COPY[input.reason];
172
+ const safeReason = known ?? "Verification failed.";
173
+ return `${COMMON_HEAD}
174
+ <body>
175
+ <h1>Verification failed</h1>
176
+ <p>${htmlEscape(safeReason)}</p>
177
+ </body>
178
+ </html>`;
179
+ }