Selected work / 01
GateKeep
Encrypted, expiring notes with passphrase gates, atomic read limits, and access analytics.
The problem
Sharing a sensitive note usually means choosing between a permanent link and a screenshot. A link is convenient, but it tends to outlive the reason it was sent. GateKeep explores a stricter middle ground: a note should be readable by the right person, for a limited number of reads, and then disappear.
The hard part is not the text editor or the browser UI. It is making the promise hold when a link is opened twice at once, when a client retries, when a file is requested after the note expires, and when someone tries to guess a passphrase.
How it fits together
Browser │ JWT session ▼ Express API ─────────────── PostgreSQL │ ├─ note metadata, expiry, read counts │ ├─ access analytics │ └─ HMAC webhook events │ ├── AES-256-GCM encrypted note content │ ├── Supabase Storage │ └─ private files → short-lived signed URLs │ └── rate limits
The API owns expiry, passphrase verification, read counters, file authorization, and analytics. The browser only receives what it needs for the current request.
Design decisions
Expiry and read limits live in the database
A read is an atomic operation in Postgres, not a client-side counter. Two simultaneous viewers cannot both consume the last allowed read, and an expired note cannot be revived by refreshing the page.
Passphrases are checked before content is returned
The note link is not the secret. The passphrase gate is verified server-side before the note body or file URL is released, so the passphrase never has to live in the URL or in browser state.
Encryption at rest, private files in storage
Note content is encrypted with AES-256-GCM before storage. Uploads stay in a private Supabase bucket and are exposed only through short-lived signed URLs after authorization.
Webhooks are signed and retried
Analytics and notification events are sent with an HMAC signature so receivers can verify the payload. Retries make delivery more resilient without turning every failed request into a manual cleanup task.
At a glance
plus integration coverage
The part I care about most: the read limit is enforced by the database, not by UI state.
What I'd do next
- Add key rotation and per-note key derivation so encryption keys are not tied to one long-lived application secret.
- Run a background cleanup job for expired note rows and orphaned files.
- Give note owners an audit trail for access events instead of only aggregate analytics.
- Test concurrent reads and file requests more aggressively, including the failure path when storage is slow.