HTTP API
Every endpoint the app answers on, read out of the route files themselves — the methods they export, and the scope each one demands. A handler that changes the scope it requires changes this page on the next build.
Scopes
A token holds some of these and nothing else. Each description is the sentence somebody agrees to when they grant it.
| Scope | What it permits |
|---|---|
streams:write |
Send readings into your data streams, and create new streams |
streams:read |
Read everything your data streams have ever recorded |
schedule:read |
Read everything on your calendar for the days ahead, today included |
schedule:write |
Put blocks on your week — one-off and repeating — move and rename them, take them off a day, set reminders on them, and mark them done or skipped |
today:read |
See today's plan — the blocks and the tasks on it |
reminders:read |
See the reminders you have coming, so this device can ring for them |
habits:read |
See your habits, which are due today, and whether you kept them |
habits:write |
Mark a habit kept, or unmark one |
plugin:declare |
Name and describe itself on your integrations page |
webhooks:manage |
Send itself a message when something changes here — a task finished, a block done — to an address it chooses |
audio:read |
Read your recordings and their notes |
audio:write |
Rename your recordings and write their notes |
inventory:read |
See everything you keep and everything on your shopping list |
inventory:write |
Add things, tick them bought, change how many you keep, and take things off |
locations:read |
See where your things live, and what is in each room and drawer |
locations:write |
Add and change rooms and drawers, and say where a thing lives |
calendar:read |
Show your plan in a calendar app. It can see the plan and change nothing |
notes:read |
Read your diary and your notebooks, and the pictures and recordings in them |
notes:write |
Write in your diary and your notebooks |
ideas:read |
See your ideas, and the pictures and recordings in them |
ideas:write |
Add ideas, change them, and remove them |
tasks:read |
Read your todo list and your goals, and the pictures and recordings on them |
tasks:write |
Add, finish and delete todos, move them on and off a day, and close a goal |
kitchen:read |
Read your recipes, and their photographs |
kitchen:write |
Add and change recipes |
workouts:read |
See your workouts |
workouts:write |
Add and change workouts and their categories, put them away, and mark one done |
bills:read |
See your bills, your recorded income, and what you have actually paid or received |
statements:read |
Read your imported bank-statement lines, their categories and tags, and the monthly in/out figures |
statements:write |
Add and remove the rules that sort your statement lines |
bills:write |
Add and change bills, and mark them paid |
people:read |
See the people in your life, whose birthday is coming, and their photographs |
people:write |
Add people, and change what is recorded about them |
search:read |
Search everything you have written, in one go |
tags:read |
See the labels you use, and the colours you gave them |
tags:write |
Rename your labels, colour them, and merge two into one |
destructive |
Delete things outright — with only the write grants, it can add and change but never remove |
notebooks:deliver |
File things into your other notebooks, and move this notebook’s things there — seeing only their names, never what is in them |
Endpoints
| Endpoint | Method | Scope |
|---|---|---|
/.well-known/oauth-authorization-server |
GET | — |
/.well-known/oauth-protected-resource |
GET | — |
/account/export |
GET | — |
/api/assistant/chat |
POST | — |
/api/billing/paddle |
POST | — |
/api/billing/play/claim |
POST | — |
/api/billing/play/rtdn |
POST | — |
/api/capture-options |
GET | — |
/api/client-errors |
POST | — |
/api/imports |
GET | — |
/api/jobs/reminders |
POST | — |
/api/jobs/weekly-reviews |
POST | — |
/api/live |
GET | — |
/api/mcp |
POST | — |
/api/mcp |
GET | — |
/api/notifications |
POST | — |
/api/notifications/raised |
POST | — |
/api/pricing |
GET | — |
/api/push |
POST | — |
/api/push |
DELETE | — |
/api/push/test |
POST | — |
/api/recordings |
GET | — |
/api/reminders |
GET | — |
/api/reminders |
POST | — |
/api/report |
POST | — |
/api/ringtones/[id] |
GET | — |
/api/saved-filters |
GET | — |
/api/saved-filters |
POST | — |
/api/search |
GET | — |
/api/subscribe |
POST | — |
/api/tutorial |
POST | — |
/api/v1/audio/[id] |
GET | audio:read |
/api/v1/audio/[id] |
PATCH | audio:write |
/api/v1/audio/[id]/file |
GET | audio:read |
/api/v1/finance/budgets |
GET | statements:read |
/api/v1/finance/commitments |
GET | statements:read |
/api/v1/finance/ledgers |
GET | statements:read |
/api/v1/finance/movements |
GET | statements:read |
/api/v1/finance/summary |
GET | statements:read |
/api/v1/inventory |
GET | inventory:read |
/api/v1/inventory/items |
POST | inventory:write |
/api/v1/inventory/items/[id]/bought |
POST | inventory:write |
/api/v1/me |
GET | — |
/api/v1/notebooks/[id]/[section] |
GET | — |
/api/v1/plugin |
GET | plugin:declare |
/api/v1/plugin |
PUT | plugin:declare |
/api/v1/plugin |
DELETE | plugin:declare |
/api/v1/reminders/upcoming |
GET | reminders:read |
/api/v1/schedule/upcoming |
GET | schedule:read |
/api/v1/streams |
GET | streams:read |
/api/v1/streams |
POST | streams:write |
/api/v1/streams/[slug]/points |
POST | streams:write |
/api/v1/streams/[slug]/points |
GET | streams:read |
/api/v1/streams/[slug]/points/[externalId] |
DELETE | streams:write |
/api/v1/today |
GET | today:read |
/api/v1/webhooks |
GET | webhooks:manage |
/api/v1/webhooks |
POST | webhooks:manage |
/api/v1/webhooks/[id] |
DELETE | webhooks:manage |
/api/v1/widget |
GET | — |
/calendar/[token] |
GET | calendar:read |
/favicon.ico |
GET | — |
/health/meals |
GET | — |
/health/trainings |
GET | — |
/healthz |
GET | — |
/inventory/list |
GET | — |
/inventory/things |
GET | — |
/kitchen/[...path] |
GET | — |
/manifest.webmanifest |
GET | — |
/media |
POST | — |
/media/[id] |
GET | — |
/media/audio |
POST | — |
/media/audio/[id] |
GET | — |
/metrics |
GET | — |
/oauth/register |
POST | — |
/oauth/token |
POST | — |
/robots.txt |
GET | — |
/settings/account/export |
GET | — |
/shopping |
GET | — |
/.well-known/oauth-authorization-server
GET
/.well-known/oauth-protected-resource
GET
/account/export
The export moved with the page it hangs off.
GET
/api/assistant/chat
The in-app chat's own door, and the one route that streams an answer.
Session-only on purpose: this is the signed-in person talking to their own
account, so there is no token to mint. What it may do is still the account's
to decide: every assistant grant, and destructive where the Chat tab says
so — see chatScopes. An external assistant keeps using /api/mcp.
A POST rather than a form action because the answer is a stream — the same
reason /api/live is an endpoint.
Switched off. The in-app chat is parked — CHAT_IN_APP in
$lib/features — and this answers 404 until it comes back. An assistant
reaching in from outside is unaffected: that is /api/mcp.
POST
/api/billing/paddle
Where Paddle tells us what happened.
Unauthenticated by design — the signature is the authentication. The raw body is read as text and hashed before anything parses it, because re-serialising JSON changes bytes and the comparison would fail for a reason nobody could see.
Always answers 200 once the signature checks out, including for events it ignores: a provider that gets an error retries, and retrying something we deliberately did nothing with is noise for both sides.
POST
/api/billing/play/claim
The store copy hands over its purchase.
The purchase already happened on the device, inside Play's own sheet — what arrives here is the purchase token, and the only trustworthy thing to do with it is ask Google. The claim verifies the token, checks it is for the plan the client says it bought, acknowledges it (Play refunds anything unacknowledged after three days), and writes the entitlement. Signed-in only: a purchase belongs to the account that made it.
POST
/api/billing/play/rtdn
Where Google's Pub/Sub tells us a subscription changed.
The push subscription is configured with this URL carrying a shared token — that token is the authentication, compared in constant time. The message itself is never trusted: the handler re-fetches the purchase's state from Google, so the worst a forged or replayed body can cause is a re-sync.
Always 200 once the token checks out, whatever we did with the message: a non-2xx makes Pub/Sub retry, and retrying something deliberately ignored is noise on both sides.
POST
/api/capture-options
The choices the capture dialogs offer, fetched when one opens.
Capture lives in the app shell, which is on every page — so putting these into the layout's own load would run three queries on every request to serve a dialog most visits never open. Asked for once, the first time one is opened, and held for the rest of the session.
GET
/api/client-errors
Where the page sends what broke, and the answer it was given.
Session-authenticated like /api/search — this exists for the page that is
already open, not for a plugin. One body, two shapes: { decision } records
the person's yes or no, { error } is a report, accepted only after a yes —
or with once, which is the error page's own button and speaks for that one
report only.
POST
/api/imports
What this instance can bring things in from, for whoever is listing it.
ontoplano.com's FAQ answers "can I bring my tasks over" by name, and it is a
different repository with no database, built on a laptop — so the list was
typed there by hand and went stale the day Google Keep landed. This is the
same arrangement /api/pricing has for the one number that must not be typed
twice: the app is the source, this is how anything outside reads it, and
make deploy-site fetches it at build time.
Public and cacheable, and it discloses nothing: it is a list of other people's products, identical on every instance of this version.
GET
/api/jobs/reminders
The minute's reminders, done by the process that is already running.
The timer used to be npx tsx scripts/deliver-reminders.ts, which is a fresh
Node, a fresh TypeScript compile of the whole service graph and a fresh
database handle — about three seconds of CPU and a hundred megabytes, every
minute, for a job whose usual answer is "nothing is due". That is four per
cent of a core burned permanently on a small box, and it was noticed exactly
as it should have been: by somebody reading the journal and asking whether
that could possibly be right.
The app has the code loaded and the database open. Asking it costs a request.
The token gate and the stamp the instance page reads are jobEndpoint's.
POST
/api/jobs/weekly-reviews
The hour's weekly review mail, done by the process that is already running.
The same shape as /api/jobs/reminders, for the same reason — see
$lib/server/jobs. Hourly, because seven in the morning is a different
instant for every timezone; sendWeeklyReviews already does nothing for
the twenty-three runs that are not somebody's seven.
POST
/api/live
The stream a tab holds open to hear that its data moved.
Session only — no bearer token. A token is for something acting on the account from outside; this is the account's own browser being told what that something did, and there is no reason to hand a script a tap on somebody's activity.
The two lines that are not optional
X-Accel-Buffering: no turns off nginx's response buffering for this
response only. Without it nginx holds the bytes until its buffer fills, which
for a stream of 60-byte messages is never — so the page hears nothing for
minutes and then everything at once. Cache-Control: no-store stops anything
in between deciding a stream is a document worth keeping.
The heartbeat is a comment line every 25 seconds. It carries nothing; it
exists because proxies close idle connections at 30 or 60, and a stream that
dies silently is one where EventSource waits its full backoff before
noticing.
GET
/api/mcp
The one address an assistant talks to.
Streamable HTTP, in its simplest legal form: a POST carrying one JSON-RPC message (or a batch of them) and a JSON answer. No session id, no event stream, nothing kept between calls — every request carries its own bearer token and is answered on its own.
Authentication is the app's existing API token, the same one a plugin uses, with the scopes decided when it was made. A token with no assistant scopes authenticates fine and is offered no tools, which is the honest answer to "what can this do here": nothing.
GET answers 405 deliberately. A client that asks to open a server-initiated stream is told there is not one, rather than being left holding a connection that will never carry anything.
POST
GET
What this is, for anybody who opens the address in a browser.
Not the protocol — a GET is how a client asks for a server-initiated event stream, and there is not one. It is a signpost, and it names nothing about the instance beyond what it is.
/api/notifications
Marking notifications read.
Two shapes, because there are two ways of seeing one: opening the list sees all of them at once, and following one sees that one. A POST rather than a form action because the bell is in the shell — it has no page of its own to hang an action on, and every page in the app is behind it.
Answers the count back, so the badge is what the database says rather than what the browser guessed after pressing something.
POST
/api/notifications/raised
Something the app told you itself, written down.
Most notifications come from the server, and pushToUser records every one
on its way out. A reminder that comes due while a page is open does not: the
page raises it (Reminders.svelte), and the phone's own alarms do the same.
Nothing was sent, so nothing was recorded — and the bell list then held a
reminder or not depending on whether the app happened to be open when it
fired, which is the one thing that should make no difference.
So the page says so. The row goes in already read: you were looking at the screen when it appeared, so it belongs in "what was I told today" and does not belong in the count of things waiting on you.
Idempotent by key rather than by luck. Push and the page can both raise
the same reminder — the notification tag already stops two appearing on
screen, and this stops two rows.
POST
/api/pricing
What this instance charges, for whoever is quoting it.
There is one number and it has to be the same everywhere: on the billing page, in the terms, and on ontoplano.com — which is a different repository with no database, built on a laptop. The price it prints was typed into that repo by hand, so it could quote $4.90 while the checkout took something else, and nothing anywhere would notice.
So the provider is the single source, this endpoint is how anything outside
the app reads it, and make deploy-site fetches it at build time. Changing a
price is changing it at the provider and deploying; nothing is typed twice.
Public and cacheable on purpose: it is the number on a public page, and it discloses nothing an anonymous visitor cannot already read. It carries no account, no ids and no provider keys.
GET
/api/push
A browser signing itself up to be interrupted, or asking to stop.
Session-authenticated like /api/reminders rather than token-based: the
caller is the page somebody has open, and the account is taken from that
session rather than from the body — a subscription belongs to whoever was
signed in when their browser said yes, and nothing a page sends can make it
belong to anybody else.
POST
DELETE
/api/push/test
Push one notification to this account's devices, now.
The chain between "I pressed allow" and "my phone buzzed" has six links in it — permission, a subscription, a row, keys, a timer, a push service — and when nothing arrives, every one of them is a candidate. Nobody should have to bisect that by setting a reminder and waiting a minute.
So: the same code path a real reminder takes, on demand, answering with what happened rather than with a status code. It is not a debug endpoint that bypasses anything — it pushes for real, which is the point.
POST
/api/recordings
This account's recordings, for a chooser inside something being written.
A page rather than the API: no scope, no token, just the session — which is why it answers the name and the id and nothing else. The bytes are their own address and are fetched by the player when somebody presses it.
GET
/api/reminders
What should have gone off by now.
Polled by the page that is already open, so it is session-authenticated like
/api/search rather than token-based like /api/v1. The reads and the
"I have shown this" are separate calls on purpose: a reminder is only marked
delivered once something has actually put it in front of somebody, so a
failed request loses nothing.
GET
POST
/api/report
How much one account may say per hour.
Enough for somebody hitting a bad afternoon and reporting every wall they walk into; not enough to be a way of posting into the operator's mailbox at will, which is what this endpoint becomes without a number here — each report is a mail the instance sends on the sender's say-so.
POST
"Something here is wrong" — or "this could be better" — from wherever somebody noticed it.
Four things travel: the account, the sentence, the page, and the browser. The page because the first question anybody reading these asks is "where", and the browser because the second is "on what" — Firefox 151 on Linux is the difference between a bug and a rendering quirk.
The dialog in HelpDock.svelte names all four. It said "nothing else" while
this quietly attached a user agent, which is the kind of promise that is
worse than saying nothing: anything added here has to be added there in the
same edit.
/api/ringtones/[id]
One uploaded sound, played by the page that raises a notification.
Yours or nothing: the lookup is scoped to the account, so an id belonging to somebody else is a 404 and not a quiet leak of three hundred kilobytes. Cached hard because the bytes never change — a ringtone is replaced by uploading another one under another name, not by editing this one.
GET
/api/saved-filters
The filters one screen has kept, and the two things done to them.
An endpoint rather than actions on each page: every list that narrows itself
wants this, and the alternative is the same three handlers copied into the
task list, the diary, the gallery and whatever comes next. The screen names
itself in surface, and the service refuses anything that is not a path
this app serves — the key goes into a settings row, so a caller that could
put anything there could write over another setting.
GET
POST
/api/search
Search, for the command palette.
Session-authenticated like the rest of the app rather than token-based like
/api/v1 — this exists for the page that is already open, not for a plugin.
GET
/api/subscribe
The one public endpoint the newsletter form posts to.
The form is in the footer of ontoplano.com, which is a different origin from
this app and a directory of static files with nothing behind it — so the
post comes here, cross-origin, and the browser will not send it without
being told. Which origin is allowed is the instance's own setting, echoed
back only when it matches exactly: an Access-Control-Allow-Origin that
reflects whatever asked is not a CORS policy.
On an instance with no newsletter this route is a 404 in both methods, so a self-hosted install does not advertise an endpoint it will refuse.
What it answers
Whether the address joined or was already there — see "What it says" in
$lib/server/services/newsletter, and the limits below, which are what
keep that answer from being a way to sweep a list of addresses.
POST
/api/tutorial
"I have been shown around."
Session-authenticated and one line long, like /api/client-errors: the shell
mounts the tour on every page, so there is no page whose form action could
own this. It is a preference the chrome writes about itself, not a mutation
of anything a page is showing.
The demo never posts here. Its account belongs to one visitor for a few hours and the next visitor gets a different one, so a dismissal there is remembered by the tab and nowhere else.
POST
/api/v1/audio/[id]
GET — requires audio:read
PATCH — requires audio:write
/api/v1/audio/[id]/file
GET — requires audio:read
/api/v1/finance/budgets
Monthly ceilings with what is spent and left against them, for a script.
One month, original currencies, never an exchange-rate total. The same numbers the Budgets screen shows: refund credits stay separate from income and an orphaned category stays visible.
GET — requires statements:read
/api/v1/finance/commitments
What the account owes, holds and has set aside, for a script.
Stocks, not bank movements or budgets: custody, payables, receivables and reserves stay separate by kind and original currency, and the totals are the same ones the Commitments screen shows. Payables and receivables are never folded into available money.
GET — requires statements:read
/api/v1/finance/ledgers
Ledgers and their evidence status, for a script.
The same rows the Ledgers screen draws, including archived ones — with
supportedMinor null when no opening was declared, so a caller reads a gap
rather than a number it can add up. balanceCents is left out: it is a
recorded subtotal kept for old UI consumers, and a script reading a field
with "balance" in its name is exactly the misreading this release removes.
Read-only: writing stays with the app and the assistant in this release.
GET — requires statements:read
/api/v1/finance/movements
A page of statement lines, newest first, for a script.
Bounded on purpose: limit defaults to 100 and cannot exceed it, and
cursor is the id the previous page answered with. No call returns the
whole history — a plugin polling a statement should not be able to pull a
decade of lines, nor pay for sorting them.
ledger narrows to one owned account. An id belonging to another account is
answered exactly like one that does not exist, so the refusal says nothing
about whose id it was.
GET — requires statements:read
/api/v1/finance/summary
The four money questions, for a script.
The page's own figures through the page's own function: supported funds, committed stocks, this month's personal spending and its exclusions, and the month's budgets, each original currency separately. Gaps are named rather than hidden in a total. Read-only, like the rest of this family.
GET — requires statements:read
/api/v1/inventory
The whole list, bought and waiting alike — the reader decides what matters.
GET — requires inventory:read
/api/v1/inventory/items
Put something on the list.
The same semantics as typing it in the app: a name already held is put back
on the list rather than duplicated, and the response says which happened.
category is a name, created if new — a producer should not need a second
request to find out what number "Dairy" is.
POST — requires inventory:write
/api/v1/inventory/items/[id]/bought
State, not a toggle: { "bought": true } twice means bought, not un-bought.
A plugin mirroring two lists needs to be able to repeat itself.
POST — requires inventory:write
/api/v1/me
Token introspection — lets a producer verify its credentials and discover the user's timezone during setup, without needing any other scope.
GET
/api/v1/notebooks/[id]/[section]
One tab of one notebook, as lines — what a widget draws.
?status=, ?order=, ?direction=, ?tag= and ?limit= take the choices
$lib/notebook-widget lists for the tab; anything else falls back to the
tab's first. The grant is the tab's own read (SECTION_SCOPE), and a key
pinned to a notebook answers for that notebook alone: asking for another is
the same 404 as asking for somebody else's.
GET
/api/v1/plugin
The manifests this account has been given.
GET — requires plugin:declare
PUT — requires plugin:declare
Declare what this plugin understands. Idempotent per source, so a producer can call it at every startup and the newest version's vocabulary wins.
DELETE — requires plugin:declare
Withdraw a manifest. The attribute keys keep working; they just lose their label.
/api/v1/reminders/upcoming
What is about to go off, for a device that will ring for it.
A phone pointed at an instance cannot be woken by it. The web view inside the app has no Push API — Android's does not implement one — and the shell's plugins reach the copy of the app it carries and no further, so a page served by a server can neither receive a push nor book an alarm. The way a phone rings for a server's reminders is that the phone asks, and books Android's own alarms with the answer.
Which makes this the alarm clock's half of the arrangement: the list of
reminders that have not gone off yet and are near enough to be worth
booking, in the shape the booking side already takes. upcomingReminders
decides what "near enough" means, once, for this and for the copy of the app
that books alarms for the instance it is part of.
Separate from /schedule/upcoming, which answers "what is planned" for
something that decides for itself what deserves an alarm. This answers "what
did they ask to be reminded of", which is already that decision — and it is
a narrower grant, because the token that reads it lives on a phone.
GET — requires reminders:read
/api/v1/schedule/upcoming
Upcoming scheduled occurrences, so an external app can act on the plan.
This is what an alarm app reads to set alarms from planner slots. Ontoplano
reports what is scheduled; deciding which occurrences deserve an alarm —
and what kind — is the consumer's business, matched on title, category
or label.
GET — requires schedule:read
/api/v1/streams
List the caller's streams.
GET — requires streams:read
POST — requires streams:write
Declare a stream. Idempotent per slug — safe to call at every producer startup.
/api/v1/streams/[slug]/points
Push points. Batch, idempotent, partial success.
Re-sending an already-stored point is reported under duplicates and is not
an error — producers should treat it as success and mark the reading synced.
POST — requires streams:write
GET — requires streams:read
Read points back, for reconciliation or charting.
/api/v1/streams/[slug]/points/[externalId]
Delete a single point by its producer-supplied id.
DELETE — requires streams:write
/api/v1/today
Today's plan, for the home-screen widget.
Separate from /schedule/upcoming, which answers "what is coming" for an
alarm consumer. This answers "what does today look like", which is a
different question and a different scope: a widget on a lock screen should
not carry a token that can also read a week ahead.
Habits come with it only for a token that was also granted habits:read.
The two used to be one permission, so the widget's token could report which
habits were kept — which nobody agreed to by ticking a line about the day's
plan.
GET — requires today:read
/api/v1/webhooks
List the caller's webhook subscriptions.
GET — requires webhooks:manage
POST — requires webhooks:manage
Subscribe an address to events.
The response carries the secret deliveries are signed with — keep it, and
verify X-Ontoplano-Signature (sha256= + HMAC-SHA256 of the raw body).
/api/v1/webhooks/[id]
DELETE — requires webhooks:manage
/api/v1/widget
What the home-screen widget holding this key should draw.
The key is the widget: its notebook, tab, filter and order are kept on the instance, so they can be changed from Settings and the phone picks them up on its next refresh. Only a key answers — a session has no widget.
Any of the reads a widget can hold lets the key in; widgetFor then
checks it holds the one its own tab needs.
GET
/calendar/[token]
The plan, as a calendar anybody's software can subscribe to.
Unauthenticated in the session sense on purpose: this URL is pasted into Google Calendar or an iPhone, which will fetch it from their own servers with no cookie, no header and no way to be asked anything. The secret is the URL — the same bargain Google makes with its own "secret address in iCal format".
Which is why the token in the path must hold calendar:read and nothing
else. A URL is written into config files, walked past by every proxy in
between, and sometimes handed to a partner; the one thing that keeps that
bounded is that the credential it carries cannot do anything but this. A
wider-scoped token pasted here is refused rather than honoured, so nobody can
arrive at a working feed with a key that also writes.
GET — requires calendar:read
/favicon.ico
The icon nothing links to and everything asks for.
A browser fetches this before it has read a line of the page, and so does anything that wants a picture for a link without rendering one — a chat unfurling a URL, a feed reader, the card an assistant draws for a connector it is about to connect to. There was no such file here, so the request fell through to the SPA fallback and every one of those callers was handed a page where an image should have been. What they drew instead was whatever they had cached, which in one case was the logo this app stopped using a year ago.
A route rather than a file in static/, because the static server the node
adapter bundles has no mime entry for this extension: the bytes went out
correct with no content type at all, and the nosniff header this app sends
on everything then forbids the browser from working out what it is holding.
The bytes arrive as a generated module rather than through $app/server's
read. That is an adapter feature — the static adapter the device build
uses does not have it, and the node build tripped over the asset's own
bookkeeping mid-vite build. scripts/build-icons.mjs writes both the
.ico and favicon-ico.ts from the same artwork as every other icon, so
this cannot be the one that stays behind.
GET
/health/meals
Where this week's meals used to be.
The tab was a read-only week and a list of what to buy — the first is the plan, which draws meals beside everything else, and the second is the shopping list, which those ingredients were already on. Putting a recipe on a day is a button on the recipe now, so this address has nothing left to show and sends people where the act lives.
GET
/health/trainings
"Trainings" was never a word anybody says.
The thing is a workout, which is what the page itself already called them — only the tab and the address said otherwise. The old one is in bookmarks and in the app shell people have installed, so it answers rather than 404s.
GET
/healthz
Is this box alive, and is it actually able to work?
A process that is listening is not the same as a process that can serve, and on a small machine the difference is the whole problem: the disk fills, or SQLite is locked by a backup, and every page 500s while the port stays open. So this touches the database rather than answering from memory.
No session and no auth, because whatever is watching this is not logged in and should not have to be. It says nothing an attacker could not learn by loading the login page.
The disk and memory numbers are the exception, and they are behind a token.
"This box is 94% full" is a sentence that tells somebody exactly which
attack is cheap today, so it is for the machine that is watching and nobody
else. Set ONTOPLANO_HEALTH_TOKEN and send it as x-health-token or
?token=; without one configured, nothing is ever disclosed.
GET
/inventory/list
The inventory was two tabs for a day.
"To buy" and "What I have" were the same rows read twice, and splitting them meant a thing you owned and a thing you needed lived on different screens with different powers — the second had no price, no tick, no archiving and no category. One page, with the locations beside it.
GET
/inventory/things
The inventory was two tabs for a day.
"To buy" and "What I have" were the same rows read twice, and splitting them meant a thing you owned and a thing you needed lived on different screens with different powers — the second had no price, no tick, no archiving and no category. One page, with the locations beside it.
GET
/kitchen/[...path]
Where the kitchen used to be.
Recipes and meals are tabs of Health now — eating is part of looking after yourself, and a room of its own for two pages was one room too many. The old addresses are in people's bookmarks, in the phone app's cached shell and in links somebody sent themselves, so they answer with a redirect rather than a 404. Permanent, because this is not coming back.
GET
/manifest.webmanifest
The installed app's own identity, which is not the same on every instance.
Served rather than shipped as a static file for one reason: two copies of this app can be installed on one phone — the instance somebody actually uses and the staging one — and if they claim the same name, the same id and the same icons, they are indistinguishable on a home screen. The week then goes into whichever one was tapped, which is a data loss nobody would think to report as a bug.
Everything else here is production's manifest verbatim. Staging changes the three things a launcher shows and nothing else, because a staging instance that differs in any other way has stopped standing in for the one it copies.
GET
/media
Somewhere to put a picture, before the writing that mentions it exists.
This is an endpoint rather than a page action, and that is a deliberate exception to the rule that pages mutate through actions. The reason is what it is for: pasting a screenshot into a note. The picture has to be stored and given an address while the person is still typing, because the address is what goes into the text — so there is no form being submitted, and nothing on the page changes. The entry itself is still saved by an ordinary form action, with the markdown the person can see and edit.
Nothing is relaxed by that. The session is required, the ceilings and the
type allowlist are the service's, and SvelteKit's origin check covers a
multipart/form-data POST exactly as it covers a form.
POST
/media/[id]
One picture, to the one account it belongs to.
The ownership is in the service's WHERE, so a signed-in stranger asking for
somebody else's id gets the same 404 as an id that never existed — there is
no arithmetic anybody can do on these numbers.
The headers are the other half. nosniff stops a browser from deciding for
itself that a file the service typed as an image is really a document;
Content-Disposition: inline with a filename we sanitised keeps a crafted
name from steering the header; sandbox is not needed because the type
allowlist has no format that can execute anything.
Cached hard and privately: a row here never changes — a different picture is a different id — so a browser may keep it for as long as it likes, and no shared cache may keep it at all.
GET
/media/audio
A recording, posted as bytes.
Not a form action, because what a MediaRecorder hands back is already
exactly the bytes to store — wrapping it in a multipart body to unwrap it
again buys nothing and costs a copy. multipart/form-data is still what
arrives, for the same reason the picture endpoint beside this takes it:
SvelteKit's origin check covers it exactly as it covers a form, so a page on
another site cannot post here with somebody's cookie.
Everything the request says about what it is sending is ignored. The name is
rewritten, the type is read off the bytes, and both ceilings are counted from
the database. See services/audio.ts.
POST
/media/audio/[id]
One recording, to the one account it belongs to.
The same bargain the picture endpoint beside this makes. Ownership lives in
the service's WHERE, so a stranger's id and a picture's id are both the
same 404 — there is no arithmetic anybody can do on these numbers.
The headers are the other half. nosniff stops a browser deciding for itself
that bytes typed as audio are really a document; attachment rather than
inline because nothing here needs to be rendered — the page plays it
through an <audio> element pointed at this URL, and a container that lies
about its insides is then noise rather than a document on this origin.
GET
/metrics
The numbers $lib/server/metrics keeps, for whatever collects them.
Behind the same token as /healthz's detail, for the same reason: which
routes are slow and what the process weighs tells somebody which request
to send a thousand times. Sent as Authorization: Bearer … — what a
Prometheus scrape config writes — or as x-health-token. With no token
configured this route does not exist.
GET
/oauth/register
A client introducing itself (RFC 7591).
Unauthenticated, because it has to be: the assistant meets this instance before the person it belongs to has signed in, and there is nobody to authenticate as yet. Nothing it can do here reaches an account — it gets an id, and an id is worth nothing until somebody says yes on the consent screen.
It is still the one write an anonymous caller can cause, so it is budgeted per address, and a refusal is a refusal rather than a queue.
POST
/oauth/token
A code, spent for a key.
What comes out is an ordinary api_tokens row — the same thing the key form
makes, carrying the scopes the person agreed to and named after the client
that asked, so it stands in the list under Settings → AI & Integrations with
a revoke button beside it. An assistant connected this way is not a second
kind of access with a second way to take it away.
The token does not expire, so no refresh token is issued: a refresh of something that never goes stale is a round trip that buys nobody anything. Revoking is what ends it.
POST
/robots.txt
What a crawler is welcome to read.
A route rather than a file in static/, because the right answer differs by
instance and a static file cannot know which one it is on.
Worth being honest about what this does and does not do. It is a request, and
only well-behaved crawlers honour it — the addresses the box is banning are
hitting sixty failed requests a minute looking for /wp-admin and .env, and
they have never read a robots.txt in their lives. This is here so that the
pages behind a login do not turn up in a search result, and so that the demo
is not indexed as a second copy of the site; the banning stays the firewall's job.
Everything under a login already redirects, so a crawler learns nothing from following them — but a redirect still costs a request, and a list of paths that were crawled and bounced is noise in the log.
GET
/settings/account/export
The account's data as a JSON download.
A +server route rather than a form action, because the answer is a file rather than a page — the "no +server routes" convention is about mutations, and this reads.
GET
/shopping
Where the shopping list used to be, on its own.
It is one half of Inventory now — "I need it" beside "I have it, and it is in the second drawer" — because they were always the same rows. The address is in bookmarks and in the installed app's shell, so it answers.
GET