Borrow what you need. Lend what you don't.
LendLoop is a neighborhood tool-lending board. Neighbors list tools, request to borrow them for a date range, approve or decline requests, and mark loans returned. Each loan change sends a notification (including to a borrower whose pending dates another approved loan just took), and borrowers get overdue reminders. n8n delivers both and talks to Convex over HMAC-signed webhooks in both directions. The stack is Next.js 15 (App Router), Clerk, Convex, n8n (Docker) and Tailwind.
What each role can do
| Role | Can do |
|---|---|
| Guest | See the public list of available tools (name and category only), then sign up or sign in. |
| Signed-in user | Browse every tool with status, condition, owner name (first name and last initial, e.g. "Ben C.") and Trusted Lender badge; filter by category and search by name. List, edit and delete their own tools. Request someone else's tool for up to 30 days. Cancel their own pending requests. Approve, decline and mark returned on requests for their tools. See every email sent about each request, including failed ones. |
| Admin | Everything a user can do. Also: see all loans, tools (including removed ones), neighbors and automated emails (failed ones show why), resend a failed email, remove any listing, and toggle Trusted Lender. |
Roles are stored only in the Convex users table. See Admin bootstrap.
Live demo: https://lendloop-demo.vercel.app, running on Vercel with a Convex production deployment and the Clerk development instance. Emails only go out while n8n is running on my PC: see Deployment.
- Demo accounts (a lender, a borrower and an admin): the sign-in details are sent with my
application. No password is in this repo, its tests or its history. The seed and test scripts
do name the demo people's addresses, but they are all on the reserved
example.comdomain, which has no mailboxes, so an address alone can't be used to sign in. The admin account isn't one of them. - Or create your own: sign-up is open. Use Sign up on the home page (or "Don't have an
account? Sign up" on the sign-in card) and you get a normal user account. Admin is never given
at sign-up: it comes only from the Convex
userstable (Admin bootstrap). - Emails (request notices, overdue reminders) only go out while n8n runs on my PC; everything else works any time.
flowchart LR
B["Browser"] -->|"pages"| N["Next.js App Router<br/>Clerk middleware: redirects only"]
B <-->|"sign-in, session JWT"| C["Clerk"]
B -->|"queries / mutations + Clerk JWT<br/>(ConvexProviderWithClerk)"| Q["Convex queries and mutations<br/>requireUser / requireAdmin first"]
Q -->|"insert queued row"| E[("automationEvents")]
Q -->|"scheduler.runAfter(0)"| D["automation.deliver<br/>internal action"]
D -->|"retry after 30s, 2m, 10m"| D
D -->|"signed POST<br/>X-LendLoop-Signature"| W["n8n: request-notification<br/>verify HMAC, switch on type"]
W --> O["mock outbox<br/>(or SMTP email)"]
D -->|"markSent / markFailed"| E
S["n8n: overdue-reminders<br/>daily 09:00"] -->|"signed GET /n8n/overdue-loans"| H["Convex HTTP actions<br/>verify HMAC"]
S -->|"signed POST /n8n/reminder-status"| H
S --> O
H -->|"upsert by idempotencyKey"| E
W -. "on failure" .-> X["n8n: error-handler"]
S -. "on failure" .-> X
What happens when a borrower makes a request
- The browser calls
loans.requestwith the Clerk JWT attached byConvexProviderWithClerk. Convex validates the token against the Clerk issuer. requireUser(ctx)runs first. The mutation then checks the tool (missing or removed givesNOT_FOUND; the caller's own tool givesFORBIDDEN) and the dates (R2), and inserts apendingloan.- In the same transaction it inserts one
automationEventsrow (queued, key<loanId>:loan.requested) and schedulesinternal.automation.deliver. The mutation itself never calls the network. deliverbuilds the minimal payload, which contains the lender's name and email only. It signs the payload withCONVEX_TO_N8N_SECRETand POSTs it toN8N_REQUEST_WEBHOOK_URL. On a 2xx the row becomessent. Otherwise it retries after 30 s, 2 min and 10 min, then becomesfailedwithlastError.- n8n checks the signature against the raw body. It answers 401 if the check fails. Otherwise it
routes on
type, writes the message to the mock outbox and answers 200. - The borrower's
/my/requestsview is a reactive Convex query, so the email's line in the request's Activity ledger turns from SENDING to EMAIL (or FAILED) without a refresh.
Overdue reminders work the other way round. Every day at 09:00, n8n makes a signed GET for
overdue loans, sends one reminder per loan and writes each result back with a signed POST. The
write-back is keyed <loanId>:<YYYY-MM-DD>, so retries and re-runs never add a duplicate.
docs/ARCHITECTURE.md covers the modules, the function-by-function access table, the data model, sequence diagrams and failure modes.
Design. The UI follows a library checkout card: paper background (#F3F4F1), ink text
(#15201B), pine green for actions (#1F6B4E) and sun yellow for attention (#F5C518).
Headings use Archivo, body text Atkinson Hyperlegible, and dates and statuses IBM Plex Mono, all
self-hosted with next/font. Dates read US style ("Nov 8", "Nov 8–10"). Motion is limited to
feedback: a rubber-stamp press when you send, approve, decline, return or resend, a slide-up
borrowing card on phones, and button press scaling. All of it is off under
prefers-reduced-motion. The tokens live in app/globals.css.
- Node 20+ (developed on Node 22), npm, Git
- Docker Desktop (for n8n via
docker-compose) - Free accounts at Clerk and Convex
cloudflared(or n8n Cloud) so that Convex, which runs in the cloud, can reach your n8n
npm install
cp .env.example .env.local- Create a Clerk application. Email sign-in is enough.
- Go to Configure → Integrations → Convex → Activate. Copy the Issuer URL it shows, for
example
https://<your-app>.clerk.accounts.dev. That value isCLERK_JWT_ISSUER_DOMAIN. - Add email and name to the session token. With the current Clerk integration, Convex
receives Clerk's session token (
aud = convex), which carries no email or name by default. Without themADMIN_EMAILScan't match anyone and notifications have no recipient. Open Configure → Sessions → Customize session token and add:Then sign out and back in, so{ "email": "{{user.primary_email_address}}", "name": "{{user.full_name}}", "email_verified": "{{user.email_verified}}" }users.ensurestores the email (and appliesADMIN_EMAILS). - Copy the publishable key and secret key into
.env.localasNEXT_PUBLIC_CLERK_PUBLISHABLE_KEYandCLERK_SECRET_KEY.
The sign-in and sign-up URLs are set in code (/sign-in, /sign-up), so no Clerk URL env vars
are needed.
npx convex dev # logs in, creates the dev deployment, writes NEXT_PUBLIC_CONVEX_URL
# (and CONVEX_DEPLOYMENT) into .env.local, then watches convex/In a second terminal, set the server-side variables. Generate each secret with
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))":
npx convex env set CLERK_JWT_ISSUER_DOMAIN <issuer url from Clerk>
npx convex env set ADMIN_EMAILS you@example.com # comma-separated, optional
npx convex env set CONVEX_TO_N8N_SECRET <random hex> # Convex signs, n8n verifies
npx convex env set N8N_TO_CONVEX_SECRET <another random hex> # n8n signs, Convex verifies
# N8N_REQUEST_WEBHOOK_URL is set after n8n is running (step 4)npm run dev # http://localhost:3000Full walkthrough: n8n/README.md.
- Tunnel. Convex cannot reach this machine, so give n8n's webhooks a public URL: run
docker compose --profile tunnel up -dand read thehttps://<random>.trycloudflare.comURL fromdocker compose logs tunnel. The tunnel points at theedgeproxy (n8n/edge/nginx.conf), which forwards only/webhook/*and/healthzand answers 404 to everything else. The n8n editor and its login page are reachable only on http://localhost:5678 (the port is bound to 127.0.0.1), never through the tunnel or the local network. Don't pointcloudflared tunnel --urlat port 5678 directly: a quick tunnel has no ingress rules, so that would publish the whole editor. n8n/.env. Runnode n8n/scripts/write-env.mjs --webhook-url <tunnel URL>. It copies both shared secrets fromnpx convex env list, derivesCONVEX_SITE_URLfrom your deployment, setsWEBHOOK_URL, and prints names only. Or copyn8n/.env.exampleton8n/.env(git-ignored) and fill it in by hand. Optionally setALERT_WEBHOOK_URLto a Slack or Discord webhook.- Start. Run
docker compose up -d(again after changingn8n/.env, so n8n picks it up), open http://localhost:5678 and create the owner account. The image is pinned ton8nio/n8n:2.47.1. - Import the three files in
n8n/workflows/. No credentials need selecting, because all secrets come from$env. - Error workflow. In request-notification and overdue-reminders, open ⋯ → Settings → Error workflow and choose LendLoop: error handler. The export can't carry this setting, because ids change on import.
- Publish request-notification and overdue-reminders. In n8n 2.x, Publish replaces the old Active toggle.
- Point Convex at n8n. Copy the Webhook node's production URL
(
<WEBHOOK_URL>/webhook/lendloop-request), then:A quick tunnel gets a new URL each time it restarts. When that happens, updatenpx convex env set N8N_REQUEST_WEBHOOK_URL <that url>
WEBHOOK_URL, restart n8n and repeat this step.
n8n/scripts/signed-request.mjs sends signed, forged and stale test requests (see n8n/README.md).
There are two ways to make someone an admin. Neither goes through a client-callable function.
ADMIN_EMAILS(Convex env var, comma-separated).users.ensureruns after every sign-in and promotes a user whose Clerk email is on the list. It only promotes, never demotes, so taking an email off the list does not lock out a working admin.users.setRole(internal mutation). Use it to promote or demote from the CLI or the Convex dashboard (Functions → users:setRole → Run). Find the user id in the dashboard'suserstable:npx convex run users:setRole '{"userId":"<users id>","role":"admin"}' npx convex run users:setRole '{"userId":"<users id>","role":"user"}'
node scripts/clerk-test-users.mjsThis uses the Clerk Backend API with CLERK_SECRET_KEY from .env.local (it refuses anything
but an sk_test_ key, and never prints it). It creates or renames five test users in your Clerk
development instance, with first and last names set, all on example.com addresses (the script
lists them):
| User | Role in the demo |
|---|---|
| Ben C. | lender in the e2e run; owns three seed tools |
| Maya R. | borrower in the e2e run |
| Marcus D., Olivia P., Jake T. | seed neighbors |
The e2e run signs these users in with Clerk sign-in tokens made from CLERK_SECRET_KEY, so it
never needs an email code. The script then runs the internal seed:neighbors, linked to everyone's Clerk ids. It
gives Ben the design's three listings (cordless drill, hedge trimmer, pressure washer) and puts
Marcus, Olivia and Jake on the board with two tools each (jigsaw, carpet cleaner, ladder, stand
mixer, tent, tile cutter). Running it again changes nothing: a tool is skipped when its owner
already has one with that name (in any case, removed ones included), and tools the e2e run lists
for Ben are never touched. Add --prod to seed the Convex production deployment instead.
Simpler demo sign-ins. To sign in by hand with a password instead of an email code, run this in an interactive terminal (PowerShell or Windows Terminal):
node scripts/clerk-demo-logins.mjsIt adds ben_test@, maya_test@, marcus_test@, olivia_test@ and jake_test@example.com to
the matching accounts, verified and primary, and sets one password you type for all five. Input
is hidden, and the password is sent only to Clerk: it's never printed, stored in a file or kept
in an env var. The script's original addresses stay on the accounts, so the e2e run keeps
finding them. Clerk enforces its own password rules (this instance: at least 15 characters, and not a
known breached password). --check shows each account's state without changing anything. The
seed matches people on either address, because users.ensure copies the primary address into
the Convex row at sign-in.
The app shows every name as first name and last initial. For that, the Clerk session token must carry the user's name (step 1).
To see the reminder in the app, make yourself the borrower. Sign in once, then copy your
_id from the Convex dashboard's users table:
npx convex run seed:overdueLoan '{"borrowerId":"<your users id>"}'This creates an approved loan of Olivia P.'s stand mixer that ended yesterday (UTC), so you can't
be both sides. Then open overdue-reminders in n8n and click Execute workflow. "Overdue
reminder sent to you" appears in the loan's Activity on your /my/requests, under a yellow
"due back" banner. Run the workflow again and nothing is sent, because Convex skips loans already
reminded today.
Without arguments (npx convex run seed:overdueLoan) the borrower is Jake T., a seed user. The
reminder then shows only in Admin → Automated emails. Pass toolId as well to reuse one of
your own tools; its owner becomes the lender. The command can be run repeatedly.
Names only. Values live in .env.local, in Convex (npx convex env set) or in n8n/.env, and
never in the repo.
| Name | Where it's set | Used by | Purpose |
|---|---|---|---|
NEXT_PUBLIC_CONVEX_URL |
.env.local (written by npx convex dev) |
Next.js client | Convex deployment URL for ConvexReactClient. |
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY |
.env.local |
Next.js (Clerk) | Clerk frontend key. |
CLERK_SECRET_KEY |
.env.local |
Next.js middleware / server (Clerk) | Clerk backend key. Secret. |
CLERK_JWT_ISSUER_DOMAIN |
Convex env | convex/auth.config.ts |
Clerk issuer URL. Convex accepts only JWTs from this issuer, with aud = convex. |
ADMIN_EMAILS |
Convex env | users.ensure |
Comma-separated emails that are promoted to admin on sign-in (never demoted). |
N8N_REQUEST_WEBHOOK_URL |
Convex env | automation.deliver |
Production URL of the n8n request-notification Webhook node. |
CONVEX_TO_N8N_SECRET |
Convex env and n8n/.env |
automation.deliver (sign), n8n request-notification (verify) |
HMAC secret for Convex → n8n. |
N8N_TO_CONVEX_SECRET |
Convex env and n8n/.env |
n8n overdue-reminders (sign), convex/http.ts (verify) |
HMAC secret for n8n → Convex. |
CONVEX_SITE_URL |
n8n/.env |
n8n overdue-reminders | https://<deployment>.convex.site (HTTP actions origin, no trailing slash). |
CONVEX_SITE_URL_PROD |
n8n/.env (optional) |
n8n overdue-reminders | The production deployment's .convex.site. When set, reminders cover dev and production. |
N8N_TO_CONVEX_SECRET_PROD |
n8n/.env (optional) |
n8n overdue-reminders | Production's N8N_TO_CONVEX_SECRET, if it differs from dev's. Falls back to N8N_TO_CONVEX_SECRET. |
WEBHOOK_URL |
n8n/.env |
n8n itself | Public tunnel URL, so n8n shows the right production webhook URL. |
ALERT_WEBHOOK_URL |
n8n/.env (optional) |
n8n error-handler | Slack or Discord incoming webhook for error alerts. If empty, alerts go to the mock outbox only. |
docker-compose.yml also sets these non-secret values: N8N_RUNNERS_MODE=internal,
NODE_FUNCTION_ALLOW_BUILTIN=crypto,fs, N8N_BLOCK_ENV_ACCESS_IN_NODE=false,
LENDLOOP_MOCK_OUTBOX, GENERIC_TIMEZONE/TZ (these decide when "09:00" fires) and
execution-data pruning (EXECUTIONS_DATA_MAX_AGE=168).
| Piece | Runs on | Deployed with |
|---|---|---|
| Next.js app | Vercel project lendloop, https://lendloop-demo.vercel.app |
GitHub Action on every push to main, or vercel deploy --prod by hand |
| Convex functions and data | The Convex project's production deployment | npx convex deploy (not part of the Vercel build) |
| n8n and the tunnel | Docker on my PC | docker compose up -d (see step 4) |
- Vercel env vars (Production only):
NEXT_PUBLIC_CONVEX_URL(the production deployment's.convex.cloudURL),NEXT_PUBLIC_CLERK_PUBLISHABLE_KEYandCLERK_SECRET_KEY(stored as a Secret). Preview deployments get none, so a branch preview fails its build instead of writing to production data. - Convex production env vars: the same five as dev (
ADMIN_EMAILS,CLERK_JWT_ISSUER_DOMAIN,CONVEX_TO_N8N_SECRET,N8N_TO_CONVEX_SECRET,N8N_REQUEST_WEBHOOK_URL), set withnpx convex env set --prod NAME, which reads the value from stdin. Set them before the firstnpx convex deploy, becauseconvex/auth.config.tsneedsCLERK_JWT_ISSUER_DOMAINat deploy time. - Order: when a change touches
convex/, runnpx convex deploybefore deploying the frontend, so the app never calls a function that isn't deployed yet. For CI I'd set the Vercel build command tonpx convex deploy --cmd 'npm run build'with a productionCONVEX_DEPLOY_KEY. - Auto-deploy:
.github/workflows/deploy.ymlrunsvercel deploy --prodon every push tomain(or by hand from the Actions tab). Vercel's own Git integration isn't connected, because the Vercel account's GitHub login is a different GitHub user. The Action needs aVERCEL_TOKENrepo secret (a Vercel token with an expiry date) and theVERCEL_ORG_IDandVERCEL_PROJECT_IDrepo variables (ids from.vercel/project.json). Vercel builds with its own env vars, so no app secret is on the runner. - Uploads: a manual
vercel deployuploads the working folder, not the Git tree..vercelignorekeeps env files,n8n/.env,qa/.authand local-only folders out of the upload.
The live app still uses the Clerk development instance (pk_test_ and sk_test_ keys), on
purpose for now: a Clerk production instance needs a domain I own, and a *.vercel.app domain
can't be used. As a result, the sign-in page shows Clerk's "Development mode" label, the browser
console warns about development keys, and Clerk's development usage limits apply. Because
the live site shares this instance, Clerk's test mode must be off on it: test mode accepts a
fixed verification code for test addresses, and the demo accounts' addresses are in the seed
scripts. SECURITY.md lists this under demo-only choices.
To move to production: create a Clerk production instance on a custom domain, put its keys in
Vercel, and set its issuer as CLERK_JWT_ISSUER_DOMAIN on the Convex production deployment.
Browsing, listing, requesting, approving, returning and everything in /admin run on Vercel and
Convex, and work with my PC off. Only the n8n side needs it:
- Request emails (new request, approved, declined, returned, canceled) go from Convex through
the cloudflared tunnel to n8n. With the PC off, Convex retries after 30 s, 2 min and 10 min and
then marks the email
failed. The borrower sees it on My requests, and an admin can resend it from Automated emails once n8n is back. - Overdue reminders come from n8n's daily 09:00 workflow, which only runs while n8n is up. It
covers both deployments: dev from
CONVEX_SITE_URLand production fromCONVEX_SITE_URL_PROD(n8n/.env, filled bynode n8n/scripts/write-env.mjs --prod). Each deployment gets its own signed GET and write-back, so each keeps its own one-reminder-per-day key, and a deployment that can't be reached doesn't stop the other (it is logged and alerted). LeaveCONVEX_SITE_URL_PRODempty to cover dev only. After changingn8n/.env, rundocker compose up -d n8nso the container picks it up. - Tunnel URL: a quick tunnel gets a new URL each time it restarts. Then set
N8N_REQUEST_WEBHOOK_URLagain on both deployments (npx convex env set --prod ...for production).
npm run check # tsc --noEmit && next lint && vitest run && next build
npm run test:e2e # Playwright: starts `npm run dev`; needs .env.local (Clerk keys + Convex URL)
npm run test:kickoff # live end-to-end run of the brief's manual checks (see below)npm run test:kickoff (qa/e2e/kickoff.spec.ts) drives the real stack: Clerk dev instance,
Convex dev deployment, and n8n in Docker with the workflows imported and active. Lender, borrower
and admin sign in through @clerk/testing sign-in tokens, created with CLERK_SECRET_KEY from
.env.local, so no password is typed. Ben C. lends and Maya R. borrows; create them first with node scripts/clerk-test-users.mjs
(Setup step 6). The admin is the first address in ADMIN_EMAILS. The run covers
listing, requesting, approving, both notifications sent, admin removal and Trusted Lender, and an
overdue reminder that a second workflow run does not repeat. Screenshots go to qa/screenshots/,
with every non-example.com email masked.
The acceptance suite (tests/acceptance/) maps one or more tests to each acceptance criterion in
SPEC.md:
| File | Covers |
|---|---|
tools-public.test.ts, tools-validation.test.ts |
AC2 projection { _id, name, category }, field limits |
rbac.test.ts |
AC4–AC8. Sweeps every exported public function: no identity gives UNAUTHENTICATED, a plain user on admin.* gives FORBIDDEN, and no function takes a role argument |
loans.test.ts |
AC9–AC12: UTC date rules, inclusive overlap → CONFLICT, allowed transitions only, returns |
automation.test.ts |
AC13, AC14, AC17, AC19: one queued event per change, exact payload keys and signature, 4 attempts then failed, activity per loan in loans.listMine |
hmac.test.ts, http.test.ts |
AC14–AC16: known HMAC vector, 401 on missing, wrong, stale or tampered signatures, idempotent write-back |
no-secrets.test.ts |
AC18: workflow exports and every committable file are free of secret patterns, non-example emails and local env values |
readme.test.ts |
AC20: this README |
e2e/public-page.spec.ts, e2e/auth-gate.spec.ts |
AC1 public page, AC3 signed-out redirects to /sign-in |
Backend tests use convex-test with withIdentity, fake timers pinned to 2026-11-06T12:00:00Z
and a stubbed fetch. The rendering of AC19 activity on /my/requests is checked by the kickoff
run (two EMAIL lines sent, one overdue reminder) and recorded in qa/QA-REPORT.md.
tests/unit/ holds the unlocked tests added later: admin-resend.test.ts (an admin re-queues a
failed notification on the same row and key; one job, a single extra attempt, admin-only) and
my-requests-email-status.test.ts (loans.listMine reports each loan's emailStatus and which
email each activity entry was).
Locked tests. A tester agent wrote the acceptance tests and CONTRACT.md before any app code
existed, then created .tests-locked. While that file exists, a hook stops every agent from
editing tests/acceptance/ or SPEC.md. If a builder thinks a test is wrong, it records the case
in qa/TEST-DISPUTES.md. Only the human can unlock. The tester then fixes the assertion (never
deleting or weakening it) and locks again.
SECURITY.md has the threat model (threat → mitigation → code → test → how to verify it yourself), the demo-only shortcuts and what production would add.
Authorization lives in Convex.
- Every non-public query, mutation and action calls
requireUser(ctx)orrequireAdmin(ctx)fromconvex/lib/auth.tsas its first line, before any lookup or validation. The only public function without it istools.listPublic. - Role comes only from the Convex
usersrow. It is never read from Clerk metadata, JWT claims or arguments. No public function takes aroleargument, and unknown arguments are rejected by theargsvalidators. Role changes only throughADMIN_EMAILS(promote-only) or the internalusers.setRole. - Ownership is checked server-side: only the owner can edit or delete a tool, only the lender can
approve, decline or mark returned, and only the borrower can cancel. Admins moderate through
admin.removeListingand do not override ownership. admin.resendEventis admin-only and re-queues a failed request notification on its existing row, so the<loanId>:<type>key still allows one notification per transition. It refuses sent or queued events (CONFLICT, so a double click schedules one job), emails the loan has moved past (CONFLICT: no "new request" email after the borrower cancelled) and overdue reminders (INVALID; n8n owns those). A resend is one extra attempt with no new retry chain.- The Next.js middleware only redirects signed-out visitors to
/sign-in. That is a convenience, not the security boundary. The/adminpage checksusers.me.roleonly to avoid an error flash; Convex still answersFORBIDDEN. - Errors are
ConvexError({ code, message })with generic messages that never contain another user's name or email.
Webhooks are signed both ways with HMAC-SHA256.
- Signature:
X-LendLoop-Signature: sha256=<hex HMAC-SHA256(secret, timestamp + "." + rawBody)>withX-LendLoop-Timestamp: <ms>. A GET signs the empty body ("<ts>."). - Two separate secrets:
CONVEX_TO_N8N_SECRET(outbound) andN8N_TO_CONVEX_SECRET(inbound). The outbound secret is not accepted inbound. - Replay window:
|now − timestamp| ≤ 5 minutes. Each outbound attempt is freshly signed, so a retry 10 minutes later is still valid. n8n signs each overdue POST just before sending it. - Constant-time compare: Convex uses a full-length XOR loop (Web Crypto, default runtime), and n8n
uses
crypto.timingSafeEqual. Thesha256=+ 64-hex format is checked first, so the buffers are always the same length. - Both sides verify over the raw body bytes, never over re-serialized JSON. The n8n Webhook node has Raw Body enabled, and if the raw body is missing it rejects the request.
- Unsigned, wrong, stale or tampered requests get 401 with an empty body. If the secret is unset, Convex also answers 401: an empty secret never means "accept everything". n8n throws instead, so the error workflow raises an alert about the misconfiguration.
Idempotency.
- Outbound notifications are keyed
<loanId>:<type>. R7 lets each transition happen at most once per loan, so a double-fired approve, decline, cancel or return can't queue two notifications. - A new request is a new loan, so
loans.requestguards it separately: it answersCONFLICTwhen the caller already has a pending request on that tool with overlapping dates (a double click sends one request), andCONFLICTagain after 10 requests in a rolling hour per user, so one account can't flood a lender with emails.deliverskips any event that is no longerqueued, so a stray duplicate job never sends twice. - Overdue reminders are keyed
<loanId>:<YYYY-MM-DD>.POST /n8n/reminder-statusinserts once per key. A second call with the same key changes nothing and returns 200. The key must belong to an existing loan and use exactly that format; anything else gets 400.GET /n8n/overdue-loansleaves out loans already reminded today.
Mutations never call the network. They insert a queued automationEvents row and schedule
the internal action automation.deliver with ctx.scheduler.runAfter(0, …). That keeps
mutations deterministic and gives retries and an audit trail.
Failures are visible.
- Outbound: each attempt times out after 10 s. 1 attempt plus 3 retries (30 s, 2 min, 10 min), then
status: "failed"with a shortlastErrorsuch as "HTTP 500 from webhook". The secret and webhook URL are replaced with[redacted], and the text is capped at 300 chars. Admins see this in the Automated emails table, as a plain next step ("n8n couldn't be reached. Check the tunnel address.") above the redactedlastError, with a Resend button while the email still describes the loan. The borrower sees a FAILED line in the request's Activity, and a red note while that email is still current. A missing webhook URL or secret counts as a failed attempt, so misconfiguration shows up there too. A cron (convex/crons.ts) checks every 5 minutes for emails stuck inqueuedfor over 15 minutes (a delivery that died mid-flight) and counts that as a failed attempt: the next retry is scheduled, or the email is markedfailedand can be resent. - Reminder errors written back by n8n have email-shaped text replaced with
[redacted-email]before they're stored, so an SMTP error can't put an address in the admin view. - n8n: HTTP nodes retry 3 times, 10 s apart. Errors go to the error-handler workflow (mock outbox
plus optional Slack or Discord). A failed reminder send is written back as
failed. Each overdue loan is sent and written back before the next one starts, and a write-back that still fails is logged and alerted at the end of the run without stopping the other loans.
Secrets.
- Secrets live in
.env.local(git-ignored), Convex env vars orn8n/.env(git-ignored). They are never in code, tests, fixtures or workflow JSON. - Workflows read secrets through
$env.*(N8N_BLOCK_ENV_ACCESS_IN_NODE=false), and credentials are referenced by id and name only. Code nodes may only loadcryptoandfs. .env.exampleandn8n/.env.examplehold names only.no-secrets.test.tsscans every committable file for key patterns, non-example.comemails and the actual local env values.
Input validation.
- Every public function declares
argsvalidators. - Tool names are trimmed to 1–80 chars and descriptions to at most 500. Category and condition must come from the schema lists.
- Dates must be strict, real
YYYY-MM-DDvalues. The start must be today or later, where "today" is the day in the acting user's own time zone (users.timeZone, validated as a real IANA name; UTC if unset). The end must be on or after the start, and a loan lasts at most 30 days counting the first and last day (end − start ≤ 29). Approving re-checks the start against the lender's own "today". The daily overdue check stays on UTC. - Each user picks their time zone from the header menu (the browser's zone is the default). Every date, "today" and time on screen is shown in it, with the zone named.
- Inbound bodies are shape-checked after the signature check and get 400 if malformed.
Replay inside the window. n8n keeps the eventIds it has sent (24 h, workflow static data). A
second signed delivery of the same eventId sends nothing, answers 200 and is logged in the outbox
as a duplicate. An id is remembered only after its send succeeded, so a real retry still works.
n8n is not public. The quick tunnel points at an nginx edge (n8n/edge/nginx.conf) that passes
only /webhook/* and /healthz; the editor, login page and REST API answer 404 through the tunnel.
The editor is reachable only on http://localhost:5678 (port published on 127.0.0.1).
Browser headers. Clerk's middleware sets a Content-Security-Policy with a per-request nonce and
'strict-dynamic', plus our additions in lib/securityHeaders.ts (Convex https/wss, Clerk,
frame-ancestors 'none', object-src 'none'). next.config.ts adds Referrer-Policy, nosniff,
Permissions-Policy and X-Frame-Options. Because of the nonce, pages render per request (no static
HTML), which costs nothing here since every signed-in page is live Convex data.
CSP_REPORT_ONLY=1 switches the policy to report-only.
The reasoning behind each choice is recorded one line at a time in docs/DECISIONS.md.
- Public page:
tools.listPublicreturns exactly{ _id, name, category }for available, non-removed tools. It includes no owner, condition, description or status, and a test asserts the keys. - Each query returns only what its screen shows.
tests/unit/projections.test.tsasserts the exact keys for Browse, My listings, My requests, Incoming,users.meand the four admin queries. - Signed-in lists: other users' names leave Convex as first name and last initial
(
convex/lib/public.ts displayName), with the Trusted Lender badge, and never emails.tools.listreturnsisMineinstead ofownerId. Incoming tells borrowers apart with an opaque per-responseborrowerKey, not a user id./my/requestsactivity shows kind, status, which email it was (type, e.g.loan.approved) and time, plus the loan'semailStatus, without idempotency keys or n8n error text. - Admin: no full email reaches the browser, even for admins.
admin.listUsersreturns a maskedemailHint(b***@example.com), shown next to the last 6 characters of the user id so two people with the same short name read differently. The events list sends the emailtypeand blocker parsed on the server, not the idempotency key. - Convex → n8n payload:
eventId, type, loanId, toolName, recipientName, recipientEmail, startDate, endDate, sentAt. It carries only the recipient's contact, never both parties'. The recipient is the party who did not act. - HTTP endpoints:
GET /n8n/overdue-loansreturns exactlyloanId, toolName, borrowerName, borrowerEmail, endDate, idempotencyKey. The write-back returns{ ok: true }. 401 responses have an empty body. - n8n: the Verify node passes on only the payload fields. Mock-outbox lines and node outputs
mask the address (
a***@example.com). The error handler redacts email-shaped text and caps messages at 500 chars. Execution data, which still holds one recipient email, is pruned after 7 days.
- Real delivery: replace the mock Code nodes with an email node that uses an n8n SMTP or
provider credential, and drop
fsfromNODE_FUNCTION_ALLOW_BUILTIN. - Hosting: use a named Cloudflare tunnel or a real domain (or n8n Cloud) instead of a quick tunnel. Run n8n with external task runners and queue mode (workers and Redis).
- Production instances: the live demo already uses a Convex production deployment, but it shares the Clerk development instance and the HMAC secrets with dev. Use a Clerk production instance on a custom domain, separate secrets per environment and a second n8n (or an environment field in the payload), so dev and production never share a webhook.
- User sync: add Clerk webhooks (signed with Svix) to sync and delete users server-side,
instead of relying on
users.ensurefrom the client. - Scale: add pagination and indexes such as a
by_statusindex and paginated admin lists, because lists currently collect whole tables. - Abuse:
loans.requesthas a per-user limit (10 an hour); add the same totools.createand per-IP limits on the webhook routes, with Convex's rate-limiter component instead of a query over recent rows. - Secret rotation: accept two secrets during rotation (current and next) and rotate both directions together.
- Known limit: admin promotion fails open on email verification.
users.ensureblocksADMIN_EMAILSpromotion only when the token saysemail_verified: false. If the claim were dropped from the Clerk session token, an unverified address on the list would be promoted. Production should requireemail_verified === true(a Clerk production instance, where verification can't be skipped, makes it meaningful). - Known limit: the signature doesn't cover method, path or deployment. HMAC is over
timestamp.rawBodyonly, and dev and production share the secrets, so a captured signedGET /n8n/overdue-loanscan be replayed for 5 minutes, on either deployment, and returns borrowers' names and emails. Production should signmethod + path + timestamp + bodyand use separate secrets per deployment. - Replay inside the window: n8n already drops a repeated
eventId; add the same nonce store on the Convex side for the inbound write-back (idempotent today, but not nonce-checked). - Operations: structured logging and alerting on
failedevents, a dead-letter view with manual replay, and an audit log of admin actions (removals, Trusted Lender, role changes). - Quality: e2e tests that sign in (Clerk testing tokens) and CI running
npm run checkplus Playwright on every PR. - Product and privacy: overdue reminders at each borrower's own 9 AM (per-user time zones already drive the date rules and the UI), GDPR export and deletion, and execution data for webhooks not saved at all.
app/ Next.js routes: / (public), /sign-in, /sign-up, (signed-in)/tools, /my/*, /admin
components/ UI: EnsureUser gate, header, QueryBoundary, stamps, tools, listings, loans, admin
lib/ Client helpers (labels, US dates, short names, activity ledger)
scripts/ clerk-test-users.mjs (Clerk dev test users + Convex seed)
middleware.ts Clerk redirect for /tools, /my/*, /admin (convenience only)
convex/
schema.ts users, tools, loans, automationEvents
tools.ts loans.ts users.ts admin.ts public API, one module per domain
automation.ts internal: deliver + retries, overdue list, reminder write-back
http.ts signed n8n -> Convex routes
seed.ts internal demo fixtures (neighbors and their tools, overdue loan)
lib/ auth, hmac, dates, events, removal, validation, views, errors
n8n/workflows/ request-notification, overdue-reminders, error-handler (exported JSON)
n8n/scripts/ signed-request.mjs (manual signed, forged and stale requests)
docker-compose.yml local n8n
.github/workflows/ deploy.yml (Vercel production deploy on push to main)
tests/acceptance/ locked acceptance tests + CONTRACT.md
docs/ ARCHITECTURE.md, DECISIONS.md
qa/ QA report and test disputes
I built this with Claude Code agents in a fixed loop. A tester agent first turned SPEC.md
into locked acceptance tests and an API contract. Builder agents (backend, frontend, n8n, docs)
then implemented their own slices against that contract. They were not allowed to edit the tests
or the spec, and recorded every non-obvious choice in docs/DECISIONS.md. A QA agent then ran
the checks and reviewed the result, and the loop repeated until qa/QA-REPORT.md ended in
RESULT: PASS. I reviewed the decisions and own the result. The interesting parts, such as where
authorization lives, the HMAC scheme and the idempotency keys, are explained above in my own
terms.