Add accounts and multi-tenancy
Every event, profile and photo is now scoped to a signed-in account, so separate people can track separate puppies on one server. Server: - users + sessions tables; bcrypt passwords; random session tokens stored hashed and set as an HttpOnly cookie. Middleware gates /api/* behind a valid session. - register/login/logout/me endpoints. Registration requires a shared invite code (-invite-code / PUPPY_INVITE_CODE); empty disables it. - events, config and photos are keyed by user_id; the sync upsert guards against cross-user overwrites and reads are scoped, so accounts are isolated. Photos live under photos/<user_id>/ and are only served to their owner. - in-place schema migration adds user_id and reshapes config; legacy single-tenant data (including imported events.json) is parked ownerless and adopted by the first account to register. Client: - login/register gate in front of the app; the tracker only boots once the session check resolves. localStorage is namespaced per user. - 401s bounce back to login; an offline reload falls back to the last cached session so offline-first still works. Logout clears the session and reloads. Deployment: - module.nix gains inviteCodeFile (secret via EnvironmentFile) and secureCookies options. Verified end to end (curl + a headless-browser run of the auth flow): isolation between accounts, invite enforcement, first-user adoption, photo ownership, and session persistence across reload. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -21,12 +21,15 @@ source-of-truth and sync between devices.
|
||||
`config.json` sitting alongside it, renaming them to `*.imported`.
|
||||
- Service worker bypasses cache for `/api/*` so writes always hit the server
|
||||
when online; static assets are still cached for offline use.
|
||||
- The puppy's name and birthday are a shared profile stored on the host
|
||||
- The puppy's name and birthday are a per-account profile stored on the host
|
||||
(`GET`/`PUT /api/config`), so a new device picks them up automatically instead
|
||||
of being configured per-client. The client caches the last-seen values in
|
||||
`localStorage` for offline/instant paint and reconciles with the server by
|
||||
last-write-wins on `updatedAt`. The age shown in the header (in weeks and
|
||||
months) is derived from the birthday.
|
||||
- All data is scoped to the signed-in account (see [Accounts](#accounts)): every
|
||||
event, profile and photo carries a `user_id`, and `localStorage` is namespaced
|
||||
per user so two accounts on one browser never mix.
|
||||
|
||||
A status pill in the header shows `syncing…` / `synced 2m ago` / `pending` /
|
||||
`sync error` / `offline`. Tap it to force-sync.
|
||||
@@ -40,7 +43,8 @@ puppy-tracker/
|
||||
├── server/
|
||||
│ ├── go.mod
|
||||
│ ├── go.sum
|
||||
│ └── main.go # SQLite store, LWW sync, static file serving
|
||||
│ ├── main.go # SQLite store, LWW sync, static file serving
|
||||
│ └── auth.go # accounts, sessions, invite-gated registration
|
||||
└── src/ # the web app
|
||||
├── index.html
|
||||
├── app.js
|
||||
@@ -56,10 +60,32 @@ puppy-tracker/
|
||||
nix run # http://localhost:8080, data in $XDG_DATA_HOME/puppy-tracker
|
||||
PUPPY_ADDR=:9000 nix run # custom port
|
||||
|
||||
# Registration needs an invite code (see Accounts). Set it in the environment:
|
||||
PUPPY_INVITE_CODE=letmein nix run
|
||||
|
||||
# Hot-iterate (data in /tmp):
|
||||
nix develop -c sh -c 'cd server && go run . -static ../src -data /tmp/puppy.db'
|
||||
nix develop -c sh -c 'cd server && go run . -static ../src -data /tmp/puppy.db -invite-code letmein'
|
||||
```
|
||||
|
||||
## Accounts
|
||||
|
||||
The app is multi-tenant: each person signs in and sees only their own puppy's
|
||||
events, profile and photos.
|
||||
|
||||
- **Sessions.** Passwords are hashed with bcrypt; login mints a random session
|
||||
token stored (hashed) in the `sessions` table and set as an `HttpOnly` cookie.
|
||||
`/api/*` (except `login`/`register`/`logout`) requires a valid session.
|
||||
- **Registration is invite-gated.** Sign-up requires the shared secret passed via
|
||||
`-invite-code` / `PUPPY_INVITE_CODE`. With no code set, registration is
|
||||
disabled (existing accounts can still log in). Share the code with whoever you
|
||||
want to give an account.
|
||||
- **First account adopts existing data.** When accounts are introduced on a DB
|
||||
that already had single-tenant data (or that imported a legacy `events.json`),
|
||||
the first account to register inherits all of it — events, profile and photos.
|
||||
- **Serve over HTTPS in production.** Session cookies are only marked `Secure`
|
||||
when you pass `-secure-cookies` (enable it behind a TLS proxy), so passwords
|
||||
aren't sent in the clear.
|
||||
|
||||
## Use it on NixOS
|
||||
|
||||
In your system flake:
|
||||
@@ -78,6 +104,11 @@ In your system flake:
|
||||
enable = true;
|
||||
port = 8080;
|
||||
openFirewall = true;
|
||||
# Registration secret, kept out of the Nix store. The file holds:
|
||||
# PUPPY_INVITE_CODE=some-shared-secret
|
||||
inviteCodeFile = "/run/secrets/puppy-invite-code";
|
||||
# Enable once you terminate TLS in front of the service.
|
||||
secureCookies = false;
|
||||
};
|
||||
}
|
||||
];
|
||||
@@ -92,5 +123,7 @@ alongside it under `photos/`).
|
||||
|
||||
## Notes
|
||||
|
||||
- No auth. Intended for a home LAN. If exposing publicly, terminate TLS and
|
||||
authenticate with a reverse proxy in front (Caddy / nginx / Tailscale Funnel).
|
||||
- Accounts gate access, but there is no built-in TLS. If exposing publicly,
|
||||
terminate TLS with a reverse proxy in front (Caddy / nginx / Tailscale Funnel)
|
||||
and set `secureCookies = true`. Without HTTPS, passwords and session cookies
|
||||
travel in the clear.
|
||||
|
||||
Reference in New Issue
Block a user