A two-person scoring app, end to end: a 6,000-line single-file frontend, 27 Edge Functions, 12 Postgres tables, and one rule that shapes all of it — the client may read, but only a function may write.
There is no build step, no framework, and no server the couple owns. index.html carries every style and every screen's markup; app.js carries all 6,000 lines of behaviour. GitHub Pages serves both as static files. Everything dynamic happens in two places: Supabase Edge Functions (Deno) holding the business rules, and Postgres holding the data.
That shape is deliberate for a couple's app that must outlive the enthusiasm of whoever maintains it. There is nothing to keep running, nothing to patch, and no dependency that can rot: open the repo in five years, and node --check app.js is the entire toolchain.
The security model is a shape, not a checklist. This is that shape.
There are two ways out of the browser and into the database, and they are deliberately not equal. Both carry the same public key and the same user session; what differs is what each is permitted to do at the far end.
SELECT.This was tested by attack rather than by reading the policy. A genuine signed-in session was used to PATCH a row directly: the request answers HTTP 204, which looks like success, and changes nothing. Reading the value back afterwards is the only way to tell.
shop-buy is a missing check, full stop — which is why the score-sum logic lives in one shared helper that both shop-buy and bag-claim call, rather than being written twice.partner A taps 说早安 +5. Here is everything that happens, in order.
Data.addEntry(). The client computes todayStr() and monthKey() from the device clock — never toISOString(), which is UTC and rolls a day early for a UTC+8 couple.snFetch('/entries', {method:'POST'}) rewrites the ServiceNow-era path onto a function slug: …/functions/v1/entries. Thirty such translations exist so none of the call sites had to change during the migration.Authorization: Bearer carries the user's access token. Both are needed — the key alone gets you an anonymous session that RLS shows nothing to.apikey is a custom header, the browser sends OPTIONS first. Every function answers it through a shared wrapper; when that was missing, every request failed with nothing but "Failed to fetch".getCaller() validates the JWT and reads profiles for the caller's match_id and char_id. No match_id ever arrives from the client — it is derived, so a forged one is not a thing that exists.match_id from step 5 and the date from step 1, plus a snapshot of the category's name, icon and point value. The snapshot is why deleting a category later doesn't corrupt the history.{id, success:true} — the identical shape the ServiceNow resource returned, which is the reason app.js needed no change here at all.Twelve tables. One couple. Two people who must see the same numbers — except where they must not.
matches, and RLS scopes every read to the caller's own couple. bag is the single table also scoped per person — your unredeemed reward is yours, not the couple's — and the only one whose foreign key points two different ways depending on what kind of thing the row is.Two people must see byte-identical numbers, or the app is lying to one of them. But a handful of things are genuinely individual, and getting that wrong in either direction is a bug: share a bag and one partner spends the other's reward; split the pet and they are no longer raising it together.
| Table | Rows | Scope | Why |
|---|---|---|---|
| entries | 605 | couple | Both see every entry; each row's char says whose score it moves |
| config | 1 | couple | Targets, goal, and the pet — one shared row, hence one shared pet |
| categories / shop rewards / punishments | 18/5 4/0 | couple | Either partner edits; both see the change immediately |
| monthly | 2 | couple | Settled history, with each partner's total on the row |
| letters / photos | 26/10 | couple | Written by one, read by both |
| bag | 64 | per person | Your unredeemed rewards are yours — the partner must not be able to spend them |
| bag where source_type = decor | 16 | couple | The exception to the exception: the room is shared, so a sofa one partner bought must be placeable by the other |
| rewards claimed_1 / _2 | — | per person | Each claims each reward once per round; settling reopens both |
bag row is where the migration hurt. The old ServiceNow /bag endpoint filtered to the caller's own rows — correct for the app, quietly wrong for a backup, which therefore never captured the other partner's items. At cutover, partner B's seven unredeemed rewards had to be read off a screenshot because no export contained them. The backup now uses a service key and bypasses RLS precisely so one run sees both.6,063 lines of app.js, 4,665 of index.html, no build step.
All state lives in one object, S. All server access goes through one object, Data, which has a ServiceNow-era shape the migration deliberately preserved — and a localStorage twin behind the same methods, which is what Demo mode runs on. Every screen is markup already present in index.html, shown and hidden rather than constructed.
GET /photos used to return full base64 images, so pulling it on every home refresh re-downloaded megabytes on mobile data. The rule that came out of that is still in force even though photos are now signed URLs:
S. Only views that report exact counts force a reload.Data.init(), because boot() runs a refresh before login — which would otherwise latch the cache onto an empty dataset for the whole session.app.js is always fetched fresh; index.html — which carries every style and all the markup — can sit in a phone's cache for days. Running new JS against old HTML produces unstyled elements and invisible layouts. So HTML_V in app.js must equal the app-html-v meta in index.html; the app compares them on boot and reloads once, cache-busted, when they differ. The retry flag is keyed by version, or one stale flag would block every future release.
There is no websocket. There is a poll that knows when to care.
Measured against the real backend, a config poll is small but takes about a second, so "instant" does not exist here — a couple of seconds is the floor. Rather than poll fast all the time, the gap adapts to whether anything is happening:
| When | Gap | Reasoning |
|---|---|---|
| Within 60s of any change | 4s | You are both in there right now — this is when latency is felt |
| Next 3 minutes | 12s | Someone is still around, but it has gone quiet |
| Idle after that | 30s | A quiet hour costs about 120 requests — fewer than a fixed 20s poll |
| Tab in the background | — | Does not poll at all; returning polls immediately rather than waiting out the gap |
It is a self-rescheduling setTimeout, not setInterval, so the gap can change between ticks without tearing the timer down. Change detection is an order-independent fingerprint of the room — _roomSig() — rather than a timestamp, so two phones that disagree about the clock still agree about whether anything moved.
Two accounts, one couple, and a six-digit code that exists for about a minute.
matches row is created with a random six-digit pair_code, an auth.users record with a hashed password, and a profiles row claiming char1.profiles row joins it as char2. A unique (match_id, char_id) constraint means the slot cannot be taken twice.match_id and char_id from profiles. The client never sends them, so it cannot lie about them.Supabase Auth requires an email address and these usernames are not emails, so one is synthesised deterministically from the username. That detail caused the migration's last bug: it must be derived from the stored username, because cs and partner A hash to different addresses — and ServiceNow's queries had been case-insensitive for years, so a lowercase login that had always worked started reporting "account does not exist".
Everything else adds. This is the only thing that sweeps the board.
A 月末结算 archives a month: it writes a monthly row holding each partner's total and outcome, stamps monthly_id onto exactly the entries it counted, and clears every reward's claim flags so both partners can claim again next round. Afterwards those entries no longer appear in the live score — they are history.
/entries returns, groups by each entry's own month, and settles once per group — so a month that was missed becomes its own history row instead of being merged into today.?year=YYYY exists to return everything for a year, settled included — otherwise 年度回顾 would only ever see the current unsettled month.27 functions. ServiceNow needed 41 resources for the same surface, because it wanted one per path and method.
| Area | Functions | Carries |
|---|---|---|
| Identity | auth-register · auth-login auth-password · auth-charimg | Pairing by six-digit code, case-insensitive usernames, session rotation on password change |
| Settings | config | GET and PUT on one function; holds the monotonic pet-EXP floor |
| Scoring | entries · entries-id | Unsettled-only by default; ?year= for the annual recap |
| Catalogs | categories · categories-id rewards · rewards-id punishments · punishments-id shop · shop-id | CRUD, with ownership re-checked against match_id before every mutation — a row from another couple answers 404, never an error that confirms it exists |
| Economy | shop-buy · bag · bag-use bag-history · bag-claim · decor-buy | Score checks, per-person ownership, and the coin ledger furniture spends from |
| Settlement | monthly-settle · history | Archives entries and reopens reward claims for both partners |
| Content | letters · letters-id photos · photos-id | Photos upload to Storage and come back as signed URLs |
Each of these is in the schema or the function layout because forgetting it once caused a real bug.
entries.monthly_id IS NULL is the live score — not a date filter. A month filter once hid a whole month of entries the instant the calendar rolled over without a settlement, and the score silently read zero; the couple thought their data had been deleted. A partial index is built for exactly this query.date/month from the request and only falls back to its own clock if absent.pet_exp is a high-water mark the server refuses to lower, because EXP is derived from activity that can shrink — deleting a photo, or settling a negative month. Adoption is the one legal reset, signalled by pet_base arriving alongside it.bag keeps the item's name and icon; entries keeps the category's name and points, copied at write time. Deleting a shop item or category clears the link (ON DELETE SET NULL) without erasing history — which is also why Postgres had to be taught to allow that delete at all, since ServiceNow never blocked it.shop-buy and bag-claim, because when those two drifted apart the UI showed a purchase as affordable and the server rejected it as insufficient.