CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

fix: unwedge CI, and three real defects the broken test harness was hiding #4040

Draft⚡ AI-generatedXLccantynz wants to mergeclaude/bug-finding-mission-gegp06main↑17 ↓121opened Jul 25, 202611/15 tasksLive: 0 editing
15 changed files+687−119
ModifiedCLAUDE.md+19−1View fileUnifiedSplit
588588| 117 | **Observability gaps that let issue #105 run 9 days unnoticed, beyond deploy-drift (issue #78, fixed) and no-alerting-at-all (issue #72, already tracked).** No send-volume anomaly detection exists anywhere — only a flat per-account rate-limit ceiling (`middleware/rate-limit.ts`), sized for legitimate heavy use, with nothing comparing current volume to an account's own historical baseline. Failed-login protection is the same shape: a flat 10-req/min per-IP cap on `/v1/auth/*`, no per-account failed-attempt tracking, no credential-stuffing detection (many IPs, each individually under the cap, targeting one account), and nothing alerts when the cap trips. | HIGH | 2026-07-20 | OPEN — depends on issue #72 (no alerting pipeline exists yet) to be actionable, not just logged |
589589| 118 | **`lib/ai.ts`'s `aiComplete()` — used by AI triage, `ai-rules.ts`, `ai-intelligence.ts`, and other callers — has the same prompt-injection framing gap fixed in `ai-writing.ts` this session (issue #107), but was deliberately left unfixed pending a survey.** | MEDIUM | 2026-07-20 | FIXED 2026-07-20 — surveyed all 4 callers (all single user-role messages, no multi-turn) and applied the same delimiter-wrapping fix once inside `aiComplete()` itself, so both providers (Claude + Vapron) inherit it. Test added. |
590590| 119 | **Migration caution: `emails_account_message_id_idx` (migration `0008_overjoyed_risque.sql`, part of issue #107's dedup fix) is a new unique constraint on `(account_id, message_id)`.** It was reasoned safe (idempotent sends dedupe upstream of the DB write; synthetic import IDs are always-fresh UUIDs) but was not verified against live production data. | LOW | 2026-07-20 | **FIXED/VERIFIED 2026-07-20** — ran a read-only `GROUP BY ... HAVING COUNT(*) > 1` query against the live production `emails` table on Jarvis (via `bun run --env-file`, no raw credentials handled): **0 duplicate `(account_id, message_id)` pairs found.** Migration 0008 is confirmed safe to apply on the next `db:migrate`. |
591| 120 | **CI was red on `main` for BOTH required gates, so no PR could merge at all.** (a) `bun run lint` exited non-zero on two pre-existing errors (`routes/messages.ts` dead assignment, `routes/sentiment-timeline.ts` dropped error cause) — and per issue #106 "Lint" is a required status check, so branch protection was blocking every merge regardless of the PR's own quality. (b) `bun run test` failed with 32 failures, gating the "GateTest Quality Gate" check too. Net effect: the protection added in #106 to stop main breaking had instead wedged the repo shut, and the only reason it wasn't noticed is that `enforce_admins` is deliberately off (#66) so admin direct-pushes still landed. | HIGH | 2026-07-25 | FIXED 2026-07-25 — both lint errors fixed; all 32 test failures resolved via #121/#122. Lint now 0 errors / 41 pre-existing no-console warnings (#30), typecheck 36/36 workspaces, tests 48/48 tasks and 245/245 in apps/api. |
592| 121 | **`@alecrae/ai-engine`'s export map pointed every subpath's `import` condition at a `./dist/*.js` tree that is never built** (the package's `build` script is a bare `echo`). Bun resolves the `bun` condition so production and typecheck were clean — but vitest/Vite resolves `import`, so 20 tests across 6 files died at module load. Those included the tests for **hard quota enforcement, suppression-list enforcement at send time, and `POST /v1/messages/send` validation** — i.e. three real protections whose tests had not executed for as long as the map has been in this shape, which is exactly how issue #122 hid. CLAUDE.md issue #29 had recorded this as "a pre-existing, unrelated vitest↔`@alecrae/ai-engine` dist-resolution gap" and treated the 6 voice-message tests as unrunnable in the sandbox; that diagnosis was wrong — it was the export map, not the sandbox. | HIGH | 2026-07-25 | FIXED 2026-07-25 — `import` now points at the same source file as `bun`, matching the package's own `main`/`types`. Strictly safe: no consumer could ever have resolved the phantom dist path, and `apps/web` does not import this package. All 20 tests now run and pass, the 6 voice-message ones included. |
593| 122 | **Monthly email quota was silently unenforced whenever Redis was unavailable.** `checkQuota()`'s DB fallback counts `events` rows of type `"email.queued"` — and **nothing in the entire codebase ever inserted one**; the only reference to that string anywhere was quota.ts's own `SELECT`. So the fallback counted 0 forever, `allowed` was `true` unconditionally, and the 429 (when raised elsewhere) reported "0 sent". `incrementQuota()` early-returned on no-Redis behind the comment *"DB counter is updated separately by the existing code"* — describing code that does not exist, so outage-period sends were never counted even retroactively. Not an exotic path: `getRedis()` returns `null` until ioredis's async `ready` event lands, so **the first send after every API restart took it**, and per this file's own infra history the box ran with **no redis-server installed at all until 2026-07-14** — quota was simply not enforced for that entire period. | HIGH | 2026-07-25 | FIXED 2026-07-25 — `incrementQuota()` now writes the durable `email.queued` event row (with optional emailId/messageId/recipient provenance) as well as the Redis counter. No double-count: `checkQuota()` reads Redis OR the DB, never sums; and because the row is written regardless of Redis health, a mid-month outage falls back to a whole-month count rather than restarting at zero. Deliberately **no** webhook dispatch for `email.queued` — unlike `email.received` (#70) it is not offered as a subscribable event in any UI or SDK surface, so there is no user-facing gap. 8 tests in `quota-db-fallback.test.ts`; 3 of them fail against the old code, and the load-bearing one asserts the writer and reader agree on the exact event-type string (the defect was the two sides disagreeing, not either being wrong alone). The pre-existing `quota.test.ts` could never have caught this — it mocks the whole quota module, so it only checked how the route reacts to a `checkQuota` result the test itself invented. |
594| 123 | **Plan-tier resolution failed OPEN to paid tiers in three places**, inverting the purpose of `middleware/plan-gate.ts` (#86). (a) `normaliseTier()` returned `"starter"` for any unrecognised/missing value, so a bearer token with no `tier` claim arrived as a paid tier and passed `requirePlan("personal")`. (b) `resolveApiKeyFromDb()` escalated harder: if the accounts lookup **threw**, a production API key was handed `tier: "pro"` outright — a transient Postgres blip unlocked all 60 `requirePlan("pro")` mounts (every Claude-backed endpoint) with no spend ceiling, precisely the exposure #86 exists to close. (c) `lib/jwt.ts`'s refresh path defaulted to `"starter"` while every login/register/OAuth path in `routes/auth.ts` already defaulted to `"free"` — so a user's tier could silently change, **upward**, purely by refreshing a token. | HIGH | 2026-07-25 | FIXED 2026-07-25 — all three resolve to `"free"`, and the two silently-swallowed catch blocks now log. Worst case of this direction is a paying customer briefly seeing an upgrade prompt; worst case of the old direction was uncapped AI spend on a free account. 13 tests in `tier-fail-closed.test.ts`, which also pin the DB-vs-API spelling bridge (`"professional"``"pro"`) since regressing *that* 403s every paying Pro customer on every Pro-gated route — the opposite failure mode. |
595| 124 | **Two findings from the same sweep, reported not fixed** (each needs a decision that isn't mine to make unilaterally). (a) **Rate limiting is trivially bypassable.** `middleware/rate-limit.ts`'s `getClientIp()` reads `CF-Connecting-IP` first, then `X-Forwarded-For[0]`, then `X-Real-IP`, with **no trusted-proxy verification anywhere in the repo** (grep confirms zero `trust proxy`/`TRUSTED_PROXY` config). Any client that can reach the API other than through Cloudflare — e.g. Traefik on the box by Host header, or direct to the origin — sets `CF-Connecting-IP` to a fresh random value per request and gets a fresh bucket every time, defeating `authRateLimit`'s 10/min brute-force protection on `/v1/auth/*` and `globalIpRateLimit` alike. This compounds issue #117 (no per-account failed-login tracking, no credential-stuffing detection): the flat per-IP cap was the only thing there, and it isn't binding. Fixing it needs to know which proxy is authoritative and whether the origin is reachable off-Cloudflare — getting it wrong breaks real IP attribution, so it's Craig's infra call. Lower-severity same-file note: in Redis mode a rejected request is `ZADD`ed *before* the count check, so rejections extend the lockout; the in-memory fallback doesn't count them, giving genuinely different limiter behaviour depending on Redis health. (b) **`middleware/idempotency()` has no in-flight lock**, only a post-hoc response cache, and it is mounted on `POST /v1/messages/send`. Two *concurrent* requests carrying the same `Idempotency-Key` both miss the cache and both execute the handler — two real sends. Sequential retries (the documented case) do work. A correct fix is a `SET NX` reservation before `next()` plus a wait/409 for the loser, which is a behaviour change on the send hot path and deserves its own scoped change. | HIGH (a) / MEDIUM (b) | 2026-07-25 | OPEN — both reported with root cause + suggested fix; neither touched. |
591596## 🧭 PRODUCT DECISIONS LOG
592597
593598| Date | Decision | Rationale |
758763- **Mail services** (`alecrae-mta` outbound worker, `services/inbound` MX
759764 listener) are **not running anywhere yet** — phased bring-up is the mail plan.
760765
761**Last updated:** 2026-07-22 09:00 UTC
766**Last updated:** 2026-07-25 12:15 UTC
767**Shipped 2026-07-25 — bug-finding mission (issues #120–#124):** Craig asked for a serious bug hunt. Started by actually running the gates rather than trusting the docs, which immediately turned up the biggest finding: **CI was red on `main` for both required status checks, so no PR could merge at all** (#120). `bun run lint` failed on two pre-existing errors and `bun run test` had 32 failures. The branch protection added in #106 to stop main breaking had instead wedged the repo shut; the only reason nobody hit it is that `enforce_admins` is deliberately off (#66), so admin direct-pushes kept landing.
768
769Root-causing the test failures found the more interesting bug underneath. **`@alecrae/ai-engine`'s export map pointed every subpath's `import` condition at a `dist/` tree that is never built** (#121) — Bun uses the `bun` condition so production and typecheck were clean, but vitest resolves `import`, so 20 tests died at module load. Among them: **hard quota enforcement, suppression-list enforcement at send time, and `POST /v1/messages/send` validation.** Three real protections whose tests had not run for as long as that map has been in this shape. Worth noting for the audit trail: issue #29 had recorded this as an unrelated sandbox/vitest limitation and written off 6 voice-message tests as unrunnable here. That diagnosis was wrong — one line per export entry fixed it and all 6 pass.
770
771And the broken harness was hiding a real defect exactly where you'd expect. **Monthly email quota was silently unenforced whenever Redis was unavailable** (#122): `checkQuota()`'s DB fallback counts `events` rows of type `email.queued`, and nothing in the codebase ever wrote one — the only reference to that string anywhere was quota.ts's own SELECT. So the fallback counted 0 forever and `allowed` was unconditionally `true`. `incrementQuota()` early-returned on no-Redis behind a comment describing code that does not exist. This is not a corner case: `getRedis()` returns null until ioredis's async `ready` fires, so the first send after every restart took that path, and this file's own infra history says the box ran with no redis-server installed until 2026-07-14 — quota was simply not enforced for that whole period.
772
773Third real bug, found reading the plan gate: **tier resolution failed open to PAID tiers in three places** (#123). A token with no `tier` claim became `"starter"` and passed `requirePlan("personal")`; worse, if the accounts lookup *threw*, a production API key was handed `"pro"` outright, so a transient Postgres blip unlocked all 60 Pro-gated Claude endpoints with no spend ceiling — the exact inverse of what #86 added plan-gate.ts to do. And the refresh path defaulted to `"starter"` while every login path already defaulted to `"free"`, so a user's tier could silently move *upward* just by refreshing a token. All three now fail closed to `"free"`.
774
775Also rewrote the three vapron test files, which still asserted the guessed tRPC transport that #83 replaced — they were not just failing, they encoded known-wrong behaviour as the expectation. Both surfaces are now covered separately (REST for email/AI/storage, tRPC for the DNS methods that genuinely still use it) so they can't be swapped again silently.
776
777**Two findings reported and deliberately NOT fixed (#124),** because each needs a decision I shouldn't make unilaterally: rate limiting is trivially bypassable by spoofing `CF-Connecting-IP` (no trusted-proxy verification exists anywhere in the repo — this defeats the 10/min brute-force cap on `/v1/auth/*` and compounds #117, but fixing it requires knowing which proxy is authoritative and whether the origin is reachable off-Cloudflare — Craig's infra call); and `middleware/idempotency()` has no in-flight lock, only a post-hoc cache, so two *concurrent* requests with the same `Idempotency-Key` both send for real on `POST /v1/messages/send`.
778
779Gates after this session: lint 0 errors, typecheck 36/36 workspaces, tests 48/48 tasks and 245/245 in apps/api. Every fix carries regression tests, and the quota + tier tests were verified to fail against the pre-fix code.
762780**Shipped 2026-07-22 — full journey audit + build plan:** Craig's frustration was explicit and specific: features get added but nobody follows through on what actually drives them (the DNS/domains issue he hit live is the proof), and existing "audits" have relied on markdown docs and grep-based coverage tools that don't verify real behavior — he asked for a fresh, ground-truth audit of every tab, live-verified, producing a real build plan. Ran a 49-agent parallel workflow, one per dashboard nav tab, each explicitly instructed to distrust CLAUDE.md's own claims and trace the real frontend→API→DB/AI path independently. Result: `docs/audits/2026-07-22-full-journey-audit.md` — 14 real, 22 partial, 7 broken-today, 5 never-wired, 1 outright fabricated (Encryption's "auto-encrypted" claim: the server generates an unrelated keypair instead of using the client's real key, so nothing "encrypted" to a user could ever be decrypted by them). The standout finding: 11 separate features (Knowledge Graph, Hygiene, Productivity, Sentiment, Attachments, Scheduling, Scripts, Programs, Auto-Responder, A/B Testing, Mail Merge) all promise to "run automatically on new mail," and none of them do, because nothing in the real email-ingestion pipeline ever dispatches to any of them — one real fix, not eleven. Craig's own Domains blocker was root-caused live in the same session: adding a domain never touches DNS at all, and the one auto-config path relevant to his setup (Vapron, whose nameservers his domain actually uses) is broken on the same unverified transport bug fixed for email/AI/storage earlier the same day — deliberately left unfixed pending real Vapron DNS docs, which only Craig can obtain. This audit doc is now the authoritative status source for these 49 journeys per the Ground-Truth Sources table — supersedes prior "FIXED" claims for anything it covers, and is checked into git so it can't be lost the way the 2026-07-19 audit was.
763781**Shipped 2026-07-21:** Continued straight through the open audit backlog with no check-in pauses (standing instruction — see memory). Closed issue #116(c): dunning state transitions (payment failed / downgraded / recovered) now actually email the customer, gated behind an opt-in env var, with 13 new tests covering both "fires exactly once" and "correctly silent" cases (a voluntary cancel never gets a false "payment failed" email; a mid-cycle retry doesn't re-send). Separately, Craig supplied working Vapron platform API docs + a live key, which surfaced that issue #83 was bigger than a wrong key prefix — `lib/vapron.ts`'s entire email/AI/storage transport had been built against a guessed, unpublished tRPC shape that never matched the real REST API. Rewrote that transport against the real docs (DNS methods left untouched — no docs supplied for those, not re-guessed) and used the new `storage.getUploadUrl()` to fully fix issue #29's file-upload and voice-message stubs (previously an honest 501; now a real presigned-upload flow, honest 502/503 on failure, never a fabricated URL). Could not live-verify against the real API from this sandbox — outbound calls carrying a live secret are blocked by the permission classifier by design; verification is pending either Craig running the curl himself or the next box deploy. All changes typecheck clean across all 36 workspace tasks.
764782**Shipped 2026-07-20 (continued) — audit backlog, round 2:** Fixed 9 more of the findings logged as issues #108-119 (see those rows for full detail), same standard as round 1 (real tests, full typecheck, individually committed/pushed): applied `ssrf-guard.ts` to the 3 real gaps found (webhook delivery, integration test-send, transcription — extended `safeFetch` to support POST+body); closed delegation's cross-workspace grant + cross-account inbox leak, and made push-subscription re-parenting explicit; verified migration 0008's unique constraint against live production data (0 duplicates, confirmed safe); surveyed and fixed `lib/ai.ts`'s prompt-injection framing gap (all 4 callers are single-turn, safe to apply the same fix as `ai-writing.ts`); wired the dead-code bounce classifier into real delivery events; added service/port drift detection (`scripts/check-service-drift.sh`) — the actual gap class behind issue #105, since #78 only ever caught code-version drift; fixed the incident-response runbook's undocumented JWT_SECRET-rotation side effect; closed the duplicate-Stripe-subscription risk on repeat checkout; fixed Outlook sync to handle delta-query deletion markers instead of mis-parsing them as garbage messages. 4 findings remain open (#110 web token storage — architectural, #114 HTML rendering pipeline — deliberately not rushed given the stakes, #116(b)(c)(d) multi-currency/dunning-notification/GDPR export, #117 observability — blocked on #72's alerting pipeline not existing yet).
Modifiedapps/api/src/lib/jwt.ts+9−4View fileUnifiedSplit
423423 throw new TokenError("membership_revoked", "No longer a member of this workspace");
424424 }
425425
426 // Look up account tier
427 let tier = "starter";
426 // Look up account tier. Defaults to "free", never a paid tier — a refresh
427 // that can't resolve the account's plan must not mint a token claiming one
428 // (see normaliseTier in middleware/auth.ts).
429 let tier = "free";
428430 try {
429431 const [account] = await db
430432 .select({ planTier: accounts.planTier })
432434 .where(eq(accounts.id, activeAccountId))
433435 .limit(1);
434436 if (account) tier = account.planTier ?? "free";
435 } catch {
436 // fall through
437 } catch (err) {
438 console.warn(
439 "[jwt] Account tier lookup failed on refresh; defaulting to free (fail closed):",
440 err instanceof Error ? err.message : String(err),
441 );
437442 }
438443
439444 // Issue new pair in the same family
Modifiedapps/api/src/lib/quota.ts+55−4View fileUnifiedSplit
121121/**
122122 * DB fallback: count queued events for this account in the current UTC month.
123123 * Uses the events table as the source of truth when Redis is unavailable.
124 *
125 * These rows are written by `incrementQuota()` on every enqueue. Until that was
126 * wired, NOTHING in the codebase ever inserted an `email.queued` event, so this
127 * query always returned 0 and the fallback silently disabled quota enforcement
128 * entirely (see incrementQuota's doc comment).
124129 */
125130async function getCountFromDb(accountId: string): Promise<number> {
126131 const db = getDatabase();
180185 };
181186}
182187
188/** Optional provenance for the durable `email.queued` row. */
189export interface QueuedEmailRecord {
190 emailId?: string;
191 messageId?: string;
192 recipient?: string;
193}
194
183195/**
184 * Atomically increment the quota counter AFTER successful enqueue.
185 * Fire-and-forget safe — failures are logged but do not block the caller.
196 * Record one queued email against the account's monthly quota, AFTER a
197 * successful enqueue. Fire-and-forget safe — failures are logged, never thrown.
198 *
199 * Writes BOTH counters, deliberately:
200 *
201 * - A durable `email.queued` event row. This is the source of truth
202 * `getCountFromDb()` reads whenever Redis is unavailable. It previously
203 * wrote nothing at all: the only reference to `"email.queued"` anywhere in
204 * the codebase was quota.ts's own SELECT, so the fallback counted 0 forever
205 * and `checkQuota()` returned `allowed: true` unconditionally — no account
206 * could ever exceed its plan limit's enforcement, and the 429 response
207 * reported "0 sent". That fired on any Redis blip AND on the first send
208 * after every API restart, because `getRedis()` returns null until the
209 * async "ready" event lands. The old code's comment here ("DB counter is
210 * updated separately by the existing code") described code that did not
211 * exist.
212 *
213 * - The Redis month bucket, which `checkQuota()` prefers as the fast path.
214 *
215 * Writing both never double-counts: `checkQuota()` reads Redis OR the DB, never
216 * sums them. Because the event row is written on every send regardless of Redis
217 * health, a mid-month Redis outage falls back to a count that covers the whole
218 * month rather than restarting from zero.
186219 */
187export async function incrementQuota(accountId: string): Promise<void> {
220export async function incrementQuota(
221 accountId: string,
222 record: QueuedEmailRecord = {},
223): Promise<void> {
224 // Durable counter first — it's the one enforcement falls back to.
225 try {
226 const db = getDatabase();
227 await db.insert(events).values({
228 id: crypto.randomUUID().replace(/-/g, ""),
229 accountId,
230 emailId: record.emailId ?? null,
231 messageId: record.messageId ?? null,
232 type: "email.queued",
233 recipient: record.recipient ?? null,
234 });
235 } catch (err) {
236 console.warn("[quota] Failed to record email.queued event:", (err as Error).message);
237 }
238
188239 const redis = getRedis();
189 if (!redis) return; // DB counter is updated separately by the existing code
240 if (!redis) return;
190241
191242 try {
192243 const key = currentMonthKey(accountId);
Modifiedapps/api/src/middleware/auth.ts+28−6View fileUnifiedSplit
8686/**
8787 * Plan tier mapping. The DB stores plan_tier enum values like "professional",
8888 * but our API types use "pro". Normalise here.
89 *
90 * Unknown/missing values resolve to "free", NOT a paid tier. This used to
91 * default to "starter", which meant any caller whose tier couldn't be
92 * determined — a JWT with no `tier` claim, an account row that failed to load —
93 * was silently granted a paid tier, defeating middleware/plan-gate.ts for every
94 * `requirePlan("personal")` feature. Plan gating must fail closed: the worst
95 * case of getting this wrong is a paying customer briefly seeing an upgrade
96 * prompt, not a free account with uncapped AI spend.
8997 */
9098function normaliseTier(dbTier: string | null | undefined): PlanTier {
9199 switch (dbTier) {
99107 case "enterprise":
100108 return "enterprise";
101109 default:
102 return "starter";
110 return "free";
103111 }
104112}
105113
114/**
115 * Test-only alias for `normaliseTier`. Exported so the fail-closed contract can
116 * be asserted directly (tests/tier-fail-closed.test.ts) rather than inferred
117 * through a full request; not intended for production callers.
118 */
119export const normaliseTierForTest = normaliseTier;
120
106121async function resolveApiKeyFromDb(
107122 rawKey: string,
108123): Promise<AuthContext | null> {
150165 )
151166 : [];
152167
153 // Look up account to get the real plan tier
154 let tier: PlanTier = "starter";
168 // Look up account to get the real plan tier. Fail CLOSED to "free" if the
169 // account row is missing or the lookup errors — this previously defaulted
170 // to "starter" and, on a DB error, actively escalated a production key to
171 // "pro", i.e. a transient Postgres blip handed out every Pro-gated
172 // Claude-backed endpoint with no spend ceiling. That inverts what
173 // plan-gate.ts exists to do.
174 let tier: PlanTier = "free";
155175 try {
156176 const [account] = await db
157177 .select({ planTier: accounts.planTier })
161181 if (account) {
162182 tier = normaliseTier(account.planTier);
163183 }
164 } catch {
165 // Fall back to a safe default if the account lookup fails
166 tier = normaliseTier(record.environment === "test" ? "starter" : "pro");
184 } catch (err) {
185 console.warn(
186 "[auth] Account tier lookup failed; defaulting to free (fail closed):",
187 err instanceof Error ? err.message : String(err),
188 );
167189 }
168190
169191 return {
Modifiedapps/api/src/routes/messages.ts+10−3View fileUnifiedSplit
360360 // path used whatever was stored at connect time with no expiry check, so
361361 // sending broke ~1 hour after connecting and stayed broken until the user
362362 // manually reconnected the account.
363 let freshAccessToken = connectedAcct.accessToken;
363 let freshAccessToken: string;
364364 try {
365365 const fresh = await ensureFreshAccessToken({
366366 provider: connectedAcct.provider as "gmail" | "outlook",
847847 // ── 6b. Record send against warm-up counter (fire-and-forget) ────
848848 warmupOrchestrator.recordSend(domainRecord.id).catch(() => { /* fire-and-forget */ });
849849
850 // ── 6c. Increment quota counter in Redis (fire-and-forget) ──────
851 incrementQuota(auth.accountId).catch(() => {
850 // ── 6c. Record the send against the monthly quota (fire-and-forget) ──
851 // Writes both the durable `email.queued` event row (what checkQuota falls
852 // back to when Redis is down) and the Redis month bucket — see quota.ts.
853 const quotaRecipient = allRecipients[0] ?? input.to[0]?.email;
854 incrementQuota(auth.accountId, {
855 emailId: id,
856 messageId,
857 ...(quotaRecipient !== undefined ? { recipient: quotaRecipient } : {}),
858 }).catch(() => {
852859 /* fire-and-forget */
853860 });
854861
Modifiedapps/api/src/routes/sentiment-timeline.ts+1−0View fileUnifiedSplit
2323 err instanceof Error && err.message.includes("ANTHROPIC_API_KEY")
2424 ? "Sentiment analysis is unavailable — no AI provider configured."
2525 : `Sentiment analysis failed: ${err instanceof Error ? err.message : String(err)}`,
26 { cause: err },
2627 );
2728 }
2829}
Modifiedapps/api/tests/ai.test.ts+5−5View fileUnifiedSplit
1717}
1818
1919function vapronResponse(text: string): Response {
20 // tRPC/superjson success envelope wrapping an OpenAI-style gateway payload.
20 // Plain-JSON OpenAI-style gateway payload — the REST platform surface has no
21 // envelope to unwrap (issue #83's transport correction).
2122 return new Response(
2223 JSON.stringify({
23 result: {
24 data: { json: { id: "cmpl_1", choices: [{ index: 0, message: { role: "assistant", content: text } }] } },
25 },
24 id: "cmpl_1",
25 choices: [{ index: 0, message: { role: "assistant", content: text } }],
2626 }),
2727 { status: 200 },
2828 );
5757 process.env["VAPRON_API_KEY"] = "vpk_test";
5858 globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
5959 if (String(url).includes("api.anthropic.com")) return new Response("upstream", { status: 503 });
60 expect(String(url)).toContain("api.vapron.ai");
60 expect(String(url)).toBe("https://vapron.ai/api/platform/ai/chat");
6161 return vapronResponse("from vapron");
6262 }) as unknown as typeof fetch;
6363
Modifiedapps/api/tests/pii-redact.test.ts+2−2View fileUnifiedSplit
2525
2626 it("redacts an Anthropic-style API key", () => {
2727 const result = redactPii("Here's my key: sk-ant-api03-abcdefghijklmnopqrstuvwxyz1234567890");
28 expect(result.text).not.toContain("sk-ant-api03-abcdefghijklmnopqrstuvwxyz1234567890");
28 expect(result.text).not.toContain("REDACTED_BY_GLUECRON");
2929 expect(result.text).toContain("[REDACTED-KEY:");
3030 expect(result.redactedTypes).toContain("api_key");
3131 });
3232
3333 it("redacts a GitHub personal access token", () => {
34 const result = redactPii("token=ghp_1234567890abcdefghijklmnopqrstuvwxyz");
34 const result = redactPii("REDACTED_BY_GLUECRON");
3535 expect(result.text).toContain("[REDACTED-KEY:");
3636 });
3737
Addedapps/api/tests/quota-db-fallback.test.ts+223−0View fileUnifiedSplit
1/**
2 * Regression tests for the quota DB fallback.
3 *
4 * The bug: `getCountFromDb()` counts `events` rows of type `"email.queued"`,
5 * but NOTHING in the codebase ever inserted one — the only reference to that
6 * string anywhere was quota.ts's own SELECT. So whenever Redis was unavailable,
7 * the fallback counted 0, `checkQuota()` returned `allowed: true`
8 * unconditionally, and plan limits were silently unenforced. That path is not
9 * exotic: `getRedis()` returns null until ioredis's async "ready" event lands,
10 * so the first send after every API restart took it, and the box ran with no
11 * Redis installed at all for weeks.
12 *
13 * The existing tests/quota.test.ts mocks the whole quota module, so it could
14 * never catch this — it only ever asserted how the route reacts to a
15 * `checkQuota` result someone else made up. These tests exercise quota.ts
16 * itself with Redis forced unavailable.
17 */
18
19import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
20
21// ── Mock state ───────────────────────────────────────────────────────────────
22
23/** Every `eq(column, value)` the module under test builds, in order. */
24const eqCalls: { column: unknown; value: unknown }[] = [];
25
26vi.mock("drizzle-orm", async (importOriginal) => {
27 const actual = (await importOriginal()) as Record<string, unknown>;
28 return {
29 ...actual,
30 eq: vi.fn((column: unknown, value: unknown) => {
31 eqCalls.push({ column, value });
32 return { __eq: [column, value] };
33 }),
34 };
35});
36
37let mockPlanTier = "free";
38/** Rows the mocked `events` SELECT should report as already queued this month. */
39let mockQueuedCount = 0;
40/** Every `insert(events).values(...)` payload captured, in order. */
41const insertedEvents: Record<string, unknown>[] = [];
42/** Table identity of the most recent `.from()` call. */
43let lastFrom: string | null = null;
44
45vi.mock("@alecrae/db", () => {
46 const accounts = { id: "id", planTier: "plan_tier", __table: "accounts" };
47 const events = {
48 id: "id",
49 accountId: "account_id",
50 emailId: "email_id",
51 messageId: "message_id",
52 type: "type",
53 recipient: "recipient",
54 timestamp: "timestamp",
55 __table: "events",
56 };
57
58 const db = {
59 select: vi.fn().mockReturnThis(),
60 from: vi.fn().mockImplementation(function (this: unknown, table: { __table?: string }) {
61 lastFrom = table?.__table ?? null;
62 return this;
63 }),
64 where: vi.fn().mockImplementation(function (this: unknown) {
65 // The accounts lookup ends in .limit(); the events count ends here.
66 if (lastFrom === "events") return Promise.resolve([{ count: mockQueuedCount }]);
67 return this;
68 }),
69 limit: vi.fn().mockImplementation(() => Promise.resolve([{ planTier: mockPlanTier }])),
70 insert: vi.fn().mockReturnThis(),
71 values: vi.fn().mockImplementation((v: Record<string, unknown>) => {
72 insertedEvents.push(v);
73 return Promise.resolve(undefined);
74 }),
75 };
76
77 return { getDatabase: () => db, accounts, events };
78});
79
80// ── Setup ────────────────────────────────────────────────────────────────────
81
82beforeEach(() => {
83 // Point Redis at a closed port so getRedis() never becomes ready and every
84 // call takes the DB fallback path — the exact scenario the bug hid in.
85 process.env["REDIS_URL"] = "redis://127.0.0.1:1";
86 mockPlanTier = "free";
87 mockQueuedCount = 0;
88 insertedEvents.length = 0;
89 eqCalls.length = 0;
90 lastFrom = null;
91 vi.clearAllMocks();
92});
93
94afterEach(() => {
95 delete process.env["REDIS_URL"];
96});
97
98describe("quota.ts — durable email.queued counter", () => {
99 it("incrementQuota writes an email.queued event row", async () => {
100 const { incrementQuota } = await import("../src/lib/quota.js");
101
102 await incrementQuota("acct_1", {
103 emailId: "em_1",
104 messageId: "<abc@alecrae.com>",
105 recipient: "to@example.com",
106 });
107
108 expect(insertedEvents).toHaveLength(1);
109 expect(insertedEvents[0]).toMatchObject({
110 accountId: "acct_1",
111 emailId: "em_1",
112 messageId: "<abc@alecrae.com>",
113 type: "email.queued",
114 recipient: "to@example.com",
115 });
116 // Id must be present and follow the codebase's bare-32-hex convention.
117 expect(insertedEvents[0]?.["id"]).toMatch(/^[0-9a-f]{32}$/);
118 });
119
120 it("writes the event row even with no provenance supplied", async () => {
121 const { incrementQuota } = await import("../src/lib/quota.js");
122
123 await incrementQuota("acct_1");
124
125 expect(insertedEvents).toHaveLength(1);
126 expect(insertedEvents[0]).toMatchObject({
127 accountId: "acct_1",
128 type: "email.queued",
129 emailId: null,
130 messageId: null,
131 recipient: null,
132 });
133 });
134
135 /**
136 * THE test for this bug. The defect was never "the SELECT is wrong" or "the
137 * INSERT is wrong" in isolation — it was that the writer and the reader
138 * disagreed, because there was no writer at all. Asserting the two sides
139 * agree on the exact same event-type string is what stops the pair drifting
140 * apart again (e.g. someone renaming the enum value on one side only).
141 */
142 it("writes exactly the event type that the DB fallback filters on", async () => {
143 const { incrementQuota, checkQuota } = await import("../src/lib/quota.js");
144
145 await incrementQuota("acct_1", { emailId: "em_1" });
146 const writtenType = insertedEvents[0]?.["type"];
147
148 eqCalls.length = 0;
149 await checkQuota("acct_1");
150
151 // getCountFromDb() filters events by type; find that comparison's value.
152 const filteredTypes = eqCalls
153 .map((c) => c.value)
154 .filter((v): v is string => typeof v === "string" && v.startsWith("email."));
155
156 expect(writtenType).toBe("email.queued");
157 expect(filteredTypes).toContain(writtenType);
158 });
159
160 it("never throws when the event insert fails (fire-and-forget contract)", async () => {
161 const { getDatabase } = await import("@alecrae/db");
162 const db = getDatabase() as unknown as { values: ReturnType<typeof vi.fn> };
163 db.values.mockRejectedValueOnce(new Error("postgres down"));
164
165 const { incrementQuota } = await import("../src/lib/quota.js");
166
167 await expect(incrementQuota("acct_1", { emailId: "em_1" })).resolves.toBeUndefined();
168 });
169});
170
171describe("quota.ts — checkQuota DB fallback with Redis unavailable", () => {
172 it("counts the email.queued rows instead of always reporting 0", async () => {
173 mockPlanTier = "free";
174 mockQueuedCount = 7;
175
176 const { checkQuota } = await import("../src/lib/quota.js");
177 const result = await checkQuota("acct_1");
178
179 // The bug's signature was `sent: 0` no matter how much had been sent.
180 expect(result.sent).toBe(7);
181 expect(result.plan).toBe("free");
182 expect(result.allowed).toBe(true);
183 });
184
185 it("BLOCKS the send once the DB count reaches the plan limit", async () => {
186 const { PLANS } = await import("../src/lib/billing.js");
187 mockPlanTier = "free";
188 mockQueuedCount = PLANS.free.emailsPerMonth;
189
190 const { checkQuota } = await import("../src/lib/quota.js");
191 const result = await checkQuota("acct_1");
192
193 // Before the fix this was unconditionally `true` with `sent: 0`, i.e. the
194 // plan limit could never be enforced without Redis.
195 expect(result.allowed).toBe(false);
196 expect(result.sent).toBe(PLANS.free.emailsPerMonth);
197 expect(result.limit).toBe(PLANS.free.emailsPerMonth);
198 });
199
200 it("still allows a send one under the limit", async () => {
201 const { PLANS } = await import("../src/lib/billing.js");
202 mockPlanTier = "free";
203 mockQueuedCount = PLANS.free.emailsPerMonth - 1;
204
205 const { checkQuota } = await import("../src/lib/quota.js");
206 const result = await checkQuota("acct_1");
207
208 expect(result.allowed).toBe(true);
209 });
210
211 it("uses the account's real plan limit, not the free default", async () => {
212 const { PLANS } = await import("../src/lib/billing.js");
213 mockPlanTier = "professional";
214 mockQueuedCount = PLANS.free.emailsPerMonth + 1;
215
216 const { checkQuota } = await import("../src/lib/quota.js");
217 const result = await checkQuota("acct_1");
218
219 // Over the FREE limit but comfortably under professional's.
220 expect(result.limit).toBe(PLANS["professional"]?.emailsPerMonth);
221 expect(result.allowed).toBe(true);
222 });
223});
Modifiedapps/api/tests/sso.test.ts+1−1View fileUnifiedSplit
2020
2121// ── Test IdP key material (self-signed, for tests only) ──────────────────────
2222
23const IDP_PRIVATE_KEY = `-----BEGIN PRIVATE KEY-----
23// // // // // // // // // // // const IDP_PRIVATE_KEY = `-----BEGIN PRIVATE KEY----- // REDACTED_BY_GLUECRON // REDACTED_BY_GLUECRON // REDACTED_BY_GLUECRON // REDACTED_BY_GLUECRON // REDACTED_BY_GLUECRON // REDACTED_BY_GLUECRON // REDACTED_BY_GLUECRON // REDACTED_BY_GLUECRON // REDACTED_BY_GLUECRON // REDACTED_BY_GLUECRON // REDACTED_BY_GLUECRON
2424MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC0Bx4ssLqfnlq4
2525VjmytALB6NYHTmcqUIrP/qv5hFN35WT0GEzsniFxCRCbx6amHFgtOJkY4QCmKLcn
2626gNDrahom0dDxIZoGlu7h635ehRDoldMEy6zxfPHYVbTP6axTnRm6YBvmx6EiQOLk
Addedapps/api/tests/tier-fail-closed.test.ts+116−0View fileUnifiedSplit
1/**
2 * Regression tests: plan-tier resolution must fail CLOSED.
3 *
4 * middleware/auth.ts's `normaliseTier()` defaulted unknown/missing values to
5 * "starter" — a PAID tier. Three concrete consequences:
6 *
7 * 1. A bearer token with no `tier` claim resolved to "starter", so
8 * middleware/plan-gate.ts's `requirePlan("personal")` (which maps to the
9 * DB's "starter") let a free account straight through.
10 * 2. `resolveApiKeyFromDb()` escalated further: if the accounts lookup THREW,
11 * a production API key was handed `tier: "pro"` — a transient Postgres blip
12 * unlocked every Pro-gated, Claude-backed endpoint with no spend ceiling.
13 * That is the exact inverse of what plan-gate.ts exists to prevent.
14 * 3. lib/jwt.ts's refresh path defaulted to "starter" while every login/
15 * register/OAuth path in routes/auth.ts already defaulted to "free", so a
16 * user's tier could silently CHANGE — upward — just by refreshing a token.
17 *
18 * These assert the fail-closed contract at the tier→plan-gate boundary.
19 */
20
21import { describe, it, expect, vi, beforeEach } from "vitest";
22import { Hono } from "hono";
23import { requirePlan } from "../src/middleware/plan-gate.js";
24import type { PlanTier } from "../src/types.js";
25
26/**
27 * Mount a route behind `requirePlan(featureTier)` with `auth.tier` pre-set, so
28 * the gate's own decision is what's under test.
29 */
30function appWithTier(tier: unknown, featureTier: string): Hono {
31 const app = new Hono();
32 app.use("*", async (c, next) => {
33 c.set("auth" as never, { accountId: "acct_1", keyId: "k_1", tier, scopes: [] } as never);
34 await next();
35 });
36 app.get("/gated", requirePlan(featureTier), (c) => c.json({ ok: true }));
37 return app;
38}
39
40describe("plan-gate — a free tier is refused paid features", () => {
41 it("blocks 'free' from a personal-tier feature", async () => {
42 const res = await appWithTier("free", "personal").request("/gated");
43 expect(res.status).toBe(403);
44 const body = (await res.json()) as { error: { code: string; currentTier: string } };
45 expect(body.error.code).toBe("plan_upgrade_required");
46 expect(body.error.currentTier).toBe("free");
47 });
48
49 it("blocks 'free' from a pro-tier feature", async () => {
50 const res = await appWithTier("free", "pro").request("/gated");
51 expect(res.status).toBe(403);
52 });
53
54 it("blocks 'starter' from a pro-tier feature", async () => {
55 const res = await appWithTier("starter", "pro").request("/gated");
56 expect(res.status).toBe(403);
57 });
58
59 it("allows 'pro' through a pro-tier feature", async () => {
60 const res = await appWithTier("pro", "pro").request("/gated");
61 expect(res.status).toBe(200);
62 });
63
64 it("allows 'enterprise' through a pro-tier feature", async () => {
65 const res = await appWithTier("enterprise", "pro").request("/gated");
66 expect(res.status).toBe(200);
67 });
68
69 it("normalises the DB's 'professional' spelling to pass a pro gate", async () => {
70 // The DB enum spells it "professional"; the API type is "pro". auth.ts's
71 // normaliseTier() bridges them — if that ever regresses, a paying Pro
72 // customer gets 403'd on all 60 Pro-gated mounts.
73 const { normaliseTierForTest } = await import("../src/middleware/auth.js");
74 expect(normaliseTierForTest("professional")).toBe("pro");
75
76 const res = await appWithTier(normaliseTierForTest("professional"), "pro").request("/gated");
77 expect(res.status).toBe(200);
78 });
79});
80
81describe("normaliseTier — unknown input resolves to free, never a paid tier", () => {
82 let normaliseTier: (t: string | null | undefined) => PlanTier;
83
84 beforeEach(async () => {
85 vi.resetModules();
86 ({ normaliseTierForTest: normaliseTier } = await import("../src/middleware/auth.js"));
87 });
88
89 it("maps every real DB enum value correctly", () => {
90 expect(normaliseTier("free")).toBe("free");
91 expect(normaliseTier("starter")).toBe("starter");
92 expect(normaliseTier("professional")).toBe("pro");
93 expect(normaliseTier("pro")).toBe("pro");
94 expect(normaliseTier("enterprise")).toBe("enterprise");
95 });
96
97 it.each([
98 ["undefined", undefined],
99 ["null", null],
100 ["empty string", ""],
101 ["an unknown tier name", "platinum"],
102 ["a legacy pricing-table name with no billing", "business_plus"],
103 ])("resolves %s to 'free', not a paid tier", (_label, input) => {
104 const resolved = normaliseTier(input as string | null | undefined);
105 expect(resolved).toBe("free");
106 // The load-bearing assertion: never silently paid.
107 expect(["starter", "pro", "enterprise"]).not.toContain(resolved);
108 });
109
110 it("an unresolvable tier cannot reach a personal-tier feature", async () => {
111 // End-to-end version of the bug: a token with no `tier` claim used to
112 // arrive as "starter" and sail through requirePlan("personal").
113 const res = await appWithTier(normaliseTier(undefined), "personal").request("/gated");
114 expect(res.status).toBe(403);
115 });
116});
Modifiedapps/api/tests/transactional-email.test.ts+6−3View fileUnifiedSplit
2424
2525 it("sends via Vapron when configured", async () => {
2626 process.env["VAPRON_API_KEY"] = "vpk_test";
27 // Vapron's platform email endpoint answers with plain JSON — no tRPC
28 // envelope (issue #83's transport correction).
2729 const fetchMock = vi.fn(
28 async () =>
29 new Response(JSON.stringify({ result: { data: { json: { id: "msg_42" } } } }), { status: 200 }),
30 async () => new Response(JSON.stringify({ id: "msg_42" }), { status: 200 }),
3031 ) as unknown as typeof fetch;
3132 globalThis.fetch = fetchMock;
3233
3738 });
3839
3940 expect(result).toEqual({ sent: true, provider: "vapron", id: "msg_42" });
40 expect((fetchMock as unknown as ReturnType<typeof vi.fn>).mock.calls.length).toBe(1);
41 const calls = (fetchMock as unknown as ReturnType<typeof vi.fn>).mock.calls;
42 expect(calls.length).toBe(1);
43 expect(String(calls[0]?.[0])).toBe("https://vapron.ai/api/platform/email/send");
4144 });
4245
4346 it("no-ops without a network call when unconfigured", async () => {
Modifiedapps/api/tests/vapron.test.ts+170−48View fileUnifiedSplit
11/**
2 * Tests for the Vapron platform client (tRPC transport).
2 * Tests for the Vapron platform client.
33 *
4 * Verifies:
5 * 1. Requests carry the Bearer auth header + correct tRPC URL/body envelope
6 * 2. tRPC { error: { json } } envelopes surface as typed VapronError
7 * 3. ai.complete unwraps result.data.json and extracts assistant text
8 * 4. Missing VAPRON_API_KEY throws "not_configured" without a network call
4 * The client speaks TWO transports (see src/lib/vapron.ts's header):
5 *
6 * 1. The plain-REST "platform" surface — email, AI gateway, object storage.
7 * Base `https://vapron.ai/api/platform`, `Bearer <key>`, plain-JSON
8 * request AND response (no envelope). This is the transport corrected in
9 * issue #83 after the original was guessed against unpublished docs.
10 * 2. The tRPC admin surface — DNS zone/record management only. Base
11 * `https://api.vapron.ai/api/trpc`, `{ json }` request envelope,
12 * `result.data.json` response envelope. Still unverified against real
13 * docs, so it stays as-built.
14 *
15 * Both are covered here so a future change to one can't silently reshape the
16 * other — which is exactly what happened when the REST rewrite landed and
17 * this file (still asserting tRPC for email/AI) was left behind.
918 */
1019
1120import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
1322
1423const realFetch = globalThis.fetch;
1524
16/** Wrap a payload in the tRPC/superjson success envelope. */
25/** Wrap a payload in the tRPC/superjson success envelope (transport 2 only). */
1726function trpcOk(data: unknown): unknown {
1827 return { result: { data: { json: data } } };
1928}
2736 ) as unknown as typeof fetch;
2837}
2938
39function callOf(fetchMock: typeof fetch, index = 0): [string, RequestInit] {
40 return (fetchMock as unknown as ReturnType<typeof vi.fn>).mock.calls[index] as [string, RequestInit];
41}
42
3043beforeEach(() => {
3144 process.env["VAPRON_API_KEY"] = "vpk_test_key";
32 process.env["VAPRON_BASE_URL"] = "https://api.vapron.ai/api/trpc";
3345});
3446
3547afterEach(() => {
3749 vi.restoreAllMocks();
3850 delete process.env["VAPRON_API_KEY"];
3951 delete process.env["VAPRON_BASE_URL"];
52 delete process.env["VAPRON_PLATFORM_BASE_URL"];
4053});
4154
42describe("vapron client", () => {
55describe("vapron client — REST platform surface (email / AI / storage)", () => {
4356 it("reports configuration state from the env", () => {
4457 expect(isVapronConfigured()).toBe(true);
4558 delete process.env["VAPRON_API_KEY"];
4659 expect(isVapronConfigured()).toBe(false);
4760 });
4861
49 it("sends email with the Bearer header, tRPC URL and { json } body", async () => {
50 const fetchMock = mockFetch(200, trpcOk({ id: "msg_123" }));
62 it("sends email as plain JSON to the REST platform base with a Bearer header", async () => {
63 const fetchMock = mockFetch(200, { id: "msg_123" });
5164 globalThis.fetch = fetchMock;
5265
5366 const result = await vapron.email.send({
5770 });
5871
5972 expect(result.id).toBe("msg_123");
60 const [url, init] = (fetchMock as unknown as ReturnType<typeof vi.fn>).mock.calls[0] as [
61 string,
62 RequestInit,
63 ];
64 expect(url).toBe("https://api.vapron.ai/api/trpc/customerEmail.send");
73 const [url, init] = callOf(fetchMock);
74 expect(url).toBe("https://vapron.ai/api/platform/email/send");
6575 expect(init.method).toBe("POST");
6676 expect((init.headers as Record<string, string>)["Authorization"]).toBe("Bearer vpk_test_key");
77 expect((init.headers as Record<string, string>)["Content-Type"]).toBe("application/json");
78 // No `{ json: ... }` envelope — the body IS the payload.
6779 expect(JSON.parse(init.body as string)).toEqual({
68 json: { to: "a@b.com", subject: "Hi", html: "<p>Hi</p>" },
80 to: "a@b.com",
81 subject: "Hi",
82 html: "<p>Hi</p>",
6983 });
7084 });
7185
72 it("surfaces a tRPC { error: { json } } envelope as a typed VapronError", async () => {
73 globalThis.fetch = mockFetch(401, {
74 error: { json: { message: "Invalid key", data: { code: "UNAUTHORIZED", httpStatus: 401 } } },
86 it("honours VAPRON_PLATFORM_BASE_URL and strips trailing slashes", async () => {
87 process.env["VAPRON_PLATFORM_BASE_URL"] = "https://staging.vapron.ai/api/platform/";
88 const fetchMock = mockFetch(200, { id: "msg_1" });
89 globalThis.fetch = fetchMock;
90
91 await vapron.email.send({ to: "a@b.com", subject: "x", html: "y" });
92
93 expect(callOf(fetchMock)[0]).toBe("https://staging.vapron.ai/api/platform/email/send");
94 });
95
96 it("surfaces a REST error body as a typed VapronError", async () => {
97 globalThis.fetch = mockFetch(401, { error: "Invalid API key" });
98
99 await expect(vapron.email.send({ to: "a@b.com", subject: "x", html: "y" })).rejects.toMatchObject({
100 name: "VapronError",
101 code: "vapron_error",
102 status: 401,
103 message: "Invalid API key",
75104 });
105 });
106
107 it("surfaces a nested { error: { message } } REST error body", async () => {
108 globalThis.fetch = mockFetch(422, { error: { message: "Recipient rejected" } });
76109
77 await expect(vapron.email.send({ to: "a@b.com", subject: "x", html: "y" })).rejects.toMatchObject(
78 {
79 name: "VapronError",
80 code: "UNAUTHORIZED",
81 status: 401,
82 message: "Invalid key",
83 },
84 );
110 await expect(vapron.email.send({ to: "a@b.com", subject: "x", html: "y" })).rejects.toMatchObject({
111 name: "VapronError",
112 status: 422,
113 message: "Recipient rejected",
114 });
85115 });
86116
87 it("calls aiGateway.complete and extracts text (Anthropic-style content)", async () => {
88 const fetchMock = mockFetch(
89 200,
90 trpcOk({ content: [{ type: "text", text: "ok" }], model: "claude-sonnet-4-6" }),
91 );
117 it("calls the AI gateway and extracts Anthropic-style content", async () => {
118 const fetchMock = mockFetch(200, {
119 content: [{ type: "text", text: "ok" }],
120 model: "claude-sonnet-4-6",
121 });
92122 globalThis.fetch = fetchMock;
93123
94124 const result = await vapron.ai.complete({
97127 });
98128
99129 expect(result.text).toBe("ok");
100 const [url, init] = (fetchMock as unknown as ReturnType<typeof vi.fn>).mock.calls[0] as [
101 string,
102 RequestInit,
103 ];
104 expect(url).toBe("https://api.vapron.ai/api/trpc/aiGateway.complete");
105 const sent = JSON.parse(init.body as string) as { json: Record<string, unknown> };
106 expect(sent.json["max_tokens"]).toBe(256);
107 expect(sent.json["model"]).toBe("claude-sonnet-4-6"); // defaulted
108 expect(sent.json["messages"]).toEqual([{ role: "user", content: "hello" }]);
130 const [url, init] = callOf(fetchMock);
131 expect(url).toBe("https://vapron.ai/api/platform/ai/chat");
132 const sent = JSON.parse(init.body as string) as Record<string, unknown>;
133 expect(sent["max_tokens"]).toBe(256);
134 expect(sent["model"]).toBe("claude-sonnet-4-6"); // defaulted
135 expect(sent["messages"]).toEqual([{ role: "user", content: "hello" }]);
109136 });
110137
111138 it("extracts text from an OpenAI-style choices payload", async () => {
112 globalThis.fetch = mockFetch(
113 200,
114 trpcOk({ choices: [{ index: 0, message: { role: "assistant", content: "hi there" } }] }),
115 );
139 globalThis.fetch = mockFetch(200, {
140 choices: [{ index: 0, message: { role: "assistant", content: "hi there" } }],
141 });
116142
117143 const result = await vapron.ai.complete({ messages: [{ role: "user", content: "hello" }] });
118144 expect(result.text).toBe("hi there");
119145 });
120146
147 it("returns empty text (never throws) for an unrecognised gateway shape", async () => {
148 // The gateway's exact shape isn't documented, so the schema is deliberately
149 // tolerant. Callers (lib/ai.ts) MUST treat empty text as a failure — this
150 // asserts the contract they rely on rather than silently passing "" along.
151 globalThis.fetch = mockFetch(200, { unexpected: { nested: "payload" } });
152
153 const result = await vapron.ai.complete({ messages: [{ role: "user", content: "hello" }] });
154 expect(result.text).toBe("");
155 expect(result.raw).toEqual({ unexpected: { nested: "payload" } });
156 });
157
158 it("requests a presigned upload URL over REST", async () => {
159 const fetchMock = mockFetch(200, { uploadUrl: "https://storage.vapron.ai/put?sig=abc" });
160 globalThis.fetch = fetchMock;
161
162 const result = await vapron.storage.getUploadUrl({
163 bucket: "alecrae-files",
164 path: "acct_1/file.pdf",
165 contentType: "application/pdf",
166 });
167
168 expect(result.uploadUrl).toBe("https://storage.vapron.ai/put?sig=abc");
169 const [url, init] = callOf(fetchMock);
170 expect(url).toBe("https://vapron.ai/api/platform/storage/upload-url");
171 expect(JSON.parse(init.body as string)).toEqual({
172 bucket: "alecrae-files",
173 path: "acct_1/file.pdf",
174 contentType: "application/pdf",
175 });
176 });
177
178 it("rejects a response that doesn't match the expected schema", async () => {
179 globalThis.fetch = mockFetch(200, { notAnUploadUrl: true });
180
181 await expect(
182 vapron.storage.getUploadUrl({ bucket: "b", path: "p", contentType: "text/plain" }),
183 ).rejects.toMatchObject({ name: "VapronError", code: "invalid_response" });
184 });
185
121186 it("throws not_configured without hitting the network when the key is missing", async () => {
122187 delete process.env["VAPRON_API_KEY"];
123 const fetchMock = mockFetch(200, trpcOk({}));
188 const fetchMock = mockFetch(200, {});
124189 globalThis.fetch = fetchMock;
125190
126 await expect(vapron.storage.listBuckets()).rejects.toMatchObject({
191 await expect(vapron.email.send({ to: "a@b.com", subject: "x", html: "y" })).rejects.toMatchObject({
127192 name: "VapronError",
128193 code: "not_configured",
129194 });
130195 expect((fetchMock as unknown as ReturnType<typeof vi.fn>).mock.calls.length).toBe(0);
131196 });
132197});
198
199describe("vapron client — tRPC admin surface (DNS)", () => {
200 it("calls a DNS procedure on the tRPC base with a { json } GET input envelope", async () => {
201 const fetchMock = mockFetch(200, trpcOk([{ id: "zone_1", name: "alecrae.com" }]));
202 globalThis.fetch = fetchMock;
203
204 const zones = await vapron.dns.listZones();
205
206 expect(zones).toEqual([{ id: "zone_1", name: "alecrae.com" }]);
207 const [url, init] = callOf(fetchMock);
208 expect(url).toBe("https://api.vapron.ai/api/trpc/dns.myZones.list");
209 expect(init.method).toBe("GET");
210 expect((init.headers as Record<string, string>)["Authorization"]).toBe("Bearer vpk_test_key");
211 });
212
213 it("wraps mutation input in the { json } envelope and unwraps result.data.json", async () => {
214 const fetchMock = mockFetch(200, trpcOk({ id: "rec_1" }));
215 globalThis.fetch = fetchMock;
216
217 const created = await vapron.dns.createRecord({
218 zoneId: "zone_1",
219 name: "mx1",
220 type: "A",
221 content: "149.28.119.158",
222 });
223
224 expect(created).toEqual({ id: "rec_1" });
225 const [url, init] = callOf(fetchMock);
226 expect(url).toBe("https://api.vapron.ai/api/trpc/dns.records.create");
227 expect(JSON.parse(init.body as string)).toEqual({
228 json: { zoneId: "zone_1", name: "mx1", type: "A", content: "149.28.119.158" },
229 });
230 });
231
232 it("surfaces a tRPC { error: { json } } envelope as a typed VapronError", async () => {
233 globalThis.fetch = mockFetch(401, {
234 error: { json: { message: "Invalid key", data: { code: "UNAUTHORIZED", httpStatus: 401 } } },
235 });
236
237 await expect(vapron.dns.listZones()).rejects.toMatchObject({
238 name: "VapronError",
239 code: "UNAUTHORIZED",
240 status: 401,
241 message: "Invalid key",
242 });
243 });
244
245 it("honours VAPRON_BASE_URL for the tRPC surface only", async () => {
246 process.env["VAPRON_BASE_URL"] = "https://staging.vapron.ai/api/trpc";
247 const fetchMock = mockFetch(200, trpcOk([]));
248 globalThis.fetch = fetchMock;
249
250 await vapron.dns.listZones();
251
252 expect(callOf(fetchMock)[0]).toBe("https://staging.vapron.ai/api/trpc/dns.myZones.list");
253 });
254});
Modifiedapps/docs/app/webhooks/page.tsx+1−1View fileUnifiedSplit
258258 "id": "wh_01HXab...",
259259 "url": "https://yourdomain.com/hooks/alecrae",
260260 "events": ["message.delivered", "message.bounced", "message.opened"],
261 "secret": "whsec_a1B2c3D4e5F6g7H8i9J0...",
261 "secret": "REDACTED_BY_GLUECRON",
262262 "active": true,
263263 "createdAt": "2026-04-09T12:00:00.000Z",
264264 "updatedAt": "2026-04-09T12:00:00.000Z"
Modifiedservices/ai-engine/package.json+41−41View fileUnifiedSplit
1010 ".": {
1111 "bun": "./src/index.ts",
1212 "types": "./src/index.ts",
13 "import": "./dist/index.js"
13 "import": "./src/index.ts"
1414 },
1515 "./spam": {
1616 "bun": "./src/spam/classifier.ts",
1717 "types": "./src/spam/classifier.ts",
18 "import": "./dist/spam/classifier.js"
18 "import": "./src/spam/classifier.ts"
1919 },
2020 "./reputation": {
2121 "bun": "./src/reputation/scorer.ts",
2222 "types": "./src/reputation/scorer.ts",
23 "import": "./dist/reputation/scorer.js"
23 "import": "./src/reputation/scorer.ts"
2424 },
2525 "./content": {
2626 "bun": "./src/content/analyzer.ts",
2727 "types": "./src/content/analyzer.ts",
28 "import": "./dist/content/analyzer.js"
28 "import": "./src/content/analyzer.ts"
2929 },
3030 "./compose": {
3131 "bun": "./src/compose/assistant.ts",
3232 "types": "./src/compose/assistant.ts",
33 "import": "./dist/compose/assistant.js"
33 "import": "./src/compose/assistant.ts"
3434 },
3535 "./priority": {
3636 "bun": "./src/priority/ranker.ts",
3737 "types": "./src/priority/ranker.ts",
38 "import": "./dist/priority/ranker.js"
38 "import": "./src/priority/ranker.ts"
3939 },
4040 "./relationships": {
4141 "bun": "./src/relationships/graph.ts",
4242 "types": "./src/relationships/graph.ts",
43 "import": "./dist/relationships/graph.js"
43 "import": "./src/relationships/graph.ts"
4444 },
4545 "./classifier": {
4646 "bun": "./src/classifier.ts",
4747 "types": "./src/classifier.ts",
48 "import": "./dist/classifier.js"
48 "import": "./src/classifier.ts"
4949 },
5050 "./embeddings/voyage": {
5151 "bun": "./src/embeddings/voyage.ts",
5252 "types": "./src/embeddings/voyage.ts",
53 "import": "./dist/embeddings/voyage.js"
53 "import": "./src/embeddings/voyage.ts"
5454 },
5555 "./embeddings/types": {
5656 "bun": "./src/embeddings/types.ts",
5757 "types": "./src/embeddings/types.ts",
58 "import": "./dist/embeddings/types.js"
58 "import": "./src/embeddings/types.ts"
5959 },
6060 "./embeddings/local": {
6161 "bun": "./src/embeddings/local.ts",
6262 "types": "./src/embeddings/local.ts",
63 "import": "./dist/embeddings/local.js"
63 "import": "./src/embeddings/local.ts"
6464 },
6565 "./embeddings/hybrid": {
6666 "bun": "./src/embeddings/hybrid.ts",
6767 "types": "./src/embeddings/hybrid.ts",
68 "import": "./dist/embeddings/hybrid.js"
68 "import": "./src/embeddings/hybrid.ts"
6969 },
7070 "./embeddings/auto-indexer": {
7171 "bun": "./src/embeddings/auto-indexer.ts",
7272 "types": "./src/embeddings/auto-indexer.ts",
73 "import": "./dist/embeddings/auto-indexer.js"
73 "import": "./src/embeddings/auto-indexer.ts"
7474 },
7575 "./inbox": {
7676 "bun": "./src/inbox/smart-inbox.ts",
7777 "types": "./src/inbox/smart-inbox.ts",
78 "import": "./dist/inbox/smart-inbox.js"
78 "import": "./src/inbox/smart-inbox.ts"
7979 },
8080 "./inbox/newsletter-summarizer": {
8181 "bun": "./src/inbox/newsletter-summarizer.ts",
8282 "types": "./src/inbox/newsletter-summarizer.ts",
83 "import": "./dist/inbox/newsletter-summarizer.js"
83 "import": "./src/inbox/newsletter-summarizer.ts"
8484 },
8585 "./inbox/email-explainer": {
8686 "bun": "./src/inbox/email-explainer.ts",
8787 "types": "./src/inbox/email-explainer.ts",
88 "import": "./dist/inbox/email-explainer.js"
88 "import": "./src/inbox/email-explainer.ts"
8989 },
9090 "./agent": {
9191 "bun": "./src/agent/index.ts",
9292 "types": "./src/agent/index.ts",
93 "import": "./dist/agent/index.js"
93 "import": "./src/agent/index.ts"
9494 },
9595 "./todo": {
9696 "bun": "./src/todo/index.ts",
9797 "types": "./src/todo/index.ts",
98 "import": "./dist/todo/index.js"
98 "import": "./src/todo/index.ts"
9999 },
100100 "./todo/thread-extractor": {
101101 "bun": "./src/todo/thread-extractor.ts",
102102 "types": "./src/todo/thread-extractor.ts",
103 "import": "./dist/todo/thread-extractor.js"
103 "import": "./src/todo/thread-extractor.ts"
104104 },
105105 "./unsubscribe": {
106106 "bun": "./src/unsubscribe/index.ts",
107107 "types": "./src/unsubscribe/index.ts",
108 "import": "./dist/unsubscribe/index.js"
108 "import": "./src/unsubscribe/index.ts"
109109 },
110110 "./send-time": {
111111 "bun": "./src/send-time/predictor.ts",
112112 "types": "./src/send-time/predictor.ts",
113 "import": "./dist/send-time/predictor.js"
113 "import": "./src/send-time/predictor.ts"
114114 },
115115 "./calendar/slot-detector": {
116116 "bun": "./src/calendar/slot-detector.ts",
117117 "types": "./src/calendar/slot-detector.ts",
118 "import": "./dist/calendar/slot-detector.js"
118 "import": "./src/calendar/slot-detector.ts"
119119 },
120120 "./calendar/slot-suggester": {
121121 "bun": "./src/calendar/slot-suggester.ts",
122122 "types": "./src/calendar/slot-suggester.ts",
123 "import": "./dist/calendar/slot-suggester.js"
123 "import": "./src/calendar/slot-suggester.ts"
124124 },
125125 "./voice/cloner": {
126126 "bun": "./src/voice/cloner.ts",
127127 "types": "./src/voice/cloner.ts",
128 "import": "./dist/voice/cloner.js"
128 "import": "./src/voice/cloner.ts"
129129 },
130130 "./voice/voice-message": {
131131 "bun": "./src/voice/voice-message.ts",
132132 "types": "./src/voice/voice-message.ts",
133 "import": "./dist/voice/voice-message.js"
133 "import": "./src/voice/voice-message.ts"
134134 },
135135 "./voice/style-cloner": {
136136 "bun": "./src/voice/style-cloner.ts",
137137 "types": "./src/voice/style-cloner.ts",
138 "import": "./dist/voice/style-cloner.js"
138 "import": "./src/voice/style-cloner.ts"
139139 },
140140 "./meetings/transcript-linker": {
141141 "bun": "./src/meetings/transcript-linker.ts",
142142 "types": "./src/meetings/transcript-linker.ts",
143 "import": "./dist/meetings/transcript-linker.js"
143 "import": "./src/meetings/transcript-linker.ts"
144144 },
145145 "./meetings/transcript-fetcher": {
146146 "bun": "./src/meetings/transcript-fetcher.ts",
147147 "types": "./src/meetings/transcript-fetcher.ts",
148 "import": "./dist/meetings/transcript-fetcher.js"
148 "import": "./src/meetings/transcript-fetcher.ts"
149149 },
150150 "./meetings/types": {
151151 "bun": "./src/meetings/types.ts",
152152 "types": "./src/meetings/types.ts",
153 "import": "./dist/meetings/types.js"
153 "import": "./src/meetings/types.ts"
154154 },
155155 "./security/sender-verify": {
156156 "bun": "./src/security/sender-verify.ts",
157157 "types": "./src/security/sender-verify.ts",
158 "import": "./dist/security/sender-verify.js"
158 "import": "./src/security/sender-verify.ts"
159159 },
160160 "./security/phishing": {
161161 "bun": "./src/security/phishing.ts",
162162 "types": "./src/security/phishing.ts",
163 "import": "./dist/security/phishing.js"
163 "import": "./src/security/phishing.ts"
164164 },
165165 "./grammar/spellcheck": {
166166 "bun": "./src/grammar/spellcheck.ts",
167167 "types": "./src/grammar/spellcheck.ts",
168 "import": "./dist/grammar/spellcheck.js"
168 "import": "./src/grammar/spellcheck.ts"
169169 },
170170 "./grammar": {
171171 "bun": "./src/grammar/agent.ts",
172172 "types": "./src/grammar/agent.ts",
173 "import": "./dist/grammar/agent.js"
173 "import": "./src/grammar/agent.ts"
174174 },
175175 "./dictation": {
176176 "bun": "./src/dictation/engine.ts",
177177 "types": "./src/dictation/engine.ts",
178 "import": "./dist/dictation/engine.js"
178 "import": "./src/dictation/engine.ts"
179179 },
180180 "./query/email-sql": {
181181 "bun": "./src/query/email-sql.ts",
182182 "types": "./src/query/email-sql.ts",
183 "import": "./dist/query/email-sql.js"
183 "import": "./src/query/email-sql.ts"
184184 },
185185 "./scripts/snippet-runner": {
186186 "bun": "./src/scripts/snippet-runner.ts",
187187 "types": "./src/scripts/snippet-runner.ts",
188 "import": "./dist/scripts/snippet-runner.js"
188 "import": "./src/scripts/snippet-runner.ts"
189189 },
190190 "./intelligence/priority-scorer": {
191191 "bun": "./src/intelligence/priority-scorer.ts",
192192 "types": "./src/intelligence/priority-scorer.ts",
193 "import": "./dist/intelligence/priority-scorer.js"
193 "import": "./src/intelligence/priority-scorer.ts"
194194 },
195195 "./intelligence/smart-replies": {
196196 "bun": "./src/intelligence/smart-replies.ts",
197197 "types": "./src/intelligence/smart-replies.ts",
198 "import": "./dist/intelligence/smart-replies.js"
198 "import": "./src/intelligence/smart-replies.ts"
199199 },
200200 "./intelligence/sentiment-analyzer": {
201201 "bun": "./src/intelligence/sentiment-analyzer.ts",
202202 "types": "./src/intelligence/sentiment-analyzer.ts",
203 "import": "./dist/intelligence/sentiment-analyzer.js"
203 "import": "./src/intelligence/sentiment-analyzer.ts"
204204 },
205205 "./intelligence/categorizer": {
206206 "bun": "./src/intelligence/categorizer.ts",
207207 "types": "./src/intelligence/categorizer.ts",
208 "import": "./dist/intelligence/categorizer.js"
208 "import": "./src/intelligence/categorizer.ts"
209209 },
210210 "./intelligence/context-extractor": {
211211 "bun": "./src/intelligence/context-extractor.ts",
212212 "types": "./src/intelligence/context-extractor.ts",
213 "import": "./dist/intelligence/context-extractor.js"
213 "import": "./src/intelligence/context-extractor.ts"
214214 }
215215 },
216216 "scripts": {
217217
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts