Daeseon Yoo
곁 · Beside

Architecture

How the system is put together — process model, data flow, trade-offs. Written from the source code.

In plain words. Beside (곁) is a private "support box": you save encouraging notes, photos, voice clips, and videos, invite a few people with a link to add their own, and get gentle scheduled reminders to open one. The engineering trick is that the very same code runs for free on a single laptop or scales up on cloud servers just by changing settings — no rewrite. Because the voice and face clips are sensitive, every file is served behind a permission check rather than a public link.

Extracted from the motivation repo source on 2026-06-20, checked against the code and cross-checked by a second adversarial pass. Numbers are measured, not estimated.

The shape of the system

Beside (곁) is a private "support-box" PWA plus an Expo mobile client: an owner stores text, photo, audio, and video encouragement items, invites other people via unauthenticated token links, and receives timed push reminders. The single Next.js 16 codebase runs entirely on a laptop with zero external accounts — embedded PGlite DB under .data/pglite, local-disk media, and an auto-generated SESSION_SECRET plus VAPID keypair written to .data/secrets.json — and promotes to production infra (Postgres over Drizzle, S3/R2 media, Web and Expo Push, Terraform across AWS/OCI/NCP) by setting env vars only. The same /api contract is consumed by the browser over a cookie session and by the native app over a bearer token.

SurfaceSize
App/lib/component TS6,822 LOC
API route handlers26
Postgres tables (Drizzle)6
Env-var driver switch points3 files
Terraform files (AWS + OCI + NCP)10
Automated tests0
TODO/stub markers26 (19 English-only)

Request path

Rendering diagram…

Both clients hit a same-origin CSRF check in proxy.ts (matcher /api/:path*) on every mutating request before reaching the route handlers. Auth runs through getCurrentUser in lib/auth, which reads the HMAC-SHA256 signed cookie first and an Authorization: Bearer token of the same format second; OAuth identities (Apple/Google/Kakao) arrive as identity-token JWTs verified server-side. The unauthenticated contributor POST is the one public write path and passes its own stack of guards.

One codebase, two backends — driver swap by env var

getDb() in lib/db/index.ts dynamically imports drizzle-orm/node-postgres plus a pg.Pool when DATABASE_URL is set, and otherwise falls back to embedded PGlite (@electric-sql/pglite) writing to .data/pglite, cast to NodePgDatabase so the query API is identical at every call site. Migration runs raw DDL once — pool.query(DDL) for Postgres, client.exec(DDL) for PGlite — guarded by a globalThis.__bk_ready promise so tables are ensured exactly once per process (lib/db/init.ts). Storage mirrors the pattern: lib/storage/index.ts picks S3StorageDriver versus LocalStorageDriver on process.env.S3_BUCKET behind a single StorageDriver interface, and lib/env.ts getSecrets() auto-generates SESSION_SECRET (via crypto.randomBytes) and a VAPID keypair into .data/secrets.json when env vars are absent, with a read-only-filesystem fallback to memory-only.

The trade-off is centralization: selection is dynamic-imported so each environment loads only its own dependencies, but it also means the entire local/prod behavioral split lives in three files. Get one branch wrong and the same artifact behaves differently than the deploy it was tested against.

lib/auth/index.ts issues HMAC-SHA256-signed tokens of base64url(uid.exp) with a 180-day expiry (MAX_AGE_SECONDS = 60*60*24*180), verified with a constant-time safeEqual over crypto.timingSafeEqual in lib/tokens.ts. getCurrentUser() reads the httpOnly bk_session cookie first, else the bearer token of the same format — the scheme the Expo app uses over the identical /api/* contract. establishUser() upserts user and box together (one box per owner, enforced by .unique() on boxes.owner_id) and is shared by dev login and the OAuth callbacks.

One signed-token scheme serves browser and native without a session store. The cost is no server-side revocation: a 180-day bearer token held by a mobile client cannot be invalidated by logout. This is logged as F-005, below.

Apple identity-token verification from scratch

lib/oauth/apple.ts fetches Apple's JWKS (cached one hour) and, for an incoming identity token, checks header.alg === RS256 plus kid, finds the matching RSA JWK, and verifies the RSA-SHA256 signature over the signing input via crypto.createPublicKey({format:'jwk'}) and createVerify. It then validates iss === https://appleid.apple.com, an audience match (appleAudience from APPLE_BUNDLE_ID), sub presence, and exp/iat against a 60-second clock-skew window. lib/oauth/google.ts and lib/oauth/kakao.ts are parallel modules.

Native Sign in with Apple returns an identity token, not an OAuth redirect, so verifying the JWT signature and claims directly with node:crypto avoids a heavyweight auth dependency. The trade-off is hand-rolled crypto-adjacent code that has to be read carefully rather than trusted to a library.

Owner-gated media route with presigned redirect and Range streaming

app/api/items/[id]/media/route.ts requires getCurrentOwner() and checks item.boxId === owner.box.id before serving any bytes; storage keys are boxId-scoped UUIDs and are never publicly addressable. If the driver exposes presignedUrl (S3/R2), the route 302-redirects to a five-minute signed URL (expiresIn: 300) with Cache-Control: private, no-store; otherwise it streams from local disk with manual bytes= Range parsing, returning 206/416, plus X-Content-Type-Options: nosniff and Cache-Control: private, max-age=3600. The local driver normalizes keys to block ../ path traversal.

Voice and face media are the sensitive data here, so every fetch goes through an auth check rather than a public URL, and nosniff blocks a stored-media-as-HTML XSS path. Range support exists because iOS Safari <audio> and <video> seeking requires it — without 206 responses, playback stalls.

Layered abuse controls on the one unauthenticated write path

app/api/c/[token]/route.ts is the only public write endpoint. The link token is a 32-byte base64url cryptographic random string (newLinkToken in lib/tokens.ts). The POST handler applies an early Content-Length guard that rejects oversize requests before buffering, dual fixed-window rate limits (per-IP 20 per 60s and per-token 60 per hour via lib/ratelimit.ts), an in-memory idempotency cache (seenIdempotencyKey) to dedupe double-submits, expiry checks, and per-type MIME allow-lists in lib/constants.ts where svg and html are intentionally excluded to block stored XSS. The same-origin CSRF check in proxy.ts applies on top.

An open submission endpoint is the highest-risk surface in the system, so the defense is deliberately layered: DoS guard, IP/token throttle, idempotency, MIME allow-list, CSRF origin enforcement. The honest limitation is that the rate limiter and idempotency cache are in-memory — see the warts.

Dual-trigger reminder scheduler

lib/scheduler.ts has one runDueReminders() that selects enabled reminders, computes the current HH:MM per stored IANA timezone via Intl.DateTimeFormat, and fans out to each owner's push subscriptions — routing s.platform === expo subscriptions to Expo Push (lib/expo-push.ts, the exp.host API) and the rest to VAPID Web Push (lib/push.ts). Subscriptions whose send returns gone are deleted (web: 404/410; Expo: DeviceNotRegistered), so the push table self-prunes. On a persistent host, node-cron fires it every minute via startScheduler; on Vercel it skips node-cron and exposes app/api/cron/reminders for an external scheduler, gated by a CRON_SECRET bearer or x-vercel-cron header.

The same reminder logic runs whether the runtime is a long-lived process or stateless functions. The weak seam is the serverless auth path, noted below.

A traceable security pass, then an independent review

Commit ed3600c ("harden: fixes from adversarial security review") changed 11 files (+224/-55). It disabled dev-login in production, added the upload MIME allow-list and nosniff, stopped the service worker from caching authenticated box HTML (public/sw.js cache beotimok-v2), added media Range/206 support, added the proxy.ts CSRF check, added the per-token throttle and content-length guard, and added per-key secret env overrides in lib/env.ts. A separate Codex review (codex_review/full/2026-06-18-1313/comprehensive-review.en.md) re-audited and logged nine findings F-001..F-009 with severity (3 High, 6 Medium) and confidence.

This is a closed loop: issues from the adversarial pass map to specific committed diffs, and a second independent review documents remaining risk instead of claiming the system is "secure." Some Codex findings (for example F-004) are now stale against current code.

What this architecture doesn't do