LifeOS is a single-user application. It runs on a machine you own, talks to a language model you point it at, and is opened from your own devices. This document says what the software protects, what it does not, and what you are trusting when you use it. Read it once before you expose the app to anything beyond your own computer.
One process. It serves the web app and the API on one port, keeps everything
in one SQLite file under app.data_dir, and reaches the network only for
things you run or choose yourself: an OpenAI-compatible chat endpoint (the
extraction model); optionally Google Calendar and the Web Push services of your
browser vendor; and Open Food Facts, when you scan a product barcode.
There are no accounts. There is one shared token: a string you set in
config.local.yaml and enter once in each browser. Every API request must
carry it. That is the whole authentication system, and it is deliberately
no more than that: the application is for one person on their own network.
- Binds to loopback only (
server.host: 127.0.0.1). Nothing on your network can reach it until you change that. - Refuses to bind beyond loopback without a token. Setting
server.host: 0.0.0.0withapp.shared_token: nullis a configuration error and the process will not start. If you really want an unauthenticated app on your network you must say so by name (server.allow_open_bind: true), and you should not. - Serves plain HTTP. Browsers only expose the microphone and allow an
installable app (the service worker) on a secure context:
https://, orhttp://127.0.0.1. So from any other device the app must sit behind TLS. The documented path is Tailscale (tailscale serve), which gives you a real certificate and a private network in one step; any reverse proxy with a certificate works the same way. Over plainhttp://on a LAN address, the app loads, but it cannot record and cannot be installed. It tells you so on screen rather than failing silently. - Generates a VAPID keypair on first start into the data directory (mode
0600). The private half never leaves the machine; the public half is served unauthenticated at/api/push/public-key, which is what a public key is for. - Runs no outbound calls except these four, and every one of them is yours to switch off: the model, Google (only if you connect it), the push service of a browser you subscribed from, and Open Food Facts. Fonts and every asset are served from the app itself.
- The Open Food Facts lookup fires only when you scan a product barcode. It
sends that barcode and nothing else: no account, no identifier, no other
field. It goes from the server rather than from your phone, so Open Food
Facts never sees your address or your browser. Two switches stop it, and
they are not the same switch. The one in Settings, under Capture, is
labelled "Look up scanned products" and it is yours: turning it off there
stops the call from the next scan onward.
food.off_lookup: falseinconfig.yamlis the operator's, and it stops the call for good: the switch in Settings then renders disabled and says so, because a control that accepts a press and changes nothing is worse than no control. Either way, scanning asks you what the product is instead, which is the same question the app asks about any food it does not know.
The token gates every /api route except four things that cannot carry it:
| Unauthenticated | Why | What it reveals |
|---|---|---|
GET / and the app's own files |
a browser that has no token yet must be able to load the page that asks for it | the application shell, the same bytes as this repository's build |
GET /api/health |
systemd, uptime probes and the deploy smoke test | liveness and component status; when a token is configured and the request does not carry it, the identity is blanked (no display name, no model endpoint or model names, no Google account, no diagnostic detail, no first-run flags, no hardware and timing fields). The timezone, currency and public URL stay, because the page needs them before it can ask for a token |
GET /api/push/public-key |
the browser needs it to subscribe | a public key |
GET /api/gcal/callback |
Google's redirect lands in a plain browser tab | nothing; it is authenticated by the single-use state it was issued with |
The WebSocket (/api/ws) authenticates with the token as its first message,
never in the URL, because URLs are written to access logs.
The token is a shared secret, compared in constant time, stored in your browser's local storage. It is not per-user, not rotated automatically, not rate-limited, and not a substitute for the network boundary. If someone is on the network segment the app listens on and has the token, they are you. Rotating it is one overlay edit and a restart; every browser then asks for the new value.
Every note, spoken or typed, is sent to the model endpoint with the
extraction prompt, your timezone, your currency, your budget categories and
the titles of your open tasks. Wherever that endpoint is, your notes go.
A local llama-server keeps them on your machine; a hosted API sends them to
that provider under that provider's terms. LifeOS does not know or care which;
you do.
The model's output is not trusted. It is treated as a proposal:
- The response must match a JSON schema (enforced by the server when it can, validated in Python always). Unknown action types cannot exist; unknown fields are dropped and the drop is recorded on the action.
- Per-type validation runs on every payload: dates must parse, recurrence rules must be valid, categories must come from your own vocabulary, required fields must be present. Anything that fails downgrades to a confirmation card with the reason attached. It is never written.
- Actions the model marks confident are executed at once. That is the product. But every execution is reversible from the Inbox, nothing is ever hard-deleted, and the card shows what was understood, quoting the transcript it came from.
- Free text the model produces (titles, notes, journal entries) is stored as text and rendered as text. Journal markdown is sanitised before it reaches the page.
What the validation does not bound: the meaning of a well-formed action. A note that says "log fifty dollars on groceries" and a note that was crafted to make the model log fifty dollars on groceries produce the same row. Prompt injection through a dictated note is real and the defence is the same as for a mishearing: the card, the quote, and Undo. Do not feed the app text you would not want acted on.
Only what you authorise. LifeOS asks for the narrowest scope Google offers
(calendar.app.created): it can create one calendar and write events into it,
and it cannot see, list or touch any other calendar in the account, not
even to find its own calendar again if it loses the id. The OAuth client
secret lives in config.local.yaml; the refresh token lives in the database
under a key the settings API cannot read or write. Disconnecting drops the
token. Never put the client secret in the committed config.yaml.
Google, and Mozilla's and Google's push services if you enable Web Push, see
your server's public key and a contact address you configure (push.contact).
The push services also receive an encrypted payload they cannot read. That
payload carries the reminder text.
config.local.yamlholds the token and the Google client secret. Keep it mode0600. It is gitignored; keep it that way.- The database holds everything you ever said to the app. Back it up like a
diary, not like a cache. The supplied unit file sets
UMask=0077so new files are private to the service user. - The access log is the server's journal. It records request paths and client addresses, never tokens (see above) and never note content.
- Dependencies are audited at release with
pip-auditandnpm audit; the result for each release is in the release notes.
If you find a defect with a security consequence, open an issue with
[security] in the title, or email the maintainer from the address on the
repository profile. Say what you can reach that you should not be able to;
a request line and a response body are the whole report. There is no bounty;
there is a fix and a thank-you in the notes.