The first-run wizard's `admin.totp.begin` delegated to the enroll flow's
`TOTPBegin`, which is `webauthn.js`'s JSON API: the browser was shown raw
`{"otpauth_url":…}` and the wizard stalled at step 2, rendering no secret.
Extract the issue step into a write-free seam, `flow.Actions.IssueTOTP`, and
keep `TOTPBegin`'s JSON contract by having it call the seam. The wizard now
issues via the seam and renders its own HTML page (`text/html`, inside the
shell, no inline scripts) showing the otpauth URL, the human-readable secret
for manual entry, and the verify form (pre-session CSRF, A15).
`setup.Enroller` drops `TOTPBegin` — the wizard no longer calls it, and a
JSON-writing method on a no-JS seam is the coupling that caused the bug.
Tighten the tests that missed it: `setup_wizard_test.go` asserts the `begin`
response is `text/html` and carries the secret and the verify form (not merely
200), keeping the end-to-end begin→code→303 path; `enroll_login_test.go` pins
the enroll flow's JSON contract (`application/json` + `otpauth_url`).
Spec: docs/specs/spec-005-setup-wizard.md §11.
|
||
|---|---|---|
| .opencode | ||
| cmd/myday | ||
| deploy | ||
| docs | ||
| internal | ||
| .gitignore | ||
| AGENT.md | ||
| config.example.yaml | ||
| go.mod | ||
| go.sum | ||
| PROJECT_PLAN.md | ||
| README.md | ||
MyDay
A self-hosted employee time, absence & reporting app by Sechpoint. Delivered as a PWA for mobile and desktop. Single-tenant — one company per instance.
Status: 🚧 M0 + M0.1 implemented — the auth & routing foundation and the web/PWA layer
(the SPEC-002…009 slices) are built: routing · identity/sessions · the door · /setup ·
enroll/login (+ the passkey nudge) · roles/chain/recovery · the module registry · the web shell.
Next milestone: M1 — org structure. The design of record is
PROJECT_PLAN.md.
What it will do
| Area | |
|---|---|
| Org structure | users, the manager hierarchy, departments, positions |
| Time | presence and timesheets (a per-user mode) |
| Absence | leave requests, approvals, balances |
| Reporting | rollups and aggregations |
Built as one Go binary: a small core (MyDay.auth — identity, sessions, the door) plus
drop-in modules.
Running it
# 1. configure
cp config.example.yaml config.yaml # then edit; config.yaml is gitignored
# 2. build
go build -o myday ./cmd/myday
# 3. run
./myday
⚠️ First run — enable setup, then complete the wizard
The first user becomes the top admin (the root of the chain). Setup is gated by the setup
config toggle (D-045): enable it, start MyDay, complete the wizard — the app then writes
setup: false and the instance becomes productive.
# config.yaml
setup: true # enable the /setup wizard
setup_code: "" # OPTIONAL second factor — set it if the domain is reachable by others
- Start MyDay. The startup banner states the mode and prints the setup URL.
- Open the wizard at
/setup(e.g.https://myday.sechpoint.app/setup).setup_codeempty → the wizard is served directly;setup_codeset → the code form comes first; enter the code printed in the startup banner.
- Complete the wizard — domain → top admin (TOTP) → mail/locale. It creates the admin and
writes
setup: falsetoconfig.yaml. - Restart so the app adopts the persisted secrets:
systemctl restart myday.
While setup: true and setup is unfinished:
GET /points at the wizard, andGET /setupserves it (or the code form);- every other route answers 404 — there is no app yet.
While setup: false and no admin exists, the instance is locked: GET / and GET /setup
show a page telling you to set setup: true and restart, and every other route 404. This state is
not publicly claimable — a fresh instance never exposes its bootstrap by accident.
Residual risk: while
setup: true, whoever reaches/setupfirst can claim the admin. Setsetup_code(or complete setup immediately) if the domain is open to others. The code is single-use and the entry form is rate-limited.
Read the code again (when one is set):
journalctl -u myday # inside the container
pct exec 11020 -- journalctl -u myday # from the Proxmox host
Only after the top admin exists should you put MyDay behind a reverse proxy and expose it.
Note: the setup wizard enrolls TOTP; the admin adds a passkey at first sign-in on the real (https) origin, via the passkey nudge.
Deployment — behind Zoraxy (TLS at the proxy)
Full walkthrough:
docs/deploy-zoraxy.md— topology · config · the Zoraxy ProxyHost · the firewall rule · thesetup: truebootstrap · systemd · the misconfig warnings. This section is the summary.
The reference deployment terminates TLS at Zoraxy at
10.1.0.10; the app itself speaks plain HTTP. Config (config.example.yaml →
config.yaml, D-043):
- App —
server.listen: "0.0.0.0:8080"; the app binds any address (there is no setup-mode bind refusal). - Proxy — Zoraxy terminates TLS and forwards to
http://<app-host>:8080, settingX-Forwarded-ForandX-Forwarded-Proto. trusted_proxies—["10.1.0.10"](Zoraxy). The app honoursX-Forwarded-Protofrom a trusted proxy to decide a request ishttps, and then sets the session cookieSecure+__Host-and sends HSTS. Over plain http it keeps the bare, non-Securecookie. A wrong value fails silently — noSecurecookie, no HSTS.base_url—https://myday.sechpoint.app: the public origin, used in invite/recovery links.- Firewall —
:8080MUST be reachable only from10.1.0.10. Anything else that can reach the port bypasses the proxy and its TLS; the app would then serve plain HTTP to that client.
# on a fresh instance: enable setup, start MyDay, then open the domain at /setup
# config.yaml: setup: true
# (optional) setup_code: "…" # a second factor; the printed code comes first
sudo systemctl restart myday
# read the mode / setup code in the log:
journalctl -u myday # inside the container
pct exec 11020 -- journalctl -u myday # from the Proxmox host
What /setup writes — and the restart
The wizard persists config.yaml (setup: false, the base URL, mail/locale, and the secrets)
next to the loaded file — never over the committed config.example.yaml. If the file is not
writable it prints the values for you to save by hand, and setup still completes.
The secrets are generated at startup when a value is still CHANGE_ME…, so the wizard's
ceremony is never signed with a known key. The persisted file then holds those same values —
so a restart adopts them and the admin's TOTP secret (sealed with the generated key) keeps
working. Boot a productive instance from the written config.yaml, not from the example:
config.example.yaml's placeholders fail closed in productive mode by design.
Documentation
PROJECT_PLAN.md |
the plan & decision log of record |
AGENT.md |
instructions for AI agents working here |
docs/specs/ |
implementation specs |
docs/deploy-zoraxy.md |
the Zoraxy deployment guide (D-043) |
config.example.yaml |
configuration template |
Security
- Secrets live in
config.yaml(gitignored) — never commit them. - Auth is Passkey (primary) + TOTP (fallback).
- See
PROJECT_PLAN.md§10 for the deferred-security register and its beta gate.
License
TBD.