skip to content
docsspec
github

Notices — tell one person something

An addon can tell one person something — your title is ready — and every client that person uses shows it: the bell in the portal's header, and the product apps. The core ships the socket; the addon writes the words. portal-api keeps a notice for its person, shows it to them and to nobody else, and forgets it; it never interprets what a notice says.

A notice is for one person. Something for everyone is a slot row or the addon's own console.

FieldMeaning
idThe notice's id, a UUID
addonThe key of the addon it is from
addonTitle, addonIconThe addon's app, as the registry has it — what a client shows the notice is from
titlePlain text, at most 80 characters, one line
bodyPlain text, at most 280 characters; line breaks allowed
link"" for none, else where the notice leads: a path on the instance, or an absolute http(s) URL on its own origin
itemId"" for none, else the id of a catalog item a client can open
createdAt, readAtRFC 3339, UTC; readAt is null while unread

Whom a notice is for is never in an answer: a person reads their own.

An addon tells a person something — now or hours later — with its service account: a client-credentials token of the confidential client named after the addon, with the zaentrum-addon role. The addon is the one the token binds, never one the body names, and it must be installed on the instance.

Terminal window
# a client-credentials token from the instance's issuer
TOKEN=$(curl -s -X POST "$ISSUER/protocol/openid-connect/token" \
-d grant_type=client_credentials -d client_id=my-addon -d client_secret="$SECRET" | jq -r .access_token)
curl -X POST http://portal-api/api/portal/notices \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"sub":"<the person'"'"'s token subject>","title":"Your title is ready",
"body":"It is in your library now.","link":"/portal/app/my-addon#/done","itemId":"<item id>"}'
# 201 {"id":"…","addon":"my-addon","addonTitle":"My Addon",…,"readAt":null}

sub is the subject of the person's token. The addon learns it from the bearer it validates when the person calls its API through the portal's proxy — the sample addon's requireUser reads it — and keeps it with whatever it is doing for them.

AnswerWhen
201 with the noticeposted
400the body breaks a rule, or names a field it does not take — addon among them
403the token is no addon's service account (an admin's is not either), or the addon is not installed here
409the addon was removed while the notice was posted
429 with Retry-Afterthe addon posted more than its limit

An addon that holds no credential — the sample addon holds none — can still tell the person at its console something: POST /api/portal/me/notices with {addon, title, body, link?, itemId?} and the person's own bearer, which the shell hands every console (token). The notice goes to that bearer's person and nobody else: the body names no person, and one that does is refused.

// inside a console: apiBase is <portal API>/apps/<key>/
await fetch('/api/portal/me/notices', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ addon: 'my-addon', title: 'Saved', body: 'Your settings are saved.', link: '/portal/app/my-addon' }),
})

Whoever holds a person's bearer can already act as that person, and this reaches nobody else, so it grants nothing new. It also proves nothing about which addon sent it — any holder of the bearer may name any installed addon. Use it to tell the person in front of the console something now; a notice for someone else, or for later, takes the service account. A service account is refused here.

They hold whoever posts.

Rule
Texttitle at most 80 characters, one line; body at most 280, line breaks allowed; both plain text — no control characters, no bidirectional formatting characters. Clients show them as text, never as markup
LinkHeld to the slot link rule — one function decides both: a path on the instance, or an absolute http(s) URL on its own origin; never javascript:, data:, another host, //host, credentials or dot segments. A path is made absolute on the instance's public origin when portal-api knows it
ItemLetters, digits and . _ : -, at most 128
LimitsPer addon: 100 at once, then one a second. Per person posting to themselves: 10, then one every six seconds. Per replica of portal-api
KeptA person keeps their newest 100: a new one past that removes the oldest. Every notice goes after PORTAL_NOTICE_RETENTION (default 2160h, 90 days), swept at boot and every hour
Gone withRemoving the addon removes its notices, with its slot rows; deleting a person — on the People page or from the apps — removes theirs
LoggedEvery write — who, which notice — and never what a notice says

A person reads their own on portal-api, with their bearer:

Route
GET /api/portal/me/notices{notices, unread}: their own, newest first
POST /api/portal/me/notices/{id}/readone of theirs, read — once: reading again keeps when it was first read; {unread}
POST /api/portal/me/notices/read-allall of theirs, read: {read, unread}
DELETE /api/portal/me/notices/{id}one of theirs, deleted: 204

Someone else's notice is 404, as one there is not. The product apps use chino-api's copy of the same routes, which forwards their bearer and keeps nothing: GET /api/v1/notices (adding available), POST /api/v1/notices/{noticeId}/read, POST /api/v1/notices/read-all, DELETE /api/v1/notices/{noticeId}. It is best effort: with no portal-api, or one that does not answer, the list is empty with "available": false and the answer still 200, so a home screen never fails for notices; a change it cannot make is 502, and the app keeps the notice as it was.

Every client does the same:

  • shows the unread count where the person looks first, and the list newest first, each with whom it is from (addonTitle, else addon) and when;
  • shows title and body as plain text, never as markup, body's line breaks kept;
  • follows link only to its own server — a path, or an http(s) URL on the server's own origin — checking it again as portal-api did; a client that cannot show a web page shows the text alone;
  • opens itemId with its own item routes, which hold a capped viewer to their cap;
  • marks a notice read when it is opened, and offers read all and delete;
  • asks again now and then while it is shown — the portal's bell asks every minute and when the page is shown again — and shows nothing, not an error, when the answer is available: false.

GET /api/portal/notices?addon=<key> answers an admin each addon's notices counted — notices, unread, people, latest, with the kept cap and retentionHours — and never what one says, nor whom it is for.

CapabilityStatus
Notices in portal-api: posting, reading, limits, retention, deletion with the person and the addon✅ shipped
The bell in the portal's header✅ shipped
chino-api's /api/v1/notices for the apps✅ shipped
Notices in chino on the web, the TV apps and the phone apps🔶 next — each reads /api/v1/notices as above
Push to a device that is not open🧭 not designed
Addon service accounts made by the platform🧭 roadmap — made by hand today, see identity

The sample addon's console has Send Me a Notice: a notice to yourself, posted with your own bearer.