No description
  • Go 75.2%
  • templ 20%
  • Shell 2.9%
  • CSS 0.7%
  • JavaScript 0.6%
  • Other 0.6%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Tim Hårek Andreassen bde448f39c
All checks were successful
release / check (push) Successful in 2m32s
release / ci (push) Successful in 0s
ci / check (push) Successful in 2m34s
release / image (push) Successful in 1m25s
Hide claimed-ness from everyone who manages a list
8132dd3 let the list's owner — the account that created it and the person
it is for — see that an item was claimed. In practice that is the wrong
side of the trade: keeping your own wishlist then tells you what has
already been bought, which is the surprise the hidden claim exists to
protect.

Claims now reach only the claimer (their own) and other viewers (as
"taken"). Every manager is claim-blind again: no ownerSeesClaims, no claim
query behind the owner view, and no status column or "Claimed" badge in
the manager's item table. Claimer identity was never exposed and still is
not; the debug API remains the only place full claim data exists.

The JSON API drops the "taken" field from APIItem altogether rather than
leaving a key nobody can ever receive — a breaking change for a consumer
that read it. GET /api/v1/wishlists keeps carrying items.

README, PLAN.md (incl. the visibility matrix) and the sharing docs
described the old rule and now describe this one.
2026-09-18 21:54:46 +02:00
.forgejo/workflows Show the release version in the page footer 2026-09-11 18:09:02 +02:00
cmd Make the web app installable as a PWA 2026-09-17 12:16:54 +02:00
docs Hide claimed-ness from everyone who manages a list 2026-09-18 21:54:46 +02:00
internal Hide claimed-ness from everyone who manages a list 2026-09-18 21:54:46 +02:00
scripts Hide claimed-ness from everyone who manages a list 2026-09-18 21:54:46 +02:00
.air.toml Give share-link pages the same container padding as Base, and stop air excluding generated files so embedded template/CSS changes actually rebuild 2026-08-29 18:50:56 +02:00
.dockerignore Add Docker self-hosting and Forgejo CI/release workflows 2026-09-11 13:56:42 +02:00
.gitattributes Make the web app installable as a PWA 2026-09-17 12:16:54 +02:00
.gitbump.toml build: Add git-bump config 2026-09-11 14:06:37 +02:00
.gitignore build: Ignore db stuff 2026-08-29 08:31:29 +02:00
.golangci.toml Initial commit 2026-08-28 23:01:01 +02:00
.templui.json Initial commit 2026-08-28 23:01:01 +02:00
.tokeignore dev: Add tokeignore 2026-08-29 19:19:29 +02:00
compose.yaml Add Docker self-hosting and Forgejo CI/release workflows 2026-09-11 13:56:42 +02:00
Dockerfile Show the release version in the page footer 2026-09-11 18:09:02 +02:00
go.mod Add OIDC login (Pocket ID) behind OIDC_ISSUER 2026-09-08 21:10:43 +02:00
go.sum Add OIDC login (Pocket ID) behind OIDC_ISSUER 2026-09-08 21:10:43 +02:00
go.tool.mod chore(deps): Update gotailwind 2026-09-18 10:26:26 +02:00
go.tool.sum chore(deps): Update gotailwind 2026-09-18 10:26:26 +02:00
LICENSE Add MIT license 2026-09-11 14:22:53 +02:00
mise.toml Initial commit 2026-08-28 23:01:01 +02:00
PLAN.md Hide claimed-ness from everyone who manages a list 2026-09-18 21:54:46 +02:00
README.md Hide claimed-ness from everyone who manages a list 2026-09-18 21:54:46 +02:00

Gavebord

Self-hosted wishlist ("gift table" in Norwegian). Go + templ + htmx, single binary, SQLite storage, embedded assets.

Features

  • Wishlists for yourself or for others (children, grandparents — no account needed)
  • Shared management: several accounts can manage one list with equal rights, so both parents keep the same child's list — and neither sees what the other claimed
  • Share with an existing account: pick a name and they get the list under "Shared with you" from their own login — view + claim, or edit rights
  • Items with name, price, URL, photo, notes; URL preview auto-fills image + price
  • Hidden claims: "I'm buying this" — the claimer is never shown to anyone, and nobody who manages a list sees that an item was claimed at all
  • Close + triage: after the occasion, close a wishlist (claims freeze) and sort its items into the gaveprotokoll or a private backlog of future wants
  • Received gifts (gaveprotokoll): a per-occasion record of what a person actually received, co-manageable like a wishlist — both parents keep the same ledger, CSV export for thank-you cards, a person rollup across occasions
  • Planned lists: a secret gift-idea pool for one person, kept by the people who give to them — every participant adds and claims ("taken", never by whom), items get archived as Given, and the person it is for has no possible view
  • Secret viewer links (/s/{token}): read-mostly access + claiming, no account — or an edit link that grants manager rights to the account that signs in through it
  • JSON API (huma) with per-user bearer tokens, OpenAPI 3.1 spec at /openapi.json, browsable docs at /docs
  • Admin panel + a debug claims API (the only place claim identity exists)
  • Light/dark theme toggle in the nav, followed from the OS until you pick one
  • Installable (PWA): add it to a phone's home screen and it opens in its own window. The service worker caches static assets only — never a page, never a list — and shows an offline page instead of the browser's error
  • shadcn-style components, no node in the toolchain

Quick start

mise run build          # CGO_ENABLED=0 go build -o build/gavebord ./cmd/gavebord
DB_PATH=gavebord.db ./build/gavebord
# open http://localhost:8080 — the first registered user becomes admin

Or with demo data:

DB_PATH=demo.db go run ./cmd/seed
ADDR=:8080 DB_PATH=seed.db go run ./cmd/gavebord

Self-hosting with Docker

BASE_URL=https://gavebord.example.com docker compose up -d

The image is published to src.timharek.no/tim/gavebord (latest + the tag) on every v* tag. compose.yaml pulls it; uncomment build: . to build the checkout instead. State lives in the gavebord-data volume as a single SQLite file — back up gavebord.db (plus -wal/-shm if present).

The container serves plain HTTP on :8080 with no TLS and no auth in front of it. Terminate TLS in a reverse proxy and set BASE_URL to the public URL: it is what OIDC redirects and share links are built from, and an https:// BASE_URL is also what marks the session and CSRF cookies Secure. Then register in the browser — the first user becomes admin.

OIDC

OIDC is optional and turns on only when OIDC_ISSUER is set. Register an application with your provider (Pocket ID works out of the box; anything with standard discovery does too):

  • redirect URI https://<gavebord-host>/auth/oidc/callback
  • scopes openid email profile (add groups to map a group to admin)
environment:
  BASE_URL: https://gavebord.example.com
  OIDC_ISSUER: https://idp.example.com
  OIDC_CLIENT_ID: gavebord
  OIDC_CLIENT_SECRET: ...
  OIDC_ADMIN_GROUP: admins
  OIDC_SCOPES: groups

Accounts are matched by verified email and linked, so existing password accounts keep working (hybrid mode is the default) — and REGISTRATION_OPEN gates only the password form, not provisioning through the provider. Set ADMIN_EMAIL to your address before the first login so that account is admin regardless of OIDC_ADMIN_GROUP. See docs/oidc.md for the full behavior, and mise-runner for the CI runner image the workflows use.

Configuration (env)

Variable Default Meaning
ADDR :8080 listen address
DB_PATH gavebord.db SQLite file
BASE_URL http://localhost:8080 used in share links and the OIDC redirect URI
REGISTRATION_OPEN true disable to close signups
ADMIN_EMAIL — pre-seed an admin account
OIDC_ISSUER — enables OIDC login when set (issuer URL)
OIDC_CLIENT_ID — client ID from the OIDC application
OIDC_CLIENT_SECRET — client secret from the OIDC application
OIDC_SCOPES — extra scopes (e.g. groups for admin mapping)
OIDC_ADMIN_GROUP — IdP group name → role admin on login
OIDC_PROVIDER_NAME Pocket ID button label on the login page

OIDC login (Pocket ID)

OIDC activates only when OIDC_ISSUER is set; without it, everything behaves exactly as before. It uses the authorization code flow with PKCE (S256) and nonce, via discovery — any standard-compliant provider works, and hybrid mode is the default (existing password accounts keep working).

Pocket ID setup:

  1. Create an application; note client ID + secret.
  2. Set the redirect URI to https://<gavebord-host>/auth/oidc/callback (BASE_URL + that path — BASE_URL must be the public URL).
  3. Set OIDC_ISSUER to https://<pocketid-host> (the issuer URL).
OIDC_ISSUER=https://idp.example.com \
OIDC_CLIENT_ID=... OIDC_CLIENT_SECRET=... \
BASE_URL=https://gavebord.example.com ./gavebord

Behavior:

  • First login matches by verified email and links the identity to an existing account, else provisions one (role user, no password — those accounts sign in only via the provider). With OIDC on, signups via the provider are always allowed; REGISTRATION_OPEN gates only the password form.
  • Add the groups scope and set OIDC_ADMIN_GROUP to map a Pocket ID group to the admin role on login.
  • Invited viewers can sign in/up through the provider: the invite token rides along, and logout stays local.
  • Password login rejects OIDC-only accounts with a hint to use the provider.

Development

mise run dev       # templ + tailwind watch, air reload
mise run check     # vet + golangci-lint
mise run test
mise run smoke     # end-to-end against a throwaway DB
mise run seed      # demo data in seed.db

Tools live in go.tool.mod (go tool -modfile=go.tool.mod <name>) so the app module stays clean.

Installing as an app

Gavebord ships a web app manifest and a service worker, so any browser offers "Install" / "Add to Home Screen" and the app then runs without browser chrome.

Installability needs a secure context, so put it behind TLS and set BASE_URL to the public URL (see above); over plain HTTP on a LAN address the service worker simply does not register and Gavebord stays an ordinary website.

Nothing is stored offline beyond the stylesheet, scripts and icons: lists live on your server, and an offline launch says so rather than showing a stale copy. See docs/pwa.md for the caching rules and how to regenerate the icons.

Backups

Copy the SQLite file (plus -wal/-shm if present, or use VACUUM INTO). Everything — users, sessions, claims — lives in that one file.

API

Session cookie or Authorization: Bearer <token> (generate on /settings; interactive reference at /docs, spec at /openapi.json):

GET  /api/v1/me
GET  /api/v1/wishlists          # lists the token's account may edit
GET  /api/v1/wishlists/{id}     # manager, or an account it is shared with;
                                # never any claim state
POST /api/v1/wishlists/{id}/items
GET  /api/v1/debug/claims   # admin + bearer token only

Privacy model

Claims never leave the database for anyone who manages a list: not who claimed an item, and not that anyone did — for the account that created it, the person it is for, and a co-manager alike. So one parent cannot see what the other claimed, and keeping your own list never spoils the surprise. Viewers (a link, or a share) see only their own claims; everyone else's show as "taken". Admins can open any list but get the same viewer row as anyone else; full claim data exists solely behind GET /api/v1/debug/claims (admin bearer token).

License

MIT — see LICENSE.