Skip to content

Review 5/5 — Core & security: issuance lifecycle, retention, auth, org admin - #29

Merged
ArneeMe merged 7 commits into
developfrom
claude/review-5-core-security
Jul 21, 2026
Merged

Review 5/5 — Core & security: issuance lifecycle, retention, auth, org admin#29
ArneeMe merged 7 commits into
developfrom
claude/review-5-core-security

Conversation

@ArneeMe

@ArneeMe ArneeMe commented Jul 12, 2026

Copy link
Copy Markdown
Owner

Part 5 of the review-ordered stack (on develop). Everything that deserves deep human investigation, concentrated in one PR. Take your time here — the rest of the stack is rubber-stampable by comparison.

Issuance & data lifecycle (the privacy promise)

  • Cert POST: idempotent per submission — inserts the certificate, or returns the existing one (alreadyIssued: true) if this submission already has a cert. Does not delete the submission. Records issued_by (the audit CLAUDE.md explicitly allows: who issued, when, no payload). GET lists issued certs (no hash exposed, but submissionId is — it's not personal data, and the dashboard needs it).
  • Retention: lazy sweep wired into submissions GET/POST deletes rows older than 24h (SUBMISSION_TTL_HOURS, one constant) — this is the only deletion mechanism, regardless of whether a cert has been issued. That gives a regeneration window: "Generer PDF" can be re-run for the same submission any time before the sweep, e.g. to fix a lost or misprinted PDF. Dashboard shows the per-card countdown and an "Utstedt" chip on submissions that already have a cert (state seeded from the certificates GET, so it survives a reload).
  • Dashboard issuance flow: PDF preview with FORHÅNDSVISNING watermark (renders real data, registers nothing), batch issuance → one ZIP, copy-verify-URL.

Revised per review feedback: the first draft of this PR deleted the submission in the same transaction as the cert insert, then a later PR in the original stack (retention model / 24h window) replaced that with the model described above. Shipping the wrong model first and superseding it three PRs later was backwards, so the regeneration-window model is now what #29 ships directly. The email notification to admins on new submissions (Resend-based, content-free) is dropped entirely — judged as unnecessary complexity; sendInviteEmail, which the invites feature below still needs, is untouched. Follow-up: the PR further up the stack that originally introduced this retention model is now redundant and will need reconciling (likely closed) when review reaches it — tracked, not touched yet.

Accounts & membership (the authz surface)

  • Password reset (/login/glemt/login/reset via Nhost), self-signup /registrer (a fresh account has zero memberships).
  • Members API/UI: list/add/remove behind requireOrgMemberBySlug, last-member lockout guard.
  • Invites: 7-day tokens; redemption requires the session email to match the invited address, so a leaked link alone grants nothing.
  • Platform admin /admin: gated by the PLATFORM_ADMIN_EMAILS env allowlist (unset = surface disabled; checked server-side against auth.users, never client input); creates org + first member in one transaction. create-org.ts for CLI.
  • Utstedte view (per-month/per-template counts), admin NO/EN language scaffolding.

Schema & docs

  • 0000-baseline-schema.sql (full reproducible schema, IF NOT EXISTS — no-op on prod) + 2026-07-invites.sql + 2026-07-issued-by.sql. CLAUDE.md/README describe the regeneration-window model directly (not a since-superseded delete-at-issuance one).

Other changes made during review-stack sync

  • Rate limiter dropped from the submissions POST (per review feedback, same cleanup already applied to the tooling and feedback PRs) — size/shape validation on the endpoint is untouched, only the per-IP throttle is gone.
  • Rebase note: this PR's base was originally claude/review-4-feedback-branding. After Review 4/5 — Org branding on public pages #28 merged, its target wasn't develop directly, so I retargeted here and merged develop in. One conflict in the admin nav (layout.tsx): kept this PR's data-driven nav (adds Members + Issued links) but dropped the Tilbakemeldinger entry, since Review 4/5 — Org branding on public pages #28 removed that feature and its string key.

Deploy checklist (one-time)

  1. Hasura console: run 2026-07-issued-by.sql and 2026-07-invites.sql, re-track certificates, track invites.
  2. Nhost → Auth → Redirect URLs: add the production /login/reset.
  3. Cloudflare env: PLATFORM_ADMIN_EMAILS (your email); optionally RESEND_API_KEY + NOTIFY_EMAIL_FROM (invite emails only now — see .env.example).
  4. Live pass: reset email, signup, invite redeem, issue + verify one attest, confirm the submission is deleted within the 24h window.

Verification

Typecheck, lint, 119 unit tests, 10 E2E, production build (with placeholder env) — all green.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BFHkXUuHJME4cF9AJWbavB

Review tier 5 of 5 — everything that deserves deep review, in one place:

Issuance & data lifecycle
- Cert POST inserts the cert AND deletes the submission in one Hasura
  transaction; issued_by records who issued (audit CLAUDE.md allows).
- Retention sweep wired into submissions GET/POST: rows older than 24h
  are deleted lazily; dashboard shows the countdown; batch issuance
  ('Generer valgte' → one ZIP) and PDF preview share one render path.
- Per-IP rate limiting wired on the anonymous submissions POST; email
  notification (content-free) fires when Resend is configured.

Accounts & membership
- Password reset (glemt/reset), self-signup (/registrer), members
  API+UI with last-member lockout guard, invites with 7-day tokens
  whose redemption requires the session email to match the invited
  address, admin NO/EN language scaffolding.

Platform
- /admin panel gated by PLATFORM_ADMIN_EMAILS env allowlist: list orgs,
  create org + first member in one transaction. create-org.ts for CLI.
- Utstedte view (certs, per-month/template counts), baseline schema DDL
  + invites/issued_by migrations, CLAUDE.md/README document the model.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFHkXUuHJME4cF9AJWbavB
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 12, 2026

Copy link
Copy Markdown

Deploying inf319-certificate with  Cloudflare Pages  Cloudflare Pages

Latest commit: 8b690c1
Status: ✅  Deploy successful!
Preview URL: https://c80d8246.inf319-certificate.pages.dev
Branch Preview URL: https://claude-review-5-core-securit.inf319-certificate.pages.dev

View logs

ArneeMe commented Jul 12, 2026

Copy link
Copy Markdown
Owner Author

⚠️ Model change, decided after this PR was opened: the delete-submission-at-issuance behavior described here is superseded by #32 (stacked later in this chain). Issuing no longer deletes the submission; the 24h retention sweep is the sole deleter, the cert POST is idempotent per submission, and admins can regenerate the PDF within the window. Review the issuance lifecycle here together with #32's diff — the final merged state is #32's semantics.


Generated by Claude Code

ArneeMe commented Jul 12, 2026

Copy link
Copy Markdown
Owner Author

Security review notes (inline pass over every new attack surface in this stack — use as a checklist for your deep review):

Verified sound:

  • Every org-scoped route calls requireOrgMemberBySlug; ids from the client get templateBelongsToOrg/submissionBelongsToOrg ownership checks; the feedback DELETE pins the org id from the membership check so cross-org deletion is impossible.
  • All Hasura access uses GraphQL variables — no string interpolation into query documents anywhere.
  • Platform admin: allowlist is env-only (not self-grantable through any app surface), the caller's email is read from auth.users server-side, unset env disables the surface entirely.
  • Invite redemption requires session email == invited email (case-insensitive), so a leaked link alone grants nothing; tokens are unguessable UUIDs with 7-day expiry and single redemption.
  • The branding endpoint exposes only org name + default logo (both already public on every issued PDF); no other asset kind reachable unauthenticated.
  • The privacy model holds: no route logs or returns volunteer fields outside the transient submissions surface; notifications and feedback are content-free/anonymous by construction.

Two findings, fixed in #35:

  1. Cert issuance idempotency was check-then-insert — a concurrent double-click could race past it. Now backed by a unique index on (organization_id, submission_id) with the violation handled as "already issued". (Duplicates were harmless — identical hash — but messy.)
  2. Malformed invite tokens hit Hasura's uuid type validation and surfaced as 500s; now validated to a clean 404.

Accepted trade-offs (documented, not bugs): per-isolate best-effort rate limiting; members-remove lockout guard is check-then-delete (self-inflicted only, org can't reach zero members without racing yourself); admin emails visible to co-admins of the same org in the Utstedte view.


Generated by Claude Code

@ArneeMe
ArneeMe changed the base branch from claude/review-4-feedback-branding to develop July 21, 2026 19:40
claude added 6 commits July 21, 2026 19:41
…re-security

# Conflicts:
#	src/app/login/adminpage/[orgSlug]/layout.tsx
Same cleanup as the tooling and feedback branches — the limiter file
itself was removed earlier in the stack. The size/shape validation on
this endpoint (64KB cap, per-field length cap, cross-org template
check) is untouched; only the per-IP throttle is gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFHkXUuHJME4cF9AJWbavB
Per review feedback: shipping delete-at-issuance now and superseding it
three PRs later was backwards — the 24h regeneration window this PR
already introduces (the sweep) should be the ONLY deletion mechanism,
from the start.

Cert POST (certificates/route.ts): no longer deletes the submission.
Made idempotent instead — returns the existing cert if one already
exists for the submission (alreadyIssued: true), so 'Generer PDF' is
safely re-clickable any time before the sweep runs (lost PDF, misprint,
late-spotted mistake). GET now also returns submissionId so the
dashboard can tell which still-present submissions are already issued.
Check-then-insert has a small race window under concurrent double
clicks; closing it with a DB-enforced unique constraint is tracked as
follow-up hardening, not done here.

Dashboard: issuedIds is now state (was a ref used only to dodge a
now-nonexistent 'row already deleted' failure), seeded from the
certificates GET on load, rendered as an 'Utstedt' chip. Issued
submissions stay in the list — deletion is the sweep's job alone.
Confirm-dialog copy no longer claims the submission disappears the
moment a PDF is generated.

Dropped the content-free email notification to admins on new
submissions (notifyNewSubmission/buildSubmissionNotification/
getOrgNameAndAdminEmails + its test) — sendInviteEmail, which the
invites feature still needs, is untouched; .env.example's RESEND_*
comment now describes what it's actually for.

CLAUDE.md/README updated to describe the final model directly instead
of the delete-at-issuance one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFHkXUuHJME4cF9AJWbavB
'Konto' collided with the org-creation flow's 'virksomhetskonto'
concept (an organization's presence on the platform, created by the
platform admin at /admin) — the self-signup flow at /registrer creates
a personal login, not an organization. /admin already says 'organisasjon'
and 'brukerkonto' consistently; this brings /login and /registrer in
line with that existing distinction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFHkXUuHJME4cF9AJWbavB
Email verification requirement is an Nhost project setting (Settings ->
Sign-In Methods -> Email and Password -> 'Require Verified Emails'),
not code in this repo — signup() already handles both outcomes via the
session-present check. No code change needed to drop it.

Added a small hint under the email field on /registrer pointing users
who want an alias at Proton Pass, per the site owner's request.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFHkXUuHJME4cF9AJWbavB
…ffiliate

You set a 10-char minimum password length on the Nhost project side
(Settings -> Sign-In Methods -> Email and Password). Added a matching
PASSWORD_MIN_LENGTH constant in util/auth.ts and a client-side check on
both /registrer and /login/reset, so a too-short password gets a clear
inline message instead of hitting the server and surfacing whatever
raw error Nhost returns. A helperText hint under the password field
states the requirement up front.

Also labeled the Proton Pass link on /registrer as an affiliate link,
in both languages — the site's whole pitch is not monetizing anyone's
data, so a referral link should say what it is rather than read as
neutral advice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFHkXUuHJME4cF9AJWbavB
@ArneeMe
ArneeMe marked this pull request as ready for review July 21, 2026 21:29
@ArneeMe
ArneeMe merged commit 4e8b0ad into develop Jul 21, 2026
3 checks passed
ArneeMe pushed a commit that referenced this pull request Jul 21, 2026
…feature

The privacy policy (/personvern) was written against an earlier product
state and no longer matched reality after #29 and #28:

- 'Utstedelse uten sletting er teknisk umulig' / 'issuing without
  deleting is technically impossible' was true under delete-at-issuance,
  false now — #29 replaced it with the 24h sweep as the sole deletion
  mechanism, independent of issuance. Rewrote the retention and rights
  sections to state the actual guarantee: deleted within
  SUBMISSION_TTL_HOURS regardless of issuance status, deletable
  manually by the org at any time including after issuance.
- 'Etter utstedelse finnes det ingenting å slette hos oss' / 'after
  issuance there is nothing left to delete' was false for the same
  reason — a submission can still hold real personal data for up to
  the TTL window after its certificate is issued.
- Dropped the 'Frivillig tilbakemelding' / 'Voluntary feedback' section
  and the email-notification sentence under 'Who has access' — both
  describe features #28/#29 removed from the product.

This is the legal text users rely on for what the platform actually
does with their data, so it can't lag the code it describes. Also
restores the SUBMISSION_TTL_HOURS import that a prior merge dropped
(the privacy section was the only remaining reference — build was
broken on this branch until this commit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFHkXUuHJME4cF9AJWbavB
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants