Commit Graph
18 Commits
Author SHA1 Message Date
vliaudatandClaude Opus 5 235af90aed chore: record how the panel reaches the server
Neither the reverse proxy nor a production Next server logs requests, so
"is the device on TLS?" was not answerable without a packet capture —
and the capture then produced nothing, because tcpdump buffers its
output and a handful of SYN lines never filled the buffer.

One line per wake, naming the scheme and saying so plainly when the
panel is being served in clear. At a two-hour refresh that is a dozen
lines a day, and it turns a question that cost an evening into a grep.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
2026-09-21 23:14:51 +02:00
vliaudatandClaude Opus 5 767c6b9d77 fix: serve the device paths with or without a trailing slash
The panel's HTTP client does not follow redirects. It asks for
/api/setup/ with a trailing slash, Next answered 308 to normalise it,
and the firmware reported "returned code is not OK. Code - 308" and gave
up. Never having obtained a token, it then called /api/display with an
empty one, got 401, and told the user it could not reach the API.

Not TLS, not the network, not the port — a slash. Two earlier fixes were
aimed at hypotheses the evidence did not support: a certificate chain
the firmware genuinely cannot validate, and a port the shop's network
turned out not to block. Both were reasoned from silence, because
neither Traefik nor a production Next server logs requests by default.
The answer came from a packet capture, and from the device's own words.

/api/log now accepts a report from a panel that cannot authenticate.
Refusing it with a 401 threw away the one diagnostic that mattered: the
firmware was saying exactly what was wrong and we were discarding the
message. Nothing is stored — the rows would reference a device that does
not exist — but it reaches the server log, and the route was already
rate-limited.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
2026-09-21 22:43:39 +02:00
vliaudatandClaude Opus 5 eae3f89aca fix: let the panel reach the API over plain HTTP, deliberately
The e-ink firmware carries a certificate-authority bundle fixed when it
was built, so it cannot validate a chain rooted in an authority created
afterwards. Let's Encrypt's ISRG Root YR was issued in May 2026 and is
not even in an up-to-date Ubuntu CA bundle yet; the kit's firmware
predates it. The handshake fails before a request is ever sent, which is
why neither Traefik nor the application saw anything at all while the
device reported "API connection cannot be established".

Ruled out first, with evidence: TLS 1.2 and the ECDHE-RSA-AES-GCM suites
an ESP32 needs are both offered, and the intermediate is not
cross-signed by an older root, so no alternate path exists in what is
served.

A Traefik router now serves four device paths over :80, ahead of the
entrypoint-wide redirect. The administration stays on TLS. The device
token travels in clear; it is used for nothing else and is revocable
from the settings page, and the image URL is an unguessable content hash.

DEVICE_ALLOW_HTTP existed but was never read — a setting that does
nothing misrepresents what it protects. The device routes now refuse an
unencrypted request unless it is set, so opening this door is a written
decision rather than the silent consequence of a proxy change.

DEPLOY.md records the whole diagnosis, including the commands that
distinguish a TLS failure from an application one, and what to do the
day the firmware learns the new roots.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
2026-09-21 22:17:16 +02:00
vliaudatandClaude Opus 5 efd91c3e8e fix: survive an unconfigured identity provider
Auth.js refuses to build a provider with no issuer, and that refusal
takes down the whole auth layer — including reading a session that
already exists. A missing or misspelled AUTH_AUTHENTIK_ISSUER would
therefore lock everyone out of an otherwise healthy application, and
explain itself only as a stack trace in the logs.

The provider is now registered only when its three settings are present.
Sessions stay readable either way, and the sign-in page says which
variables are missing instead of offering a button that fails.

Found by the first CI run, which has no .env to inherit from: every
signed-in test failed at once, looking exactly like a broken cookie.
The local suite had been passing on variables Playwright was quietly
inheriting from the development environment — so the E2E server is now
given explicit placeholders rather than whatever happens to be around.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
2026-09-20 23:41:45 +02:00
vliaudatandClaude Opus 5 446021cd75 test: add the end-to-end suite, on desktop and on a phone
Twenty-six tests across two viewports, covering what the spec asks for:
changing today's hours reaches the panel, a message survives the
translation service being unavailable, a closure period closes the days
it covers — plus the authorisation paths and the sign-out regression.

They run against the standalone build served the way the container
serves it, not `next start`, which refuses to work with standalone
output anyway. The suite therefore exercises the artifact that ships
rather than a second arrangement that could drift from it.

Sign-in mints the session cookie Auth.js would have issued rather than
driving Authentik. What is under test is the application's behaviour for
a given role; the handshake itself is verified against the live provider
separately, and standing up an identity provider per run would trade a
lot of machinery for coverage of somebody else's code. The secret lives
in one module imported by both the config and the fixtures — when it
differed, every signed-in test failed at once while looking like an
authorisation bug.

Database access goes through plain SQL rather than the Prisma client,
whose generated module format Playwright's loader and Next's bundler
disagree about. That traded one problem for a subtler one: node-postgres
parses a DATE column into a local-midnight Date, so reading it back
shifted the day at UTC+2. Dates are read as text now.

The mobile profile runs on Chromium: WebKit needs system packages only
root can install, and a suite nobody can run locally is a suite nobody
runs. The config says how to switch to the real engine.

Two real defects surfaced, both found by the tests rather than by
reading. The seven "Ouvert" checkboxes on the hours page were
indistinguishable to a screen reader; each now names its day. And on a
phone the signed-in address appeared nowhere at all — the header hides
it to save room — so nobody could tell which account was about to sign
an audit entry on a device the shop shares. It is on the dashboard now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
2026-09-20 23:13:58 +02:00
vliaudatandClaude Opus 5 1668240724 feat: add the settings page with the audit trail
Shop identity, the cantonal code that drives the holiday calendar, the
two wake intervals and the image format, plus the device list and a
translation self-test. The audit trail sits at the bottom, rendered
field by field in French rather than as raw JSON.

Regenerating a device token shows it once and stores only its digest, so
the panel has to be re-paired through the captive portal afterwards.
That is the point rather than a drawback: this is the control you reach
for when a token may have leaked, and a version that let you read the
old one back would not be one.

The server URL to type into the captive portal is reconstructed from the
incoming request, so the value shown is the one the device would
actually have to reach — not one assembled from configuration that may
not match what the proxy is serving.

Wake intervals are bounded by the same constants the device API enforces,
so a value the settings page accepts cannot be one the panel is refused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
2026-09-20 21:26:05 +02:00
vliaudatandClaude Opus 5 830e595393 feat: sync Geneva public holidays and let the shop choose which close it
The rolling twelve-month window is fetched from openholidaysapi.org each
night at 03:00 local, and can be triggered from the page, from
POST /api/admin/holidays/sync, or from `npm run holidays:sync` for the
first run after a deployment.

The calendar is fetched twice, once per language, and the two answers
joined on the entry id. Holiday names are proper nouns with established
English forms — "Jeûne genevois" is not something a translation model
should be improvising, and this costs one extra HTTP call.

Two properties are load-bearing and tested against a real database.
The sync is idempotent: running it twice leaves exactly what running it
once did, verified live as well as against a mock. And it never touches
`isAutoClosed` on an existing row — that is the shop's decision, not the
API's, and a nightly job quietly reopening a day the owner had closed
would be invisible until someone found the door locked.

When the API is down the local cache is left untouched and the failure
is recorded with its timestamp, so the page can say how stale the
calendar is rather than showing nothing. Retries widen the gap between
attempts; the nightly job can afford to wait, the shop cannot afford a
stale calendar for a day.

node-cron runs inside the application process rather than an external
cron hitting an endpoint: one container, one shop, no second instance to
coordinate with, and no trigger endpoint to protect and document. The
reasoning is recorded next to the schedule.

Verified against the live API: nine Geneva holidays, both languages,
including the cantonal Restauration de la République.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
2026-09-20 21:23:47 +02:00
vliaudatandClaude Opus 5 ea945b50d6 feat: add the dashboard
Current state at the top, the panel as it stands beside what is known
about the device, the next seven days resolved with their exceptions,
and alerts when something needs attention.

There is no "push now" button, because in BYOS there is nothing to push:
the panel sleeps on battery and fetches when it wakes. Pretending
otherwise would be the most misleading control on the page. It says so
in plain words instead, and shows when the device is next due back.

Everything about the device is inference from one timestamp and one
interval, so it lives in a tested module rather than in a template. A
panel is only called overdue once it has missed its slot by half its
interval again: firmware wake-ups drift, and an alert that cries wolf
every cycle is an alert nobody reads. Battery falls back to a voltage
estimate, labelled as an estimate, because "about a third left" is the
only thing anyone acts on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
2026-09-20 21:18:41 +02:00
vliaudatandClaude Opus 5 f21268a9f6 feat: add the display messages page with live translation
French in, English out, with the character counter tied to the same
constant the renderer uses — so the warning and the space actually
available on the panel cannot drift apart.

Translation is a second call, not part of the save. A slow or broken
service must never cost the shop its message: the row is stored first
and marked pending, the translation follows, and a failure shows as a
badge with a retry rather than as a lost notice.

The status transitions are the subtle part and are pinned by tests.
Editing the French clears the English, including a translation someone
had corrected by hand — a translation of text that has changed is worse
than no translation. Editing only the dates or the priority leaves it
alone. A hand-written translation is never overwritten while its French
stands, and emptying it returns the row to pending.

"On the screen" is decided by the same selector the renderer uses, so
the badge cannot disagree with the panel about which message is live.

The preview is served by the device's own pipeline — payload, SVG,
threshold — so what the admin sees is the shop window down to the last
thresholded pixel. A preview drawn any other way would eventually
disagree with reality, quietly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
2026-09-20 20:16:46 +02:00
vliaudatandClaude Opus 5 c430205bf2 feat: add the closure periods page
Create a period with a start, an end and a label, see the twelve months
ahead at a glance, delete one. The label is required and trimmed: it
goes straight onto the shop window.

Overlapping periods are refused, and the message names the one they
collide with. Two rows covering the same day would both be "in effect"
with no way to say which, and silently merging them would lose whichever
label the owner meant. Periods that merely touch — one ending the 10th,
the next starting the 11th — are fine.

The year view exists because a list of date ranges is precise and hard
to picture, while "have I left a gap in August?" is the question people
actually ask. It renders on the server; it only changes when the data
does.

A vacation period stays a source of truth and is never expanded into
exception rows, so a one-off exception placed inside one still wins and
nothing is overwritten. The page says so rather than leaving it to be
discovered.

The first version of the calendar built the months but never applied the
periods to them, so no closure would ever have shown. Caught before
commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
2026-09-20 20:10:58 +02:00
vliaudatandClaude Opus 5 7d23b14ad8 feat: add the one-off schedule changes page
The page opens on the gesture the shop actually makes: changing today's
hours from a phone, behind the counter. "Closed today", "opens later at"
and "closes earlier at" are one tap plus a time, and each shows the
hours it would produce before it is applied.

The derivation is the delicate part and is pure and tested. Opening at
14:00 drops a 10:00-13:00 morning rather than keeping it, and trims the
slot the new time falls inside rather than dropping it. Closing early is
the mirror. Both are computed against what the day would normally be,
ignoring any exception already recorded, since that is what "late" is
late relative to.

The upcoming list is built by resolving each of the next sixty days, not
by reading the exception table. Vacations and public holidays are never
materialised as rows, so the table alone would quietly omit most of what
is actually in effect. Each entry is badged with the rule that produced
it, and only stored exceptions offer a delete.

Ranges are capped at 92 days and point at the holidays page beyond that:
a typo in a year field should produce an error, not thirty thousand rows.

Editing the French note clears the English one. A translation of text
that has changed is worse than no translation.

Three exports were removed from the actions module before committing:
every export in a 'use server' file becomes a publicly callable
endpoint, and those three had ended up unused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
2026-09-20 19:11:28 +02:00
vliaudat 2090584e0a Revert "refactor: make the reference week a dense one-row-per-day table"
This reverts commit c8235763b0.
2026-09-20 19:08:01 +02:00
vliaudatandClaude Opus 5 c8235763b0 refactor: make the reference week a dense one-row-per-day table
One card per day pushed the week to roughly 1100px, so checking a change
meant scrolling back up past the day you had just edited. All seven days
now fit on one phone screen.

Each row carries the day, an open switch, its slots inline and its two
actions. The per-row "duplicate onto the other open days" button was
repeated five times in prose; it is now a short labelled control on the
row itself, with the full sentence kept as its accessible name.

Unsaved days are marked with a dot rather than a badge, and the footer
still names which days are about to change — the point of the preview is
that it is readable without leaving the week.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
2026-09-20 19:04:42 +02:00
vliaudatandClaude Opus 5 eb23eb650b feat: edit the reference week from the admin
The first administration page, built for the actual use case: a phone
held in one hand behind the counter. One card per day, native time
inputs so the platform keyboard does the work, and the consequences
shown before the save rather than after — the footer names which days
are about to change, and the button stays disabled until something
actually has.

"Duplicate onto the other open days" leaves closed days closed. Someone
copying Tuesday's hours means "the days I open, I open like this", not
"open seven days a week".

Validation runs in the browser for the feedback and again in the action
before the write: the client is a convenience, not a guarantee, and this
is the schedule the shop window shows. A day being closed drops its
leftover slots rather than failing on them.

A save that changes nothing writes nothing — no rows, no audit entry,
and so no needless panel redraw. Reordering slots does not count as a
change. The audit diff stores one readable line per day in French, so
the log can be read without cross-referencing the schema.

The editing helpers are pure and tested, and the write path is tested
against a real database including the read-only refusal.

Test files now run sequentially: the integration files share one
database and each truncates it, so parallel files raced. The suite takes
six seconds; giving every file its own database would buy nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
2026-09-20 18:57:46 +02:00
vliaudatandClaude Opus 5 f6ff81bf16 feat: explain why an account is read-only
A viewer account has exactly two causes, fixed in completely different
places: the identity provider sent no groups at all, or it sent groups
that do not include the one granting write access. From the outside the
two look identical, so the dashboard now says which it is and what to
do about it, and the sign-in logs the same thing server-side.

The groups are carried in the session for that purpose, capped so the
cookie cannot grow with someone's group membership. Group names are not
secrets, and a support conversation that starts with the actual claim is
a thirty-second fix rather than a guessing game.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
2026-09-20 18:33:02 +02:00
vliaudatandClaude Opus 5 ac6be97d9b feat: authenticate against Authentik with admin and viewer roles
Sign-in goes through Authentik over OIDC with PKCE. Verified against the
live provider: the discovery issuer matches the configured one exactly,
and the authorize redirect carries code_challenge_method=S256.

Sessions are JWTs with no database adapter, which keeps the module
usable from edge middleware and makes a sign-in cost no query. The
trade-off is stated in the code: the role travels in the token, so
removing someone from the admin group takes effect at the next sign-in
or when the eight-hour session expires, not instantly. Immediate
revocation would mean asking Authentik on every request, which is what
api_llm_loxi does and what this application deliberately does not — it
drives a shop window, not a fleet.

Group matching is trimmed and case-insensitive. Authentik group names
are case-sensitive, but a capitalisation mismatch between the group and
the environment variable locks the shop owner out silently, and that is
the worse of the two failures. An empty variable never promotes anyone.

Authorisation is enforced twice. The middleware covers every /admin page
and /api/admin route at the edge; a guard inside the handlers repeats
the check, because a matcher is a string, strings get edited, and a
route falling outside one should not be the same thing as a route with
no access control. The rule itself lives in its own framework-free
module so it can be tested directly. Unknown HTTP verbs count as writes:
new methods arrive locked.

Pages get a redirect to the sign-in screen, API routes get a status
code — a fetch that receives an HTML login page is a confusing way to
learn you are signed out. The device API stays outside the matcher, as
the panel cannot sign in and carries its own bearer token; this is
covered by a check that /api/display still answers 401 rather than
redirecting.

The audit diff compares values by their JSON form, so slot arrays and
dates compare by value rather than identity, and a save that changed
nothing writes no entry. Recording never throws: losing the trail is
bad, refusing the user's change because the trail could not be written
is worse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
2026-09-20 18:26:47 +02:00
vliaudatandClaude Opus 5 dd4d1b97c8 feat: serve the BYOS device API
The panel now pairs, fetches its image and files its logs against this
application rather than against the TRMNL cloud.

Four endpoints: /api/setup issues a token on first contact,
/api/display hands back an image and a wake interval, /api/log stores
firmware diagnostics, and /api/device/image/<hash> serves the bytes.

The wake interval is where freshness and battery are traded off. In BYOS
nothing can be pushed: the device sleeps, wakes, asks and sleeps again.
So the interval is short while the shop trades and long overnight, and
it is shortened further whenever a change of state falls inside it —
the door opening in twenty minutes means waking in twenty-one,
whatever the base interval says.

The image filename is the hash of its own bytes. The firmware skips the
redraw when the name is unchanged, which is the whole battery strategy,
and the URL is immutable, unguessable and safe to cache forever. Two
integration tests pin this: unchanged data must yield the same filename
and store one row, changed hours must yield a different one.

MAC addresses are normalised before use. They are a primary key here,
and firmwares are inconsistent about case and separators; without this a
panel could register twice by capitalising itself differently. Header
names are read in both the hyphen and underscore spellings for the same
reason — the TRMNL docs and the Seeed sources disagree, and being
liberal costs nothing while being wrong costs a blank shop window.

Pairing is deliberately made to survive a rendering failure. The token
is issued once and only its digest is kept, so a device stranded by a
failed response would be registered yet hold no credential, and unable
to register again. The welcome image is worth far less than that. This
was found by running the flow, not by reading it.

satori, yoga and harfbuzz are marked external: bundling rewrites the
relative path satori uses to load its WebAssembly, and the renderer dies
on a missing hb.wasm.

The integration tests run against a real Postgres, in CI too. Mocking
Prisma here would only prove the mock works.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
2026-09-20 18:00:50 +02:00
vliaudatandClaude Opus 5 a9809f83ca chore: scaffold Next.js 16 admin app with ITA ITO design tokens
Set up the project skeleton for the ITA ITO opening-hours display admin:
Next.js 16 (App Router) with TypeScript in strict mode, Tailwind CSS 4,
Vitest, ESLint and a blocking CI workflow.

The design tokens are copied verbatim from the model_ita_ito project
(palette, Inter Variable + Source Serif 4, radii, dark theme) so the two
applications look like one family, as required by the spec.

ESLint is pinned to v9: eslint-config-next bundles a react plugin that
crashes on ESLint 10. The typed `consistent-type-imports` rule is left
out because `verbatimModuleSyntax` already enforces the same discipline
at compile time, without the cost of typed linting across the repo.

PLAN.md records the agreed architecture, including the decisions that
depart from the original spec — most importantly the move from BYOD to
a self-hosted BYOS server, which removes the TRMNL private plugin, the
Liquid template and the webhook entirely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
2026-09-20 17:30:19 +02:00