Commit Graph
7 Commits
Author SHA1 Message Date
pastilhas 2cefa3d378 apns credentials belong in the secret store, and cannot go there yet
env is the interim. the secret store's own reasoning applies almost verbatim:
bun auto-loads .env into every pm2 process, so the .p8 is readable from
/proc/<pid>/environ of twenty of them. officer-music has no business being
able to read a key that can push to the owner's apps forever and never
expires.

the obstacle is not a missing purpose string, which is what it looks like from
the outside. getKey(purpose) GENERATES a key and returns it — there is no
setter, because everything in the store so far is something the platform
minted itself. the .p8, the key id, the team id and the fcm service account
all come from outside, so this needs putSecret and a decision about who may
call it. same for DISCORD_WEBHOOK_URL and every plugin credential still in env.

recorded in TODO.md and in the design doc rather than left as an intention.
2026-08-19 19:45:56 +00:00
pastilhas e598a5dcba notify: codes replace the type enum, and android goes data-only
the sidecar now sends what the design describes.

producer contract: { code, userId, n?, id? }. validated on the SHAPE of the
code, never against the catalogue — a producer firing a code the mapping has
not caught up with is sending a real event, and dropping it here would trade a
degraded notification for none when the device already has a defined fallback.
the shape check still catches a typo at the producer instead of on a phone.

`ok` is gone. a failed job is its own code, officer/core:job-failed, which is
the thing the five-value enum could not express without a boolean bolted on.

apns: mutable-content: 1, alert.title fixed to the generic, and officer:{c,n,id,mv}
beside it. deliberately NOT the catalogue's title — sending the real text would
hand apple the category vocabulary in plaintext, which is what the code exists
to withhold.

fcm: data-only, no notification block. a notification block is rendered by the
system, which would put the code on the lock screen and give the app no chance
to resolve it. priority high is load-bearing — a normal-priority data message
can sit in doze indefinitely.

discord renders server-side from the catalogue, because it has no app to
resolve anything. safe: catalogue titles are templates and carry no user
content.

the catalogue is loaded ONCE per dispatch, not per channel or per device, so
every copy of one notification carries the same mv. two channels reading disk
either side of a plugin install would otherwise tell two devices different
things about the same event.

40 tests. verified live: a malformed code is refused, officer/core:test is
accepted and dispatches to zero configured channels.
2026-08-19 19:37:05 +00:00
pastilhas 55d6eba511 notification catalogue: GET /api/notify/mapping
the piece that makes a new notification kind a server change rather than an
app store release. a push carries a code and integers; this is what the code
means, synced to the device instead of compiled into it.

deliberately the same shape as affordances rather than a second mechanism: a
`notifications/` directory of yaml in the plugin repository, copied to
capabilities/notifications/<publisher>/<appName>/ on install, removed on
uninstall. publish.ts mirrors affordances/publish.ts closely enough to diff.

the namespace is DERIVED from where the file lands, never from what it
declares — same rule as mountPrefix. a plugin writing `officer/core:test` as a
key gets `<publisher>/<plugin>:officer/core:test` and the real core entry is
untouched. that is the security property of the whole scheme and it is pinned
by test.

version is a content hash, not a counter. a counter needs somewhere to live
and is then wrong after a restore, a rollback, or two servers built from the
same install; a hash needs no storage and is right in all three. so the client
compares mv !== version, and both docs now say so — i had written "higher".

core entries ship in code rather than on disk, because capabilities/ is
installed rather than committed and `test` has to exist on a machine that has
installed nothing — it is what first light is proved with.

read from disk per request, not memoised: installing a plugin has to change
the answer without a restart, or it presents as "the new plugin's
notifications don't work", which is the hardest kind of bug to place.

verified live: 401 through the platform, plugin catalogue appearing and
disappearing with no restart, version returning to its prior value when the
plugin is removed. 10 catalogue tests, 100% coverage of catalogue.ts.
2026-08-19 19:07:49 +00:00
pastilhas e57d6cc1cc push notifications: the app-side spec
the 2026-07-31 design promised this document to `officer-suite/COMMS/`, a repo
path that has never existed. writing it where the work is instead, to be
handed to whoever builds the client.

exact contracts rather than prose: the three device routes with real request
and response shapes read off devices.ts, what is required versus defaulted,
and that the server takes the user from the session and ignores a userId in
the body.

most of it is the failure modes that cost a day each and are invisible from
the client — a send with no credentials configured returns success and
delivers nothing; a sandbox token registered as production fails at apple with
no user-visible symptom; the notification extension must not touch the network
or it will pass every test at a desk and fail on cellular with the vpn off.

leaves three questions for the owner: whether apps/mobile has a bundle id and
a provisioning profile at all, its url scheme, and expo managed versus bare —
that last one decides how the extension gets built and is probably the longest
pole in phase 3.
2026-08-19 18:49:15 +00:00
pastilhas 347e679712 push notifications: the hub design
second design, superseding 2026-07-31. the reversals are recorded rather than
overwritten, because the first one called its payload rule "not optional" and
this changes it.

one app on the device receives everything and renders it. the payload is a
namespaced code plus integers; the code's meaning lives in a server-owned
mapping synced to the device ahead of time, so a new notification kind is a
server change and never an app build. plugins declare their own codes on the
affordances pattern, which already does exactly this shape.

what moved: privacy relaxed but not abandoned — codes keep content off apple
and google while dropping the rigid category enum that made the doorbell
painful. appSlug inverts from "deliver to this app" to "deep-link to this
app". extensibility becomes the binding constraint.

the piece that makes it work is that the ios extension does a LOCAL lookup
against a synced mapping, not a fetch on arrival — so it does not depend on
the tailnet being up when the push lands, and it costs one build ever rather
than one per kind.

records two things it does not solve: the code vocabulary is still a
fingerprint to apple and google, and the grapheneos/fcm contradiction between
apps/mobile/PUSH_NOTIFICATIONS.md and the platform design is still unresolved.
2026-08-19 18:41:33 +00:00
pastilhas fd7c9becb6 notify is core again
built 2026-07-31, switched off 2026-08-13 when it was slated to become a
plugin alongside memos and photos. those two were extracted; this one never
was, so it spent six days mounted nowhere with its schema commented out and
no pm2 entry — complete code that nothing could reach.

core rather than a plugin because the producers are the platform's own: the
job queue, an agent turn ending, mail arriving. a notifier the owner can
uninstall is one those producers cannot rely on.

- hono.ts mounts /api/notify again; verified 401 unauthenticated, so the
  permission gate covers it and assertPermissionTotality accepts the mount
- push_devices exported from the core schema and pushed: 10 columns,
  uq_push_devices_token_bundle, idx_push_devices_user
- officer-notify added to CORE_PROCESSES, so gen:ecosystem writes it
- credentials read OFFICERDEV_X ?? X, the convention it predates

safe as core because it boots with nothing configured: every channel reports
isConfigured() false and dispatch resolves empty. verified by importing it
with the env stripped — "channels: none configured", no throw. an install
with no apns key runs it as a no-op rather than crash-looping.

APNS_ENV is dropped from the doc's credential table. it was never read: the
host comes from each device's `environment` column, so one server pushes to a
testflight build and a release build at once.

doc status corrected. the app side is not started — there is no officer-suite
repo, no PUSH_NOTIFICATIONS_APP.md, and apps/chat actively strips the ios push
entitlement. recorded there rather than here.
2026-08-19 18:05:33 +00:00
pastilhasandClaude Opus 5 15f262539f push notifications: design, and the device registry
Design in docs/push-notifications.md. The short version: Apple and Google are unavoidable —
iOS suspends apps so only APNs can wake one, and Android only accepts pushes from FCM — but
Expo is not. Its push service is a relay in front of both and does not remove either
credential, so we talk to Apple and Google ourselves.

Both protocols were verified in Bun before designing around them: node:http2 works as a
client (APNs is HTTP/2-only), and ES256 signing produces the raw 64-byte r||s form Apple
requires rather than Node's default DER, which is silently rejected. No push library is
needed for either channel.

The load-bearing decision is the payload: a push is a doorbell, not a message. Apple and
Google see metadata regardless, so they must not also see content — a notification carries a
category and an id, never a subject, sender or error, and the app composes the visible text
locally and fetches the real thing over the tailnet on tap.

This commit is the registry: push_devices, holding native APNs/FCM tokens. environment is a
column because APNs sandbox and production are different hosts AND different token
namespaces — a debug-build token fails against production with a silent BadDeviceToken, so
guessing is not an option. Registration upserts on (token, bundle_id) because tokens rotate
and the app re-registers every launch. Failure counting prunes dead tokens; a hard rejection
deletes at once.

Nothing sensitive lands here: a token is useless without the APNs key or FCM service
account, both of which stay in the sidecar's env.

NOTE: `bun db:push` will fail until the telegram/whatsapp/discord rows are deleted from
server_integrations — the CHECK constraint added earlier refuses while they exist. That is
the enforcement working, not a problem to route around.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:27:07 +00:00