Replace the bare partner CTA with a "Become a partner" section: a pitch
column (with a secondary "book a partner call" link to /demo) plus an
application form — name, company, work email, website, partnership type
(reseller / white-label / not sure) and message. On submit it composes a
prefilled email to info@dezky.eu (interim, no backend), to be swapped for
a real intake/CRM later. Bilingual and responsive.
- Write the brand name as lowercase "dezky" across all user-facing copy
(the legal entity "Dezky ApS" stays capitalised).
- Change the general contact email kontakt@dezky.eu -> info@dezky.eu.
- Footer tagline now "European" rather than only Danish business.
- Compare table header logo uses the on-dark treatment (signal-green
squircle + carbon d) instead of a low-contrast green d on a light chip.
- /demo: book-a-demo page with a what-to-expect column + a form that
composes a prefilled email to info@dezky.eu (interim, no backend); built
to swap for a self-hosted scheduler later. Wire every "Book a demo" CTA
(nav, hero, pricing, the previously-dead final-CTA button, and the
contact/partners/migration/coming-soon CTAs) to /demo.
- /status: manually-maintained system-status page (overall banner,
per-service rows, incident history). Live modules operational; Video/Chat
marked coming soon.
- Roadmap: expand the board (5 items/column) + a "the bigger picture"
themes grid + a "suggest a feature" CTA + a directional-timelines note.
- Contact: purpose-specific channels (info@ / legal@ / privacy@), a
response-time note, and a company + "see it live" demo block.
- Drop /status from the [slug].vue stub map; tidy now-unused imports.
Extend /about with the "why": a "the problem" section (jurisdiction vs
geography, creeping prices, lock-in, vendor sprawl) as a 2x2 pain grid, and
a "we're founders ourselves" section. Both bilingual and responsive.
Also point the footer partner link to /partners (was the homepage
#whitelabel section).
Replace the /sla and /cookies stubs with real pages (da + en), matching
the DPA/terms/privacy layout. Drop both from the [slug].vue stub map.
- SLA: 99% uptime target framed as a transparency-backed commitment (no
public service credits); contractual credits offered only via Enterprise
agreements. Support first-response targets table; status-page reference.
- Cookie policy: strictly-necessary + language preference only, cookie-free
analytics (Umami), no third-party tracking, "no banner needed"; cookie
table; final (v1.0), no draft banner.
useLang now seeds from a first-party `dezky-lang` cookie on SSR init and
toggleLang writes it (12 months, SameSite=Lax). The chosen language now
survives reloads/return visits (fixes the reset-to-Danish quirk) and makes
the cookie-policy entry accurate.
Replace the /terms stub with a real Servicevilkår page (da + en),
mirroring the DPA/privacy layout and draft / legal-review banner.
- 12 clauses tailored to the system: the service, per-user accounts,
acceptable use, customer data (EU-hosted, DPA ref, export/no lock-in),
pricing & payment (49 kr/user/mo excl. VAT), term/renewal/cancellation,
uptime & SLA ref, IP, liability cap (last 12 months' fees), changes,
Danish law / venue Esbjerg
- Drop 'terms' from the [slug].vue stub map so the real page wins
- Contact: legal@dezky.eu
Still a 0.1 draft pending legal review (flagged on the page).
Make the marketing site mobile-friendly across every page and section.
Desktop appearance is unchanged; all breakpoint logic targets <=768px.
- Fluid section padding via clamp(); equal grids use auto-fit/minmax,
asymmetric grids stack to one column via scoped-CSS media queries
- Nav: real hamburger menu on mobile (links, lang toggle, login, CTA)
- ProductMockup: scales the whole dashboard to fit (zoom) instead of
reflowing its internals into a tall stack
- Lower oversized heading clamp() minimums so titles no longer overflow
at ~390px (hero, page headers, final CTA, brand cover/chapter)
- HowItWorks: row-gap when steps stack so node markers clear the text
- Compare + partners tables: stacked rows now label each value with its
column (Dezky vs hyperscaler / CSP) instead of an ambiguous header
- Footer columns, tiers, calculator and tables stack cleanly on mobile
Replace the /brand stub with the full Brand Guide from the Claude Design
handoff (cover, logo, color, typography, voice, applications), rendered inside
the site layout, English-only, with illustrative copy adapted to Dezky's real
EU-sovereign voice. Extend NodeMark with a `variant` (donut/solid/outline) and
NodeLockup with a separate `wordmark` colour so the mark reads correctly on
dark surfaces. Drops the old brand stub copy.
Set TMPDIR=/tmp in the dev script so the Nuxt vite-node Unix socket path stays
under macOS's 104-char limit (fixes "Failed to restrict vite-node socket
permissions" on local runs; harmless in the Linux container). Also fix the
package.json description: dezky.com → dezky.eu.
The /partners margin calculator now derives margin from the user count using
progressive brackets (like tax brackets): first 500 users at 15%, 501–1000 at
30%, 1001+ at 40%, off the 49 kr/user/mo list price. Replaces the manual margin
slider with a live per-bracket breakdown. Rates are read from the tier copy;
tier thresholds aligned to 501 / 1.001 so the cards and calculator agree.
Add the /partners page rendering the partner pitch: benefits, an interactive
margin calculator (seats × margin → monthly/annual, off the 49 kr list price),
a reseller-facing "CSP vs Dezky" comparison, partner tiers, a 3-step "get
started", and a partner FAQ. Wire the section-06 "see the partner program"
button to it, and align the whitelabel margin bullet to 15–40%.
Wire every footer link to a real route. Adds a shared `page` layout (Nav +
content + Footer), reusable PageHeader/ComingSoon components, six content pages
(about, contact, brand, roadmap, changelog, migration), and a dynamic [slug]
catch-all for the not-yet-built pages — unknown slugs 404, legal slugs get a
distinct "contact us" body.
Footer links repointed from dead "#" to real paths; section anchors ("/#suite")
smooth-scroll on the homepage and route home + scroll from a sub-page; logo
links home. Page copy (da + en) added under COPY.pages.
Hero headline was a broken comma-splice ("Den produktivitetssuite, dine data
bliver i Danmark med."); replace with a clean two-sentence line in both
languages:
da: "Din digitale arbejdsplads. Data der bliver i EU."
en: "Your digital workplace. Data that stays in the EU."
Also move all brand references off the unowned dezky.com to dezky.eu
(status page, app dashboard mockup, config/comments). External domains
(zulip.com, Google Fonts) are left untouched.
Partner demo cards in section 06 were hardcoded Danish strings, so they
stayed Danish in EN mode. Move name + subtitle into COPY.whitelabel.partners
for both languages and render them via a mapped computed; per-card accent and
the placeholder style remain presentational config in the component.
Also harden PartnerCard's avatar-initial against an empty name to satisfy
noUncheckedIndexedAccess.
New standalone apps/website (Nuxt 4) serving the public marketing site at
dezky.local / www.dezky.local. The customer portal moves off the root domain
to app.dezky.local only.
Landing page ported from the Dezky design handoff: light theme, Danish
default, hero variant A, with a working da/en toggle. Self-contained colour
system threaded through components (utils/landingTokens.ts), full bilingual
copy (utils/landingCopy.ts), and shared state (composables/useLanding.ts).
Sections live under components/landing/* with the Node logo under
components/brand/*.
Wired into docker-compose (website service, volume, Traefik labels, network
aliases) and bootstrap.sh (hosts list + service URLs).
Wire the mail/identity stack to real Stalwart/Authentik/OCIS provisioning,
replacing the mocked Domains and Users pages.
Domains (customer-admin):
- StalwartClient: real JMAP management (v0.16 dropped REST) — create/list/delete
email domains via x:Domain at the internal http://stalwart:8080 listener;
DKIM auto-generated; the records to publish are read from the domain's
dnsZoneFile. Gated by STALWART_PROVISIONING_ENABLED.
- New Domain collection + DomainsModule: add/list/recheck/set-DMARC/remove,
tenant-membership-gated and audited.
- DnsVerifierService: verifies MX/SPF/DKIM/DMARC/ownership against a public
resolver (1.1.1.1/8.8.8.8) and diffs them against the expected records.
- Remove is guarded: refuses while accounts/aliases/mailing lists still use the
domain (via Stalwart referential integrity).
- Domains page + add wizard on real data; sidebar badge counts domains needing
attention.
Users & groups (customer-admin):
- Create a member provisioned across Authentik SSO, a Stalwart mailbox on the
tenant's primary domain, and OCIS — returning a one-time password.
- Lifecycle: suspend/resume (Authentik is_active + freeze the mailbox via
account permissions, original password preserved), force-logout (terminate
sessions, filtered client-side so it can never end other users' sessions),
reset password (new one-time password on SSO + mailbox), and remove (tear down
mailbox + SSO identity + OCIS + doc; mailbox-in-use aware for multi-tenant
users). Self-suspend / self-force-logout are blocked.
Infra: point platform-api at the internal Stalwart listener; document the new
STALWART_/provisioning vars in .env.example.
GET /tenants/:slug/users now returns a tenant-scoped `tenantRole`
(resolved server-side via roleForTenant), and the operator tenant page
displays it instead of the global `role` — so a user who is admin here
but member elsewhere reads correctly in this tenant's context. The
global `role` field is kept intact for other consumers.
The Storage page + endpoint landed earlier but had no working OCIS
backend credential. OCIS has no service-account/client-credentials grant
and trusts a single issuer, and basic auth resolves no user in our
external-IdP setup — so authenticate OcisClient via an OIDC
refresh-token bootstrap instead:
- One-time headless login of svc-platform-api against the ocis provider
(public client ocis-web, issuer .../o/ocis/) yields a refresh token,
persisted in Mongo (ocis_credentials) and rotated on every use.
- OcisClient mints access tokens with the refresh_token grant; the
service user holds the OCIS admin role (OCIS_ADMIN_USER_ID) so
libregraph ListAllDrives works.
- scripts/bootstrap-ocis.mjs re-runs the bootstrap if the token lapses.
- Dashboard Plan card gains a storage capacity bar beside seats;
hidden when storage is unavailable.
- compose + .env.example: OCIS service OIDC env and admin user id.
- docs/NEXT-STEPS: document the mechanism and the dead-end alternatives.
Security & audit (admin)
- Audit log: real, tenant-scoped — widened GET /tenants/:slug/audit with
q/action/outcome/actorEmail/since/before; UI gains search, outcome + time
filters, action chips, cursor pagination, and client-side CSV export.
- Security policy: new tenant.securityPolicy (mfaMode, session idle/absolute,
allowedCountries, ipAllowlist) + PATCH /tenants/:slug/security-policy
(membership-gated, audited). Editable, labelled by enforcement status.
- MFA: live enrollment overview via GET /tenants/:slug/mfa-status
(Authentik countAuthenticators per member).
- SSO apps (Dezky as IdP): real Authentik OIDC provider + application CRUD,
scoped to the tenant group. New AuthentikClient methods (provider/app/binding
+ flow/key/scope discovery), TenantSsoApp schema, TenantSsoService (rollback
on partial failure; client secret never stored), GET/POST/DELETE
/tenants/:slug/sso-apps. Validated end-to-end against live Authentik.
- Deferred: shared-flow MFA/geo/session enforcement (global auth-flow blast
radius) — to be done as its own reviewed change.
Bundled in-progress work that shares the same files (kept together so the tree
stays green):
- Storage page: StorageService + GET /tenants/:slug/storage (OCIS-backed),
storage.get proxy, storage.vue.
- Per-tenant roles: User.tenantRoles + MeProfile.tenantRoles plumbing.
Access & navigation
- Gate partner-mode strictly to partner staff so admins/end-users never inherit
leftover partner-view state; purge stale session entry on hydrate.
- Role-driven admin entry: useMe.isTenantAdmin, Admin/Personal tiles in the app
launcher, and an /admin route guard in the global middleware (fail closed).
- Drop the duplicate user identity block from the sidebar footer.
Admin pages on real data
- New tenant-scoped, membership-gated endpoints: GET /tenants/:slug/{audit,users,
invoices}; useTenant composable resolves the active workspace + subscription.
- Dashboard: real seats, spend (cycle-normalized + minor-units), plan, renewal,
and recent audit; unbacked sections removed.
- Users & groups: real members; Groups/Invitations/Service accounts shown as
honest "coming soon".
- Subscription & invoices: real plan hero, invoice history, and billing details.
Stripe payment method (Elements + SetupIntent)
- StripeClient: publishable key + getDefaultCard/createSetupIntent/setDefaultCard.
- CustomerBillingController + BillingService methods (ensure-customer on demand).
- Portal: PaymentMethodModal, useStripeJs (CDN load), proxies; hidePostalCode.
Editable billing details & whitelabel branding
- PATCH /tenants/:slug/billing-info (narrow: company/VAT/country/email).
- TenantBranding schema/service + GET/PUT /tenants/:slug/branding: real product
name, accent colour, and per-tenant email-template overrides.
- Branding preview + sidebar workspace mark wired to real name/plan/seats/colour
with YIQ auto-contrast (readableOn util).
Session resilience
- Request offline_access so Authentik issues a refresh token (automaticRefresh).
- Silent refresh + single retry on 401 for writes (useApiFetch, incl. partner
pages) and reads (useMe.fetchMe) — no redirect, no lost input.
- Modal backdrop closes only on press+release on the backdrop (no more
drag-select-to-close).
Editing a catalog amount now propagates beyond MongoDB. Stripe Prices are
immutable, so each changed currency mints a fresh Stripe Price at the new
amount, overwrites the cached stripePriceIds[currency] (which also fixes the
stale-price bug for new subscriptions), and repoints every live subscription
on that (row, currency) onto it with proration_behavior 'none' — the new
amount takes effect at the customer's next billing cycle, no mid-cycle charge.
The per-seat snapshot is refreshed so MRR reflects the go-forward rate.
Before committing the edit, the operator sees a warning with the affected
customer count, driven by a new GET /prices/:id/impact endpoint. Per-sub
failures are logged, never fatal; Stripe-disabled rows still re-snapshot.
A partner or tenant admin could complete the dezky-operator OIDC flow and
land on the operator portal. The platform-api OperatorGuard already 403s
their data, but the login/UI layer had no authorization check at all — the
only gate was a manual Authentik UI setting with nothing in git enforcing it.
Close it with defense-in-depth across three independent layers:
1. IdP — operator-application.yaml blueprint binds an
ak_is_group_member("dezky-platform-admins") policy to the dezky-operator
app, so Authentik denies the OIDC flow for non-admins. The blueprint also
provisions the provider + application (state: created, so a fresh env is
built from code while an existing hand-made provider is left untouched).
Wire OPERATOR_OIDC_* into both authentik containers and mount the
blueprints dir on the worker (it applies blueprints, and previously lacked
the mount).
2. Operator app — require-platform-admin.global.ts requires platformAdmin and
routes a non-admin to not-authorized.vue, which triggers a full sign-out
(local + Authentik IdP) for shared-workstation safety. Fails open on a
transient /api/me error by design, to avoid mass-signout on platform-api
restarts; layers 1 and 3 contain the exposure.
3. platform-api — OperatorGuard (unchanged) requires dezky-operator audience
plus platformAdmin resolved from the DB on every request.
Also harden the partner surface: it shares the dezky-portal client with tenant
users so it has no IdP gate, and its /partner/* route middleware now fails
CLOSED when identity can't be confirmed.
Docs (AUTHENTIK-SETUP.md) and .env.example updated; the operator client secret
must be set before first boot since the blueprint now consumes it.
pricing.vue nested <PageHeader> inside .stage, so the header inherited .stage's 40px horizontal + 24px top padding on top of its own — over-indenting the title and pushing it down vs every other operator page. Move PageHeader to the top level with .stage wrapping only the content below, matching index/tenants/audit/infrastructure/billing/reports.
Replace the last two holdouts: the dashboard partner-identity fallback now uses the real useMe().partner name, and the decorative MRR sparkline (dashboard + reports) moves to a generated useMrrTrendline() — deterministic, clearly placeholder-only, until a daily MRR-snapshot job exists. Removes the dead sparkLast/sparkTrendPct vars. The data/customers.ts fixture module is now fully deleted; the partner portal carries no mock business data.
Migrate the partner-mode customer switcher, in-customer banner, sidebar tile and the team invite/teammate panels off the data/customers fixture onto the real /api/partner/tenants list (shared key, gated to partner-staff so the global shell doesn't 403 for other users). Active customer resolves by tenant _id (the key the customers page already passes to partnerMode.enter); partner-identity labels now use the real partner name from useMe. Removes the now-unused customers + CustomerOrg-list fixture export and the dead setCustomer helper. Verified in UI: switcher + enter/exit show real Baslund Test / Baslund Research ApS.
Drop partnerTeam, partnerInvoices, partnerActivity and partnerAudit from data/customers.ts — these had no remaining importers now that team, billing, dashboard activity and audit run on real data. The still-used partner / customers / partnerMrrSparkline exports (partner-mode customer switcher + decorative sparkline) stay until those consumers are migrated.
Add BillingService.generatePayouts: idempotent per-partner/month/currency snapshot of gross MRR x marginPct into Payout rows (never rewrites a paid row), plus platformPayouts(). A PayoutWorker generates the current month daily (and on boot; PAYOUTS_AUTOGEN=false to disable). Operator endpoints GET /billing/payouts + POST /billing/payouts/generate, an operator payouts ledger table with a Generate button, and the proxy routes. The partner Payouts tab now shows real data.
Tenants always belong to a partner, so the operator must not create orphan (partnerless) tenants. Remove the NewTenantModal component, the POST /api/tenants proxy route, and the New-tenant button/modal from the tenants page. Tenant creation now happens only via the partner portal wizard (which forces partnerId).
Add a lazy/guarded Stripe client (boots without keys), Invoice/Payout schemas, per-currency Price.stripePriceIds, and a BillingService deriving partner/platform summaries, invoices and a partner-cut payout ledger. Partner and operator billing controllers plus a signature-verified Stripe webhook (Fastify raw body). Frontend: partner and operator billing pages and the operator tenant billing/audit tabs on real data. Gated behind new_billing_engine and BILLING_STRIPE_ENABLED; live money paths stay off until keys are set.
Partner reports — health cohorts, revenue-by-plan, top customers, signup/churn cohorts, plus saved custom reports (create/list/delete). Operator platform-wide reports (MRR, revenue by plan, top tenants, growth). Replaces the reports fixtures in both apps.
Backend (platform-api): computed tenant health plus industry/brandColor; partner-scoped tenant update/suspend/resume guarded by assertPartnerOwnsTenant; enriched partner users (MFA + access level) with invite/remove; partner settings and whitelabel branding persistence; Authentik authenticator counting and group removal. Audit on every mutation.
Frontend (portal): all five partner pages on real data — dashboard alerts, customers edit/suspend, team MFA/access with invite/remove, editable settings, branding fetch/save.
Operator: dashboard and infrastructure service health driven by real liveness probes; fabricated uptime/p95/error-rate removed.
Move partner domain types out of data/customers.ts into types/partner.ts so the fixture data exports can be removed later without breaking type imports. Add usePartnerTenants / usePartnerMrr composables wrapping the shared-key partner fetches.
Upgrade both Nuxt apps to Nuxt 4.4.6 (vue-tsc 3, TypeScript 5.6, undici 7) and add a root tsconfig.json to each app. Fix the strict-null / noUncheckedIndexedAccess errors surfaced by Nuxt 4's stricter generated tsconfig and vue-tsc 3. Drop the nuxt-oidc-auth pnpm patch (Nuxt 4 fixes the prepare:types crash natively).
Wires the previously-dead 'New tenant' button on /tenants to a modal
that collects slug + name + plan + optional primary domain, POSTs to
the existing platform-api /tenants endpoint via a new operator proxy,
and navigates into the freshly-created tenant detail page. Slug
auto-derives from the name until the operator types in the slug field
themselves. Billing details and provisioning are still done from the
tenant detail page after creation — this modal is the minimum that
backend validators will accept.
Every page header's Refresh button rendered a downward chevron because
the icon set had no refresh glyph. Added a circular-arrow 'refresh'
icon to UiIcon and pointed all seven Refresh buttons (Overview,
Tenants, Partners, Users, Operator team, Audit, Infrastructure) at it.
partner.updated events previously recorded only the field names that
changed (metadata.changes). Now they record metadata.diff — a
{ field: { from, to } } map — by reading the partner before the
findOneAndUpdate and comparing serialized values. Only fields that
actually differ make it into the diff, so a save-without-changes
records an empty diff instead of every DTO key.
The operator audit row's expanded panel renders the diff as a small
inline table (field · from → to). Older audit rows that still carry
metadata.changes fall back to the original chip layout so historical
events stay readable.
Rows with a non-empty metadata object now show a chevron and become
clickable. Clicking expands a detail row underneath that lists every
metadata field — partner.updated/tenant.updated render their 'changes'
array as a row of mono chips, everything else falls back to a generic
key/value layout. Toggling is per-row, so the operator can open
several at once when comparing actions.
inline-grid with minmax(96px, auto) gave the 4-option control a fixed
~390px intrinsic width; when the contract card's right column was
narrower, 'terminated' spilled out. Switch to a full-width grid with
minmax(0, 1fr) so columns share space equally, and let button labels
ellipsize when the cell shrinks below their preferred width.
Toggle the partner detail cards from read-only to editable in place. Edit
button in the PageHeader flips to Cancel + Save changes; cards expose
text inputs for name/domain/contact/billing, a 4-option segmented
control for status, and a 0–100 range slider for marginPct. Save sends
a PATCH diff (only fields that actually changed), refreshes the page
data, and exits edit mode. Cancel with unsaved changes confirms first.
Also tightens audit metadata: previously `Object.keys(dto)` on the
ValidationPipe-instantiated DTO listed every @IsOptional() field, even
when the request body didn't touch them. The partner.updated audit
event now records only the keys the operator actually sent.
New "Invite operator" button + modal on /operator-team. Replaces the
bounce-to-Authentik flow with an inline invite that creates the user via
the Authentik API and pre-populates our local User doc so they appear
immediately.
services/platform-api/src/integrations/authentik.client.ts:
- findUserByEmail(): early-conflict check before we attempt the create
- createUser(): POST /core/users/ with username = email, internal type,
is_active, attached to the supplied group PKs
- addUserToGroup(): kept for tenant-member invites later
- recoveryLink(): tries POST /core/users/{pk}/recovery/, returns
undefined when no recovery flow is configured on the Authentik brand
(we soft-fail and the service falls back to setInitialPassword)
- setInitialPassword(): POST /core/users/{pk}/set_password/. Returns 204
No Content so we bypass request<T>'s JSON parser and call fetch
directly with explicit ok check.
services/platform-api/src/users/users.service.ts:
- inviteOperator(dto, actor) orchestrates: dedup by email →
findOrCreate Authentik group → create user in group → pre-create
local User doc with platformAdmin=true so the list reflects them
immediately → try recovery link → fall back to temp password →
record platform.user_invited audit event with handoff method.
- Return type is { subject, userId, link? | tempPassword? } —
exactly one credential mode set depending on Authentik config.
- generateTempPassword(): 16-char with at least one upper/lower/digit/
symbol, shuffled. Confusable chars (I/O/0/1/l) omitted.
- Cached platform-admin group ID after first lookup.
services/platform-api/src/users/users.controller.ts:
- POST /users/invite behind OperatorGuard. Calls the service with
actor + IP from the JWT/request.
apps/operator:
- server/api/users/invite.post.ts: standard platformApi proxy.
- components/InviteOperatorModal.vue: 2-step form. Step 1: name +
email with client-side validation. Step 2: shows whichever
credential the backend returned — recovery link OR username+
temp-password — with copy-to-clipboard buttons and a note about
SMTP/recovery-flow follow-up paths.
- pages/operator-team.vue: "Invite operator" replaces "Manage in
Authentik" as the primary action; Authentik link demoted to
secondary. Refreshes the list on @invited so the new user shows
up without a manual reload.
Verified end-to-end against real Authentik:
- Invite created user pk=7, uid=f22f2bb…, group=dezky-platform-admins,
is_active=true, temp password set. Modal showed both fields with
copy buttons; operator-team count went 1 → 2 immediately. Audit
event recorded (platform.user_invited with handoff='temp-password').
- Recovery link path is preferred but Authentik has no recovery flow
configured on the default brand. AuthentikClient.recoveryLink()
soft-fails on the "No recovery flow set." 400, returns undefined,
and inviteOperator transparently falls back to set_password. Once
a recovery flow is configured (Authentik admin → Flows), the link
path becomes active and the temp-password path stops firing
without any code changes.
Known follow-ups:
- Configure Authentik recovery flow so the link path activates
(one-time admin task, not in code)
- Outbound SMTP wiring (Phase 5/6) → Authentik can email link/temp
directly; modal stops showing the credential
- Deactivate / remove operator from inside the app (currently still
Authentik UI; defensible until proven needed)
- Tenant-member invite — similar flow but adds to tenant group
instead, exposed from /users (global users) or tenant detail
Final piece of the audit work. Events older than the hot retention window
move to S3-compatible object storage with signed manifests. Production uses
Hetzner Object Storage; dev uses a MinIO container with the same API.
Infra (infrastructure/docker-compose):
- New `minio` service exposing the S3 API at minio:9000 + admin console at
minio.dezky.local. Healthchecked. Bucket-init sidecar runs `mc mb` once
to create `dezky-audit`; safe to re-run.
- .env adds MINIO_ROOT_USER + MINIO_ROOT_PASSWORD.
- platform-api env: AUDIT_COLD_{ENDPOINT,REGION,BUCKET,ACCESS_KEY,SECRET_KEY}
+ AUDIT_HOT_RETENTION_DAYS=90 + ARCHIVE_ENABLED=false (dormant in dev;
operator UI's "Run archive now" bypasses this gate). AUDIT_COLD_SSE
opts into SSE-S3 — left unset in dev because MinIO without a KMS rejects
AES256 PUTs with "KMS is not configured".
Platform-api (services/platform-api/src/cold/):
- cold-storage.client.ts: thin @aws-sdk/client-s3 wrapper — put/head/list.
forcePathStyle=true so MinIO and Hetzner both work; same code, env-swap.
- archive.service.ts: runOnce() selects chained events with at < cutoff →
serializes to JSONL → gzip → sha256s → uploads JSONL + signed manifest
→ HEAD-confirms both objects exist → records an ArchiveBatch doc → only
then deletes from hot Mongo. Crash-safe: a failed upload leaves events
in hot. Manifest uses the Phase 3 AUDIT_SIGNING_KEY (HMAC-SHA-256), so
archives + checkpoints share trust chain. Bypassable via { override:
true } for the operator's UI force-run.
- archive.worker.ts: hourly tick guarded by configured run-hour-UTC
(default 03:00) + day-guard so the same UTC day doesn't archive twice.
Disabled until ARCHIVE_ENABLED=true.
- archive-batch.schema.ts: { archivedAt, startSeq, endSeq, eventCount,
manifestSha256, jsonlKey, manifestKey, bytesUncompressed }. The
manifest sha256 stored in Mongo lets us detect manifest tampering
without downloading the actual manifest.
Audit module additions:
- audit.controller.ts: GET /audit/archives, POST /audit/archive/run,
/audit/verify now reports { oldestHotSeq, highestArchivedSeq } so the
UI shows the tier boundary.
Operator UI (apps/operator):
- 2 new proxies: /api/audit/archives + /api/audit/archive/run (force
override=true). Both behind operator auth via the existing platformApi
helper.
- audit.vue: new "Cold storage" card with batch table (archived-at, seq
range, event count, size, truncated manifest sha256), "Run archive
now" button + per-run result line.
Smoke-tested end-to-end:
- 7 chained events in hot. /api/audit/archive/run → ok=true, batchId
returned. JSONL + manifest both exist in MinIO (verified via mc ls +
mc cat). Mongo's chained set went 7 → 0. Verify reports
highestArchivedSeq=1446 (since we burn-allocate seqs on Authentik
dup-key rejections). Operator /audit panel shows the batch with
manifest hash 1d8263…
- First attempt with SSE-S3 enabled failed cleanly (MinIO KMS not
configured) — archive service correctly left events in hot Mongo.
Made SSE opt-in via AUDIT_COLD_SSE=true; prod turns it on.
Out of scope (each could be its own session):
- Restore-to-hot endpoint (today: download from S3 + offline query)
- Client-side encryption (today: SSE-S3 in prod, none in dev)
- Multi-region replication
- Soft TTL safety net (defense-in-depth on top of app-managed deletion)
This completes the four-phase audit log work:
1. platform-api as audit hub
2. External system ingest (Authentik / Stalwart / OCIS)
3. Hash-chain + signed checkpoints (tamper evidence)
4. Cold-storage archival (retention without unbounded Mongo growth)
The audit log now carries cryptographic chain-of-custody. Every chained
event references the previous event's sha256, and periodic checkpoints
sign the head with HMAC-SHA-256. An attacker who modifies a historical
row must also forge every checkpoint signature past it — which requires
the AUDIT_SIGNING_KEY, kept outside Mongo.
Schema (services/platform-api/src/schemas/):
- audit-event.schema.ts: new `seq` (monotonic) + `chained` (Phase-3-or-
later flag) + `prevHash` + `hash`. Compound unique index on seq with
partial filter so pre-Phase-3 rows don't collide on null.
- audit-counter.schema.ts: single doc `_id='audit_seq'`, incremented
atomically by findOneAndUpdate($inc).
- audit-checkpoint.schema.ts: { at, headSeq, headHash, signature,
sigAlg, reason }. Reason ∈ {startup, interval, threshold, manual}.
Audit module (services/platform-api/src/audit/):
- canonical.ts: stable JSON form + hashCanonical (sha256) +
checkpointSignature (HMAC-SHA-256) + verifyCheckpointSignature
(timingSafeEqual). Single source of truth for hash inputs — schema
additions land here at the same time as the field.
- audit.service.ts: record() now allocates seq → looks up lastHash() →
computes hash → inserts. Per-process write mutex serializes the
allocate+lookup so concurrent writers don't both chain off the same
predecessor. Documented multi-instance caveat (needs Mongo replica
set + transactions OR a distributed lock).
- checkpoint.service.ts: scheduler triggers on startup + every 5min
+ threshold of 100 events accumulated. Skips when no new chained
events since the last anchor.
- verifier.service.ts: walks chain in seq order, recomputes each
hash, validates checkpoint signatures. Returns a precise break:
'event-hash-mismatch' (in-place modification), 'event-prev-hash-
mismatch' (insertion/deletion), or 'checkpoint-signature-mismatch'.
- audit.controller.ts: GET /audit/verify, GET /audit/checkpoint/latest,
POST /audit/checkpoint (manual force).
Operator UI (apps/operator/):
- 3 new proxies under /api/audit/{verify, checkpoint/latest, checkpoint}.
- pages/audit.vue: new "Tamper evidence" card with "Force checkpoint"
+ "Verify chain" buttons. Header shows live head seq; result line
shows verified count or a precise break (kind + seq + expected vs
actual hash). Background tinted green/red on ok/broken.
Env (.env + docker-compose.yml):
- new AUDIT_SIGNING_KEY (32-byte hex HMAC secret). Prod swaps this for
ed25519 from an HSM/KMS; verifier code stays the same because sigAlg
is on the checkpoint doc.
Smoke-tested all three break paths against a clean chain of 5 events:
- normal verify: ok=true, 5/5 events verified, 1 checkpoint signed
- modified seq=3 in Mongo directly: verify returns ok=false with
break = { kind: 'event-hash-mismatch', seq: 3, expected, actual }
- restored, nuked checkpoint signature: break = { kind:
'checkpoint-signature-mismatch', headSeq: 5 }
- operator UI's verify panel reflects all three states correctly.
Legacy data: pre-Phase-3 events stay `chained: false` and are excluded
from the chain walk. Retroactive chaining of historical entries is a
one-off migration script we can run if we ever care to.
Out of scope (Phase 4 etc.):
- TTL + cold-storage archival to Hetzner Object Storage
- GDPR right-to-erasure tooling
- ed25519 / HSM signing (swap is well-defined; sigAlg field is ready)
- Multi-instance write coordination (Mongo transaction OR distributed
lock when we scale platform-api beyond 1 replica)
Phase 1 of the audit work — capture everything we control today, ingest from
external systems (Authentik / OCIS / Stalwart) in a later phase. The mock
OP_AUDIT fixture is gone; both the /audit page and Overview's activity card
now show real events recorded by AuditService.record() in platform-api.
Schema (services/platform-api/src/schemas/audit-event.schema.ts):
AuditEvent { at, actorType, actorId, actorEmail, actorIp, action, outcome,
resourceType, resourceId, resourceName, tenantSlug, partnerSlug, source,
metadata, prevHash, hash }
Indexes: {at:-1}, {tenantSlug,at:-1}, {actorId,at:-1}, {action,at:-1}.
prevHash/hash are nullable now; hash-chain tamper evidence is a later phase.
AuditService:
- record() — best-effort write, swallows errors so the underlying mutation
that succeeded isn't failed by a downstream log issue. Surfaces failures
via Logger.
- list() — filters: since/until/before, action (exact OR prefix match
via leading-anchor regex), tenantSlug, partnerSlug, actorEmail, outcome,
free-text q across action/resourceName/actorEmail/tenantSlug, limit
(default 100, max 500). Cursor pagination via `before`.
- No UPDATE/DELETE surface — entries are append-only by construction.
AuditController: GET /audit, behind JwtAuthGuard + OperatorGuard. No mutations
exposed; entries written internally by other modules.
X-Forwarded-For threading:
- apps/operator/server/utils/platform-api.ts forwards the originating
client IP to platform-api so audit entries carry a real address.
- services/platform-api/src/auth/client-ip.ts extracts leftmost
X-Forwarded-For, falls back to socket.remoteAddress.
Instrumented mutations (every one threads actor + IP through):
Tenants: create, update, softDelete, setStatus(suspend/resume)
Partners: create, update, terminate
Flags: create, update (incl. flag.killed verb when state=off+note=kill-switch),
remove
Users: deactivate
Each controller resolves the User doc via ActorService, extracts IP via
clientIp(req), and passes { userId, email, ip } as AuditActor to the service.
FlagsService's local ActorRef collapses to AuditActor so flag history and the
audit log share one shape.
Operator UI:
- /api/audit proxy that forwards query params verbatim
- types/audit.ts
- pages/audit.vue: real list with quick-pick action chips (All/Tenants/
Partners/Flags/Users), outcome filter, free-text search, "Load older
events" cursor pagination
- pages/index.vue: Overview activity card swaps mock OP_AUDIT for the
same /api/audit endpoint, rows link into /audit
- data/fixtures.ts: OP_AUDIT / AuditEntry / AuditTone exports removed
Verified end-to-end: suspended + resumed acme, flipped oci_versioning through
rollout → kill → on, then /audit returned all 5 events with the right action
verbs (tenant.suspended, tenant.resumed, flag.updated, flag.killed,
flag.updated), actor admin@dezky.local, IP 192.168.65.1. Filters (action
prefix + free-text q) narrow correctly.
Out of scope for this commit (each gets its own conversation):
- Authentik / OCIS / Stalwart ingest adapters (Phase 2)
- Hash-chain tamper evidence (Phase 3)
- TTL + cold-storage archival to Hetzner Object Storage (Phase 4)
- GDPR right-to-erasure tooling
Client-side helper for the portal to consume feature flags. Hits platform-api
through a new portal-side proxy that derives the tenant slug from the
signed-in user's JWT groups — so callers don't pass a slug, they just check
`useFeatureFlag('key')`.
apps/portal/server/api/flags/evaluate.post.ts:
- Reads access token from the nuxt-oidc-auth session
- Decodes the JWT and picks the first non-admin group as the tenant slug
(admin groups: dezky-platform-admins, "authentik Admins"). Filters
duplicates Authentik double-lists via policy bindings.
- Forwards { tenantSlug } to platform-api POST /flags/evaluate
- Caller can still pass an explicit tenantSlug in the request body to
override the auto-derivation (rare).
apps/portal/composables/useFeatureFlag.ts:
- Singleton module-level state shared across every component — one bulk
eval per session, not one per flag check
- `useFeatureFlag(key)` → ComputedRef<boolean>, lazily triggers the first
eval, fail-closed (every flag stays false on error)
- `useFeatureFlags()` → { flags, ready, pending, refresh } for the rare
case where you need the full map or want to re-evaluate (long-lived
session, admin flipped a flag mid-flight)
- Returns refs that update once the bulk eval lands; gated UI stays
hidden during the ~25ms round trip
apps/portal/nuxt.config.ts:
- Vite 7 `server.allowedHosts` set to ['app.dezky.local'] — same fix we
already shipped on the operator side; without it, the proxy returned a
plaintext 403 "Blocked request" instead of forwarding.
Verified end-to-end: signed in to app.dezky.local, hit /api/flags/evaluate
with no body → 200 with the full truth map (same shape as the operator's
direct eval), latency ~25ms, explicit-slug override returns identical
results.