Email infrastructure + password reset (#5), organizations (#6) #8

Merged
Cordy merged 5 commits from feat/email-orgs into main 2026-07-30 00:10:44 +00:00
Owner

#5 — Email + forgot password

  • email.go: stdlib-only SMTP mailer from env (SMTP_HOST/PORT/USER/PASS/FROM, as configured in /etc/cairn-license/env; SMTP_PASSWORD/MAIL_FROM accepted as aliases). Implicit TLS on 465, STARTTLS otherwise. All sends async — handler timing never reveals account existence.
  • reset.go: POST /v1/password-reset always 204; existing account gets a 1-hour single-use token mailed as reset.html?token=…. POST /v1/password-reset/confirm burns the token on first attempt, sets the password, revokes all sessions. Rate-limited via the existing limiter.

#6 — Organizations

  • Every account belongs to exactly one org. Registration creates a single-member admin org; startup migration backfills pre-org accounts and adopts email-linked licenses into the owner's org (idempotent, also runs lazily on license reads so webhook purchases attach when the buyer registers later).
  • Roles: admins invite (7-day single-use emailed tokens), promote/demote, remove members, rename, manage labels; members view licenses + keys. Last-admin rail on demote and remove. Removed members lose all sessions and land in a fresh personal org.
  • Invite accept creates the account (invitee sets password) or moves an existing one when that cannot orphan anything (sole member, no licenses) — otherwise 409.
  • GET /v1/licenses is now org-scoped; label editing requires org admin.
  • CORS fix: PATCH/DELETE added to Allow-Methods (the label PATCH from the browser would have failed preflight).

Tests: register→org, full reset flow (oracle-free, single-use, session revocation), invite→accept→member visibility, role rails, removal semantics, migration. All green on the runner.

**#5 — Email + forgot password** - `email.go`: stdlib-only SMTP mailer from env (`SMTP_HOST/PORT/USER/PASS/FROM`, as configured in `/etc/cairn-license/env`; `SMTP_PASSWORD`/`MAIL_FROM` accepted as aliases). Implicit TLS on 465, STARTTLS otherwise. All sends async — handler timing never reveals account existence. - `reset.go`: `POST /v1/password-reset` always 204; existing account gets a 1-hour **single-use** token mailed as `reset.html?token=…`. `POST /v1/password-reset/confirm` burns the token on first attempt, sets the password, revokes all sessions. Rate-limited via the existing limiter. **#6 — Organizations** - Every account belongs to exactly one org. Registration creates a single-member admin org; **startup migration** backfills pre-org accounts and adopts email-linked licenses into the owner's org (idempotent, also runs lazily on license reads so webhook purchases attach when the buyer registers later). - Roles: admins invite (7-day single-use emailed tokens), promote/demote, remove members, rename, manage labels; members view licenses + keys. **Last-admin rail** on demote and remove. Removed members lose all sessions and land in a fresh personal org. - Invite accept creates the account (invitee sets password) or moves an existing one when that cannot orphan anything (sole member, no licenses) — otherwise 409. - `GET /v1/licenses` is now org-scoped; label editing requires org admin. - CORS fix: PATCH/DELETE added to Allow-Methods (the label PATCH from the browser would have failed preflight). Tests: register→org, full reset flow (oracle-free, single-use, session revocation), invite→accept→member visibility, role rails, removal semantics, migration. All green on the runner.
Cordy merged commit 69c6b987b4 into main 2026-07-30 00:10:44 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: Cordy/cairn-license-server#8
No description provided.