Jekardah COD, deployed at cod.jekardah.com, is a classifieds marketplace built around one product decision: the platform moves no money. Its tagline — ketemu orangnya, cek barangnya, baru bayar: meet the person, check the item, then pay — describes a transaction the software cannot see, so the software's job becomes arranging the meeting and recording what happened around it. That framing shapes every layer. The public surface is one origin: a Next.js 16 front end rewrites /api/* to a separate Go project so that the session cookie, the OAuth state cookie and the Google callback never cross a domain. The API is plain Go net/http with the standard library's method-and-path router, run as a Vercel function that initialises one application per warm instance under double-checked locking, keeps its PostgreSQL pool to four connections because the platform may spawn many instances at once, and serialises schema migration under a transaction-scoped advisory lock so parallel cold starts cannot race. Configuration refuses to start when it would be unsafe — a default JWT secret, demo mode, missing OAuth or a plaintext backend are all rejected the moment the front-end URL is HTTPS, and the demo-login route is not merely disabled in production but never registered. Listing photos may only come from three places: the project's own upload path, the Vercel Blob host, or a placeholder service; both the Next upload route and the Go handler identify image type from leading bytes rather than from the declared content type, and the Next route confirms the session with the backend before it touches storage. Chat is the transaction layer — one conversation per listing and buyer, membership checked on every read and write, incremental polling by last-seen message id — and a review can only be written for a listing marked sold by a buyer who actually opened a conversation on it, addressed to its seller and no one else. The result is a marketplace in which trust is manufactured from the few facts the platform can verify, and nothing it cannot.
Most marketplace software is organised around payment: escrow, settlement, refunds, disputes. Cash-on-delivery classifieds — the dominant way second-hand goods change hands in Indonesian cities — invert that. The buyer and seller meet in a public place, the buyer inspects the item, and money changes hands in person. The platform is not a party to the transaction; it is the place where the two parties found each other and agreed where to stand.
Jekardah COD takes that division of labour as a design brief rather than a limitation. There is no checkout, no wallet, no payment integration. What the platform can genuinely verify is small: who signed in, what they listed, who talked to whom about which listing, and what the seller eventually marked as sold. Everything the product does — search, listing, chat, wishlist, profiles, reviews — is built to make those few verifiable facts as trustworthy as possible, and to refuse to fabricate any it cannot check.
Technically the application is two Vercel projects presenting as one site: a Next.js 16 front end of roughly three thousand lines of TypeScript, and a Go API of roughly two and a half thousand lines behind it, both pinned to the Singapore region, with a serverless PostgreSQL database and a public object store for photographs. This paper describes the decisions that hold it together.
Cookie-based sessions and OAuth both become fragile the moment a front end and its API live on different domains: cookies need cross-site attributes, the OAuth state cookie set by one host must be read by another, and the provider's callback lands on whichever host was registered. Jekardah avoids all of it by never exposing the API on its own domain. The Next.js configuration rewrites /api/:path* to an internal backend URL, so every browser request goes to cod.jekardah.com/api/… and is forwarded server-side. The session cookie, the ten-minute OAuth state cookie and Google's callback all belong to a single origin, and the front-end fetch helper sends credentials: 'same-origin' and nothing more exotic.
The back end keeps a distinction the configuration comments spell out: the public backend URL, which the Go application uses when it builds OAuth redirect and upload URLs, is separate from the internal reverse-proxy target, so that pointing both at the public domain cannot create a rewrite loop. The front end adds the standard hardening headers on every path — strict transport security for a year with subdomains, frame denial, content-type sniffing off, a conservative referrer policy and a permissions policy that disables camera, microphone and geolocation — and all five are present on the live site.
The API is standard-library Go: net/http, the method-aware pattern router that arrived in Go 1.22 ("GET /api/listings/{id}"), pgx for PostgreSQL, and no web framework. It runs two ways from the same code — as a long-lived binary locally, and as a Vercel Go function in production — through a small bridge package that Vercel compiles as its entry point.
Three serverless-specific decisions live in that bridge and the database layer. First, one application object is created per warm instance under double-checked locking, and a failed initialisation is retried on the next request rather than poisoning the instance; the handler returns a JSON 503 in the meantime. Second, the connection pool is capped at four connections with no minimum, on the stated reasoning that the platform may run many instances in parallel and the connection string already goes through the provider's pooler — a pool sized for a single server would exhaust a serverless database quickly. Third, schema migration is idempotent and serialised.
SELECT pg_advisory_xact_lock(81425091337)
Every cold start runs the migration, so several instances can attempt it simultaneously. Taking a transaction-scoped advisory lock before the CREATE TABLE IF NOT EXISTS and ADD COLUMN IF NOT EXISTS statements means only one proceeds at a time and the rest find the work already done. The same migration also performs a one-off deduplication — deleting duplicate conversations for the same listing and buyer — before creating the unique index that will prevent them in future, which is the correct order and would be unsafe to run concurrently without the lock.
The router is wrapped in three middlewares: a recover that turns a panic into a logged stack trace and a JSON 500 rather than a dropped connection, request logging with duration, and CORS restricted to the single configured front-end origin with credentials allowed.
The most consequential code in the back end is a validation function that runs before the database is opened. It treats an HTTPS front-end URL as the definition of production and applies a set of refusals from that.
| Condition | Outcome |
|---|---|
| JWT secret shorter than 32 characters | Refuse to start, in any environment. |
| Production and the JWT secret is the shipped default | Refuse to start. |
| Production and demo mode enabled | Refuse to start. |
| Production and Google OAuth not configured | Refuse to start. |
| HTTPS front end with a plaintext backend URL | Refuse to start. |
| Client ID or secret set without the other | Refuse to start. |
| Front-end or backend URL carrying a path, query, fragment or userinfo | Refuse to start; only bare origins are accepted. |
The pattern converts a class of production incident — the forgotten environment variable, the copied development secret — into a deployment that fails immediately and loudly instead of one that runs insecurely for weeks. Three of the unit tests exist specifically to hold this behaviour in place: one for the development defaults, one for each production guard, and one for the origin-only rule.
Demo login follows the same philosophy one step further. It is gated by an allowlist of two demo accounts and by demo mode, but in production the route is not disabled — it is never registered on the mux. A request to it on the live site returns a plain 404, indistinguishable from a path that never existed.
Sign-in is Google OAuth with the state parameter bound to a short-lived, HTTP-only cookie: the callback compares the returned state against the cookie, clears the cookie regardless of the outcome, and redirects to an error page on any mismatch before exchanging the code. Profiles without a verified email are rejected. The user row is upserted on email, keeping an existing Google identifier if one is already stored.
The session itself is an HS256 JSON Web Token with a thirty-day expiry, carried in an HTTP-only, same-site cookie whose Secure flag follows the forwarded protocol so local development over plain HTTP still works. The parser pins the signing method: a token presenting any other algorithm is rejected before its signature is examined, and a dedicated test asserts that rejection. The front end's token helper exists only to delete a localStorage token left behind by earlier builds — a small artefact of a migration from JavaScript-readable storage to cookies that scripts cannot touch.
Request bodies are read through a decoder that caps input at one mebibyte, disallows unknown fields, and rejects a body containing anything after the first JSON object. On top of that, listing creation applies a short table of rules.
| Field | Rule |
|---|---|
| Title | Trimmed; 3 to 120 characters. |
| Description / location | At most 5,000 and 120 characters; location defaults to “Indonesia”. |
| Price | Strictly positive integer rupiah. |
| Category | Required. |
| Condition | baru or bekas; defaults to new. |
| Photos | One to eight; each URL must pass the allowlist below. |
The photo allowlist is the rule with teeth. A listing image URL is accepted only if it points at the Vercel Blob public host over HTTPS, at the placeholder service used by sample data, or at the application's own /uploads/ path on its own backend host. Anything else — an arbitrary external image, a tracking pixel, a URL that resolves somewhere unexpected when the page renders — is refused with a 400. The same check is applied to chat image attachments and to listing updates, and it has its own unit test.
Updates are partial: every field is a pointer, so absence and emptiness are distinguishable, and only fields that were sent are validated and written. Ownership is checked first and a non-owner receives a 403 in plain language. When photos are replaced, the delete and re-insert run inside one transaction with the field updates, so a failure cannot leave a listing with no images. Status transitions are constrained to active, sold and deleted.
Uploads take a different path in production and development, and both paths refuse to trust the declared content type. In production the Next.js upload route reads the first twelve bytes and recognises JPEG, PNG, GIF and WebP by signature, caps files at four megabytes, and only then writes to the object store under a random UUID name with the detected MIME type. In development, and whenever no storage token is present, it forwards the multipart body to the Go handler, which caps at twelve megabytes, sniffs with the standard library's content detector over the first 512 bytes, and writes to a local directory under a UUID name.
Before either happens the Next route makes one call: it forwards the incoming cookie to the back end's /api/me and refuses the upload unless that call succeeds. The route therefore cannot become an anonymous write path into storage even though it runs in a different project from the code that owns the session — an easy mistake to make when a front end gains a storage token of its own.
Because no money moves, the conversation is the closest thing the platform has to an order. A conversation is created per listing and buyer, guaranteed unique by index, and refused when the buyer is the seller. Both parties are inserted as participants, and every subsequent read and write checks participant membership and returns a 403 otherwise — a conversation id is not a capability.
Delivery is incremental polling rather than a socket. The first load returns the most recent hundred messages in ascending order; subsequent polls pass after_id and receive only what is newer, and each read stamps the reader's last_read_at so unread counts on the conversation list are derived rather than stored. Messages are capped at two thousand characters, may carry an image subject to the same URL allowlist as listings, and must contain at least one of text or image. The trade-off is stated in the README: polling every four seconds is not real-time, and real-time is listed among the things this version does not do.
Review systems fail when anyone can review anyone. Jekardah narrows the door to the one path its data can support.
| Condition | Enforced where |
|---|---|
| The listing's status is sold | SQL predicate |
| The reviewer opened a conversation on that listing as buyer | SQL EXISTS subquery |
| The reviewee is the listing's seller | Handler comparison |
| The reviewer is not the seller | Handler comparison |
| Rating is 1–5 | Handler check and a database CHECK constraint |
| One review per reviewer per listing | Unique constraint; a second submission updates the first |
None of this proves that cash changed hands — nothing in the system can. What it proves is that a seller declared the item sold and that this particular buyer had been talking to them about it, which is the strongest statement the platform can make honestly. Seller rating averages and counts are computed in the listing query itself, so a card on the search page carries the seller's reputation without a second request.
An empty marketplace is unusable, so the migration seeds listings on first run: five per category across sixteen categories, each row flagged is_sample and carrying a source_name and source_url naming where the example came from, so the attribution is data rather than a footnote. A unique partial index on a sample_key makes the seeding idempotent across cold starts. Most sample photographs are not photographs at all: a front-end route renders an SVG illustration per item kind — hatchback, scooter, handheld console, sofa, guitar — and serves it with a year-long immutable cache header, while a handful of used-car listings drawn from social posts ship with their original images. Two tests pin the sample set: five per category, and the presence of the featured and photographed items.
The README is candid about scope, and the code agrees with it. Chat is polling, not push; moderation, reporting, notifications and seller verification are absent. Search is a lowercased LIKE over title and description, which is adequate at the current hundred-odd listings but cannot use the title index and will not scale to a large catalogue without full-text indexing. Neither listing creation nor messaging is rate-limited beyond the request-body caps, so a signed-in account can post quickly; that is a reasonable omission at this size and the natural next control to add. Photos are public objects under deterministic names, which is correct for listing images that are meant to be seen but means deletion of a listing does not yet delete its blobs. And the sale itself remains outside the system: sold is a seller's assertion, and the review gate inherits that.
Jekardah COD is an exercise in building a marketplace around what the software can verify and declining to pretend about the rest. Its architecture is conventional in the best sense — standard-library Go, a relational schema, one public origin — and its discipline lives in small refusals: to start with a default secret, to register a demo route in production, to accept a photo from an unknown host, to let a non-participant read a conversation, or to accept a review from someone who never talked to the seller. For a product whose entire proposition is meet the person, check the item, then pay, that is the right place for the rigour to be.