Access and sessions
The allowlist
Sign-in is Google-only, and only for addresses on the allowlist. There is no password path, and none should be added.
| Variable | Behaviour |
|---|---|
LUNA_OWNER_EMAIL | A comma- or whitespace-separated list of Google accounts. A single address works as before. Empty or unset means every login is rejected. |
LUNA_OWNER_EMAIL_FILE | Optional path to a file holding the list, for example the mounted allowed-emails key of the luna-secrets Secret. If set, it wins over LUNA_OWNER_EMAIL and is re-read every 5 seconds, so allowlist edits need no rollout. An unreadable file means an empty list (fail closed). Not yet wired in the deployment manifest. |
Accounts are added through CORE’s @add_users issue workflow, which writes
luna-secrets/allowed-emails the same way it does for the other Runink apps.
One deployment, one Luna per account. Each allowlisted account has its own journal, memory, plans, avatar, streaks and level, keyed by a hash of the address.
How sign-in works
- The app gets a Google ID token (scopes
openid email profile) and callsIdentityService.GoogleLogin. - The server verifies the token locally against Google’s published keys,
with
LUNA_GOOGLE_CLIENT_IDas the audience. The issuer is Google unlessLUNA_OIDC_ISSUERoverrides it. - It requires a verified email on the allowlist, then opens a session.
Sessions
Sessions are rows in LUNA’s own appfs root (run/sessions), not signed
tokens. Each one is a 256-bit opaque id, stored only as its SHA-256 hash,
and lives for 12 hours.
- Every RPC validates its session against the shared table (cached for 5 seconds) and re-checks the allowlist.
IdentityService.Logoutrevokes the session on every replica. The app’s Sign out calls it.- Removing an address revokes access without a rollout. The first request from a removed account writes the revocation, and a reconcile loop (at boot and every 30 seconds) writes it for the rest. Adding the address back doesn’t bring an old session back.
- Sessions need
CORE_ENVELOPE_KEK. Without it there is no session store, and every authenticated RPC answersUnavailable.
Only IdentityService/GoogleLogin and gRPC reflection are public. Everything
else needs authorization: Bearer <session token>.
Admin subcommands
The server binary is luna-server (the Cobra root is luna). In the
deployment, run subcommands inside the pod, where the object-store
credentials and the KEK are already present:
kubectl -n luna-system exec deploy/luna -- /app/luna-server <subcommand> [flags]Every command that takes --account requires it and refuses to guess.
| Subcommand | What it does |
|---|---|
serve [-p PORT] | Runs the backend: gRPC, gRPC-Web and the static web bundle on one port. The port comes from PORT, and 0 means auto-assign. The image runs serve. |
mint-session --account <email> | Opens a session for an allowlisted account and prints its token, for smoke tests. It must share the server’s appfs root and CORE_ENVELOPE_KEK. |
revoke-sessions --account <email> | Revokes every session the account holds, on all replicas, with no rollout. |
grant-level --account <email> [--level N] [--reason "…"] | Writes a LEVEL_UP receipt (level 1–10, default 10) into that account’s journal. It never invents workouts or meals. |
claim-legacy-data --account <email> [--dry-run] | Assigns records written before per-account scoping (the default partition) to one account. Until then, those records are readable by nobody. |
migrate-appfs | Moves journals, owner notes and memory from the older record-store layout into the appfs root. It is idempotent, never deletes the old objects, and also runs automatically after each connect. Run it after a claim-legacy-data. |
migrate-bucket --from <bucket> | Copies every object from another bucket into this deployment’s bucket (OBJECTSTORE_BUCKET). It refuses if the source and destination are the same. |
WORKOUT or MEAL_ADHERENCE events to give someone points.
LogEvent accepts any occurred_at, but doing this writes false health
history into the journal that grounds every piece of coaching. Use
grant-level instead.