Compare commits

..

29 Commits

Author SHA1 Message Date
Trey T 225fb1306b dev: add Kratos + Mailpit local-dev stack
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
docker-compose.dev.yml gains a Kratos identity service (public :4433 / admin
:4434) and a Mailpit SMTP catcher for local onboarding email codes, plus a
postgres-init mount. deploy/local/kratos/ holds the local Kratos config +
identity schema (placeholder dev cookie secret only). Supports the local
backend the XCUITest suite seeds against.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 00:11:06 -05:00
Trey T b54493f785 backend: GDPR export + retention cleanups + worker metrics (BE-1/2/3)
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
BE-3 observability: expose the worker's Prometheus metrics on :6060/metrics
(apns/fcm/asynq histograms + a new cache_ops_total counter were recorded all
along but never scraped — which is why those dashboard panels read empty); add
the worker containerPort, the vmagent worker scrape job, and two additive
NetworkPolicies. Instrument cache Get/Set hit/miss.

BE-2 retention: three periodic Asynq cleanup crons mirroring the reminder-log
cleanup — notifications (90d), webhook dedup log (180d), audit_log (365d).

BE-1 GDPR data export: POST /api/auth/export/ enqueues a low-priority Asynq job
that gathers all of the user's data (owned residences + their tasks/contractors/
documents/share-codes, plus profile/notifications/prefs/push-tokens/subscription/
audit log), zips one JSON file per category, and emails it as an attachment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 22:15:26 -05:00
Trey T 3b2ea9959a deploy: add node-exporter DaemonSet + vmagent scrape job
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Per-node host metrics (node_filesystem_*, node_memory_*, node_load*) were
missing — a node running out of disk would silently fail the cluster before
any dashboard signal (RUNBOOK §11.1 gap #9). Adds:
- node-exporter DaemonSet (pod-networked, :9100; host /proc,/sys,/ ro) so
  vmagent scrapes it pod-to-pod over the cluster CIDR, independent of node
  public IPs (the netpol node-IP list is OVH-stale).
- two additive NetworkPolicies (default-deny-all is in force): ingress to
  node-exporter from vmagent, and vmagent egress to the pod CIDR on :9100.
- a node-exporter scrape job in the vmagent-config ConfigMap.

Feeds the new "Node host health" row (disk/mem/load) on the eli5 dashboard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:41:40 -05:00
Trey T cf054959bd Auth: require email-verified by default for all app-data routes
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Previously only 2 share-code routes required a verified email; every other
authenticated route (residences, tasks, contractors, documents, notifications,
subscription, users, uploads, media — ~70 routes) accepted an authenticated but
UNVERIFIED user. This inverts the default to verified-by-default.

- router.go: add a `verified` sub-group that applies RequireVerified() ONCE at
  the group level, and move all app-data route setups under it. Verification is
  now the default; new routes are gated automatically. The authenticated-only
  allow-list is just the sign-up surface (/auth/me, /auth/profile, /auth/account).
  Public stays: register, health, webhooks, lookups.
- kratos_auth.go: fix a latent bug the gating exposed — the Redis session cache
  stored the verified flag for 24h, so a user who verified their email mid-session
  was still seen as unverified until the TTL expired (sign up -> verify -> create
  residence would 403). Now only a cached verified=true is trusted (verification
  is sticky); a cached verified=false re-resolves the live status from Kratos.
- auth_safety_test.go: add RequireVerified unit tests (verified passes,
  unverified -> 403, no-user -> 401).

Validated: API gating test (unverified->403, verified->200) + full iOS XCUITest
suite green (211 passed) including the onboarding verify->use-immediately flow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:49:37 -05:00
Trey T 12de5a230a i18n: backend-localized lookups, suggestions, and static data (10 languages)
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
- suggestion_service: fix scorer (stringList unmarshal accepts scalar|array;
  anchor scoring on base universal score so bool matches no longer tie); add
  localizeReasons for human-readable, Accept-Language-localized match reasons
- lookup_i18n: localize lookup display names, home-profile options, document
  types/categories via internal/i18n
- static_data_handler: per-locale seeded-data response (display_name, home
  profile options, document types/categories) with per-locale cache + ETag
- settings_handler: invalidate per-locale seeded-data cache on lookup change
  instead of pre-warming a single non-localized blob
- cache_service: per-locale seeded-data keys + ETag
- DTOs: add DisplayName fields (task/residence/contractor)
- translations: add suggestion.reason.* and lookup.* keys across all 10 langs
- cmd/api: extract startup helpers + tests

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 20:54:54 -05:00
Trey t 25897e913e Auto-verify Sign in with Apple emails
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Apple OIDC mapper now marks the email verified unconditionally via
verified_addresses. SIWA cryptographically proves control of the Apple ID and
Apple owns/verifies the (relay) email, so a code is redundant. Gating on
Apple's `email_verified` claim was unreliable — Apple omits it on many
authorizations, which made verification random (sometimes a surprise code
prompt). Password sign-ups still verify via the honeyDue API flow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:30:33 -05:00
Trey t 81e454d86d Add admin-create registration + live email-verified flag
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Registration now goes through POST /api/auth/register, which admin-creates the
Kratos identity (unverified email, NO auto-sent code). Kratos self-service
registration never returns the verification flow id, so the client could never
submit the user's code to the right flow; admin creation lets the client own a
single verification flow instead. Also surface the live Kratos verified flag
and fix Apple audience + team IDs.

- kratos.Client.CreateIdentity via admin API; ErrIdentityExists / ErrInvalidCredentials
- AuthService.Register + AuthHandler.Register + public POST /api/auth/register/
- CurrentUser overrides stale user_profile.verified with the live Kratos flag;
  UserRepository.MarkVerified mirrors it back
- configmap: additional_id_token_audiences allows the .dev bundle id_token
- fix Apple/APNs team id V3PF3M6B6U -> X86BR9WTLD in .env.example + dev init

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 17:46:30 -05:00
Trey t 7b87f2e392 fix(kratos): drop cloudflare-only middleware on auth ingress
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
iOS Sign In with Apple failed silently — the KMP client never reached
Kratos. Traced to the cloudflare-only Traefik middleware rejecting every
request at the auth ingress.

Root cause: on this cluster klipper-lb sits in front of Traefik and
SNATs the source IP. Traefik's ipAllowList sees the klipper-lb pod IP,
not Cloudflare's real source IP — so even legitimate iOS requests
proxied through Cloudflare get 403'd. The api ingress doesn't have
this middleware (and works correctly), so removing it from auth
matches the working pattern.

Kratos is the user-facing OIDC endpoint — every iOS/web user device
needs to reach it. Cloudflare's edge still does DDoS protection;
Kratos applies its own per-flow rate limits. The IP allowlist was
buying nothing here and breaking everything.

Verified after this change:
  - GET /health/alive → 200
  - GET /health/ready → 200
  - GET /self-service/login/api → 200 + valid flow body listing apple
    as an OIDC provider option

Related but not fixed by this commit: the same klipper-lb SNAT issue
affects admin.myhoneydue.com (which retains cloudflare-only). Admin
basic auth still gates real access there, but the IP check is dead
weight. Proper fix is configuring Traefik ipStrategy to read the
client IP from X-Forwarded-For (set by Cloudflare). Tracked as a
follow-up.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 11:14:35 -05:00
Trey t 6de90acef7 feat(kratos): deploy Ory Kratos to production (Apple-only OIDC)
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Auth was structurally broken — the api's Kratos middleware was pointing
at http://kratos:4433 but Kratos wasn't deployed. The only thing keeping
users logged in was a 5-min Redis cache; once it expired the middleware
called Whoami → no DNS → 401 → forced relogin with no path back.

This commit deploys Kratos for real:

Manifests:
  - kratos.yaml + migrate-job.yaml: pin oryd/kratos:v26.2.0@sha256:92eedc...
    (CalVer current stable as of 2026-06-03)
  - configmap.yaml: drop Google OIDC provider (not in scope); fill the
    Apple provider with real Services ID / Team ID / Key ID — Apple now
    sits at providers[0]
  - kratos.yaml: drop the Google-secret env binding; rebind APPLE_PRIVATE_KEY
    to PROVIDERS_0_APPLE_PRIVATE_KEY (shifted from index 1)
  - network-policies.yaml: add a kratos egress rule to allow-egress-from-api.
    Without this, even with kratos running, the api gets "connection refused"
    on http://kratos:4433 (post-DNAT NetworkPolicy enforcement — runbook §9.2).

Operator prerequisites that were completed alongside this commit:
  - Neon kratos database created (separate from honeyDue, owner neondb_owner)
  - Cloudflare DNS for auth.myhoneydue.com (3 A records, proxied)
  - kratos: block added to config.yaml (gitignored): DSN to the Neon DIRECT
    endpoint, cookie + cipher secrets generated, Fastmail SMTPS URI,
    .p8 contents inline

Out of scope intentionally:
  - Google sign-in (additive; can append providers[] later)
  - Migrating existing auth_user rows onto Kratos identities — pre-prod;
    existing users will need to sign in fresh, which creates a new Kratos
    identity and a new local user row (per migration plan in
    manifests/kratos/README.md).

Verified end-to-end:
  - 338 schema migrations applied successfully
  - 2/2 kratos pods Ready
  - api → kratos:4433/sessions/whoami returns 401 for invalid token (was
    "connection refused" before this commit's NetworkPolicy patch)
  - auth.myhoneydue.com resolves through CF; cloudflare-only middleware
    keeps the origin protected exactly like the other hostnames

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 11:08:09 -05:00
Trey t 64c656bde1 fix(auth): keep users logged in while Kratos is down
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Production is running with no Kratos deployed in-cluster (the deploy
script's kratos-secrets prerequisite isn't satisfied yet — see runbook
§11 #7). That means Whoami calls ALWAYS fail, so any time a user's Redis
session cache expires they get a 401, which the iOS app treats as session
invalid → forced re-login → can't re-authenticate because the same
Whoami is the only way back in.

Two-part mitigation:

1. Bump kratosSessionCacheTTL from 5 minutes to 24 hours. Active users
   stay logged in indefinitely; idle users get bounced after a day.
2. Refresh the cache TTL on every successful cache hit (sliding window)
   so usage-driven expiry is no longer a cliff at the original TTL.

When Kratos actually comes up:
  - revert the TTL constant to a sensible value (1-15 min)
  - the sliding-window refresh is fine to keep; it's good UX regardless

Caveat: this papers over the missing Kratos. New sign-ins still cannot
complete because the api needs Kratos to populate the cache the first
time. Real fix is to deploy Kratos.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 10:48:12 -05:00
Trey t d74cfeee62 feat(subscription): temporarily disable subscription gating
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Subscriptions aren't a shipping feature for now. Make
GET /api/subscription/status/ return a "limitations disabled" / pro-tier
stub at the top of the function with no DB or Redis work:

  - tier="pro"
  - is_active=true
  - limitations_enabled=false  (master kill switch in SubscriptionHelper.kt;
                                every canCreate* check short-circuits true)
  - usage=0 across the board
  - limits map present with empty entries (all-nil = unlimited per the KMM
    model convention) so client tier-lookups don't NPE

The original implementation is preserved verbatim as the unexported
getSubscriptionStatusFromDB method. Re-enabling is a one-line change:
swap GetSubscriptionStatus's body to call s.getSubscriptionStatusFromDB.

Two integration tests in subscription_is_free_test.go assert the original
"limitations actually apply based on settings/IsFree" behavior. They now
t.Skip with the same TEMPORARILY DISABLED marker pointing back to the
service comment. CheckLimit-based tests in the same file still pass
because that codepath is unchanged.

Perf side effect: POST/GET on this route drops to ~1ms (just JSON marshal),
removing 4-5 serial Neon RTTs from every cold call. Was the slowest endpoint
in the live dashboard (~213ms p95 / ~480ms after the pod roll).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 10:07:06 -05:00
Trey t 52bf1ff3c7 perf(task): offload completion notification fan-out to Asynq worker
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
POST /api/task-completions/ was spending ~1.5-1.75s synchronously on
APNs push + SMTP email + B2 image fetches inside sendTaskCompletedNotification.
Per-user loop made it scale linearly with residence membership; one image
attached + one residence user is the 1.75s baseline observed in the live
honeydue-eli5-overview Grafana panel.

Replace the inline call (and the fire-and-forget goroutine in QuickComplete,
which violated the project's "no goroutines in handlers" rule) with an
Asynq job:

  - new task type notification:task_completed (worker/scheduler.go)
  - new payload {task_id, completion_id} — IDs only, worker re-reads
    canonical state from Postgres so concurrent edits between enqueue
    and dequeue are reflected
  - new HandleTaskCompletedNotification on jobs.Handler delegates to
    TaskService.SendTaskCompletedNotificationByID
  - new dispatchTaskCompletedNotification in task_service.go picks
    between enqueue (preferred) and inline (fallback) when Redis is
    unreachable or the enqueuer isn't wired (tests / local dev)

Other changes required to wire it up:

  - widen worker.NewTaskClient signature to accept asynq.RedisClientOpt
    so the file-mounted Redis password (audit HIGH-1) can be supplied;
    no prior callers, no breakage
  - extend worker.Enqueuer interface with EnqueueTaskCompletedNotification
  - add TaskEnqueuer field to router.Dependencies; wire from cmd/api/main.go
    with the standard typed-nil interface guard
  - wire a worker-side TaskService in cmd/worker/main.go so the handler
    can use the shared SendTaskCompletedNotificationByID implementation
    (storage service shared with the existing upload-cleanup wiring)

Expected impact on POST /api/task-completions/ p50:
  ~1.75s -> ~120-170ms (DB + tx + Asynq enqueue only)

Notifications still deliver; they just go via the worker instead of in
the request path. MaxRetry=3; "row not found" returns nil so a deleted
task/completion doesn't churn the retry loop.

All 31 test packages pass. No DB migrations.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 09:34:52 -05:00
Trey t e448ec66dc docs(runbook): rewrite for OVH BHS cluster + Tier-3 observability TODOs
Brings the runbook in line with the 2026-06-03 Hetzner → OVH cutover:

- Section 1-5: topology, machines (3x OVH VPS-1 BHS), software versions,
  network/firewall, DNS, filesystem layout — all reflect the live OVH
  install instead of the historical Hetzner setup.
- Section 6: canonical install-from-clean-boxes procedure (the literal
  commands run on 2026-06-03), so anyone can stand up a backup cluster
  by following along.
- Section 9: keeps existing gotchas (vmagent NetPol, token-blown-away,
  healthy-but-empty) and adds four new ones discovered during the OVH
  build: rbac.yaml not in 03-deploy.sh, namespace label missing from api
  metrics (use service="api"), cluster-label collision when two clusters
  push concurrently, worker double-firing on cutover.
- Section 11.1: enumerates Tier-3 observability gaps surfaced while
  building the honeydue-eli5-overview dashboard (node-exporter not
  deployed, Traefik metrics off, push success counters absent, worker
  /metrics endpoint absent, cache hit rate uninstrumented, APNs latency
  uninstrumented).
- Section 12: dated audit trail of cluster changes.

Pure documentation; no code or manifest changes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 09:34:35 -05:00
Trey t 3d3ba84df0 fix(auth): delete the Kratos identity on account deletion
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Account deletion removed all local data but left the Ory Kratos
identity intact — an orphaned identity that can still authenticate.
Close the gap:

- kratos.Client gains the admin API: NewClient(publicURL, adminURL)
  and DeleteIdentity (DELETE /admin/identities/{id}; a 404 is treated
  as success so a retry after a partial failure is idempotent).
- AuthService.DeleteAccount deletes the Kratos identity FIRST; if that
  call fails it aborts before touching local data, so the operation is
  retryable rather than partially applied.
- KRATOS_ADMIN_URL config (default http://kratos:4434) + router wiring.
- kratos NetworkPolicy split: the api pods may now reach the admin API
  :4434 (Traefik still reaches only the public API :4433).
- kratos CORS: allow_credentials + OPTIONS so the web browser flows
  (ory_kratos_session cookie) work; origins stay an explicit allowlist.
- Regression tests: identity teardown happens, and a Kratos failure
  aborts the deletion instead of orphaning local data.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:55:33 -05:00
Trey t 81578f6e27 feat(auth): replace hand-rolled auth with Ory Kratos — phase 2 backend
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Delegates all credential management (login, register, password reset,
email verification, social sign-in) to Ory Kratos. The Go API now acts
as a resource server: the new KratosAuth middleware validates sessions
against the Kratos whoami endpoint, writes the local User mirror into
Echo context, and all existing domain handlers continue working
unchanged. Hand-rolled token auth, AuthToken model, apple_auth/
google_auth services, and the auth refresh flow are removed. Tests are
updated to use the fake-token middleware pattern so existing integration
assertions require no rewrite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:55:56 -05:00
Trey t b66151ddd9 feat(auth): scaffold Ory Kratos identity service — phase 1 (infrastructure)
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
First phase of replacing the hand-rolled auth (internal/services/auth_service.go
et al.) with Ory Kratos. This commit is infrastructure only — Kratos will run
but nothing consumes it yet; the Go API still does its own auth until phase 2.

Adds deploy-k3s/manifests/kratos/:
- configmap.yaml  — kratos.yml, identity schema, Google/Apple OIDC claim
                     mappers (no secrets in the ConfigMap)
- migrate-job.yaml — `kratos migrate sql`, run before the Deployment
- kratos.yaml     — Deployment (x2), Service, NetworkPolicies
- ingress.yaml    — auth.myhoneydue.com -> Kratos public API :4433
- README.md       — operator prerequisites + deploy runbook

Wiring:
- 02-setup-secrets.sh creates kratos-secrets, gated on a config.yaml `kratos:`
  block (DSN, cookie/cipher, SMTP URI, OIDC client secret, Apple key).
- 03-deploy.sh applies the Kratos manifests + runs the migrate Job, gated on
  the kratos-secrets Secret existing.

Both gates mean the existing stack deploys completely unaffected until the
operator completes the prerequisites (Neon `kratos` DB, auth.myhoneydue.com
DNS, Apple/Google OAuth apps, Kratos image version). Pre-production, so no
user-data migration — see manifests/kratos/README.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:24:38 -05:00
Trey t c845771946 feat(observability): drop health/metrics probe noise from shipped logs
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
The api logs every request, so k8s liveness/readiness probes on
/api/health/ and vmagent's /metrics scrape drowned Loki in 2xx access
logs. Alloy now drops successful probe/scrape access lines at ingest
(loki.process stage.drop) — a non-2xx health check, or one logged
above info level, still matches nothing and is kept.

Also hardens Alloy's read-offset store: moved /tmp/alloy from an
emptyDir to a hostPath and set loki.source.file tail_from_end=true, so
a pod restart resumes from the saved offset instead of re-reading log
files from the start — which made Loki 400-reject the now-too-old
entries ("entry too far behind") and stalled shipping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:29:15 -05:00
Trey t 93fddc3769 feat(observability): ship pod logs to Loki via Grafana Alloy
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Adds a Grafana Alloy DaemonSet that tails honeydue-namespace pod logs
from /var/log/pods and pushes them to Loki at obs.88oakapps.com,
reusing the existing OBS_INGEST_TOKEN (14-day retention).

- deploy-k3s/manifests/observability/alloy-logs.yaml — DaemonSet + RBAC
  + token Secret + Alloy config. Runs as root (/var/log/pods is 0750
  root:root) but otherwise locked down: all caps dropped, read-only
  root filesystem, seccomp RuntimeDefault, read-only hostPath mount.
- network-policies.yaml — allow-egress-from-alloy-logs (DNS + k8s API
  + obs HTTPS), mirroring the vmagent egress policy.
- 03-deploy.sh — applies alloy-logs with the OBS_INGEST_TOKEN
  substitution and waits for the DaemonSet rollout.

The Loki container, nginx /loki/api/v1/push route, and Grafana Loki
datasource live on the obs server and are not repo-managed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:04:09 -05:00
Trey t c77ff07ce9 fix(security): remediate 2026-05-12 audit findings (Stages 2–5)
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Remediation of the 2026-05-12/13 audits (78 findings + cluster gaps),
tracked in deploy-k3s/SECURITY.md, plus fixes from two independent
post-remediation reviews.

Auth & sessions:
- SHA-256 hashed auth-token storage (C1); prior-token cache eviction on
  re-login (MEDIUM-1)
- local Google JWKS verification, iss/aud/exp checks (C2/C3)
- constant-time login + generic errors (L1/LIVE-L11/LIVE-L13)
- per-account login lockout keyed on distinct source IPs (M5/MEDIUM-3)
- verified-email gating, login rate limiting (LIVE-L19, H1-H3)

IAP & webhooks:
- Apple/Google cross-account replay protection (C5/C6/C10/C13, H5/H6)
- migrations 000003-000006 (token hashing, IAP replay, audit_log +
  webhook_event_log table creation, append-only audit log)

Authorization & races:
- file-ownership owner-OR-member fix (C7), atomic share-code join
  (C9/H9), device-token reassignment (C8/LOW-3)

Secrets & deploy:
- secrets file-mounted at /etc/honeydue/secrets, not env (F8); Redis
  password out of the ConfigMap (HIGH-1); B2 keys reconciled
- digest-pinned images, admin ingress hardening, CSP/HSTS, /metrics
  lockdown; kubeconfig 0600, etcd secrets-encryption, fail2ban +
  unattended-upgrades at provision; secret-rotation runbook

Build, vet, and the full test suite (incl. -race) pass; the goose
migration chain is verified against PostgreSQL 16.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:28:33 -05:00
Trey t 2004f9c5b2 fix(observability): relax vmagent liveness probe — was crash-looping every ~5m
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
The previous probe had timeoutSeconds=1 which is too tight for the
shell pipeline (sh + wget + grep + comparison). On a busy node the
wget call regularly exceeded 1s, the exec timed out, and 3 consecutive
timeouts triggered SIGTERM. Result: vmagent restarted ~5x per 30 min,
causing brief gaps that made the Grafana "Pods up" panel render 0
whenever a refresh happened to coincide with a restart.

The relaxed probe still catches the original failure mode (zero healthy
targets) but only kills the pod after 10 full minutes of consecutive
failure (5 attempts × 2 min period), not 3 minutes (3 × 1 min).

  timeoutSeconds: 1 → 5
  periodSeconds: 60 → 120
  failureThreshold: 3 → 5
  initialDelaySeconds: 120 → 180

Also added wget -T 4 inside the command so wget itself bounds its
network call to 4s — leaving 1s of slack within the 5s exec budget.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 00:39:23 -05:00
Trey t 139a990ebc fix(observability): unbreak vmagent SD on fresh deploy + ship kube-state-metrics
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
vmagent's k8s service discovery has been silently broken for 17+ days
because k3s's NetworkPolicy controller evaluates egress AFTER kube-proxy's
DNAT (contrary to the k8s spec). Pod → ClusterIP 10.43.0.1:443 was
DNAT'd to <node_public_ip>:6443, and the resulting :6443 destination
matched none of vmagent's egress rules → TCP RST → "connection refused"
on every SD watch attempt. Grafana panels using kube_* or up{} metrics
returned empty as a result.

Changes:

- network-policies.yaml: commit the previously-cluster-only NetPols
  (allow-egress-from-vmagent, allow-vmagent-to-api) so a fresh deploy
  produces a working cluster. The vmagent egress rule now includes :6443
  to public IPs (the post-DNAT path) and :8080 to the pod CIDR (for
  scraping kube-state-metrics).

- observability/kube-state-metrics.yaml: new manifest. Provides the
  kube_pod_*, kube_deployment_*, kube_service_* metrics that Grafana
  panels need to count pods, replicas, etc. Runs in kube-system with
  cluster-scoped RBAC.

- observability/vmagent.yaml:
  * add kube-state-metrics scrape job to the ConfigMap
  * add vmagent-kube-system Role+RoleBinding so cross-namespace SD works
  * replace the misleading liveness probe (was /-/healthy, which lies
    while SD is broken) with an exec probe that checks /api/v1/targets
    for at least one healthy target — automatic recovery from future
    stale-SD incidents

- scripts/03-deploy.sh: actually apply network-policies.yaml (was
  committed but never applied) and apply kube-state-metrics.yaml.

- RUNBOOK.md (new): documents the post-DNAT gotcha, the liveness probe
  trap, bearer-token recovery procedure, drift-detection diff, and a
  post-redeploy verification checklist.

- .gitignore: cover kubeconfig.tunnel (created during SSH-tunnelled
  kubectl sessions) so admin client cert can't be committed by accident.

Verified via kubectl --dry-run on all three modified manifests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 00:30:11 -05:00
Trey t 7cc5448a7c fix(uploads): switch from S3 POST policy to presigned PUT
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Backblaze B2's S3-compatible endpoint does not implement the S3 POST
Object operation. It returns HTTP 501 to every POST regardless of URL
style — both path-style (https://s3.<region>.backblazeb2.com/<bucket>/)
and virtual-hosted-style (https://<bucket>.s3.<region>.backblazeb2.com/).

Yesterday's BucketLookupDNS fix produced virtual-hosted URLs, which is
correct for AWS but doesn't help here — B2 rejects POST on either form.
Verified with `curl -X POST https://...backblazeb2.com/honeyDueProd/`
returning 501 directly, with no signature involved.

Replace minio-go's PresignedPostPolicy with PresignHeader + http.MethodPut.
The signed URL now points at a single PUT endpoint, with Content-Type
and Content-Length signed via headers — B2/S3/MinIO all accept it. Drop
the min/max content-length range (we sign exactly one length now);
post-upload size verification still happens in VerifyAndClaim via HEAD.

Response shape:
  - URL  (was: signed POST endpoint)  → now: signed PUT URL
  - Fields → renamed to Headers; client sends them as request headers,
    not multipart form parts
  - Method (new): always "PUT", emitted explicitly so clients don't
    have to hardcode

Companion KMP/iOS commits switch the client paths from multipart POST
to single PUT. Existing builds in the field will need to be rebuilt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:41:48 -05:00
Trey t 5d8559b495 chore(deploy): mark deploy_prod.sh as deprecated; point at k3s flow
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Production migrated from Docker Swarm to k3s on 2026-04-24, but
deploy_prod.sh continued to target the old hetzner1 Swarm manager.
Without dockerd running there it spent 30+ seconds doing SSH probes
before dying on a confusing "Got: false" Swarm-state error.

Add an early guard that fails immediately with a pointer to
deploy-k3s/scripts/03-deploy.sh and the kubeconfig-fetch one-liner.
ALLOW_LEGACY_SWARM_DEPLOY=1 still bypasses if anyone needs the old path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:46:13 -05:00
Trey t 191c9b08e0 feat(static): rebuild landing page on amber-on-midnight brand system
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Replace the off-brand "VIBRANT EDITION" CSS (generic SaaS blue/purple/
teal/pink) with a strict palette derived from icon.png and style_guide.md:

  --gold #FCCE38, --amber #F5A623, --pollen #FFE082, --sun-bloom #F9BB2F
  --midnight #181E37, --deep #162140, --comb-line #232230
  --cream #FFF1D0

Spacing/radius scale mirrors iOS DesignSystem.swift (AppSpacing 4/8/12/
16/24/32/48/64; AppRadius 4/8/12/16/20/24) so the web feels native to
the same brand system. 56px button height, 16px card radius, identical
elevation language.

Page architecture:
- Sticky translucent nav with hex brand mark (1+6 cluster)
- Hero with iPhone frame mock showing real kanban view (overdue/due
  soon/in progress/done with priority dots and meta chips)
- Cream "What's due, what's done, what's yours" pillars
- Four feature deep-dives (residences, tasks/kanban, contractors,
  documents/warranties) with product UI mocks built from real app
  concepts
- "Each cell, a task" comb section with JS-generated 8x10 honeycomb
  completion grid that fills more densely toward the top
- iOS polish section: Home Screen widget mock with quick-complete,
  push notification with inline actions, Face ID lock, 11 themes
- Sharing section with share-card mock (HIVE-7K2D-Q9 code + 3 keepers)
- Free vs Pro pricing with "Most chosen" tag
- Final CTA with brand mark + golden glow

Honeycomb motif:
- Brand mark uses gold-on-navy with a radial halo (no currentColor
  dependency — renders identically everywhere)
- Hex grid background uses a properly tessellating flat-top tile
  (3 hexes per 126x73 unit, sharing full edges, no seams)
- Hex bullets, hex pills, completion grid all flat-top per style guide

Copy follows style_guide.md voice — calm, specific, no banned words
(chore, simplified, seamless), sentence case throughout. Canonical
tagline "A home is a hive. We'll help you keep it." used verbatim
in the hero and footer.

JS: mobile nav toggle, scroll-state nav, IntersectionObserver reveal,
deterministic comb-grid generator. Respects prefers-reduced-motion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:34:32 -05:00
Trey t 4efc87559a fix(uploads): force virtual-hosted-style URLs for B2 presigned POST
Backblaze B2's S3-compatible endpoint only implements POST Object on
virtual-hosted-style URLs (https://<bucket>.s3.<region>.backblazeb2.com/).
Path-style POST returns HTTP 501 Not Implemented.

minio-go's BucketLookupAuto only flips to virtual-hosted for AWS,
Google, and Aliyun endpoints — for B2 it falls through to path-style,
which is why every PresignedPostPolicy() call has been handing the
mobile clients a URL that B2 then refuses with 501.

Force BucketLookupDNS only when the endpoint is backblazeb2.com so
MinIO dev (no DNS for arbitrary buckets at minio:9000) keeps its
path-style default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:34:05 -05:00
Trey t 1347ffadf5 docs: presigned-URL upload flow + B2 lifecycle setup
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
09-storage.md:
  - Replaced the "Upload flow" section. The previous text described the
    multipart-via-API path that was removed in b7f8329. Now documents
    the three-step direct-to-B2 flow (presign → POST to B2 → attach
    via upload_ids[]) with an ASCII diagram and a server-side
    enforcement-points table.
  - Replaced the "Future: signed URLs" placeholder (since presigned
    URLs are now the present, not the future).
  - Added "Lifecycle and retention" subsections covering the
    pending_uploads cleanup cron (worker, 30 * * * *), the B2 bucket
    lifecycle as backstop (uploads/ prefix, 7-day hide + 1-day delete),
    and the still-open user-deletion cascade gap.

14-deployment-process.md:
  - Added a "One-time B2 bucket lifecycle (manual)" section explaining
    why the rule can't live in the deploy script (B2's S3 lifecycle
    API is partial), the exact rule to apply via the Backblaze
    console, and a verification command.

docs/deployment/README.md:
  - Updated the chapter 9 description to mention presigned-URL uploads.

README.md (root):
  - Added a paragraph under "Object storage" pointing to the new
    upload architecture and the relevant deployment-book chapters.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 17:44:08 -07:00
Trey t 14026251b7 fix(worker): wire B2 credentials so pending_uploads cleanup cron can run
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
The new TypeUploadCleanup cron (30 * * * *) constructs a StorageService at
worker startup so it can call b.client.RemoveObject on B2 when reaping
expired pending_uploads rows. Without B2_KEY_ID + B2_APP_KEY the storage
service falls back to local disk and crashes on this pod's read-only
root filesystem, leaving the cleanup as a no-op.

Mirrors the api deployment which already wires these.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:25:53 -07:00
Trey t b7f83293b8 refactor(uploads): drop legacy multipart code paths
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
The presigned-URL upload flow (POST /api/uploads/presign + direct B2 POST
+ upload_ids[] in entity creation) is now the only image upload path. The
legacy multipart routes and DTO fields used by older clients are removed:

Removed:
  - POST /api/uploads/image/        (legacy multipart upload → URL)
  - POST /api/uploads/document/     (legacy multipart upload → URL)
  - POST /api/uploads/completion/   (legacy multipart upload → URL)
  - Multipart branch in POST /api/task-completions/ (now JSON-only)
  - CreateTaskCompletionRequest.ImageURLs DTO field
  - UpdateTaskCompletionRequest.ImageURLs DTO field
  - CreateDocumentRequest.ImageURLs DTO field
  - Service-layer ImageURLs loops in task_service.CreateCompletion,
    task_service.UpdateCompletion, document_service.CreateDocument
  - Tests exercising the removed paths
  - Now-unused imports (strings/time/decimal) in task_handler.go

Kept:
  - DELETE /api/uploads/  (orphan-cleanup endpoint, still useful)
  - POST /api/uploads/presign/  (the new path)
  - POST /api/documents/:id/images/  (uses storage_service.Upload directly,
    same multipart pattern but separate code path; deferred for now)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:19:21 -07:00
Trey t 29c9014a33 feat(uploads): direct-to-B2 presigned uploads with content-length-range policy
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Replaces the multipart-via-API path for image uploads with a three-step
direct-to-storage flow:

  1. Client POSTs /api/uploads/presign with content_length + content_type;
     server validates size (10 MB cap), mime allow-list per category, rate
     limit (50/hour/user via Redis sliding window), and concurrent unclaimed
     cap (10 in-flight per user). On success it persists a pending_uploads
     row, signs an S3 POST policy with content-length-range bound to the
     claimed length ±256 bytes, and returns the URL+fields.
  2. Client POSTs the bytes directly to B2 using the signed policy. B2
     enforces size, content-type, and key match before accepting.
  3. Client passes upload_ids[] to /api/task-completions/ or /api/documents/.
     Service HEADs each B2 object, verifies size matches expected_bytes
     within slack, marks pending_uploads claimed_at, and creates the
     associated TaskCompletionImage / DocumentImage rows.

Bytes never traverse our API server. The 1 MB Echo BodyLimit middleware
that was rejecting all task-completion image uploads becomes irrelevant
for this path. Existing multipart endpoints stay functional alongside,
soak-testing the new path before legacy removal.

Cleanup:
  - cmd/worker registers a new hourly cron (TypeUploadCleanup, "30 * * * *")
    that reaps pending_uploads where claimed_at IS NULL AND expires_at < NOW().
    Reaps both the B2 object and the row.
  - B2 bucket lifecycle rule on `uploads/` prefix (7 days hide → 1 day delete)
    documented in deploy-k3s/manifests/b2-lifecycle.md as a backstop.

Schema:
  - migrations/000002_pending_uploads.sql adds the table + partial index for
    cleanup + nullable pending_upload_id FKs on task_taskcompletionimage and
    task_documentimage.

Policy (single tier, no free/pro split):
  - 10 MB cap per upload
  - 50 presigns/hour/user
  - 10 concurrent unclaimed uploads/user
  - allow-list: jpeg/png/heic/heif/webp for image categories;
    + pdf for document_file

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 14:36:42 -07:00
153 changed files with 11671 additions and 9745 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ DEFAULT_FROM_EMAIL=honeyDue <noreply@honeyDue.treytartt.com>
# Release builds: com.myhoneydue.honeyDue
# Debug builds: com.myhoneydue.honeyDue.dev
APPLE_CLIENT_ID=com.myhoneydue.honeyDue.dev
APPLE_TEAM_ID=V3PF3M6B6U
APPLE_TEAM_ID=X86BR9WTLD
# APNs Settings (iOS Push Notifications)
# Direct APNs integration - no external push server needed
+3 -3
View File
@@ -1,5 +1,5 @@
# Admin panel build stage
FROM node:20-alpine AS admin-builder
FROM node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 AS admin-builder
WORKDIR /app
@@ -109,7 +109,7 @@ FROM go-base AS worker
CMD ["/app/worker"]
# Admin panel runtime stage
FROM node:20-alpine AS admin
FROM node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 AS admin
WORKDIR /app
@@ -131,7 +131,7 @@ ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
# Default production stage (for Dokku - runs API + Admin)
FROM node:20-alpine AS production
FROM node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 AS production
# Install runtime dependencies
RUN apk add --no-cache ca-certificates tzdata curl
+9
View File
@@ -184,6 +184,15 @@ needed for local dev. For the complete production env var reference
Leave all four `B2_*` empty in dev to fall back to a local `/app/uploads` volume.
**Upload architecture (since `b7f8329`)**: Image and document uploads go
**directly from the client to B2** via a presigned POST policy issued by
`POST /api/uploads/presign`. Bytes never traverse the api server. B2
enforces a 10 MB per-object cap at the protocol level. The worker reaps
orphaned upload sessions hourly via the `maintenance:upload_cleanup`
cron. See [`docs/deployment/09-storage.md`](./docs/deployment/09-storage.md)
for the full flow, and [`docs/deployment/14-deployment-process.md`](./docs/deployment/14-deployment-process.md#one-time-b2-bucket-lifecycle-manual)
for the one-time bucket lifecycle setup.
### Worker schedules (UTC hours)
| Variable | Description | Default |
+34 -2
View File
@@ -9,6 +9,7 @@ import (
"syscall"
"time"
"github.com/hibiken/asynq"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
@@ -20,6 +21,7 @@ import (
"github.com/treytartt/honeydue-api/internal/router"
"github.com/treytartt/honeydue-api/internal/services"
"github.com/treytartt/honeydue-api/internal/tracing"
"github.com/treytartt/honeydue-api/internal/worker"
"github.com/treytartt/honeydue-api/pkg/utils"
)
@@ -54,11 +56,13 @@ func main() {
// Initialize OpenTelemetry tracing — exports to obs.88oakapps.com
// (Jaeger via OTLP/HTTP) when OBS_TRACES_URL is set; otherwise installs
// a no-op tracer so call sites can use otel.Tracer() unconditionally.
// config.SecretValue (not os.Getenv) so file-mounted secrets resolve
// after audit F8 removed these from the process environment.
tracingShutdown, err := tracing.Init(context.Background(), tracing.Config{
ServiceName: "honeydue-api",
Environment: deploymentEnvironment(cfg.Server.Debug),
EndpointURL: os.Getenv("OBS_TRACES_URL"),
BearerToken: os.Getenv("OBS_INGEST_TOKEN"),
EndpointURL: config.SecretValue("OBS_TRACES_URL"),
BearerToken: config.SecretValue("OBS_INGEST_TOKEN"),
SampleRatio: tracing.SampleRatioFromEnv(),
})
if err != nil {
@@ -192,6 +196,28 @@ func main() {
Msg("Push notification client initialized")
}
// Initialize Asynq enqueuer (api-side). Used by services that move
// long-running work off the request path (currently: task-completion
// notification fan-out). Same Redis as cmd/worker — file-mounted password
// applied separately because cfg.Redis.URL does not embed it (audit HIGH-1).
var taskEnqueuer *worker.TaskClient
if redisOpt, parseErr := asynq.ParseRedisURI(cfg.Redis.URL); parseErr != nil {
log.Warn().Err(parseErr).Msg("Failed to parse Redis URL for Asynq enqueuer — completion notifications will run inline")
} else if clientOpt, ok := redisOpt.(asynq.RedisClientOpt); ok {
if cfg.Redis.Password != "" {
clientOpt.Password = cfg.Redis.Password
}
taskEnqueuer = worker.NewTaskClient(clientOpt)
defer func() {
if cerr := taskEnqueuer.Close(); cerr != nil {
log.Warn().Err(cerr).Msg("Failed to close Asynq enqueuer on shutdown")
}
}()
log.Info().Msg("Asynq enqueuer initialized")
} else {
log.Warn().Msg("Redis opt is not RedisClientOpt — Asynq enqueuer skipped; completion notifications will run inline")
}
// Setup router with dependencies (includes admin panel at /admin)
deps := &router.Dependencies{
DB: db,
@@ -203,6 +229,12 @@ func main() {
StorageService: storageService,
MonitoringService: monitoringService,
}
// Only assign the enqueuer when we actually constructed one. Assigning a
// nil *worker.TaskClient directly would create a typed-nil interface that
// fails the `if deps.TaskEnqueuer != nil` check in router.SetupRouter.
if taskEnqueuer != nil {
deps.TaskEnqueuer = taskEnqueuer
}
e := router.SetupRouter(deps)
// Create HTTP server
+32
View File
@@ -0,0 +1,32 @@
package main
import "time"
// shouldInitEmail returns true if email config has host and user set.
func shouldInitEmail(host, user string) bool {
return host != "" && user != ""
}
// shouldInitStorage returns true if upload directory is configured.
func shouldInitStorage(uploadDir string) bool {
return uploadDir != ""
}
// shouldInitEncryption returns true if encryption key is set.
func shouldInitEncryption(encryptionKey string) bool {
return encryptionKey != ""
}
// connectWithRetry attempts a connection with exponential backoff.
// Returns nil on success or the last error after all retries fail.
func connectWithRetry(connect func() error, maxRetries int) error {
var err error
for i := 0; i < maxRetries; i++ {
err = connect()
if err == nil {
return nil
}
time.Sleep(time.Duration(i+1) * time.Millisecond) // use ms in tests
}
return err
}
+107
View File
@@ -0,0 +1,107 @@
package main
import (
"errors"
"testing"
)
// --- shouldInitEmail ---
func TestShouldInitEmail_BothSet_True(t *testing.T) {
if !shouldInitEmail("smtp.example.com", "user@example.com") {
t.Error("expected true when both set")
}
}
func TestShouldInitEmail_MissingHost_False(t *testing.T) {
if shouldInitEmail("", "user@example.com") {
t.Error("expected false when host empty")
}
}
func TestShouldInitEmail_MissingUser_False(t *testing.T) {
if shouldInitEmail("smtp.example.com", "") {
t.Error("expected false when user empty")
}
}
func TestShouldInitEmail_BothEmpty_False(t *testing.T) {
if shouldInitEmail("", "") {
t.Error("expected false when both empty")
}
}
// --- shouldInitStorage ---
func TestShouldInitStorage_Set_True(t *testing.T) {
if !shouldInitStorage("/uploads") {
t.Error("expected true")
}
}
func TestShouldInitStorage_Empty_False(t *testing.T) {
if shouldInitStorage("") {
t.Error("expected false")
}
}
// --- shouldInitEncryption ---
func TestShouldInitEncryption_Set_True(t *testing.T) {
if !shouldInitEncryption("secret-key-123") {
t.Error("expected true")
}
}
func TestShouldInitEncryption_Empty_False(t *testing.T) {
if shouldInitEncryption("") {
t.Error("expected false")
}
}
// --- connectWithRetry ---
func TestConnectWithRetry_SucceedsFirst_NoRetry(t *testing.T) {
calls := 0
err := connectWithRetry(func() error {
calls++
return nil
}, 3)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if calls != 1 {
t.Errorf("calls = %d, want 1", calls)
}
}
func TestConnectWithRetry_SucceedsSecond_OneRetry(t *testing.T) {
calls := 0
err := connectWithRetry(func() error {
calls++
if calls == 1 {
return errors.New("fail")
}
return nil
}, 3)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if calls != 2 {
t.Errorf("calls = %d, want 2", calls)
}
}
func TestConnectWithRetry_AllFail_ReturnsError(t *testing.T) {
calls := 0
err := connectWithRetry(func() error {
calls++
return errors.New("fail")
}, 3)
if err == nil {
t.Error("expected error")
}
if calls != 3 {
t.Errorf("calls = %d, want 3", calls)
}
}
+91 -2
View File
@@ -23,6 +23,7 @@ import (
"github.com/treytartt/honeydue-api/internal/repositories"
"github.com/treytartt/honeydue-api/internal/services"
"github.com/treytartt/honeydue-api/internal/tracing"
"github.com/treytartt/honeydue-api/internal/worker"
"github.com/treytartt/honeydue-api/internal/worker/jobs"
"github.com/treytartt/honeydue-api/pkg/utils"
)
@@ -47,11 +48,13 @@ func main() {
// Initialize OpenTelemetry tracing for the worker process. Same OTLP
// destination as the api; service.name distinguishes them in Jaeger.
// config.SecretValue (not os.Getenv) so file-mounted secrets resolve
// after audit F8 removed these from the process environment.
tracingShutdown, err := tracing.Init(context.Background(), tracing.Config{
ServiceName: "honeydue-worker",
Environment: workerDeploymentEnv(cfg.Server.Debug),
EndpointURL: os.Getenv("OBS_TRACES_URL"),
BearerToken: os.Getenv("OBS_INGEST_TOKEN"),
EndpointURL: config.SecretValue("OBS_TRACES_URL"),
BearerToken: config.SecretValue("OBS_INGEST_TOKEN"),
SampleRatio: tracing.SampleRatioFromEnv(),
})
if err != nil {
@@ -106,6 +109,17 @@ func main() {
if err != nil {
log.Fatal().Err(err).Msg("Failed to parse Redis URL")
}
// Audit HIGH-1: the Redis password is a file-mounted secret (REDIS_PASSWORD),
// not embedded in REDIS_URL — REDIS_URL travels in the honeydue-config
// ConfigMap. Apply the password onto the parsed opt so the Asynq server,
// inspector and monitoring client (all derived from redisOpt below)
// authenticate against a requirepass-protected Redis.
if cfg.Redis.Password != "" {
if clientOpt, ok := redisOpt.(asynq.RedisClientOpt); ok {
clientOpt.Password = cfg.Redis.Password
redisOpt = clientOpt
}
}
// Initialize monitoring service (if Redis is available)
var monitoringService *monitoring.Service
@@ -167,6 +181,43 @@ func main() {
// Create job handler
jobHandler := jobs.NewHandler(db, pushClient, emailService, notificationService, cfg)
// Wire upload service for the pending_uploads cleanup cron AND share the
// underlying storage service with the TaskService below so the worker can
// load completion images for email embedding. Storage may be local-disk
// (no S3 backend), in which case the upload service stays nil and the
// cleanup handler no-ops. Cache is optional — the cleanup path doesn't
// rate-limit and works fine with a nil cache.
var sharedStorageService *services.StorageService
if storageService, sErr := services.NewStorageService(&cfg.Storage); sErr == nil {
sharedStorageService = storageService
if s3 := storageService.S3Backend(); s3 != nil {
pendingUploadRepo := repositories.NewPendingUploadRepository(db)
uploadService := services.NewUploadService(pendingUploadRepo, s3, &cfg.Storage, nil)
jobHandler.SetUploadService(uploadService)
}
} else {
log.Warn().Err(sErr).Msg("Failed to initialize storage service for upload cleanup; cleanup cron will no-op")
}
// Wire a TaskService for the task-completed notification handler. The
// worker re-creates this (vs. importing the api's wired instance) because
// each binary owns its own dependency graph. The handler is fully nil-safe
// — if any of the wired services are absent, the corresponding side of
// notification delivery (push or email) is skipped.
taskRepo := repositories.NewTaskRepository(db)
residenceRepo := repositories.NewResidenceRepository(db)
workerTaskService := services.NewTaskService(taskRepo, residenceRepo)
if notificationService != nil {
workerTaskService.SetNotificationService(notificationService)
}
if emailService != nil {
workerTaskService.SetEmailService(emailService)
}
if sharedStorageService != nil {
workerTaskService.SetStorageService(sharedStorageService)
}
jobHandler.SetTaskService(workerTaskService)
// Create Asynq mux and register handlers
mux := asynq.NewServeMux()
@@ -180,6 +231,12 @@ func main() {
mux.HandleFunc(jobs.TypeSendPush, jobHandler.HandleSendPush)
mux.HandleFunc(jobs.TypeOnboardingEmails, jobHandler.HandleOnboardingEmails)
mux.HandleFunc(jobs.TypeReminderLogCleanup, jobHandler.HandleReminderLogCleanup)
mux.HandleFunc(jobs.TypeUploadCleanup, jobHandler.HandleUploadCleanup)
mux.HandleFunc(jobs.TypeNotificationCleanup, jobHandler.HandleNotificationCleanup)
mux.HandleFunc(jobs.TypeWebhookLogCleanup, jobHandler.HandleWebhookLogCleanup)
mux.HandleFunc(jobs.TypeAuditLogCleanup, jobHandler.HandleAuditLogCleanup)
mux.HandleFunc(worker.TypeTaskCompletedNotification, jobHandler.HandleTaskCompletedNotification)
mux.HandleFunc(worker.TypeDataExport, jobHandler.HandleDataExport)
// Register email job handlers (welcome, verification, password reset, password changed)
if emailService != nil {
@@ -219,6 +276,32 @@ func main() {
}
log.Info().Str("cron", "0 3 * * *").Msg("Registered reminder log cleanup job (runs daily at 3:00 AM UTC)")
// Schedule pending_uploads cleanup (hourly at :30 to avoid colliding with
// the top-of-hour reminder + digest crons). Reaps unclaimed expired
// upload sessions; the B2 bucket lifecycle (7 days on uploads/ prefix)
// is the backstop if this worker is offline for an extended period.
if _, err := scheduler.Register("30 * * * *", asynq.NewTask(jobs.TypeUploadCleanup, nil)); err != nil {
log.Fatal().Err(err).Msg("Failed to register upload cleanup job")
}
log.Info().Str("cron", "30 * * * *").Msg("Registered pending_uploads cleanup job (runs hourly)")
// Data-retention cleanups (BE-2). Staggered off the 3:00 reminder cleanup to
// avoid piling DELETEs onto the same Neon connection window.
if _, err := scheduler.Register("0 2 * * *", asynq.NewTask(jobs.TypeNotificationCleanup, nil)); err != nil {
log.Fatal().Err(err).Msg("Failed to register notification cleanup job")
}
log.Info().Str("cron", "0 2 * * *").Msg("Registered notification cleanup job (daily 02:00 UTC, 90d retention)")
if _, err := scheduler.Register("30 2 * * 0", asynq.NewTask(jobs.TypeWebhookLogCleanup, nil)); err != nil {
log.Fatal().Err(err).Msg("Failed to register webhook log cleanup job")
}
log.Info().Str("cron", "30 2 * * 0").Msg("Registered webhook log cleanup job (weekly Sun 02:30 UTC, 180d retention)")
if _, err := scheduler.Register("30 3 * * 0", asynq.NewTask(jobs.TypeAuditLogCleanup, nil)); err != nil {
log.Fatal().Err(err).Msg("Failed to register audit log cleanup job")
}
log.Info().Str("cron", "30 3 * * 0").Msg("Registered audit log cleanup job (weekly Sun 03:30 UTC, 365d retention)")
// Handle graceful shutdown
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
@@ -230,6 +313,12 @@ func main() {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok"}`))
})
// Expose Prometheus metrics so vmagent can scrape the worker. The
// apns_send_*, fcm_send_*, asynq_job_* and cache_ops_* series have been
// recorded on this process all along — they were just never exposed, which
// is why those dashboard panels read empty. Same :6060 as health; in-cluster
// only (not externally published).
healthMux.Handle("/metrics", prom.HTTPHandler())
healthSrv := &http.Server{
Addr: workerHealthAddr,
Handler: healthMux,
+1 -1
View File
@@ -92,7 +92,7 @@ ADMIN_PW="$(openssl rand -base64 16)"
EMAIL_USER="treytartt@fastmail.com"
APNS_KEY_ID="9R5Q7ZX874"
APNS_TEAM_ID="V3PF3M6B6U"
APNS_TEAM_ID="X86BR9WTLD"
log ""
log "Pre-filled from existing dev server:"
+1
View File
@@ -3,6 +3,7 @@ config.yaml
# Generated files
kubeconfig
kubeconfig.*
cluster-config.yaml
prod.env
+966
View File
@@ -0,0 +1,966 @@
# honeyDue k3s Cluster — Operations Runbook
Living document for the honeyDue production cluster. Add entries when you hit
something non-obvious so future-you (or your replacement) doesn't have to
rediscover it.
Last full revision: **2026-06-03** (Hetzner → OVH BHS cutover; cluster solo
production from that date forward). For pre-OVH history, see
`MIGRATION_NOTES.md` (Swarm → k3s migration on Hetzner, 2026-04-24).
---
## 1. Topology and inventory
### Hosting
| | |
|---|---|
| Provider | OVHcloud (us.ovhcloud.com) |
| Datacenter | BHS — Beauharnois, Quebec, Canada |
| Plan | VPS-1 × 3 (~$6.46/mo each, ~$19/mo total) |
| Node spec | 4 vCPU (Intel Haswell, shared), 7.6 GB RAM, 75 GB NVMe |
| Public bandwidth | 400 Mbps per node, unlimited traffic |
| Private network | **None.** Nodes have public IPv4 + IPv6 only; inter-node traffic crosses the public internet (encrypted by flannel WireGuard backend — see §3) |
### Nodes
| SSH alias | Kubernetes node name | Public IPv4 | Public IPv6 | Roles |
|---|---|---|---|---|
| `ovhcloud1` | `vps-1624d691` | `51.81.83.33` | `2604:2dc0:101:200::5a9a` | control-plane, etcd, redis-pinned |
| `ovhcloud2` | `vps-c0f51be2` | `51.81.87.86` | `2604:2dc0:101:200::30d4` | control-plane, etcd |
| `ovhcloud3` | `vps-dbca24c7` | `51.81.85.248` | `2604:2dc0:101:200::450f` | control-plane, etcd |
The cluster is **all-control-plane** (workloads schedule on the same nodes that
run etcd and the API server). `vps-1624d691` carries the
`honeydue/redis=true` label so the Redis Deployment's `nodeSelector` binds
there; the Redis PVC (`local-path`, host-pinned) lives on that node's disk.
### SSH access
`~/.ssh/config` entries (operator workstation):
```
Host ovhcloud1
HostName 51.81.83.33
Port 22
User ubuntu
IdentityFile ~/.ssh/ovhcloud
IdentitiesOnly yes
Host ovhcloud2
HostName 51.81.87.86
Port 22
User ubuntu
IdentityFile ~/.ssh/ovhcloud
IdentitiesOnly yes
Host ovhcloud3
HostName 51.81.85.248
Port 22
User ubuntu
IdentityFile ~/.ssh/ovhcloud
IdentitiesOnly yes
```
`ubuntu` has passwordless sudo (`/etc/sudoers.d/90-cloud-init-users` from OVH's
cloud-init).
### kubectl access
```bash
export KUBECONFIG=/Users/treyt/Desktop/code/honeyDue/honeyDueAPI-go/deploy-k3s/kubeconfig
kubectl get nodes
```
The `deploy-k3s/kubeconfig` file (mode 0600, gitignored) is the OVH cluster's
admin kubeconfig with `server: https://51.81.83.33:6443`. A stale Hetzner copy
lives next to it as `kubeconfig.hetzner.bak` for historical reference; the
Hetzner cluster is powered off and that file's API server is unreachable.
To refresh from the cluster (if the local copy is lost or rotated):
```bash
ssh ovhcloud1 'sudo cat /etc/rancher/k3s/k3s.yaml' \
| sed 's|server: https://127.0.0.1:6443|server: https://51.81.83.33:6443|' \
> deploy-k3s/kubeconfig
chmod 600 deploy-k3s/kubeconfig
```
The k3s API at `:6443` is open to the public internet (token-protected).
---
## 2. Software
### Kernel-level
| | |
|---|---|
| OS | Ubuntu 26.04 LTS (set by OVH's VPS-1 image) |
| Kernel | `7.0.0-14-generic` |
| Init | systemd |
| Container runtime | containerd 2.2.2 (bundled with k3s) |
| Firewall | `ufw` (per-node, configured at install — see §3) |
| Other host packages | `fail2ban` (SSH brute-force protection, default jail), `unattended-upgrades` (security updates), `open-iscsi` (k3s prereq for some storage backends), `curl` |
### Kubernetes
| | |
|---|---|
| Distribution | k3s |
| Version | **`v1.34.6+k3s1`** (pinned in `config.yaml:cluster.k3s_version`) |
| Control plane | 3-node HA, embedded etcd (no external Postgres backing store) |
| CNI / networking | flannel with **WireGuard-native backend** (`--flannel-backend=wireguard-native`). Encrypts pod-to-pod and etcd peer traffic because nodes only have public IPs (no private network). ~3-5% CPU overhead under load. |
| Service LB | klipper-lb (default k3s `servicelb`). The `svclb-traefik` DaemonSet binds host ports `:80` and `:443` on each node and forwards to the Traefik Service. **Not** the DaemonSet-w/-hostNetwork Traefik pattern used on the old Hetzner cluster — see §10 *Differences from MIGRATION_NOTES*. |
| Ingress controller | Traefik (k3s default), single-replica Deployment, exposed via klipper-lb |
| DNS | CoreDNS (k3s default) |
| Secrets encryption | Enabled (`--secrets-encryption`); etcd values are AES-CBC encrypted at rest |
| kubeconfig perms | `0600` (`--write-kubeconfig-mode=0600`) |
| Cloud controller | Disabled (`--disable-cloud-controller`) — no provider integration on OVH |
| Misc | `--node-ip` / `--node-external-ip` / `--advertise-address` all set to each node's public IPv4. TLS SANs cover all 3 IPs so any IP can serve the API. |
### Application stack (in cluster, `honeydue` namespace)
| Deployment | Replicas | Image (digest-pinned) | Notes |
|---|---:|---|---|
| `api` | 3 | `gitea.treytartt.com/admin/honeydue-api@sha256:34fde6...` | Go REST API on `:8000`, exposes `/metrics` |
| `web` | 3 | `gitea.treytartt.com/admin/honeydue-web@sha256:8c62cf...` | Next.js, server-side proxy to api |
| `admin` | 1 | `gitea.treytartt.com/admin/honeydue-admin@sha256:b81263...` | Next.js admin panel, gated behind Traefik basic-auth |
| `worker` | 1 | `gitea.treytartt.com/admin/honeydue-worker@sha256:fe1f5e...` | Asynq scheduler + Redis-backed jobs (singleton — must not run as >1 replica or every cron fires N×) |
| `redis` | 1 | `redis:7-alpine@sha256:6ab0b6...` | Pinned to `vps-1624d691` via `honeydue/redis=true`. PVC `redis-data` (local-path, 5 Gi). Password-auth required. |
| `vmagent` | 1 | `victoriametrics/vmagent@sha256:...` (default tag) | Scrapes api `/metrics` + kube-state-metrics; remote-writes to obs.88oakapps.com |
| `kube-state-metrics` | 1 | `kube-state-metrics@sha256:...` | In `kube-system`, scraped by vmagent for `kube_*` cluster-state metrics |
| `alloy-logs` (DaemonSet) | 3 (1/node) | `grafana/alloy@sha256:...` | Tails `/var/log/pods/*` and ships to Loki at obs.88oakapps.com |
The Asynq scheduler inside `worker` registers these cron jobs:
| Cron | Job | Notes |
|---|---|---|
| `0 * * * *` | Smart reminder check (per-user hour) | Default user hour: 14:00 UTC |
| `0 * * * *` | Daily digest check (per-user hour) | Default user hour: 03:00 UTC |
| `0 10 * * *` | Onboarding emails | 10:00 UTC |
| `0 3 * * *` | Reminder log cleanup | 03:00 UTC |
| `30 * * * *` | Pending uploads cleanup | xx:30 every hour |
### External dependencies
| Service | Endpoint | Purpose | Failure mode |
|---|---|---|---|
| Neon Postgres | `ep-floral-truth-amttbc5a-pooler.c-5.us-east-1.aws.neon.tech:5432` | App data. Pooler endpoint (transaction-mode PgBouncer in front of Neon compute) so connections stay warm. | api / worker pods crash-loop with `dial tcp: connection refused`. Health endpoint returns `postgres: error`. |
| Backblaze B2 (S3-compatible) | `s3.us-east-005.backblazeb2.com` (bucket `honeyDueProd`) | User uploads (photos, PDFs, completion attachments) | Upload routes return 5xx; reads of cached/static files still work. |
| Cloudflare | `myhoneydue.com` zone | DNS + TLS termination + edge cache + DDoS | Traffic stops reaching origin. Direct `https://51.81.x.x` still works for diagnostics. |
| obs.88oakapps.com | Operator-run Grafana + VictoriaMetrics + Loki | Metrics & logs | vmagent + alloy-logs back off and retry. No app-side impact. |
| Apple APNs | `api.push.apple.com:443` (production) | iOS push notifications | Push fails; circuit breaker opens; failure logged. App functionality unaffected. |
| Fastmail SMTP | `smtp.fastmail.com:587` | Transactional emails (verification, recovery, digests) | Email send fails in the worker; logged; user reset/digest flow degrades. |
| Gitea registry | `gitea.treytartt.com` | Container image registry | Deploys can't pull. Existing pods keep running on cached images. |
---
## 3. Network and firewall
### Per-node `ufw` configuration
Applied during install (same on all 3 nodes):
```
default deny incoming
default allow outgoing
allow 22/tcp (SSH, world)
allow 80/tcp (HTTP via Cloudflare, world — see GAP-1)
allow 443/tcp (HTTPS, same — GAP-1)
allow 6443/tcp (k3s API, world, token-protected)
allow 2379:2380/tcp from <other 2 OVH IPs> (etcd client + peer)
allow 10250/tcp from <other 2 OVH IPs> (kubelet)
allow 51820/udp from <other 2 OVH IPs> (WireGuard tunnel)
allow 8472/udp from <other 2 OVH IPs> (VXLAN, defense-in-depth fallback)
```
To inspect: `ssh ovhcloudN sudo ufw status numbered`.
### Cluster networking
- **Pod CIDR**: `10.42.0.0/16` (default k3s)
- **Service CIDR**: `10.43.0.0/16` (default k3s)
- **Flannel backend**: WireGuard-native. Each node hosts a `flannel-wg` interface on UDP 51820 and tunnels pod traffic to peers. Verify: `ssh ovhcloudN ip -d link show flannel-wg`.
### Traefik ingress flow
```
Cloudflare → node:80/443 (public)
→ klipper-lb svclb-traefik DaemonSet pod (hostPort:80/443)
→ Traefik Service (ClusterIP 10.43.245.127:80/443)
→ Traefik Deployment pod (single replica)
→ matches Ingress host rule (api.myhoneydue.com etc.)
→ routes to backend Service (api / web / admin)
→ backend Pod
```
The Traefik default also lives in `kube-system` and is managed by k3s's
HelmChart. **No HelmChartConfig override is applied on OVH** (unlike Hetzner
— see §10).
---
## 4. DNS configuration (Cloudflare)
The `myhoneydue.com` zone in Cloudflare has these public records. **All
hostnames are proxied (orange cloud)** — required by the `cloudflare-only`
Traefik middleware which 403s any non-CF source IP.
| Host | Type | Values | Proxy |
|---|---|---|---|
| `api.myhoneydue.com` | A × 3 | `51.81.83.33`, `51.81.87.86`, `51.81.85.248` | Proxied |
| `app.myhoneydue.com` | A × 3 | (same trio) | Proxied |
| `admin.myhoneydue.com` | A × 3 | (same trio) | Proxied |
| `myhoneydue.com` (apex `@`) | A × 3 | (same trio) | Proxied |
Cloudflare round-robins among the 3 origins, klipper-lb on whichever node CF
hits forwards to Traefik, and Traefik routes by Host header. Per-request,
effectively load-balanced across the 3 nodes for ingress, with no central LB.
**SSL/TLS mode**: Flexible (CF terminates TLS at the edge; origin is plain
HTTP on `:80`). Upgrading to Full (strict) is on the deferred list — would
need an origin certificate provisioned to `cloudflare-origin-cert` secret and
Traefik configured for TLS termination.
---
## 5. Filesystem layout (`deploy-k3s/`)
```
deploy-k3s/
├── config.yaml # Single config source (gitignored; contains tokens)
├── config.yaml.example # Template
├── kubeconfig # OVH admin kubeconfig (gitignored, 0600)
├── kubeconfig.hetzner.bak # Old Hetzner kubeconfig (unreachable, kept for history)
├── kubeconfig.tunnel # Optional: localhost-pointing copy for SSH-tunnel use
├── secrets/
│ ├── README.md
│ ├── postgres_password.txt # Neon DB password
│ ├── secret_key.txt # 32+ char app-token signing secret
│ ├── email_host_password.txt # Fastmail SMTP app password
│ ├── fcm_server_key.txt # FCM server key (currently unused — Android push disabled)
│ ├── apns_auth_key.p8 # APNs auth key (binary)
│ ├── cloudflare-origin.crt # Origin certificate (currently unused — CF Flexible)
│ └── cloudflare-origin.key
│ (all gitignored except README.md)
├── manifests/
│ ├── namespace.yaml
│ ├── network-policies.yaml # default-deny + per-app egress/ingress (13 NetPols total)
│ ├── rbac.yaml # api/worker/admin/web/redis ServiceAccounts (NOT applied by 03-deploy.sh; manual once)
│ ├── pod-disruption-budgets.yaml # api-pdb, web-pdb, worker-pdb (NOT applied by 03-deploy.sh; manual once)
│ ├── traefik-helmchartconfig.yaml # Hetzner-only DaemonSet+hostNetwork override (do NOT apply on OVH; we use default klipper-lb)
│ ├── kyverno-verify-images.yaml # Operator-gated policy (do NOT apply blindly — see file comment)
│ ├── api/{deployment,service,hpa}.yaml
│ ├── worker/deployment.yaml
│ ├── admin/{deployment,service}.yaml
│ ├── web/{deployment,service}.yaml
│ ├── redis/{deployment,service,pvc}.yaml
│ ├── ingress/{middleware,ingress-simple}.yaml
│ ├── migrate/job.yaml # goose migration Job (image-subbed at deploy time)
│ ├── observability/{kube-state-metrics,vmagent,alloy-logs}.yaml
│ └── kratos/ # Ory Kratos identity service (NOT yet deployed; gated on operator OIDC setup)
└── scripts/
├── _config.sh # Sourced by all scripts: cfg(), generate_env(), generate_cluster_config()
├── 01-provision-cluster.sh # Hetzner-Cloud-specific (uses hetzner-k3s CLI) — DO NOT RUN ON OVH
├── 02-setup-secrets.sh # Creates honeydue-secrets etc. from secrets/ + config.yaml; kubeconfig-driven
├── 03-deploy.sh # Build + push + apply manifests + roll deployments; kubeconfig-driven
├── 04-verify.sh # Post-deploy health + security checks; kubeconfig-driven
└── rollback.sh # `kubectl rollout undo` across all deployments
```
The `deploy/prod.env` file (sibling to `deploy-k3s/`, gitignored) holds
observability + admin credentials that `02/03-deploy.sh` read but never
display:
```
OBS_INGEST_URL (https://obs.88oakapps.com/api/v1/write)
OBS_TRACES_URL (https://obs.88oakapps.com/v1/traces)
OBS_INGEST_TOKEN (bearer token for VM + Loki + traces — all use same token)
GRAFANA_URL (https://grafana.88oakapps.com)
GRAFANA_ADMIN_USER (admin)
GRAFANA_ADMIN_PASSWORD
ADMIN_EMAIL / ADMIN_PASSWORD (in-app admin login)
```
---
## 6. Install from clean boxes — the truthful procedure
This is what we ran on 2026-06-03 to stand up the live cluster, exactly. If
you ever rebuild from zero this is the canonical sequence. Total wall-clock:
~12 min for cluster bootstrap; ~10 min for workloads.
### 6.1 Prerequisites
- 3 fresh Ubuntu VPS instances (any provider with public IPv4, ≥4 GB RAM,
≥40 GB disk)
- `~/.ssh/config` entries (`ovhcloud1/2/3`) pointing at them, with
passwordless sudo
- Local `kubectl` and `curl`
- The repo's `deploy-k3s/secrets/` populated (or the ability to copy live
secrets from another running cluster — see §7.2)
- `deploy/prod.env` populated with obs token + Grafana creds
### 6.2 Per-node OS hardening + firewall (all 3 in parallel)
For each `ovhcloudN`, over SSH:
```sh
export DEBIAN_FRONTEND=noninteractive
sudo apt-get update -qq
sudo apt-get install -y -qq fail2ban unattended-upgrades open-iscsi curl ufw
sudo systemctl enable --now iscsid fail2ban
sudo dpkg-reconfigure -f noninteractive -plow unattended-upgrades
sudo ufw --force reset
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 6443/tcp
SELF=$(hostname -I | awk '{print $1}')
for peer in 51.81.83.33 51.81.87.86 51.81.85.248; do
[ "$peer" = "$SELF" ] && continue
sudo ufw allow from "$peer" to any port 2379:2380 proto tcp
sudo ufw allow from "$peer" to any port 10250 proto tcp
sudo ufw allow from "$peer" to any port 51820 proto udp
sudo ufw allow from "$peer" to any port 8472 proto udp
done
sudo ufw --force enable
```
**Watch ordering:** `allow 22/tcp` MUST precede `ufw enable`. Existing SSH
sessions survive (`ufw` only affects new connections), but a misordered script
locks you out of fresh logins.
### 6.3 Install k3s on `ovhcloud1` (the init node)
```sh
ssh ovhcloud1 'curl -sfL https://get.k3s.io | \
INSTALL_K3S_VERSION=v1.34.6+k3s1 \
sh -s - server \
--cluster-init \
--node-ip=51.81.83.33 \
--node-external-ip=51.81.83.33 \
--advertise-address=51.81.83.33 \
--flannel-backend=wireguard-native \
--flannel-external-ip \
--secrets-encryption \
--write-kubeconfig-mode=0600 \
--tls-san=51.81.83.33 \
--tls-san=51.81.87.86 \
--tls-san=51.81.85.248 \
--disable-cloud-controller'
```
Wait for `sudo k3s kubectl get nodes` to show this node Ready (~2-5 s).
Read the cluster token:
```sh
ssh ovhcloud1 'sudo cat /var/lib/rancher/k3s/server/node-token'
```
### 6.4 Join `ovhcloud2`, then `ovhcloud3` (sequential)
Joining etcd one node at a time avoids split-brain on slow networks.
Replace `<TOKEN>` with the value from 6.3.
For `ovhcloud2`:
```sh
ssh ovhcloud2 'curl -sfL https://get.k3s.io | \
INSTALL_K3S_VERSION=v1.34.6+k3s1 \
K3S_TOKEN=<TOKEN> \
sh -s - server \
--server=https://51.81.83.33:6443 \
--node-ip=51.81.87.86 \
--node-external-ip=51.81.87.86 \
--advertise-address=51.81.87.86 \
--flannel-backend=wireguard-native \
--flannel-external-ip \
--secrets-encryption \
--write-kubeconfig-mode=0600 \
--tls-san=51.81.83.33 --tls-san=51.81.87.86 --tls-san=51.81.85.248 \
--disable-cloud-controller'
```
Then identical for `ovhcloud3` with `--node-ip=51.81.85.248` and
`--advertise-address=51.81.85.248`. After each, wait for `kubectl get nodes`
to show the new node Ready before proceeding.
### 6.5 Pull kubeconfig to the operator workstation
```sh
ssh ovhcloud1 'sudo cat /etc/rancher/k3s/k3s.yaml' \
| sed 's|server: https://127.0.0.1:6443|server: https://51.81.83.33:6443|' \
> deploy-k3s/kubeconfig
chmod 600 deploy-k3s/kubeconfig
export KUBECONFIG=$(pwd)/deploy-k3s/kubeconfig
kubectl get nodes -o wide # All 3 Ready, INTERNAL-IP = public IP
```
### 6.6 Label the redis node
```sh
kubectl label node vps-1624d691 honeydue/redis=true --overwrite
```
(Use whichever k8s node name corresponds to `ovhcloud1`. The Redis
Deployment's `nodeSelector` binds to this label.)
### 6.7 Bootstrap manifests NOT applied by `03-deploy.sh`
These must be applied manually on a fresh cluster, **before** running
`03-deploy.sh`, or workloads will fail to schedule:
```sh
kubectl apply -f deploy-k3s/manifests/rbac.yaml
kubectl apply -f deploy-k3s/manifests/pod-disruption-budgets.yaml
```
`rbac.yaml` creates the 5 ServiceAccounts (`api`, `worker`, `admin`, `web`,
`redis`) referenced by the Deployment manifests. Without these, ReplicaSets
hang on `FailedCreate: error looking up service account` and pods never
start. Symptom on first deploy: `kubectl get deploy` shows `0 up-to-date`
across the board with no pod activity — see §9 *Gotchas*.
**Do NOT apply** `traefik-helmchartconfig.yaml` (Hetzner-only — see §10) or
`kyverno-verify-images.yaml` (gated on operator Kyverno install).
### 6.8 Seed secrets
Two paths; pick whichever fits your situation:
**Path A — clean install from local files** (the original design):
```sh
KUBECONFIG=$(pwd)/deploy-k3s/kubeconfig ./deploy-k3s/scripts/02-setup-secrets.sh
```
Requires `deploy-k3s/secrets/` to contain real `postgres_password.txt`,
`secret_key.txt`, `email_host_password.txt`, `fcm_server_key.txt`,
`apns_auth_key.p8`, `cloudflare-origin.crt`, `cloudflare-origin.key`. The
script reads `config.yaml` for `registry.*`, `redis.password`,
`admin.basic_auth_*`, and `storage.b2_*`.
**Path B — clone live secrets from another running cluster** (what we
actually did during the migration; useful if `secrets/` is empty or you want
exact-byte equivalence):
```sh
HETZNER=$(pwd)/deploy-k3s/kubeconfig.hetzner.bak # or any kubeconfig with the secrets
OVH=$(pwd)/deploy-k3s/kubeconfig
kubectl --kubeconfig=$OVH apply -f deploy-k3s/manifests/namespace.yaml
for S in honeydue-secrets honeydue-apns-key gitea-credentials cloudflare-origin-cert admin-basic-auth; do
kubectl --kubeconfig=$HETZNER -n honeydue get secret $S -o json \
| python3 -c "
import json, sys
d = json.load(sys.stdin)
m = d['metadata']
for k in ('uid','resourceVersion','creationTimestamp','generation','managedFields','ownerReferences','selfLink'):
m.pop(k, None)
m.pop('annotations', None)
print(json.dumps(d))" \
| kubectl --kubeconfig=$OVH apply -f -
done
```
After either path, verify:
```sh
kubectl -n honeydue get secrets
# Expect: admin-basic-auth, cloudflare-origin-cert, gitea-credentials,
# honeydue-apns-key, honeydue-secrets
```
### 6.9 Deploy workloads
```sh
KUBECONFIG=$(pwd)/deploy-k3s/kubeconfig \
./deploy-k3s/scripts/03-deploy.sh --skip-build --tag latest
```
- `--skip-build` skips Docker build + push, deploys whatever's already in the
registry at the named tag. Use this when migrating between clusters to
guarantee both run identical bits.
- Without flags it builds the api / worker / admin / web images from the
local repo HEAD and pushes to `gitea.treytartt.com` first.
- The script applies (in order): namespace, network-policies (13 of them),
redis, ingress, then runs the goose migration Job (blocking on success),
then api / worker / admin / web Deployments, then observability
(kube-state-metrics, vmagent, alloy-logs).
- It does NOT apply: `rbac.yaml`, `pod-disruption-budgets.yaml`,
`traefik-helmchartconfig.yaml`, `kyverno-verify-images.yaml`. The first
two must be applied manually (see §6.7); the latter two are Hetzner-only
or operator-gated.
- It does NOT apply: anything under `kratos/` (skipped until
`kratos-secrets` exists, which requires real OIDC client IDs).
### 6.10 Verify
```sh
KUBECONFIG=$(pwd)/deploy-k3s/kubeconfig ./deploy-k3s/scripts/04-verify.sh
```
Expect: all deployments `READY=desired`, 13 NetworkPolicies, 7 ServiceAccounts
(api, worker, admin, web, redis, vmagent, alloy-logs), 3 PDBs, cloudflare-only
middleware present, in-cluster `/api/health/` returns 200.
External smoke test (DNS-aware, but the api `/health/` route is exempt from
the cloudflare-only middleware so direct-IP works for diagnostics):
```sh
for IP in 51.81.83.33 51.81.87.86 51.81.85.248; do
curl -s -o /dev/null -w "$IP -> %{http_code}\n" \
-H 'Host: api.myhoneydue.com' http://$IP/api/health/
done
# All three should return 200.
```
### 6.11 DNS cutover (if migrating)
In the Cloudflare dashboard for `myhoneydue.com`, set the 4 hostnames in §4 to
the OVH IPs and keep proxied. Effective propagation ~30 s to 5 min through
the Cloudflare proxy.
If you have a previous cluster, **scale its worker to 0 before flipping** to
avoid scheduled-job double-fires:
```sh
KUBECONFIG=<previous> kubectl -n honeydue scale deploy/worker --replicas=0
# (cut DNS)
KUBECONFIG=<new> kubectl -n honeydue scale deploy/worker --replicas=1
```
Run those last two lines back-to-back. Worker work is mostly scheduled
(hourly+), so a brief gap is harmless; overlap would cause duplicate emails.
---
## 7. Day-to-day operations
### Common kubectl one-liners
```sh
export KUBECONFIG=$(pwd)/deploy-k3s/kubeconfig
# Cluster state
kubectl get nodes -o wide
kubectl -n honeydue get pods
kubectl -n honeydue get deploy
kubectl top nodes
kubectl -n honeydue top pods
# Tail logs
kubectl -n honeydue logs deploy/api -f --tail=50
kubectl -n honeydue logs -l app.kubernetes.io/name=api -f --tail=20
stern -n honeydue api # if stern is installed (multi-pod)
# Restart a deployment (no image change, picks up ConfigMap changes)
kubectl -n honeydue rollout restart deploy/api
# Rollback one revision
kubectl -n honeydue rollout undo deploy/api
# Scale (worker MUST stay at 0 or 1)
kubectl -n honeydue scale deploy/api --replicas=4
# Get into a pod
kubectl -n honeydue exec -it deploy/api -- sh
```
### Redeploy after code changes
```sh
KUBECONFIG=$(pwd)/deploy-k3s/kubeconfig ./deploy-k3s/scripts/03-deploy.sh
```
Builds images from local HEAD, tags with the git short SHA, pushes to Gitea,
runs `goose up` (idempotent), rolls api/worker/admin/web. Total: ~3-5 min
when images change.
To deploy without rebuilding (pin to a specific tag):
```sh
./deploy-k3s/scripts/03-deploy.sh --skip-build --tag <tag-or-:latest>
```
### Migrations
Goose migrations live in `migrations/`. New file pattern:
```
make migrate-new name=add_foo_column # generates migrations/YYYYMMDDHHMMSS_add_foo_column.sql
# Edit the file with -- +goose Up / -- +goose Down sections
```
`03-deploy.sh` runs a one-shot Job (`manifests/migrate/job.yaml`) that
executes `goose up` against Neon (direct compute endpoint, not pooler — see
file comment). The Job blocks api/worker rollout and aborts the deploy on
failure. No app pod runs `AutoMigrate`; api/worker startup verifies
`goose_db_version` is current and refuses to boot on mismatch.
### Grafana
URL: https://grafana.88oakapps.com (creds in `deploy/prod.env`)
Three dashboards in the `honeyDue` folder:
| UID | Title | Use |
|---|---|---|
| `honeydue-eli5-overview` | honeyDue — Overview (ELI5) | Single-screen at-a-glance health: pods up, crashes, errors, RPS, latency, Postgres, memory, top endpoints, push failures, worker activity, recent error logs. Created 2026-06-03. |
| `honeydue-red` | honeyDue API — RED | Rate/Errors/Duration cuts (legacy) |
| `honeydue-logs` | honeyDue — Production Logs | Live log explorer |
For the ELI5 dashboard's queries, **api-side metrics use `service="api"`,
NOT `namespace="honeydue"`.** vmagent's scrape config drops the namespace
label from api metrics — only `service`, `pod`, `node`, `job`, plus the
metric's own labels (route, method, status, etc.) survive. Queries that
filter on `namespace="honeydue"` for api metrics silently match nothing.
### kubectl tunnel (if 6443 is firewalled to your IP)
Currently `6443` is open WAN-side (matching the previous Hetzner posture).
If you tighten that to operator-IPs-only and your IP changes, use an SSH
tunnel:
```sh
ssh -fN -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 \
-i ~/.ssh/ovhcloud \
-L 127.0.0.1:6443:127.0.0.1:6443 \
ubuntu@51.81.83.33
cp deploy-k3s/kubeconfig deploy-k3s/kubeconfig.tunnel
sed -i.bak 's|https://51.81.83.33:6443|https://127.0.0.1:6443|' deploy-k3s/kubeconfig.tunnel
export KUBECONFIG="$(pwd)/deploy-k3s/kubeconfig.tunnel"
```
---
## 8. Disaster recovery
### "I lost the kubeconfig"
```sh
ssh ovhcloud1 'sudo cat /etc/rancher/k3s/k3s.yaml' \
| sed 's|server: https://127.0.0.1:6443|server: https://51.81.83.33:6443|' \
> deploy-k3s/kubeconfig
chmod 600 deploy-k3s/kubeconfig
```
If `ovhcloud1` is down but `ovhcloud2` or `3` is up, swap host and IP — the
TLS SAN covers all three.
### "A node is unresponsive"
```sh
kubectl drain vps-XXX --ignore-daemonsets --delete-emptydir-data
# Reboot via OVH manager or:
ssh ovhcloudN sudo reboot
# Wait for Ready, then:
kubectl uncordon vps-XXX
```
The cluster tolerates 1 node down (etcd quorum 2/3). With 2 down, etcd
loses quorum and the API server stops accepting writes.
### "etcd quorum lost (2+ nodes dead)"
Bring nodes back online if possible. If not:
```sh
ssh ovhcloud1 'sudo k3s server --cluster-reset --cluster-reset-restore-path=/var/lib/rancher/k3s/server/db/snapshots/<latest>'
```
k3s takes automatic etcd snapshots every 12h, keeping 5. List with:
```sh
ssh ovhcloud1 sudo ls -la /var/lib/rancher/k3s/server/db/snapshots/
```
This is destructive — workload state since the snapshot is lost, but Neon
(actual app data) is unaffected.
### "I have to rebuild the whole cluster from scratch"
Provision 3 fresh boxes, then exactly the sequence in §6. End-to-end is
~30 min. The dependencies that make this possible:
| Stays put through rebuild | Where |
|---|---|
| Application data | Neon Postgres (managed) |
| User uploads | Backblaze B2 (managed) |
| Container images | `gitea.treytartt.com` (self-hosted, but not on the OVH cluster) |
| Operator secrets | `deploy-k3s/secrets/` + `config.yaml` + `deploy/prod.env` on the operator workstation (gitignored) |
| DNS | Cloudflare control panel |
If `gitea.treytartt.com` is on the same OVH cluster, you have a circular
dependency — rebuilding requires images you can't pull until the cluster is
up. Currently Gitea is NOT in the honeyDue cluster (separate Hetzner-era
host), so this isn't a problem today, but worth flagging if that ever
changes.
### "Cutover back to Hetzner / failover to a backup cluster"
There is **no warm standby today.** Bringing up a second cluster is the
same §6 procedure on different hardware, then a Cloudflare DNS swap. The
worker-swap dance is critical:
```sh
KUBECONFIG=<current> kubectl -n honeydue scale deploy/worker --replicas=0
# (Update Cloudflare DNS to new cluster's IPs — proxied)
KUBECONFIG=<new> kubectl -n honeydue scale deploy/worker --replicas=1
```
---
## 9. Known gotchas
### 9.1 First-deploy "0 up-to-date" across all Deployments
**Symptoms:** `kubectl get deploy` shows `READY 0/N, UP-TO-DATE 0` for
api/worker/admin/web/redis. `kubectl get events` shows
`FailedCreate: error looking up service account honeydue/<name>: serviceaccount "..." not found`.
**Cause:** `rbac.yaml` (ServiceAccounts) is NOT applied by `03-deploy.sh`. On
a fresh cluster the SAs don't exist; the ReplicaSet controller can't create
pods.
**Fix:**
```sh
kubectl apply -f deploy-k3s/manifests/rbac.yaml
kubectl -n honeydue rollout restart deploy/api deploy/worker deploy/admin deploy/web deploy/redis
```
This was hit during the 2026-06-03 OVH bootstrap. Permanently fix by adding
`kubectl apply -f rbac.yaml` to `03-deploy.sh` between the namespace and
network-policies apply, but until that lands, follow §6.7 on every fresh
cluster.
### 9.2 vmagent SD broken on fresh deploy ("0 pods up" in Grafana)
**Symptoms:**
- Grafana panels using `kube_*` metrics or `up{job=...}` show 0
- vmagent logs: `dial tcp 10.43.0.1:443: connect: connection refused` every ~30 s
- Direct test from a pod also refused
**Cause:** k3s's NetworkPolicy controller evaluates egress rules *after*
kube-proxy's DNAT (not before, contrary to spec). Pod-to-`kubernetes`-Service
(`10.43.0.1:443`) gets DNAT'd to `<node_ip>:6443`, *then* the policy check
runs. Without an explicit egress rule for `:6443`, the packet is rejected.
The `allow-egress-from-vmagent` NetPol in `network-policies.yaml` includes
both rules:
```yaml
- to:
- ipBlock: { cidr: 10.43.0.0/16 }
ports:
- { port: 443, protocol: TCP }
- to:
- ipBlock:
cidr: 0.0.0.0/0
except: [10.42.0.0/16]
ports:
- { port: 6443, protocol: TCP }
```
**If this happens:** confirm `network-policies.yaml` was applied:
```sh
kubectl -n honeydue get netpol allow-egress-from-vmagent -o yaml | grep -A 5 6443
```
Counter-evidence that confirms diagnosis: `kube-state-metrics` in
`kube-system` works fine (no NetPols in that namespace).
### 9.3 vmagent appears healthy but no data in Grafana
vmagent's `/-/healthy` returns 200 as long as the process is alive and
remote-write is TCP-functional. It doesn't check that scrapes are actually
*succeeding*. The liveness probe in `vmagent.yaml` queries `/api/v1/targets`
and fails the pod if no target is `up`. After ~3 failures (~3 min), kubelet
recycles it.
If vmagent runs for weeks but Grafana is empty, the probe was disabled or
the exec command broke.
### 9.4 vmagent bearer token destroyed by direct `kubectl apply`
The committed `vmagent.yaml` has `bearer_token: TOKEN_PLACEHOLDER`. The real
token is `sed`-substituted at deploy time by `03-deploy.sh`. Applying the
file directly:
```sh
kubectl apply -f deploy-k3s/manifests/observability/vmagent.yaml # WRONG
```
overwrites the Secret with the literal `TOKEN_PLACEHOLDER` and remote-writes
401. To restore without a full redeploy:
```sh
OBS_TOKEN_B64=$(kubectl -n honeydue get secret honeydue-secrets \
-o jsonpath='{.data.OBS_INGEST_TOKEN}')
kubectl -n honeydue patch secret vmagent-remote-write --type=json \
-p="[{\"op\":\"replace\",\"path\":\"/data/bearer_token\",\"value\":\"${OBS_TOKEN_B64}\"}]"
kubectl -n honeydue rollout restart deploy/vmagent
```
Or just re-run `./deploy-k3s/scripts/03-deploy.sh` — the sed handles it.
### 9.5 Dashboard queries: api metrics need `service="api"` not `namespace="honeydue"`
vmagent's scrape config (`vmagent-config` ConfigMap) explicitly chooses which
Kubernetes pod-metadata labels to copy onto each scraped series. **Namespace
isn't one of them.** Labels you can use on api-side metrics:
- `service` (literal `"api"`)
- `job` (literal `"api"`)
- `pod` (the api pod name)
- `node` (the k8s node name)
- `cluster` (vmagent external_label, currently `"honeydue-k3s"`)
- `environment` (vmagent external_label, currently `"prod"`)
- Plus each metric's own labels (`method`, `route`, `status` for HTTP; etc.)
`kube_*` metrics from kube-state-metrics DO carry `namespace` natively
(KSM publishes it as a label, vmagent passes it through). Loki streams have
`namespace` because alloy-logs explicitly relabels it. So the rule is:
| Metric prefix | Use |
|---|---|
| `kube_*` | `namespace="honeydue"` |
| `http_*`, `gorm_*`, `go_*`, `process_*` (api) | `service="api"` |
| Loki logs `{...}` | `namespace="honeydue"` |
### 9.6 Cluster-label collision when two clusters run together
Both Hetzner and OVH vmagents push as `cluster=honeydue-k3s, environment=prod`
(same external_labels). During the migration overlap this made dashboards
sum both clusters' data. The simplest narrowing during overlap is by node
name pattern (`node=~"vps-.*"` for OVH, `node=~"ubuntu-.*"` for Hetzner). If
you ever bring up a backup cluster long-term, change one cluster's
`external_labels.cluster` to something distinct (e.g. `honeydue-ovh`
vs. `honeydue-backup`).
### 9.7 Worker double-firing scheduled jobs
If two `worker` Deployments run concurrently (e.g. two clusters both pointing
at the same Neon DB), Asynq schedulers each fire crons independently — users
get duplicate emails. Workaround: scale all-but-one worker to 0. This is the
exact mechanic used during cutovers (§6.11).
### 9.8 Node kubeconfig mode
`/etc/rancher/k3s/k3s.yaml` on each node is mode `0600` because we install
with `--write-kubeconfig-mode=0600`. Tightening from k3s default (0644) was
intentional. Don't change without coordinating — any tooling on the node
that expects to read it (none today) will break.
---
## 10. Differences from MIGRATION_NOTES.md (Hetzner-era)
`MIGRATION_NOTES.md` documents the Swarm → k3s migration on Hetzner
(2026-04-24). Most of it still applies, with these OVH-specific deltas:
| What MIGRATION_NOTES says | What OVH actually has |
|---|---|
| `hetzner-k3s` provisioner | Manual k3s install (§6) |
| Hetzner Load Balancer (not used) → Cloudflare round-robin | Same — Cloudflare round-robin (§4) |
| Traefik as DaemonSet + hostNetwork via HelmChartConfig | Traefik default Deployment + klipper-lb svclb DaemonSet. The `traefik-helmchartconfig.yaml` file is **NOT applied** on OVH. |
| `servicelb` disabled (`--disable=servicelb`) | `servicelb` enabled (we didn't pass `--disable=servicelb`). This is what makes klipper-lb work. |
| sysctl `net.ipv4.ip_unprivileged_port_start=0` for hostNetwork Traefik | Not needed — klipper-lb proxies the port binding instead |
| UFW rules between 3 Hetzner IPs | UFW rules between 3 OVH IPs (51.81.83.33, 51.81.87.86, 51.81.85.248) |
| Kubeconfig at `~/.kube/honeydue-k3s.yaml` | Kubeconfig at `deploy-k3s/kubeconfig` |
| TLS at origin: not configured (CF Flexible) | Same — CF Flexible. `cloudflare-origin-cert` Secret exists (carried over) but Ingress doesn't reference it. |
---
## 11. Outstanding follow-ups (deferred, not blocking)
1. **No warm standby / rollback cluster.** OVH is solo production. An OVH
outage is a real outage; recovery time = §6 procedure (~30 min). User
plans to bring a second cluster up as a target.
2. **UFW allows 80/443 from world.** Hetzner had a network-layer Cloudflare-IP
allowlist on these ports. OVH currently relies on the L7
`cloudflare-only` Traefik middleware, which protects admin but NOT api /
web / apex (those routes have to be reachable from anywhere, but they're
then trivially DDoSable bypassing Cloudflare). Fix: add ufw allow rules
restricting `80/tcp` and `443/tcp` to Cloudflare's published IP ranges
(~22 IPv4 prefixes from https://www.cloudflare.com/ips-v4/).
3. **Cloudflare TLS Flexible → Full(strict).** Origin certs exist as Secret
but Ingress doesn't terminate TLS. Upgrading to Full(strict) requires
Traefik configured with the cert + an HTTPS entrypoint + Ingress
`tls:` block.
4. **`rbac.yaml` + `pod-disruption-budgets.yaml` should be in `03-deploy.sh`.**
They're currently bootstrap-only. Adding them is idempotent and prevents
the §9.1 footgun.
5. **Push notification metrics are log-derived, not counters.** Successes
aren't logged or counted. Proper Prometheus instrumentation (~15 lines in
`internal/push/client.go`) would give a real success/failure ratio.
6. **Worker has no `/metrics` endpoint.** `cmd/worker/main.go` serves `:6060`
for healthz only. Adding Asynq's `metrics.NewPrometheusExporter()` + a
ServiceMonitor + uncommenting the `worker` job stanza in
`vmagent-config` ConfigMap would give real queue depth and job latency.
7. **Ory Kratos.** Manifests exist (`manifests/kratos/`) but the deploy
is gated on operator-side prerequisites (Neon `kratos` database,
`auth.myhoneydue.com` DNS, real Apple+Google OIDC clients, Kratos image
tag pinned). Until `kratos-secrets` exists, `03-deploy.sh` silently
skips the Kratos apply.
8. **Hetzner cluster fully retired? `config.yaml` `nodes:` block describes
OVH; the bak kubeconfig is at `kubeconfig.hetzner.bak`. Boxes themselves
are operator-managed.
### 11.1 Dashboard observability gaps (raised 2026-06-03 during dashboard build)
Surfaced while building the `honeydue-eli5-overview` Grafana dashboard. Each
needs code or infra changes to expose; none blocks today's operations.
9. **node-exporter not deployed.** No node-level metrics today
(`node_filesystem_avail_bytes`, `node_memory_*`, `node_load1`, etc.).
The dashboard's pod-level memory/CPU panels are app-process only — a
node running out of disk would silently fail the cluster before any
dashboard signal showed it. Highest-priority Tier-3 item. Fix: deploy
`node-exporter` as a DaemonSet (~50 lines of YAML), add a scrape stanza
to `vmagent-config`, add a `Node disk free` stat panel.
10. **Traefik metrics not enabled.** Traefik can expose `/metrics` with
`traefik_entrypoint_requests_total` + `traefik_service_request_duration_seconds`,
giving edge-level visibility into requests that never reached api
pods (404s, redirects, middleware blocks). Enable via a
HelmChartConfig override that sets `metrics.prometheus.entryPoint=metrics`
+ adds a `:9100` entryPoint + a scrape stanza. Skipped today to avoid
Traefik restart risk; safe additive change when ready.
11. **Push notification success/failure counters** (already #5). Add
`prometheus.NewCounterVec` in `internal/push/client.go` with labels
`platform={ios,android}, outcome={success,failed,breaker_open,disabled}`.
Increments at every Send/SendActionable branch. Replaces the
log-derived "Push failures" stat on the dashboard with a real success
rate.
12. **Worker queue / job metrics** (already #6). Asynq has a built-in
Prometheus exporter (`asynq/x/metrics`). Wire it into the worker's
`:6060` health server (a single `healthMux.Handle` line) and
uncomment the worker scrape stanza in `vmagent-config`. Surfaces
queue depth, retry count, processing time per task type.
13. **Cache hit / miss rate.** `internal/services/cache_service.go` has
no counters. Add a Counter with labels `{operation=get|set, result=hit|miss}`
around the cache wrapper. ~10 lines. Useful once real traffic flows
to verify the ETag and Redis caches are paying their keep.
14. **APNs send-latency histogram.** Wrap `internal/push/apns.go::Send`
in a `prometheus.NewHistogramVec` keyed on outcome. Tells you when
Apple's gateway is slow (which correlates with their incident page).
---
## 12. Audit trail
| Date | Change |
|---|---|
| 2026-04-24 | Initial k3s cluster on Hetzner (Swarm → k3s migration) — see MIGRATION_NOTES.md |
| 2026-04-25 | `config.yaml` reconstructed from live ConfigMap (original file lost) |
| 2026-05-15 | Audit fixes: Redis auth required, admin basic auth, secrets-encryption flag |
| 2026-05-16 | `02-setup-secrets.sh` started carrying B2 credentials (was a manifest/script drift) |
| 2026-06-02 | Kratos scaffolding committed (not deployed) |
| 2026-06-03 | **Hetzner → OVH BHS cutover.** New 3-node cluster on 51.81.83.33, .87.86, .85.248. DNS cut on Cloudflare. Hetzner kubeconfig moved to `.bak`. Grafana `honeydue-eli5-overview` dashboard created. Hetzner cluster powered off later same day. |
| 2026-06-03 | Dashboard build-out: extended `honeydue-eli5-overview` to 22 panels covering Tier-1 (HTTP status, CPU per pod, goroutines, top slow) and Tier-2 (GC, network I/O, pod uptime, top 5xx) signals. Surfaced Tier-3 instrumentation gaps in §11.1. |
+896 -676
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -30,6 +30,7 @@ load_balancer_ip: ""
domains:
api: api.myhoneydue.com
admin: admin.myhoneydue.com
app: app.myhoneydue.com # web client host — added to CORS_ALLOWED_ORIGINS
base: myhoneydue.com
# --- Container Registry (GHCR) ---
+5 -1
View File
@@ -23,8 +23,11 @@ spec:
app.kubernetes.io/part-of: honeydue
spec:
serviceAccountName: admin
# Explicit pod-level opt-out (audit F11) — defense-in-depth on top of
# the ServiceAccount-level setting in rbac.yaml.
automountServiceAccountToken: false
imagePullSecrets:
- name: ghcr-credentials
- name: gitea-credentials
securityContext:
runAsNonRoot: true
runAsUser: 1001
@@ -35,6 +38,7 @@ spec:
containers:
- name: admin
image: IMAGE_PLACEHOLDER # Replaced by 03-deploy.sh
imagePullPolicy: IfNotPresent # audit CODE-L4 — explicit; images are SHA/digest-pinned
ports:
- containerPort: 3000
protocol: TCP
+26 -64
View File
@@ -23,8 +23,11 @@ spec:
app.kubernetes.io/part-of: honeydue
spec:
serviceAccountName: api
# Explicit pod-level opt-out (audit F11) — defense-in-depth on top of
# the ServiceAccount-level setting in rbac.yaml.
automountServiceAccountToken: false
imagePullSecrets:
- name: ghcr-credentials
- name: gitea-credentials
securityContext:
runAsNonRoot: true
runAsUser: 1000
@@ -35,6 +38,7 @@ spec:
containers:
- name: api
image: IMAGE_PLACEHOLDER # Replaced by 03-deploy.sh
imagePullPolicy: IfNotPresent # audit CODE-L4 — explicit; images are SHA/digest-pinned
ports:
- containerPort: 8000
protocol: TCP
@@ -46,65 +50,16 @@ spec:
envFrom:
- configMapRef:
name: honeydue-config
env:
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: honeydue-secrets
key: POSTGRES_PASSWORD
- name: SECRET_KEY
valueFrom:
secretKeyRef:
name: honeydue-secrets
key: SECRET_KEY
- name: EMAIL_HOST_PASSWORD
valueFrom:
secretKeyRef:
name: honeydue-secrets
key: EMAIL_HOST_PASSWORD
- name: FCM_SERVER_KEY
valueFrom:
secretKeyRef:
name: honeydue-secrets
key: FCM_SERVER_KEY
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: honeydue-secrets
key: REDIS_PASSWORD
optional: true
# B2 (Backblaze) credentials. With both set, StorageConfig.IsS3()
# returns true and uploads stream to B2 via minio-go. With either
# missing, code falls back to local filesystem — and since
# readOnlyRootFilesystem is true on this container, that fallback
# silently fails. So both must be wired or uploads break.
- name: B2_KEY_ID
valueFrom:
secretKeyRef:
name: honeydue-secrets
key: B2_KEY_ID
- name: B2_APP_KEY
valueFrom:
secretKeyRef:
name: honeydue-secrets
key: B2_APP_KEY
# Observability — push traces (and any future OTLP metrics) to
# obs.88oakapps.com. Token gates ingest at nginx; URL is the
# same one vmagent uses for metric remote-write. Both come from
# honeydue-secrets so they aren't world-readable in ConfigMap.
- name: OBS_TRACES_URL
valueFrom:
secretKeyRef:
name: honeydue-secrets
key: OBS_TRACES_URL
optional: true
- name: OBS_INGEST_TOKEN
valueFrom:
secretKeyRef:
name: honeydue-secrets
key: OBS_INGEST_TOKEN
optional: true
# Audit CODE-F8: secrets are NOT injected as environment variables.
# Env vars are readable for the life of the pod via /proc/<pid>/environ
# and leak into crash dumps / child processes. honeydue-secrets is
# mounted read-only at /etc/honeydue/secrets (mode 0400) and the Go
# config layer (config.loadFileSecrets) reads each key from its file.
# Non-secret config still arrives via the configMapRef above.
volumeMounts:
- name: app-secrets
mountPath: /etc/honeydue/secrets
readOnly: true
- name: apns-key
mountPath: /secrets/apns
readOnly: true
@@ -121,11 +76,12 @@ spec:
httpGet:
path: /api/health/
port: 8000
# MigrateWithLock in cmd/api/main.go runs pg_advisory_lock on
# every startup. On a cold boot with 3 replicas, the first does
# AutoMigrate (~90s) and the others wait on the lock, so real
# startup runs 90240s. 48 × 5s = 240s grace absorbs it without
# healthcheck killing a still-starting replica.
# Schema migrations run separately in the honeydue-migrate Job
# *before* this Deployment rolls — the api itself does not migrate
# (it only verifies goose_db_version at boot). Cold start still
# pays the DB pool warm-up + Redis connect + APNs/FCM client init
# before /api/health/ goes green. 48 × 5s = 240s grace keeps the
# probe from killing a still-starting replica.
failureThreshold: 48
periodSeconds: 5
readinessProbe:
@@ -143,6 +99,12 @@ spec:
periodSeconds: 30
timeoutSeconds: 10
volumes:
# Audit CODE-F8: the whole honeydue-secrets Secret, projected as files.
# defaultMode 0400 → readable only by the container's runAsUser (1000).
- name: app-secrets
secret:
secretName: honeydue-secrets
defaultMode: 0400
- name: apns-key
secret:
secretName: honeydue-apns-key
+57
View File
@@ -0,0 +1,57 @@
# B2 bucket lifecycle — `uploads/` prefix
The `pending_uploads` cleanup worker (cron `30 * * * *`, see
`internal/worker/jobs/handler.go::HandleUploadCleanup`) reaps unclaimed
upload sessions every hour, deleting both the row and the corresponding B2
object. This bucket-level lifecycle rule is a **backstop** — it catches B2
objects that survive the row deletion (e.g. worker crashed mid-loop, B2
delete errored, manual DB tampering).
## Rule
Apply via the Backblaze web console: **Bucket → `honeyDueProd` → Lifecycle Settings → Custom**
```json
[
{
"fileNamePrefix": "uploads/",
"daysFromUploadingToHiding": 7,
"daysFromHidingToDeleting": 1
}
]
```
Effect: any object under the `uploads/` prefix is hidden 7 days after
upload, then permanently deleted 1 day after that. Total maximum lifetime
of an orphaned object: 8 days.
This rule does NOT affect:
- `images/`, `documents/`, `completions/` — legacy multipart-uploaded
objects, which are managed by the existing `task_completion_image` /
`document_image` / `document.file_url` references.
## Why a backstop, not the primary mechanism
The application worker is the primary mechanism because:
1. It can delete the **DB row** alongside the B2 object — lifecycle alone
would leave dangling `pending_uploads` rows.
2. It runs hourly vs. lifecycle's once-per-day evaluation — much tighter
recovery window for the common case.
3. It produces logs / metrics for orphan rate observability.
## Verification
After applying:
```bash
b2 bucket get-info honeyDueProd | jq '.lifecycleRules'
```
Should show the rule above. If you don't have the B2 CLI:
```bash
curl -u "$B2_KEY_ID:$B2_APP_KEY" https://api.backblazeb2.com/b2api/v3/b2_authorize_account
# Then use the returned authorization_token + apiUrl to call b2_get_bucket
```
@@ -53,7 +53,12 @@ metadata:
labels:
app.kubernetes.io/part-of: honeydue
annotations:
traefik.ingress.kubernetes.io/router.middlewares: honeydue-security-headers@kubernetescrd,honeydue-rate-limit@kubernetescrd
# cloudflare-only + admin-auth wired in (audit F2/F3/CODE-L6). Order
# matters: reject non-Cloudflare IPs, then basic auth, then headers,
# then rate limit. The admin-basic-auth secret is created by
# 02-setup-secrets.sh from config.yaml admin.basic_auth_* — that runs
# before 03-deploy.sh, so the middleware always has its secret.
traefik.ingress.kubernetes.io/router.middlewares: honeydue-cloudflare-only@kubernetescrd,honeydue-admin-auth@kubernetescrd,honeydue-security-headers@kubernetescrd,honeydue-rate-limit@kubernetescrd
spec:
ingressClassName: traefik
tls:
@@ -98,3 +103,98 @@ spec:
name: web
port:
number: 3000
---
# Auth-endpoint Ingress (audit F10 / LIVE-L12). A dedicated Ingress for the
# auth paths so Traefik gives their longer path-prefix routers a higher
# priority than honeydue-api's "/" router — these paths then get
# auth-rate-limit (5/min) instead of the general rate-limit (100/min).
# Anything not matched here falls through to honeydue-api unchanged.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: honeydue-api-auth
namespace: honeydue
labels:
app.kubernetes.io/part-of: honeydue
annotations:
traefik.ingress.kubernetes.io/router.middlewares: honeydue-auth-rate-limit@kubernetescrd,honeydue-security-headers@kubernetescrd
spec:
ingressClassName: traefik
tls:
- hosts:
- api.myhoneydue.com
secretName: cloudflare-origin-cert
rules:
- host: api.myhoneydue.com
http:
paths:
- path: /api/auth/login
pathType: Prefix
backend:
service:
name: api
port:
number: 8000
- path: /api/auth/register
pathType: Prefix
backend:
service:
name: api
port:
number: 8000
- path: /api/auth/forgot-password
pathType: Prefix
backend:
service:
name: api
port:
number: 8000
- path: /api/auth/reset-password
pathType: Prefix
backend:
service:
name: api
port:
number: 8000
- path: /api/residences/join-with-code
pathType: Prefix
backend:
service:
name: api
port:
number: 8000
- path: /api/auth/verify-reset-code
pathType: Prefix
backend:
service:
name: api
port:
number: 8000
- path: /api/auth/apple-sign-in
pathType: Prefix
backend:
service:
name: api
port:
number: 8000
- path: /api/auth/google-sign-in
pathType: Prefix
backend:
service:
name: api
port:
number: 8000
- path: /api/auth/refresh
pathType: Prefix
backend:
service:
name: api
port:
number: 8000
- path: /api/auth/account
pathType: Prefix
backend:
service:
name: api
port:
number: 8000
+31 -2
View File
@@ -21,12 +21,20 @@ spec:
headers:
frameDeny: true
contentTypeNosniff: true
browserXssFilter: true
# browserXssFilter removed (audit L7): it emits the deprecated
# X-XSS-Protection header, which can itself introduce XSS in legacy
# browsers. Modern browsers ignore it.
referrerPolicy: "strict-origin-when-cross-origin"
customResponseHeaders:
X-Content-Type-Options: "nosniff"
X-Frame-Options: "DENY"
Strict-Transport-Security: "max-age=31536000; includeSubDomains"
# HSTS: 2-year max-age + preload (audit L5/CODE-L3). After this is
# live on api/admin/app, submit myhoneydue.com to hstspreload.org.
Strict-Transport-Security: "max-age=63072000; includeSubDomains; preload"
# Cross-origin isolation (audit F9). COEP (require-corp) is omitted —
# it commonly breaks third-party embeds; add only after testing.
Cross-Origin-Opener-Policy: "same-origin"
Cross-Origin-Resource-Policy: "same-origin"
# Content-Security-Policy is intentionally NOT set here — the Go API
# sets a CSP in internal/router/router.go that permits Google Fonts
# for the landing page. Two CSP headers would intersect and break it.
@@ -83,3 +91,24 @@ spec:
basicAuth:
secret: admin-basic-auth
realm: "honeyDue Admin"
---
# Strict rate limit for auth endpoints (audit F10 / LIVE-L12).
# Applied via the honeydue-api-auth Ingress to login / register /
# forgot-password / reset-password / join-with-code. depth: 2 makes the
# limiter key on the real client IP rather than the Cloudflare edge IP
# (request path: client -> Cloudflare -> Traefik). This is the edge half;
# the per-account lockout in the Go app is the robust half.
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: auth-rate-limit
namespace: honeydue
spec:
rateLimit:
average: 5
burst: 10
period: 1m
sourceCriterion:
ipStrategy:
depth: 2
+92
View File
@@ -0,0 +1,92 @@
# Ory Kratos — honeyDue identity service (Phase 1: infrastructure)
This directory deploys [Ory Kratos](https://www.ory.sh/kratos/) into the
`honeydue` namespace as the identity provider — replacing the hand-rolled auth
in `internal/services/auth_service.go` etc.
**Phase 1 is infrastructure only.** Once deployed, Kratos runs but nothing uses
it yet — the honeyDue Go API still does its own auth. Phase 2 (backend swap)
and Phase 3 (KMP/web clients) follow. Migrating onto Kratos can lose all
existing user data — honeyDue is pre-production, so no user import is done.
The deploy is **gated**: `03-deploy.sh` applies Kratos only when the
`kratos-secrets` Secret exists, and `02-setup-secrets.sh` creates that Secret
only when `config.yaml` has a `kratos:` block. Until then the existing stack
deploys completely unaffected.
## Files
| File | What |
|---|---|
| `configmap.yaml` | `kratos.yml`, identity schema, Google/Apple OIDC claim mappers (no secrets) |
| `migrate-job.yaml` | `kratos migrate sql` — schema migration, run before the Deployment |
| `kratos.yaml` | Deployment (×2), Service, NetworkPolicies |
| `ingress.yaml` | `auth.myhoneydue.com` → Kratos public API :4433 |
## Operator prerequisites (must be done before deploying)
1. **Kratos version** — Ory uses CalVer (`v25.x` / `v26.x`). Pick the current
stable, then replace `REPLACE_WITH_CURRENT_STABLE_TAG` in `kratos.yaml` and
`migrate-job.yaml` with `oryd/kratos:vXX.Y@sha256:<digest>`, and set the
matching `version:` in `configmap.yaml`.
2. **Kratos database** — create a separate Neon database named `kratos` (do not
share honeyDue's). Capture its connection string as the DSN.
3. **DNS** — add `auth.myhoneydue.com` in Cloudflare (proxied), pointing at the
cluster ingress like the other honeyDue hosts. Confirm the
`cloudflare-origin-cert` TLS secret covers `auth.myhoneydue.com`.
4. **Google OAuth client** — Google Cloud Console → create an OAuth 2.0 client.
Redirect URI: `https://auth.myhoneydue.com/self-service/methods/oidc/callback/google`.
Put the **client ID** into `configmap.yaml` (`GOOGLE_OAUTH_CLIENT_ID`); the
**client secret** goes in `config.yaml`.
5. **Apple Sign In** — Apple Developer → a Services ID + a Sign in with Apple
key. Return URL: `https://auth.myhoneydue.com/self-service/methods/oidc/callback/apple`.
Put the **Services ID / Team ID / Key ID** into `configmap.yaml`
(`APPLE_SERVICES_ID` / `APPLE_TEAM_ID` / `APPLE_PRIVATE_KEY_ID`); the **.p8
private key** goes in `config.yaml`.
6. **`config.yaml`** — add a `kratos:` block:
```yaml
kratos:
dsn: "postgres://USER:PASS@HOST/kratos?sslmode=require"
secrets_cookie: "<openssl rand -hex 16>" # generate ONCE, keep stable
secrets_cipher: "<openssl rand -hex 16>" # must be exactly 32 chars
smtp_connection_uri: "smtps://USER:PASS@smtp.fastmail.com:465/"
google_client_secret: "<from Google Cloud Console>"
apple_private_key: |
-----BEGIN PRIVATE KEY-----
...
-----END PRIVATE KEY-----
```
`secrets_cookie` / `secrets_cipher` must stay stable forever — rotating them
invalidates every session and makes encrypted data unreadable.
## Deploy
```bash
cd honeyDueAPI-go
export KUBECONFIG="$(pwd)/deploy-k3s/kubeconfig"
./deploy-k3s/scripts/02-setup-secrets.sh # creates kratos-secrets from config.yaml
./deploy-k3s/scripts/03-deploy.sh # applies kratos manifests, runs migrate, rolls
```
`03-deploy.sh` applies `configmap.yaml` → runs `migrate-job.yaml` → waits →
applies `kratos.yaml` + `ingress.yaml`.
## Verify
- `kubectl -n honeydue get pods -l app.kubernetes.io/name=kratos` — 2/2 Running
- `kubectl -n honeydue logs job/kratos-migrate` — migration succeeded
- `curl https://auth.myhoneydue.com/health/ready` — `{"status":"ok"}`
- `curl https://auth.myhoneydue.com/self-service/registration/api` — returns a flow
## Not yet done (later phases)
- **Phase 2** — honeyDue Go backend: swap `middleware/auth.go` for Kratos
session validation, drop the hand-rolled auth code, rebuild the `users`
table keyed on the Kratos identity ID.
- **Phase 3** — KMP mobile + Next.js web clients point at Kratos flows.
- Admin-panel auth stays on its own JWT (out of scope).
+232
View File
@@ -0,0 +1,232 @@
# Ory Kratos configuration for honeyDue.
#
# Secrets are NOT in this ConfigMap. The DSN, cookie/cipher secrets, SMTP URI
# and OIDC client secrets are injected as environment variables from the
# kratos-secrets Secret (see kratos.yaml). Kratos is configured natively via
# env vars, so this is the idiomatic split — only non-secret config here.
#
# OIDC scope: Apple-only as of 2026-06-03. Google is intentionally absent;
# adding it later is additive — append a `- id: google` block under
# selfservice.methods.oidc.config.providers (it becomes index 1) and bind a
# matching CLIENT_SECRET env in kratos.yaml.
apiVersion: v1
kind: ConfigMap
metadata:
name: kratos-config
namespace: honeydue
labels:
app.kubernetes.io/name: kratos
app.kubernetes.io/part-of: honeydue
data:
kratos.yml: |
# version must track the Kratos image tag — kratos.yaml + migrate-job.yaml
# both pin oryd/kratos:v26.2.0 (2026-06-03). See kratos/README.md.
version: v1.3.0 # internal config schema version; do not change unless Kratos release notes require it
serve:
public:
base_url: https://auth.myhoneydue.com/
cors:
enabled: true
allowed_origins:
- https://myhoneydue.com
- https://app.myhoneydue.com
- https://admin.myhoneydue.com
allowed_methods: [GET, POST, PUT, PATCH, DELETE, OPTIONS]
allowed_headers: [Authorization, Content-Type, X-Session-Token, Cookie]
exposed_headers: [Content-Type, Set-Cookie]
# Required: the web clients call Kratos browser flows with
# credentials (the ory_kratos_session cookie). Safe here because
# allowed_origins is an explicit list, never a wildcard.
allow_credentials: true
admin:
base_url: http://kratos.honeydue.svc.cluster.local:4434/
selfservice:
default_browser_return_url: https://app.myhoneydue.com/
allowed_return_urls:
- https://app.myhoneydue.com
- https://myhoneydue.com
- honeydue://callback
methods:
password:
enabled: true
code: # email one-time codes (verify/recover)
enabled: true
oidc:
enabled: true
config:
providers:
# index 0 — Apple Sign In. apple_private_key (.p8 contents) is
# injected via env SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS_0_APPLE_PRIVATE_KEY.
# client_id is the Apple Services ID (here: the bundle ID, which
# was configured as a Services ID with Sign In with Apple
# capability — see operator notes in README.md §5).
- id: apple
provider: apple
# Production bundle id. Apple issues id_tokens with
# `aud` = the requesting app's bundle id, so this is the
# primary audience Kratos verifies against.
client_id: com.myhoneydue.honeyDue
# Debug builds out of Xcode use a `.dev` bundle id (see
# iosApp/honeyDue.xcodeproj — Debug config). Their id_tokens
# therefore have `aud: com.myhoneydue.honeyDue.dev`, which
# the primary client_id check rejects. Whitelist the dev
# audience so Apple Sign In works from a non-Release Xcode
# build without per-build Kratos reconfiguration.
additional_id_token_audiences:
- com.myhoneydue.honeyDue.dev
apple_team_id: X86BR9WTLD
apple_private_key_id: HQD3NCF99C
mapper_url: file:///etc/kratos/oidc.apple.jsonnet
scope: [openid, email, name]
flows:
error:
ui_url: https://app.myhoneydue.com/auth/error
login:
ui_url: https://app.myhoneydue.com/auth/login
lifespan: 10m
registration:
ui_url: https://app.myhoneydue.com/auth/registration
lifespan: 10m
after:
password:
hooks:
- hook: session # auto-login after registration
oidc:
hooks:
- hook: session
verification:
enabled: true
ui_url: https://app.myhoneydue.com/auth/verification
use: code
after:
default_browser_return_url: https://app.myhoneydue.com/
recovery:
enabled: true
ui_url: https://app.myhoneydue.com/auth/recovery
use: code
settings:
ui_url: https://app.myhoneydue.com/auth/settings
privileged_session_max_age: 15m
logout:
after:
default_browser_return_url: https://app.myhoneydue.com/
log:
level: info
format: json
leak_sensitive_values: false
ciphers:
algorithm: xchacha20-poly1305
hashers:
algorithm: bcrypt
bcrypt:
cost: 12
identity:
default_schema_id: honeydue
schemas:
- id: honeydue
url: file:///etc/kratos/identity.schema.json
courier:
smtp:
from_address: noreply@myhoneydue.com
from_name: honeyDue
# connection_uri is injected via env COURIER_SMTP_CONNECTION_URI
session:
lifespan: 720h # 30-day sessions (mobile)
cookie:
domain: myhoneydue.com
same_site: Lax
identity.schema.json: |
{
"$id": "https://honeydue.app/identity.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "honeyDue user",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email",
"title": "Email",
"minLength": 3,
"maxLength": 320,
"ory.sh/kratos": {
"credentials": {
"password": { "identifier": true },
"code": { "identifier": true, "via": "email" },
"totp": { "account_name": true }
},
"verification": { "via": "email" },
"recovery": { "via": "email" }
}
},
"name": {
"type": "object",
"title": "Name",
"properties": {
"first": { "type": "string", "title": "First name", "maxLength": 100 },
"last": { "type": "string", "title": "Last name", "maxLength": 100 }
}
}
},
"required": ["email"],
"additionalProperties": false
}
}
}
oidc.google.jsonnet: |
// Maps Google OIDC claims onto the honeyDue identity schema.
local claims = std.extVar('claims');
{
identity: {
traits: {
email: claims.email,
[if 'given_name' in claims || 'family_name' in claims then 'name']: {
first: if 'given_name' in claims then claims.given_name else '',
last: if 'family_name' in claims then claims.family_name else '',
},
},
},
}
oidc.apple.jsonnet: |
// Maps Apple OIDC claims onto the honeyDue identity schema. Apple only
// returns the name on the very first authorization and not in the ID
// token claims, so only email is mapped here.
//
// Sign in with Apple emails are marked verified UNCONDITIONALLY: completing
// SIWA cryptographically proves the user controls that Apple ID, and Apple
// owns/verifies the (relay or real) email, so a 6-digit code would be
// redundant. We deliberately do NOT gate this on Apple's `email_verified`
// claim — Apple omits that claim on many authorizations (only sends it on
// the first grant), which made auto-verification random: sometimes verified,
// sometimes a surprise code prompt (observed 2026-06-03). Marking it
// verified on every SIWA makes the behaviour consistent: Apple users never
// see a code; password sign-ups still verify via the honeyDue API flow.
local claims = std.extVar('claims');
{
identity: {
traits: {
email: claims.email,
},
verified_addresses: std.prune([
if 'email' in claims then {
via: 'email',
value: claims.email,
},
]),
},
}
+44
View File
@@ -0,0 +1,44 @@
# Public ingress for Ory Kratos — auth.myhoneydue.com → Kratos public API :4433.
#
# Middlewares match the honeyDue API ingress (security-headers + rate-limit).
# The cloudflare-only middleware is intentionally NOT applied here: on this
# cluster, klipper-lb SNATs the source IP before Traefik sees it, so
# cloudflare-only's IP allowlist rejects every legitimate Cloudflare request
# (verified 2026-06-03 — iOS Apple Sign In failed silently because Kratos
# never received the request). The api ingress doesn't use cloudflare-only
# for the same reason. DDoS protection still rides on Cloudflare's edge.
#
# Kratos's self-service flows are multi-request, so the strict auth-rate-limit
# (5/min) is intentionally NOT used here — Kratos applies its own per-flow
# protections.
#
# OPERATOR: confirm the cloudflare-origin-cert TLS secret covers
# auth.myhoneydue.com (apex + wildcard origin cert), and add the
# auth.myhoneydue.com DNS record in Cloudflare (proxied) → cluster ingress.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: honeydue-auth
namespace: honeydue
labels:
app.kubernetes.io/name: kratos
app.kubernetes.io/part-of: honeydue
annotations:
traefik.ingress.kubernetes.io/router.middlewares: honeydue-security-headers@kubernetescrd,honeydue-rate-limit@kubernetescrd
spec:
ingressClassName: traefik
tls:
- hosts:
- auth.myhoneydue.com
secretName: cloudflare-origin-cert
rules:
- host: auth.myhoneydue.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: kratos
port:
number: 4433
+208
View File
@@ -0,0 +1,208 @@
# Ory Kratos — identity service for honeyDue.
#
# Deployed once the operator has completed the prerequisites in kratos/README.md
# (Neon `kratos` database, auth.myhoneydue.com DNS, Apple Sign In OIDC client,
# and the kratos-secrets Secret). Until then 03-deploy.sh skips the Kratos
# apply, so the existing stack is unaffected.
#
# IMAGE: pinned to oryd/kratos v26.2.0 (CalVer current stable as of 2026-06-03)
# with the linux/amd64 digest. The schema-migration Job is in migrate-job.yaml
# and runs before this Deployment rolls.
#
# OIDC: currently Apple-only (configmap.yaml providers[0]). Google was scoped
# out at deploy time; adding it later is additive — append to providers[] in
# configmap.yaml and add the matching CLIENT_SECRET env binding here.
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: kratos
namespace: honeydue
labels:
app.kubernetes.io/name: kratos
app.kubernetes.io/part-of: honeydue
spec:
replicas: 2
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
selector:
matchLabels:
app.kubernetes.io/name: kratos
template:
metadata:
labels:
app.kubernetes.io/name: kratos
app.kubernetes.io/part-of: honeydue
spec:
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: kratos
image: oryd/kratos:v26.2.0@sha256:92eedc292ff8e1a918ac442c88ed0abe44610c75121700963114549908a45ac3
imagePullPolicy: IfNotPresent
args:
- serve
- --config
- /etc/kratos/kratos.yml
- --watch-courier # send verification/recovery email in-process
ports:
- name: public
containerPort: 4433
- name: admin
containerPort: 4434
env:
# Kratos is configured natively via env vars; secrets come from
# the kratos-secrets Secret rather than the ConfigMap.
- name: DSN
valueFrom: { secretKeyRef: { name: kratos-secrets, key: dsn } }
- name: SECRETS_COOKIE
valueFrom: { secretKeyRef: { name: kratos-secrets, key: secrets_cookie } }
- name: SECRETS_CIPHER
valueFrom: { secretKeyRef: { name: kratos-secrets, key: secrets_cipher } }
- name: COURIER_SMTP_CONNECTION_URI
valueFrom: { secretKeyRef: { name: kratos-secrets, key: smtp_connection_uri } }
# OIDC provider secrets — index must match the providers list
# order in configmap.yaml. Apple-only for now (index 0).
- name: SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS_0_APPLE_PRIVATE_KEY
valueFrom: { secretKeyRef: { name: kratos-secrets, key: apple_private_key } }
volumeMounts:
- name: config
mountPath: /etc/kratos
readOnly: true
- name: tmp
mountPath: /tmp
readinessProbe:
httpGet:
path: /health/ready
port: 4434
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /health/alive
port: 4434
initialDelaySeconds: 10
periodSeconds: 30
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: "1"
memory: 512Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumes:
- name: config
configMap:
name: kratos-config
- name: tmp
emptyDir:
sizeLimit: 64Mi
---
apiVersion: v1
kind: Service
metadata:
name: kratos
namespace: honeydue
labels:
app.kubernetes.io/name: kratos
app.kubernetes.io/part-of: honeydue
spec:
selector:
app.kubernetes.io/name: kratos
ports:
- name: public
port: 4433
targetPort: 4433
- name: admin
port: 4434
targetPort: 4434
---
# Ingress to Kratos. Traefik (the auth.myhoneydue.com IngressRoute) reaches
# only the public API :4433. The honeyDue api pods reach the public API :4433
# (session whoami) AND the admin API :4434 (identity deletion on account
# close). The admin API :4434 takes no other cluster ingress.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-ingress-to-kratos
namespace: honeydue
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: kratos
policyTypes:
- Ingress
ingress:
# Traefik ingress controller -> public API only.
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- port: 4433
protocol: TCP
# honeyDue api pods -> public API (whoami) + admin API (identity deletion).
- from:
- podSelector:
matchLabels:
app.kubernetes.io/name: api
ports:
- port: 4433
protocol: TCP
- port: 4434
protocol: TCP
---
# Kratos egress: DNS, the Neon Postgres database, SMTP, and HTTPS to the
# OIDC providers (Apple/Google token + JWKS endpoints).
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-egress-from-kratos
namespace: honeydue
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: kratos
policyTypes:
- Egress
egress:
- to:
- namespaceSelector: {}
ports:
- port: 53
protocol: UDP
- port: 53
protocol: TCP
# Neon Postgres (external)
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 10.42.0.0/16
- 10.43.0.0/16
ports:
- port: 5432
protocol: TCP
# SMTP (Fastmail) + HTTPS to Apple/Google OIDC endpoints (external)
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 10.42.0.0/16
- 10.43.0.0/16
ports:
- port: 465
protocol: TCP
- port: 443
protocol: TCP
@@ -0,0 +1,51 @@
# Ory Kratos schema migration — runs `kratos migrate sql` against the Kratos
# database before the Kratos Deployment rolls. 03-deploy.sh applies this,
# waits for completion, then applies kratos.yaml.
#
# IMAGE: pinned to oryd/kratos v26.2.0 (CalVer current stable as of 2026-06-03)
# with the linux/amd64 digest. Bump in sync with kratos.yaml's image.
apiVersion: batch/v1
kind: Job
metadata:
name: kratos-migrate
namespace: honeydue
labels:
app.kubernetes.io/name: kratos
app.kubernetes.io/part-of: honeydue
spec:
backoffLimit: 0
template:
metadata:
labels:
app.kubernetes.io/name: kratos
app.kubernetes.io/part-of: honeydue
spec:
restartPolicy: Never
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: kratos-migrate
image: oryd/kratos:v26.2.0@sha256:92eedc292ff8e1a918ac442c88ed0abe44610c75121700963114549908a45ac3
imagePullPolicy: IfNotPresent
args: ["migrate", "sql", "-e", "--yes"]
env:
- name: DSN
valueFrom:
secretKeyRef:
name: kratos-secrets
key: dsn
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 500m
memory: 256Mi
@@ -0,0 +1,61 @@
# Kyverno image-signature verification policy (audit CODE-L5).
#
# ──────────────────────────────────────────────────────────────────────────
# THIS MANIFEST IS NOT APPLIED BY 03-deploy.sh. It is intentionally outside
# the script's apply set. Applying it before the prerequisites are in place
# would block every honeydue Pod from scheduling. Operator steps:
#
# 1. Install Kyverno in the cluster (it is an admission controller):
# kubectl create -f https://github.com/kyverno/kyverno/releases/latest/download/install.yaml
# 2. Generate a cosign key pair and keep the private key safe:
# cosign generate-key-pair # -> cosign.key (PRIVATE) + cosign.pub
# Set COSIGN_KEY=cosign.key in the deploy environment so 03-deploy.sh
# signs images after pushing them (the signing step is already wired,
# guarded, into 03-deploy.sh).
# 3. Paste the contents of cosign.pub into the publicKeys block below.
# 4. Apply this policy: kubectl apply -f deploy-k3s/manifests/kyverno-verify-images.yaml
# 5. After confirming honeydue Pods still schedule, flip
# validationFailureAction from Audit to Enforce.
#
# Until then it is a documented, ready-to-use template — not active config.
# ──────────────────────────────────────────────────────────────────────────
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: verify-honeydue-images
annotations:
policies.kyverno.io/title: Verify honeyDue image signatures
policies.kyverno.io/description: >-
Requires that honeyDue application images pulled into the honeydue
namespace carry a valid cosign signature made with the operator's key.
spec:
# Audit first — logs violations without blocking. Switch to Enforce once
# signing is confirmed working end to end.
validationFailureAction: Audit
background: false
webhookTimeoutSeconds: 30
rules:
- name: verify-gitea-image-signatures
match:
any:
- resources:
kinds:
- Pod
namespaces:
- honeydue
verifyImages:
# Only the images we build and sign. Public base images
# (redis, vmagent) are pinned by digest instead — see their manifests.
- imageReferences:
- "gitea.treytartt.com/admin/honeydue-api*"
- "gitea.treytartt.com/admin/honeydue-worker*"
- "gitea.treytartt.com/admin/honeydue-admin*"
- "gitea.treytartt.com/admin/honeydue-web*"
attestors:
- count: 1
entries:
- keys:
publicKeys: |-
-----BEGIN PUBLIC KEY-----
REPLACE_WITH_CONTENTS_OF_cosign.pub
-----END PUBLIC KEY-----
+4 -1
View File
@@ -27,8 +27,10 @@ spec:
app.kubernetes.io/part-of: honeydue
spec:
restartPolicy: Never
# The migrate Job never calls the k8s API (audit F11).
automountServiceAccountToken: false
imagePullSecrets:
- name: ghcr-credentials
- name: gitea-credentials
securityContext:
runAsNonRoot: true
runAsUser: 1000
@@ -38,6 +40,7 @@ spec:
containers:
- name: goose
image: IMAGE_PLACEHOLDER # Replaced by 03-deploy.sh — same as api
imagePullPolicy: IfNotPresent # audit CODE-L4 — explicit
command: ["/bin/sh", "-c"]
# DB_HOST in the ConfigMap points at the -pooler endpoint for runtime.
# goose's session-scoped advisory lock can't survive PgBouncer
+165
View File
@@ -140,6 +140,20 @@ spec:
ports:
- protocol: TCP
port: 6379
# Kratos (in-cluster). The auth middleware validates every session via
# http://kratos:4433/sessions/whoami; the AuthService also uses :4434
# for account deletion (DELETE /admin/identities/{id}). k3s evaluates
# egress rules AFTER kube-proxy DNAT (runbook §9.2), so this podSelector
# rule covers Service ClusterIP traffic correctly.
- to:
- podSelector:
matchLabels:
app.kubernetes.io/name: kratos
ports:
- protocol: TCP
port: 4433
- protocol: TCP
port: 4434
# External services: Neon DB (5432), SMTP (587), HTTPS (443 — APNs, FCM, B2, PostHog)
- to:
- ipBlock:
@@ -275,3 +289,154 @@ spec:
ports:
- protocol: TCP
port: 443
---
# vmagent egress.
#
# IMPORTANT (gotcha): k3s's built-in NetworkPolicy controller appears to
# evaluate egress rules AFTER kube-proxy's DNAT, not before (contrary to
# the k8s spec). So traffic from a pod to the kubernetes Service
# (ClusterIP 10.43.0.1:443) is policy-checked as dst=<node_public_ip>:6443.
# That's why we need an explicit rule for :6443 to public IPs, even though
# we already allow :443 to the cluster service CIDR.
#
# Without the :6443 rule, vmagent's k8s service discovery silently fails
# and zero pods get scraped. See deploy-k3s/RUNBOOK.md ("vmagent SD broken").
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-egress-from-vmagent
namespace: honeydue
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: vmagent
policyTypes:
- Egress
egress:
# DNS (cluster-internal)
- to:
- namespaceSelector: {}
ports:
- port: 53
protocol: UDP
- port: 53
protocol: TCP
# k8s API server via ClusterIP (pre-DNAT view)
- to:
- ipBlock:
cidr: 10.43.0.0/16
ports:
- port: 443
protocol: TCP
# k8s API server post-DNAT (real path k3s NetPol enforcer sees) — REQUIRED
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 10.42.0.0/16
ports:
- port: 6443
protocol: TCP
# Scrape api Pods on :8000
- to:
- ipBlock:
cidr: 10.42.0.0/16
ports:
- port: 8000
protocol: TCP
# Scrape kube-state-metrics Pod on :8080 (pod CIDR)
- to:
- ipBlock:
cidr: 10.42.0.0/16
ports:
- port: 8080
protocol: TCP
# HTTPS to public (remote-write to obs.88oakapps.com via Cloudflare)
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 10.42.0.0/16
- 10.43.0.0/16
ports:
- port: 443
protocol: TCP
---
# Allow vmagent → api ingress on :8000 so api pods accept scrapes.
# api Pods are otherwise locked down by default-deny-all + allow-ingress-to-api
# (which only allows Traefik). This adds vmagent specifically.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-vmagent-to-api
namespace: honeydue
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: api
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app.kubernetes.io/name: vmagent
ports:
- port: 8000
protocol: TCP
---
# alloy-logs egress — Grafana Alloy discovers honeydue pods via the k8s API
# and pushes their logs to Loki at obs.88oakapps.com. Same k3s NetworkPolicy
# DNAT gotcha as vmagent: API-server traffic is policy-checked as
# dst=<node_public_ip>:6443, so an explicit :6443 rule is required.
# Alloy reads log FILES from a hostPath, so it needs no ingress and no
# egress to pod :8000/:8080 — only DNS, the API server, and obs HTTPS.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-egress-from-alloy-logs
namespace: honeydue
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: alloy-logs
policyTypes:
- Egress
egress:
# DNS (cluster-internal)
- to:
- namespaceSelector: {}
ports:
- port: 53
protocol: UDP
- port: 53
protocol: TCP
# k8s API server via ClusterIP (pre-DNAT view)
- to:
- ipBlock:
cidr: 10.43.0.0/16
ports:
- port: 443
protocol: TCP
# k8s API server post-DNAT (real path k3s NetPol enforcer sees) — REQUIRED
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 10.42.0.0/16
ports:
- port: 6443
protocol: TCP
# HTTPS to public (log push to obs.88oakapps.com via Cloudflare)
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 10.42.0.0/16
- 10.43.0.0/16
ports:
- port: 443
protocol: TCP
@@ -0,0 +1,278 @@
# honeyDue log shipper — Grafana Alloy as a DaemonSet.
#
# Each node runs one Alloy pod that tails the honeydue-namespace pod logs in
# /var/log/pods and pushes them to Loki at obs.88oakapps.com/loki/api/v1/push
# (the same nginx ingest endpoint + bearer token vmagent uses for metrics).
#
# Runs as root: /var/log/pods is 0750 root:root on the k3s nodes, so a
# non-root uid cannot even traverse it. The container is otherwise locked
# down — all capabilities dropped, read-only root filesystem, seccomp
# RuntimeDefault — and root inside the container reads only a read-only
# hostPath mount of /var/log/pods. This is the one root-running workload in
# the namespace (standard for log collectors); see docs/deployment.
#
# 03-deploy.sh substitutes TOKEN_PLACEHOLDER with OBS_INGEST_TOKEN from
# deploy/prod.env before applying — the token never lands in the repo.
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: alloy-logs
namespace: honeydue
labels:
app.kubernetes.io/name: alloy-logs
app.kubernetes.io/part-of: honeydue
---
# Least privilege: Alloy's discovery.kubernetes only lists/watches pods, and
# only in the honeydue namespace — so a namespaced Role, not a ClusterRole.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: alloy-logs
namespace: honeydue
labels:
app.kubernetes.io/name: alloy-logs
app.kubernetes.io/part-of: honeydue
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: alloy-logs
namespace: honeydue
labels:
app.kubernetes.io/name: alloy-logs
app.kubernetes.io/part-of: honeydue
subjects:
- kind: ServiceAccount
name: alloy-logs
namespace: honeydue
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: alloy-logs
---
# Bearer token for the Loki push endpoint. TOKEN_PLACEHOLDER is replaced by
# 03-deploy.sh with OBS_INGEST_TOKEN (same token vmagent uses).
apiVersion: v1
kind: Secret
metadata:
name: alloy-logs-auth
namespace: honeydue
labels:
app.kubernetes.io/name: alloy-logs
app.kubernetes.io/part-of: honeydue
type: Opaque
stringData:
bearer_token: TOKEN_PLACEHOLDER
---
apiVersion: v1
kind: ConfigMap
metadata:
name: alloy-logs
namespace: honeydue
labels:
app.kubernetes.io/name: alloy-logs
app.kubernetes.io/part-of: honeydue
data:
config.alloy: |
// honeyDue log shipper. Each DaemonSet instance discovers honeydue-namespace
// pods via the Kubernetes API, tails the container log files present on its
// own node (/var/log/pods), and pushes them to Loki at obs.88oakapps.com.
logging {
level = "warn"
format = "logfmt"
}
discovery.kubernetes "pods" {
role = "pod"
namespaces {
names = ["honeydue"]
}
}
// Turn pod metadata into Loki labels and build the on-disk log path.
discovery.relabel "pod_logs" {
targets = discovery.kubernetes.pods.targets
rule {
source_labels = ["__meta_kubernetes_namespace"]
action = "replace"
target_label = "namespace"
}
rule {
source_labels = ["__meta_kubernetes_pod_name"]
action = "replace"
target_label = "pod"
}
rule {
source_labels = ["__meta_kubernetes_pod_container_name"]
action = "replace"
target_label = "container"
}
rule {
source_labels = ["__meta_kubernetes_pod_label_app_kubernetes_io_name"]
action = "replace"
target_label = "app"
}
rule {
source_labels = ["__meta_kubernetes_pod_node_name"]
action = "replace"
target_label = "node"
}
// /var/log/pods/<namespace>_<pod>_<uid>/<container>/<n>.log
rule {
source_labels = ["__meta_kubernetes_pod_uid", "__meta_kubernetes_pod_container_name"]
separator = "/"
action = "replace"
replacement = "/var/log/pods/*$1/*.log"
target_label = "__path__"
}
}
local.file_match "pod_logs" {
path_targets = discovery.relabel.pod_logs.output
}
loki.source.file "pod_logs" {
targets = local.file_match.pod_logs.targets
forward_to = [loki.process.pod_logs.receiver]
// With no stored read offset (fresh node, or positions wiped), start
// at the END of each file instead of re-shipping history — otherwise
// Loki rejects the now-too-old entries ("entry too far behind") and
// shipping stalls. Offsets persist on a hostPath (see volumes), so a
// normal pod restart resumes exactly where it left off.
tail_from_end = true
}
// Parse the CRI log format (timestamp / stream / flags / message),
// then drop probe/scrape noise before shipping.
loki.process "pod_logs" {
forward_to = [loki.write.obs.receiver]
stage.cri {}
// Drop successful probe/scrape access logs. k8s liveness/readiness
// hits /api/health/ every few seconds and vmagent scrapes /metrics
// on a 15s interval — all 2xx, pure noise that drowns real logs.
// A non-2xx health check, or one logged above info level, does NOT
// match this regex and is kept.
stage.drop {
expression = "\"level\":\"info\".*\"path\":\"/(api/health/?|metrics)\".*\"status\":2[0-9][0-9]"
drop_counter_reason = "probe_access_ok"
}
}
loki.write "obs" {
endpoint {
url = "https://obs.88oakapps.com/loki/api/v1/push"
bearer_token_file = "/etc/alloy-secrets/bearer_token"
}
external_labels = {
cluster = "honeydue-k3s",
environment = "prod",
}
}
---
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: alloy-logs
namespace: honeydue
labels:
app.kubernetes.io/name: alloy-logs
app.kubernetes.io/part-of: honeydue
spec:
selector:
matchLabels:
app.kubernetes.io/name: alloy-logs
updateStrategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1
template:
metadata:
labels:
app.kubernetes.io/name: alloy-logs
app.kubernetes.io/part-of: honeydue
spec:
serviceAccountName: alloy-logs
# Alloy needs its SA token — discovery.kubernetes talks to the API server.
automountServiceAccountToken: true
# Root is required to traverse /var/log/pods (0750 root:root). The
# container is otherwise fully confined (see container securityContext).
securityContext:
runAsUser: 0
runAsGroup: 0
seccompProfile:
type: RuntimeDefault
tolerations:
# DaemonSet must run on every node, including any control-plane taint.
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
containers:
- name: alloy
image: grafana/alloy:v1.5.1@sha256:01a63f4e032ce54ee94b22049bc27f597e74f85566478c377f4b5c7f020c1eb3
imagePullPolicy: IfNotPresent
args:
- run
- /etc/alloy/config.alloy
- --storage.path=/tmp/alloy
- --server.http.listen-addr=0.0.0.0:12345
ports:
- name: http
containerPort: 12345
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: config
mountPath: /etc/alloy
readOnly: true
- name: auth
mountPath: /etc/alloy-secrets
readOnly: true
- name: varlogpods
mountPath: /var/log/pods
readOnly: true
- name: tmp
mountPath: /tmp/alloy
readinessProbe:
httpGet:
path: /-/ready
port: 12345
initialDelaySeconds: 10
periodSeconds: 20
resources:
requests:
cpu: 25m
memory: 64Mi
limits:
cpu: 150m
memory: 256Mi
volumes:
- name: config
configMap:
name: alloy-logs
- name: auth
secret:
secretName: alloy-logs-auth
defaultMode: 0400
- name: varlogpods
hostPath:
path: /var/log/pods
type: Directory
# Alloy's positions/WAL store. A hostPath (not emptyDir) so file read
# offsets survive pod restarts — otherwise every restart re-reads log
# files from the start and Loki rejects the now-too-old entries.
- name: tmp
hostPath:
path: /var/lib/honeydue-alloy-logs
type: DirectoryOrCreate
@@ -0,0 +1,223 @@
# kube-state-metrics — exposes cluster object state (pods, deployments,
# services, etc.) as Prometheus metrics. vmagent scrapes it via the api
# group defined in vmagent-config; Grafana panels that count pods,
# replicas, etc. consume the `kube_*` metrics this produces.
#
# Lives in kube-system because it watches resources cluster-wide.
# RBAC is cluster-scoped (ClusterRole + ClusterRoleBinding).
#
# Image: registry.k8s.io/kube-state-metrics/kube-state-metrics:v2.13.0
# (latest stable as of authoring; bump when a newer minor is released)
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: kube-state-metrics
namespace: kube-system
labels:
app.kubernetes.io/name: kube-state-metrics
app.kubernetes.io/part-of: honeydue-observability
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: kube-state-metrics
labels:
app.kubernetes.io/name: kube-state-metrics
app.kubernetes.io/part-of: honeydue-observability
rules:
# Core resources
- apiGroups: [""]
resources:
- configmaps
- secrets
- nodes
- pods
- services
- serviceaccounts
- resourcequotas
- replicationcontrollers
- limitranges
- persistentvolumeclaims
- persistentvolumes
- namespaces
- endpoints
verbs: [list, watch]
# Apps
- apiGroups: ["apps"]
resources:
- statefulsets
- daemonsets
- deployments
- replicasets
verbs: [list, watch]
# Batch
- apiGroups: ["batch"]
resources:
- cronjobs
- jobs
verbs: [list, watch]
# Autoscaling
- apiGroups: ["autoscaling"]
resources:
- horizontalpodautoscalers
verbs: [list, watch]
# Authentication / authorization (used by some ksm collectors)
- apiGroups: ["authentication.k8s.io"]
resources: [tokenreviews]
verbs: [create]
- apiGroups: ["authorization.k8s.io"]
resources: [subjectaccessreviews]
verbs: [create]
# Policy
- apiGroups: ["policy"]
resources: [poddisruptionbudgets]
verbs: [list, watch]
# Certificate signing
- apiGroups: ["certificates.k8s.io"]
resources: [certificatesigningrequests]
verbs: [list, watch]
# Discovery
- apiGroups: ["discovery.k8s.io"]
resources: [endpointslices]
verbs: [list, watch]
# Storage
- apiGroups: ["storage.k8s.io"]
resources:
- storageclasses
- volumeattachments
verbs: [list, watch]
# Admission policy
- apiGroups: ["admissionregistration.k8s.io"]
resources:
- mutatingwebhookconfigurations
- validatingwebhookconfigurations
verbs: [list, watch]
# Networking
- apiGroups: ["networking.k8s.io"]
resources:
- networkpolicies
- ingressclasses
- ingresses
verbs: [list, watch]
# Coordination (leader election)
- apiGroups: ["coordination.k8s.io"]
resources: [leases]
verbs: [list, watch]
# RBAC
- apiGroups: ["rbac.authorization.k8s.io"]
resources:
- clusterrolebindings
- clusterroles
- rolebindings
- roles
verbs: [list, watch]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: kube-state-metrics
labels:
app.kubernetes.io/name: kube-state-metrics
app.kubernetes.io/part-of: honeydue-observability
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: kube-state-metrics
subjects:
- kind: ServiceAccount
name: kube-state-metrics
namespace: kube-system
---
apiVersion: v1
kind: Service
metadata:
name: kube-state-metrics
namespace: kube-system
labels:
app.kubernetes.io/name: kube-state-metrics
app.kubernetes.io/part-of: honeydue-observability
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: kube-state-metrics
ports:
- name: http-metrics
port: 8080
targetPort: http-metrics
protocol: TCP
- name: telemetry
port: 8081
targetPort: telemetry
protocol: TCP
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: kube-state-metrics
namespace: kube-system
labels:
app.kubernetes.io/name: kube-state-metrics
app.kubernetes.io/part-of: honeydue-observability
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: kube-state-metrics
template:
metadata:
labels:
app.kubernetes.io/name: kube-state-metrics
app.kubernetes.io/part-of: honeydue-observability
spec:
serviceAccountName: kube-state-metrics
automountServiceAccountToken: true
securityContext:
runAsNonRoot: true
runAsUser: 65534
fsGroup: 65534
seccompProfile:
type: RuntimeDefault
containers:
- name: kube-state-metrics
image: registry.k8s.io/kube-state-metrics/kube-state-metrics:v2.13.0
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
name: http-metrics
- containerPort: 8081
name: telemetry
args:
- --port=8080
- --telemetry-port=8081
resources:
requests:
cpu: 25m
memory: 64Mi
limits:
cpu: 200m
memory: 256Mi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
readOnlyRootFilesystem: true
livenessProbe:
httpGet:
path: /livez
port: http-metrics
initialDelaySeconds: 5
periodSeconds: 30
readinessProbe:
httpGet:
path: /readyz
port: http-metrics
initialDelaySeconds: 5
periodSeconds: 10
@@ -0,0 +1,126 @@
# node-exporter — per-node host metrics (filesystem, memory, load, CPU).
# Runs as a normal pod (NOT hostNetwork) so vmagent scrapes it pod-to-pod over
# the cluster CIDR, avoiding any dependency on node public IPs (the netpol
# node-IP list is OVH-stale). Host /proc, /sys and / are bind-mounted read-only
# so the filesystem/memory/load collectors read the real host, not the pod ns.
# Added 2026-06-08 to close RUNBOOK §11.1 gap #9 (node disk/mem were unmonitored).
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: node-exporter
namespace: honeydue
labels:
app.kubernetes.io/name: node-exporter
app.kubernetes.io/part-of: honeydue
spec:
selector:
matchLabels:
app.kubernetes.io/name: node-exporter
template:
metadata:
labels:
app.kubernetes.io/name: node-exporter
app.kubernetes.io/part-of: honeydue
spec:
# Run on every node, including any tainted control-plane nodes.
tolerations:
- operator: Exists
securityContext:
runAsNonRoot: true
runAsUser: 65534
seccompProfile:
type: RuntimeDefault
containers:
- name: node-exporter
image: quay.io/prometheus/node-exporter:v1.8.2 # TODO digest-pin (audit K3S-F14)
imagePullPolicy: IfNotPresent
args:
- --path.procfs=/host/proc
- --path.sysfs=/host/sys
- --path.rootfs=/host/root
# Only report real host mounts; drop the kubelet/container churn.
- --collector.filesystem.mount-points-exclude=^/(dev|proc|sys|run|var/lib/kubelet/.+|var/lib/docker/.+|var/lib/containerd/.+)($|/)
- --collector.filesystem.fs-types-exclude=^(autofs|binfmt_misc|bpf|cgroup2?|configfs|debugfs|devpts|devtmpfs|fusectl|hugetlbfs|iso9660|mqueue|nsfs|overlay|proc|procfs|pstore|rpc_pipefs|securityfs|selinuxfs|squashfs|sysfs|tracefs)$
- --no-collector.wifi
- --no-collector.hwmon
- --web.listen-address=:9100
ports:
- name: metrics
containerPort: 9100
protocol: TCP
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
resources:
requests:
cpu: 30m
memory: 32Mi
limits:
cpu: 200m
memory: 128Mi
volumeMounts:
- name: proc
mountPath: /host/proc
readOnly: true
- name: sys
mountPath: /host/sys
readOnly: true
- name: root
mountPath: /host/root
mountPropagation: HostToContainer
readOnly: true
volumes:
- name: proc
hostPath:
path: /proc
- name: sys
hostPath:
path: /sys
- name: root
hostPath:
path: /
---
# default-deny-all blocks ingress; allow vmagent to scrape :9100.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-ingress-to-node-exporter
namespace: honeydue
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: node-exporter
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app.kubernetes.io/name: vmagent
ports:
- port: 9100
protocol: TCP
---
# vmagent's existing egress policy only opens :8000/:8080 to the pod CIDR.
# Additive policy (NetworkPolicies are OR'd) opening :9100 for the node-exporter
# scrape — leaves the working allow-egress-from-vmagent policy untouched.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-egress-from-vmagent-to-node-exporter
namespace: honeydue
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: vmagent
policyTypes:
- Egress
egress:
- to:
- ipBlock:
cidr: 10.42.0.0/16
ports:
- port: 9100
protocol: TCP
+118 -16
View File
@@ -42,18 +42,61 @@ data:
- target_label: service
replacement: api
# honeyDue worker — also exposes /metrics if/when we add it.
# Keep this stanza commented until the worker has a /metrics endpoint;
# uncommented form drops scrapes silently.
# - job_name: worker
# kubernetes_sd_configs:
# - role: pod
# namespaces:
# names: [honeydue]
# relabel_configs:
# - source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_name]
# action: keep
# regex: worker
# kube-state-metrics — cluster object state (kube_pod_*, kube_deployment_*,
# etc.) needed for Grafana panels that count pods/replicas/etc.
- job_name: kube-state-metrics
kubernetes_sd_configs:
- role: endpoints
namespaces:
names: [kube-system]
relabel_configs:
- source_labels: [__meta_kubernetes_service_label_app_kubernetes_io_name]
action: keep
regex: kube-state-metrics
- source_labels: [__meta_kubernetes_endpoint_port_name]
action: keep
regex: http-metrics
# node-exporter — per-node host metrics (node_filesystem_*, node_memory_*,
# node_load*). Pod-networked DaemonSet scraped on :9100 over the pod CIDR.
- job_name: node-exporter
kubernetes_sd_configs:
- role: pod
namespaces:
names: [honeydue]
relabel_configs:
- source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_name]
action: keep
regex: node-exporter
- source_labels: [__meta_kubernetes_pod_container_port_number]
action: keep
regex: "9100"
- source_labels: [__meta_kubernetes_pod_name]
target_label: pod
- source_labels: [__meta_kubernetes_pod_node_name]
target_label: node
- target_label: service
replacement: node-exporter
# honeyDue worker — exposes /metrics on :6060 (apns/fcm/asynq/cache series).
- job_name: worker
kubernetes_sd_configs:
- role: pod
namespaces:
names: [honeydue]
relabel_configs:
- source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_name]
action: keep
regex: worker
- source_labels: [__meta_kubernetes_pod_container_port_number]
action: keep
regex: "6060"
- source_labels: [__meta_kubernetes_pod_name]
target_label: pod
- source_labels: [__meta_kubernetes_pod_node_name]
target_label: node
- target_label: service
replacement: worker
---
apiVersion: v1
@@ -104,6 +147,35 @@ roleRef:
name: vmagent
apiGroup: rbac.authorization.k8s.io
---
# Allow vmagent to discover the kube-state-metrics Service/Endpoints in
# kube-system so the kube-state-metrics scrape job can find its target.
# Cross-namespace SD needs an explicit RoleBinding here.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: vmagent-kube-system
namespace: kube-system
rules:
- apiGroups: [""]
resources: [services, endpoints, pods]
verbs: [get, list, watch]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: vmagent-kube-system
namespace: kube-system
subjects:
- kind: ServiceAccount
name: vmagent
namespace: honeydue
roleRef:
kind: Role
name: vmagent-kube-system
apiGroup: rbac.authorization.k8s.io
---
apiVersion: apps/v1
kind: Deployment
@@ -135,7 +207,17 @@ spec:
type: RuntimeDefault
containers:
- name: vmagent
image: victoriametrics/vmagent:v1.106.1
# Pinned by digest (audit K3S-F14).
image: victoriametrics/vmagent:v1.106.1@sha256:90208a667c0baf65f7536b92a84c40b6e35ffe8e88bda7e4447b97b06c6ba6b8
imagePullPolicy: IfNotPresent # audit CODE-L4 — explicit
# Container-level hardening (audit F7) — matches the other 5
# workloads. vmagent only writes to the /tmp/vmagent emptyDir
# (its remoteWrite buffer), so a read-only root filesystem holds.
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
args:
- "-promscrape.config=/etc/vmagent/scrape.yaml"
- "-remoteWrite.url=https://obs.88oakapps.com/api/v1/write"
@@ -162,12 +244,32 @@ spec:
readOnly: true
- name: buffer
mountPath: /tmp/vmagent
livenessProbe:
# Process startup gate. /-/healthy returns 200 once vmagent has
# parsed config — gives the agent up to 2 min to come up before
# liveness starts evaluating.
startupProbe:
httpGet:
path: /-/healthy
port: http
initialDelaySeconds: 10
periodSeconds: 30
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 24
# Real liveness check: are scrapes actually succeeding?
# /-/healthy was the old probe and returned 200 for 17 days even
# while vmagent had zero healthy targets (stale k8s SD watch).
# This exec probe queries vmagent's own targets API and fails if
# NO target is in state "up". Three consecutive failures (3 min)
# → kubelet kills the pod → fresh SD watch.
livenessProbe:
exec:
command:
- sh
- -c
- 'n=$(wget -qO- -T 4 http://localhost:8429/api/v1/targets 2>/dev/null | grep -c ''"health":"up"''); [ "$n" -gt 0 ]'
initialDelaySeconds: 180
periodSeconds: 120
timeoutSeconds: 5
failureThreshold: 5
readinessProbe:
httpGet:
path: /-/healthy
+6 -1
View File
@@ -20,6 +20,9 @@ spec:
app.kubernetes.io/part-of: honeydue
spec:
serviceAccountName: redis
# Explicit pod-level opt-out (audit F11) — defense-in-depth on top of
# the ServiceAccount-level setting in rbac.yaml.
automountServiceAccountToken: false
nodeSelector:
honeydue/redis: "true"
securityContext:
@@ -31,7 +34,9 @@ spec:
type: RuntimeDefault
containers:
- name: redis
image: redis:7-alpine
# Pinned by digest (audit K3S-F14) — redis:7-alpine is 7.4.9-alpine.
image: redis:7-alpine@sha256:6ab0b6e7381779332f97b8ca76193e45b0756f38d4c0dcda72dbb3c32061ab99
imagePullPolicy: IfNotPresent # audit CODE-L4 — explicit
command:
- sh
- -c
+5 -1
View File
@@ -23,8 +23,11 @@ spec:
app.kubernetes.io/part-of: honeydue
spec:
serviceAccountName: web
# Explicit pod-level opt-out (audit F11) — defense-in-depth on top of
# the ServiceAccount-level setting in rbac.yaml.
automountServiceAccountToken: false
imagePullSecrets:
- name: ghcr-credentials
- name: gitea-credentials
securityContext:
runAsNonRoot: true
runAsUser: 1001
@@ -43,6 +46,7 @@ spec:
containers:
- name: web
image: IMAGE_PLACEHOLDER # Replaced by 03-deploy.sh or manual sed
imagePullPolicy: IfNotPresent # audit CODE-L4 — explicit; images are SHA/digest-pinned
ports:
- containerPort: 3000
protocol: TCP
+68 -43
View File
@@ -27,8 +27,11 @@ spec:
app.kubernetes.io/part-of: honeydue
spec:
serviceAccountName: worker
# Explicit pod-level opt-out (audit F11) — defense-in-depth on top of
# the ServiceAccount-level setting in rbac.yaml.
automountServiceAccountToken: false
imagePullSecrets:
- name: ghcr-credentials
- name: gitea-credentials
securityContext:
runAsNonRoot: true
runAsUser: 1000
@@ -39,6 +42,12 @@ spec:
containers:
- name: worker
image: IMAGE_PLACEHOLDER # Replaced by 03-deploy.sh
imagePullPolicy: IfNotPresent # audit CODE-L4 — explicit; images are SHA/digest-pinned
ports:
# health + Prometheus /metrics (in-cluster only; scraped by vmagent)
- name: metrics
containerPort: 6060
protocol: TCP
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
@@ -47,49 +56,16 @@ spec:
envFrom:
- configMapRef:
name: honeydue-config
env:
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: honeydue-secrets
key: POSTGRES_PASSWORD
- name: SECRET_KEY
valueFrom:
secretKeyRef:
name: honeydue-secrets
key: SECRET_KEY
- name: EMAIL_HOST_PASSWORD
valueFrom:
secretKeyRef:
name: honeydue-secrets
key: EMAIL_HOST_PASSWORD
- name: FCM_SERVER_KEY
valueFrom:
secretKeyRef:
name: honeydue-secrets
key: FCM_SERVER_KEY
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: honeydue-secrets
key: REDIS_PASSWORD
optional: true
# Observability — workers emit traces (e.g., asynq job spans) to
# obs.88oakapps.com over OTLP/HTTP. service.name=honeydue-worker
# so api and worker show up as separate services in Jaeger.
- name: OBS_TRACES_URL
valueFrom:
secretKeyRef:
name: honeydue-secrets
key: OBS_TRACES_URL
optional: true
- name: OBS_INGEST_TOKEN
valueFrom:
secretKeyRef:
name: honeydue-secrets
key: OBS_INGEST_TOKEN
optional: true
# Audit CODE-F8: secrets are NOT injected as environment variables.
# Env vars are readable for the life of the pod via /proc/<pid>/environ
# and leak into crash dumps / child processes. honeydue-secrets is
# mounted read-only at /etc/honeydue/secrets (mode 0400) and the Go
# config layer (config.loadFileSecrets) reads each key from its file.
# Non-secret config still arrives via the configMapRef above.
volumeMounts:
- name: app-secrets
mountPath: /etc/honeydue/secrets
readOnly: true
- name: apns-key
mountPath: /secrets/apns
readOnly: true
@@ -109,6 +85,12 @@ spec:
periodSeconds: 30
timeoutSeconds: 5
volumes:
# Audit CODE-F8: the whole honeydue-secrets Secret, projected as files.
# defaultMode 0400 → readable only by the container's runAsUser (1000).
- name: app-secrets
secret:
secretName: honeydue-secrets
defaultMode: 0400
- name: apns-key
secret:
secretName: honeydue-apns-key
@@ -118,3 +100,46 @@ spec:
- name: tmp
emptyDir:
sizeLimit: 64Mi
---
# Allow vmagent to scrape the worker's /metrics on :6060 (default-deny-all is in
# force; the worker otherwise receives no ingress). Additive — see node-exporter.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-ingress-to-worker-metrics
namespace: honeydue
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: worker
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app.kubernetes.io/name: vmagent
ports:
- port: 6060
protocol: TCP
---
# vmagent's base egress policy only opens :8000/:8080 to the pod CIDR; this
# additive policy opens :6060 for the worker scrape (leaves the base untouched).
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-egress-from-vmagent-to-worker
namespace: honeydue
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: vmagent
policyTypes:
- Egress
egress:
- to:
- ipBlock:
cidr: 10.42.0.0/16
ports:
- port: 6060
protocol: TCP
+56 -5
View File
@@ -68,6 +68,25 @@ SECRET_ARGS=(
if [[ -n "${REDIS_PASSWORD}" ]]; then
log " Including REDIS_PASSWORD in secrets"
SECRET_ARGS+=(--from-literal="REDIS_PASSWORD=${REDIS_PASSWORD}")
else
# Audit K3S-F1 (CRITICAL) / MEDIUM-4: refuse to deploy with an unauthenticated
# Redis. A previous version only warned here, which let a deploy from an
# unedited config.yaml silently bring Redis up with no password.
die "redis.password is empty in config.yaml — refusing to deploy: Redis would run with NO authentication (audit K3S-F1). Set a strong value, e.g.: openssl rand -base64 32"
fi
# B2 (Backblaze) object-storage credentials. The api/worker manifests
# reference B2_KEY_ID / B2_APP_KEY as required secret keys, so honeydue-secrets
# MUST carry them or those pods fail to start. Sourced from config.yaml so the
# script and the manifests no longer drift (was a latent gap before 2026-05-16).
B2_KEY_ID_VAL="$(cfg storage.b2_key_id 2>/dev/null || true)"
B2_APP_KEY_VAL="$(cfg storage.b2_app_key 2>/dev/null || true)"
if [[ -n "${B2_KEY_ID_VAL}" && -n "${B2_APP_KEY_VAL}" ]]; then
log " Including B2_KEY_ID / B2_APP_KEY in secrets"
SECRET_ARGS+=(--from-literal="B2_KEY_ID=${B2_KEY_ID_VAL}")
SECRET_ARGS+=(--from-literal="B2_APP_KEY=${B2_APP_KEY_VAL}")
else
warn "storage.b2_key_id / b2_app_key not set in config.yaml — B2 uploads will be disabled."
fi
# Observability ingest credentials live in deploy/prod.env (gitignored) so
@@ -100,22 +119,24 @@ kubectl create secret generic honeydue-apns-key \
--from-file="apns_auth_key.p8=${SECRETS_DIR}/apns_auth_key.p8" \
--dry-run=client -o yaml | kubectl apply -f -
# --- Create GHCR registry credentials ---
# --- Create container registry credentials ---
# Secret name is gitea-credentials (audit F6): the registry is self-hosted
# Gitea, not GHCR. Every deployment manifest references this same name.
REGISTRY_SERVER="$(cfg registry.server)"
REGISTRY_USER="$(cfg registry.username)"
REGISTRY_TOKEN="$(cfg registry.token)"
if [[ -n "${REGISTRY_SERVER}" && -n "${REGISTRY_USER}" && -n "${REGISTRY_TOKEN}" ]]; then
log "Creating ghcr-credentials..."
kubectl create secret docker-registry ghcr-credentials \
log "Creating gitea-credentials..."
kubectl create secret docker-registry gitea-credentials \
--namespace="${NAMESPACE}" \
--docker-server="${REGISTRY_SERVER}" \
--docker-username="${REGISTRY_USER}" \
--docker-password="${REGISTRY_TOKEN}" \
--dry-run=client -o yaml | kubectl apply -f -
else
warn "Registry credentials incomplete in config.yaml — skipping ghcr-credentials."
warn "Registry credentials incomplete in config.yaml — skipping gitea-credentials."
fi
# --- Create Cloudflare origin cert ---
@@ -132,7 +153,8 @@ kubectl create secret tls cloudflare-origin-cert \
if [[ -n "${ADMIN_AUTH_USER}" && -n "${ADMIN_AUTH_PASSWORD}" ]]; then
command -v htpasswd >/dev/null 2>&1 || die "Missing: htpasswd (install apache2-utils)"
log "Creating admin-basic-auth secret..."
HTPASSWD="$(htpasswd -nb "${ADMIN_AUTH_USER}" "${ADMIN_AUTH_PASSWORD}")"
# -B forces bcrypt (Traefik BasicAuth supports it; avoids weak apr1-MD5).
HTPASSWD="$(htpasswd -nbB "${ADMIN_AUTH_USER}" "${ADMIN_AUTH_PASSWORD}")"
kubectl create secret generic admin-basic-auth \
--namespace="${NAMESPACE}" \
--from-literal=users="${HTPASSWD}" \
@@ -142,6 +164,35 @@ else
warn "Admin panel will NOT have basic auth protection."
fi
# --- Create Kratos secrets (Ory Kratos identity service) ---
# Created only when config.yaml has a kratos.dsn. Until then 03-deploy.sh skips
# the Kratos deploy entirely, so the existing stack is unaffected.
KRATOS_DSN="$(cfg kratos.dsn 2>/dev/null || true)"
if [[ -n "${KRATOS_DSN}" ]]; then
log "Creating kratos-secrets..."
KR_COOKIE="$(cfg kratos.secrets_cookie 2>/dev/null || true)"
KR_CIPHER="$(cfg kratos.secrets_cipher 2>/dev/null || true)"
KR_SMTP="$(cfg kratos.smtp_connection_uri 2>/dev/null || true)"
KR_GOOGLE="$(cfg kratos.google_client_secret 2>/dev/null || true)"
KR_APPLE="$(cfg kratos.apple_private_key 2>/dev/null || true)"
[[ -n "${KR_COOKIE}" && -n "${KR_CIPHER}" ]] \
|| die "kratos.secrets_cookie / secrets_cipher must be set (generate once: openssl rand -hex 16)"
[[ ${#KR_CIPHER} -eq 32 ]] \
|| die "kratos.secrets_cipher must be exactly 32 characters (openssl rand -hex 16)"
kubectl create secret generic kratos-secrets \
--namespace="${NAMESPACE}" \
--from-literal="dsn=${KRATOS_DSN}" \
--from-literal="secrets_cookie=${KR_COOKIE}" \
--from-literal="secrets_cipher=${KR_CIPHER}" \
--from-literal="smtp_connection_uri=${KR_SMTP}" \
--from-literal="google_client_secret=${KR_GOOGLE}" \
--from-literal="apple_private_key=${KR_APPLE}" \
--dry-run=client -o yaml | kubectl apply -f -
else
warn "config.yaml has no kratos.dsn — skipping kratos-secrets (Kratos not yet configured)."
fi
# --- Done ---
log ""
+105 -9
View File
@@ -128,6 +128,56 @@ else
warn "Skipping build. Using images for tag: ${DEPLOY_TAG}"
fi
# --- Resolve immutable image digests (audit F5) ---
# A short-SHA tag is mutable — anyone who can push to the registry can
# overwrite it, and imagePullPolicy then pulls the new bits silently. We
# deploy by @sha256: digest instead, pinning the exact image that was just
# built and pushed. `docker push` populates RepoDigests; with --skip-build
# (no local image) resolve_ref falls back to the tag.
resolve_ref() {
local img="$1" digest
digest="$(docker inspect --format='{{range .RepoDigests}}{{println .}}{{end}}' "${img}" 2>/dev/null | grep -m1 '@sha256:' || true)"
if [[ -n "${digest}" ]]; then
printf '%s' "${digest}"
else
warn "could not resolve a digest for ${img} — deploying by mutable tag"
printf '%s' "${img}"
fi
}
API_REF="$(resolve_ref "${API_IMAGE}")"
WORKER_REF="$(resolve_ref "${WORKER_IMAGE}")"
ADMIN_REF="$(resolve_ref "${ADMIN_IMAGE}")"
WEB_REF="$(resolve_ref "${WEB_IMAGE}")"
log "Deploying by digest:"
log " API: ${API_REF}"
log " Worker: ${WORKER_REF}"
log " Admin: ${ADMIN_REF}"
# --- Image scan + signing (audit CODE-L5) ---
# Both steps are best-effort: the deploy does NOT fail if the tools are
# absent, so an operator who has not set up cosign/trivy yet is not blocked.
# Install trivy + cosign and export COSIGN_KEY to enforce. Cluster-side
# admission verification (Kyverno/Connaisseur) is a separate operator step.
if [[ "${SKIP_BUILD}" == "false" ]]; then
if command -v trivy >/dev/null 2>&1; then
log "Scanning images with Trivy (HIGH,CRITICAL)..."
for img in "${API_IMAGE}" "${WORKER_IMAGE}" "${ADMIN_IMAGE}"; do
trivy image --severity HIGH,CRITICAL --exit-code 0 --quiet "${img}" \
|| warn "Trivy reported findings for ${img}"
done
else
warn "trivy not installed — skipping image vulnerability scan (audit L5)"
fi
if command -v cosign >/dev/null 2>&1 && [[ -n "${COSIGN_KEY:-}" ]]; then
log "Signing images with cosign..."
for ref in "${API_REF}" "${WORKER_REF}" "${ADMIN_REF}"; do
cosign sign --yes --key "${COSIGN_KEY}" "${ref}" || warn "cosign sign failed for ${ref}"
done
else
warn "cosign not configured (need cosign + COSIGN_KEY) — skipping image signing (audit L5)"
fi
fi
# --- Generate and apply ConfigMap from config.yaml ---
log "Generating env from config.yaml..."
@@ -146,6 +196,14 @@ kubectl create configmap honeydue-config \
log "Applying manifests..."
kubectl apply -f "${MANIFESTS}/namespace.yaml"
# NetworkPolicies first — default-deny-all + per-app allow rules.
# These MUST be applied; without them the cluster falls back to default-allow
# (worse posture) AND the vmagent egress rule for :6443 (which fixes a k3s
# post-DNAT enforcement quirk for k8s API discovery) is missing.
# See deploy-k3s/RUNBOOK.md ("vmagent SD broken on fresh deploy").
kubectl apply -f "${MANIFESTS}/network-policies.yaml"
kubectl apply -f "${MANIFESTS}/redis/"
kubectl apply -f "${MANIFESTS}/ingress/"
@@ -158,7 +216,7 @@ kubectl apply -f "${MANIFESTS}/ingress/"
# pod sees a stale schema.
log "Running database migrations (goose Job)..."
kubectl delete job honeydue-migrate -n "${NAMESPACE}" --ignore-not-found --wait=true >/dev/null
sed "s|image: IMAGE_PLACEHOLDER|image: ${API_IMAGE}|" "${MANIFESTS}/migrate/job.yaml" | kubectl apply -f -
sed "s|image: IMAGE_PLACEHOLDER|image: ${API_REF}|" "${MANIFESTS}/migrate/job.yaml" | kubectl apply -f -
if ! kubectl wait --namespace="${NAMESPACE}" --for=condition=complete --timeout=10m job/honeydue-migrate; then
warn "migration Job failed — see logs:"
kubectl logs -n "${NAMESPACE}" job/honeydue-migrate --tail=200 || true
@@ -167,32 +225,64 @@ fi
log "Migrations applied; proceeding with api/worker rollout"
# Apply deployments with image substitution
sed "s|image: IMAGE_PLACEHOLDER|image: ${API_IMAGE}|" "${MANIFESTS}/api/deployment.yaml" | kubectl apply -f -
sed "s|image: IMAGE_PLACEHOLDER|image: ${API_REF}|" "${MANIFESTS}/api/deployment.yaml" | kubectl apply -f -
kubectl apply -f "${MANIFESTS}/api/service.yaml"
kubectl apply -f "${MANIFESTS}/api/hpa.yaml"
sed "s|image: IMAGE_PLACEHOLDER|image: ${WORKER_IMAGE}|" "${MANIFESTS}/worker/deployment.yaml" | kubectl apply -f -
sed "s|image: IMAGE_PLACEHOLDER|image: ${WORKER_REF}|" "${MANIFESTS}/worker/deployment.yaml" | kubectl apply -f -
sed "s|image: IMAGE_PLACEHOLDER|image: ${ADMIN_IMAGE}|" "${MANIFESTS}/admin/deployment.yaml" | kubectl apply -f -
sed "s|image: IMAGE_PLACEHOLDER|image: ${ADMIN_REF}|" "${MANIFESTS}/admin/deployment.yaml" | kubectl apply -f -
kubectl apply -f "${MANIFESTS}/admin/service.yaml"
if [[ -d "${MANIFESTS}/web" ]]; then
sed "s|image: IMAGE_PLACEHOLDER|image: ${WEB_IMAGE}|" "${MANIFESTS}/web/deployment.yaml" | kubectl apply -f -
sed "s|image: IMAGE_PLACEHOLDER|image: ${WEB_REF}|" "${MANIFESTS}/web/deployment.yaml" | kubectl apply -f -
kubectl apply -f "${MANIFESTS}/web/service.yaml"
fi
# Observability — vmagent scrapes api Pods :8000/metrics and remote-writes
# to obs.88oakapps.com. The bearer token comes from deploy/prod.env so it
# stays out of the repo; the manifest holds TOKEN_PLACEHOLDER.
# Observability — vmagent scrapes api Pods :8000/metrics + kube-state-metrics
# :8080/metrics and remote-writes everything to obs.88oakapps.com. The bearer
# token comes from deploy/prod.env so it stays out of the repo; the manifest
# holds TOKEN_PLACEHOLDER. kube-state-metrics provides the kube_* metrics
# Grafana panels need to count pods, deployments, etc.
if [[ -d "${MANIFESTS}/observability" ]]; then
# kube-state-metrics — no secrets, plain apply
kubectl apply -f "${MANIFESTS}/observability/kube-state-metrics.yaml"
# vmagent — needs the bearer-token substitution
# prod.env lives at the repo's deploy/ dir (sibling of deploy-k3s/), not
# under deploy-k3s/. It's gitignored — operator copies values there once.
OBS_TOKEN="$(grep -E '^OBS_INGEST_TOKEN=' "${REPO_DIR}/deploy/prod.env" 2>/dev/null | cut -d= -f2- || true)"
if [[ -z "${OBS_TOKEN}" ]]; then
warn "OBS_INGEST_TOKEN not found in deploy/prod.env — skipping vmagent apply"
warn "OBS_INGEST_TOKEN not found in deploy/prod.env — skipping vmagent + alloy-logs apply"
else
sed "s|TOKEN_PLACEHOLDER|${OBS_TOKEN}|" "${MANIFESTS}/observability/vmagent.yaml" | kubectl apply -f -
# alloy-logs — DaemonSet that tails honeydue pod logs and pushes them to
# Loki at obs.88oakapps.com. Same OBS_INGEST_TOKEN as vmagent.
if [[ -f "${MANIFESTS}/observability/alloy-logs.yaml" ]]; then
sed "s|TOKEN_PLACEHOLDER|${OBS_TOKEN}|" "${MANIFESTS}/observability/alloy-logs.yaml" | kubectl apply -f -
fi
fi
fi
# --- Ory Kratos (identity service) ---
# Applied only when kratos-secrets exists — i.e. the operator has completed the
# Kratos prerequisites in deploy-k3s/manifests/kratos/README.md. Otherwise
# skipped, so the existing stack deploys unaffected.
if kubectl -n "${NAMESPACE}" get secret kratos-secrets >/dev/null 2>&1; then
log "Deploying Ory Kratos..."
kubectl apply -f "${MANIFESTS}/kratos/configmap.yaml"
# The migrate Job is immutable — delete any prior run, then apply + wait.
kubectl delete job kratos-migrate -n "${NAMESPACE}" --ignore-not-found --wait=true >/dev/null
kubectl apply -f "${MANIFESTS}/kratos/migrate-job.yaml"
if ! kubectl wait --namespace="${NAMESPACE}" --for=condition=complete --timeout=5m job/kratos-migrate; then
warn "Kratos migration Job failed — logs:"
kubectl logs -n "${NAMESPACE}" job/kratos-migrate --tail=100 || true
die "aborting: Kratos schema migration failed"
fi
kubectl apply -f "${MANIFESTS}/kratos/kratos.yaml"
kubectl apply -f "${MANIFESTS}/kratos/ingress.yaml"
else
log "kratos-secrets not present — skipping Kratos deploy (see manifests/kratos/README.md)."
fi
# --- Wait for rollouts ---
@@ -209,6 +299,12 @@ fi
if kubectl -n "${NAMESPACE}" get deployment vmagent >/dev/null 2>&1; then
kubectl rollout status deployment/vmagent -n "${NAMESPACE}" --timeout=120s
fi
if kubectl -n "${NAMESPACE}" get daemonset alloy-logs >/dev/null 2>&1; then
kubectl rollout status daemonset/alloy-logs -n "${NAMESPACE}" --timeout=120s
fi
if kubectl -n "${NAMESPACE}" get deployment kratos >/dev/null 2>&1; then
kubectl rollout status deployment/kratos -n "${NAMESPACE}" --timeout=180s
fi
# --- Done ---
+21 -6
View File
@@ -100,7 +100,7 @@ lines = [
# API
'DEBUG=false',
f\"ALLOWED_HOSTS={d['api']},{d['base']}\",
f\"CORS_ALLOWED_ORIGINS=https://{d['base']},https://{d['admin']}\",
f\"CORS_ALLOWED_ORIGINS=https://{d['base']},https://{d['admin']},https://{d.get('app', 'app.' + d['base'])}\",
'TIMEZONE=UTC',
f\"BASE_URL=https://{d['base']}\",
'PORT=8000',
@@ -119,9 +119,14 @@ lines = [
f\"DB_MAX_IDLE_CONNS={db['max_idle_conns']}\",
f\"DB_MAX_LIFETIME={db['max_lifetime']}\",
f\"DB_MAX_IDLE_TIME={db.get('max_idle_time', '0s')}\",
# Redis (in-namespace DNS short form — password injected if configured;
# short form works because /etc/resolv.conf in pods searches honeydue.svc.cluster.local)
f\"REDIS_URL=redis://{':%s@' % val(rd.get('password')) if rd.get('password') else ''}redis:6379/0\",
# Redis in-namespace DNS short form (works because pod /etc/resolv.conf
# searches honeydue.svc.cluster.local). Audit HIGH-1: the password is
# intentionally NOT embedded here. This URL is emitted into the
# honeydue-config ConfigMap, which is NOT encrypted at rest and is
# readable by anyone with `get configmap`. The Redis password travels
# only in honeydue-secrets as REDIS_PASSWORD (file-mounted, F8); the API
# applies it in cache_service.go and the worker onto its Asynq opt.
'REDIS_URL=redis://redis:6379/0',
'REDIS_DB=0',
# Email
f\"EMAIL_HOST={em['host']}\",
@@ -218,8 +223,18 @@ config = {
'image': 'ubuntu-24.04',
},
'additional_packages': ['open-iscsi'],
'post_create_commands': ['sudo systemctl enable --now iscsid'],
'k3s_config_file': 'secrets-encryption: true\n',
# Audit K3S-CG2: harden the node OS at provision time — fail2ban for SSH
# brute-force, unattended-upgrades for automatic security patches.
'post_create_commands': [
'sudo systemctl enable --now iscsid',
'sudo apt-get update -qq',
'sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq fail2ban unattended-upgrades',
'sudo systemctl enable --now fail2ban',
'sudo dpkg-reconfigure -f noninteractive -plow unattended-upgrades',
],
# Audit K3S-CG1 / K3S-F4: encrypt Secrets at rest in etcd, and write the
# node kubeconfig as mode 0600 (not world-readable).
'k3s_config_file': 'secrets-encryption: true\nwrite-kubeconfig-mode: \"0600\"\n',
}
print(yaml.dump(config, default_flow_style=False, sort_keys=False))
+39
View File
@@ -0,0 +1,39 @@
{
"$id": "https://honeydue.app/identity.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "honeyDue user",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email",
"title": "Email",
"minLength": 3,
"maxLength": 320,
"ory.sh/kratos": {
"credentials": {
"password": { "identifier": true },
"code": { "identifier": true, "via": "email" },
"totp": { "account_name": true }
},
"verification": { "via": "email" },
"recovery": { "via": "email" }
}
},
"name": {
"type": "object",
"title": "Name",
"properties": {
"first": { "type": "string", "title": "First name", "maxLength": 100 },
"last": { "type": "string", "title": "Last name", "maxLength": 100 }
}
}
},
"required": ["email"],
"additionalProperties": false
}
}
}
+101
View File
@@ -0,0 +1,101 @@
version: v1.3.0
serve:
public:
base_url: http://localhost:4433/
cors:
enabled: true
allowed_origins:
- http://localhost
- http://localhost:3000
- http://localhost:8000
- http://127.0.0.1
allowed_methods: [GET, POST, PUT, PATCH, DELETE, OPTIONS]
allowed_headers: [Authorization, Content-Type, X-Session-Token, Cookie]
exposed_headers: [Content-Type, Set-Cookie]
allow_credentials: true
admin:
base_url: http://kratos:4434/
selfservice:
default_browser_return_url: http://localhost:8000/
allowed_return_urls:
- http://localhost:8000
- honeydue://callback
methods:
password:
enabled: true
config:
min_password_length: 8
identifier_similarity_check_enabled: false
code:
enabled: true
oidc:
enabled: false
flows:
error:
ui_url: http://localhost:8000/auth/error
login:
ui_url: http://localhost:8000/auth/login
lifespan: 10m
registration:
ui_url: http://localhost:8000/auth/registration
lifespan: 10m
after:
password:
hooks:
- hook: session
verification:
enabled: true
ui_url: http://localhost:8000/auth/verification
use: code
after:
default_browser_return_url: http://localhost:8000/
recovery:
enabled: true
ui_url: http://localhost:8000/auth/recovery
use: code
settings:
ui_url: http://localhost:8000/auth/settings
privileged_session_max_age: 15m
logout:
after:
default_browser_return_url: http://localhost:8000/
log:
level: debug
format: text
leak_sensitive_values: true
secrets:
cookie:
- local-dev-cookie-secret-please-change-this-32chars
cipher:
- 0123456789abcdef0123456789abcdef
ciphers:
algorithm: xchacha20-poly1305
hashers:
algorithm: bcrypt
bcrypt:
cost: 8
identity:
default_schema_id: honeydue
schemas:
- id: honeydue
url: file:///etc/config/kratos/identity.schema.json
courier:
smtp:
connection_uri: smtp://mailpit:1025/?disable_starttls=true
from_address: noreply@localhost
from_name: honeyDue Local
session:
lifespan: 720h
cookie:
same_site: Lax
+25
View File
@@ -1,6 +1,31 @@
#!/usr/bin/env bash
set -euo pipefail
# DEPRECATED — production migrated from Docker Swarm to k3s on 2026-04-24.
# This script targets the old Swarm manager + registry flow and will fail
# at the SSH/Swarm validation step because hetzner1 no longer runs dockerd.
#
# Use the k3s deploy stack instead:
#
# export KUBECONFIG="$(pwd)/deploy-k3s/kubeconfig"
# ./deploy-k3s/scripts/03-deploy.sh
#
# If you don't have deploy-k3s/kubeconfig locally, fetch it once:
# ssh -i ~/.ssh/hetzner deploy@hetzner1 'sudo cat /etc/rancher/k3s/k3s.yaml' \
# | sed 's|server: https://127.0.0.1:6443|server: https://178.104.247.152:6443|' \
# > deploy-k3s/kubeconfig
# chmod 600 deploy-k3s/kubeconfig
#
# To override and run anyway (do NOT do this casually), set:
# ALLOW_LEGACY_SWARM_DEPLOY=1 ./deploy/scripts/deploy_prod.sh
if [[ "${ALLOW_LEGACY_SWARM_DEPLOY:-0}" != "1" ]]; then
printf '[deploy][error] %s\n' \
"deploy_prod.sh is the legacy Docker Swarm flow. Production now runs on k3s." \
"Use ./deploy-k3s/scripts/03-deploy.sh instead (see top of this script for setup)." \
"If you really need the old Swarm path, set ALLOW_LEGACY_SWARM_DEPLOY=1." >&2
exit 1
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DEPLOY_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
REPO_DIR="$(cd "${DEPLOY_DIR}/.." && pwd)"
+60
View File
@@ -14,6 +14,7 @@ services:
POSTGRES_DB: ${POSTGRES_DB:-honeydue}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./deploy/local/postgres-init:/docker-entrypoint-initdb.d:ro
ports:
- "${DB_PORT:-5433}:5432" # 5433 externally to avoid conflicts with local postgres
healthcheck:
@@ -91,6 +92,10 @@ services:
# Storage encryption
STORAGE_ENCRYPTION_KEY: ${STORAGE_ENCRYPTION_KEY}
# Kratos (identity service)
KRATOS_PUBLIC_URL: "http://kratos:4433"
KRATOS_ADMIN_URL: "http://kratos:4434"
volumes:
- ./push_certs:/certs:ro
- ./uploads:/app/uploads
@@ -99,6 +104,8 @@ services:
condition: service_healthy
redis:
condition: service_healthy
kratos:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:8000/api/health/"]
interval: 30s
@@ -184,6 +191,59 @@ services:
networks:
- honeydue-network
# Mailpit — local SMTP catcher (for Kratos email codes during onboarding)
mailpit:
image: axllent/mailpit:latest
container_name: honeydue-mailpit
restart: unless-stopped
ports:
- "${MAILPIT_SMTP_PORT:-1025}:1025"
- "${MAILPIT_HTTP_PORT:-8025}:8025"
networks:
- honeydue-network
# Kratos schema migration (one-shot, runs before kratos starts)
kratos-migrate:
image: oryd/kratos:v1.3.0
container_name: honeydue-kratos-migrate
command: ["migrate", "sql", "-e", "--yes"]
environment:
DSN: "postgres://${POSTGRES_USER:-honeydue}:${POSTGRES_PASSWORD:-honeydue_dev_password}@db:5432/kratos?sslmode=disable"
depends_on:
db:
condition: service_healthy
networks:
- honeydue-network
restart: "no"
# Ory Kratos — identity service
kratos:
image: oryd/kratos:v1.3.0
container_name: honeydue-kratos
restart: unless-stopped
command: ["serve", "--config", "/etc/config/kratos/kratos.yml", "--watch-courier", "--dev"]
ports:
- "${KRATOS_PUBLIC_PORT:-4433}:4433"
- "${KRATOS_ADMIN_PORT:-4434}:4434"
environment:
DSN: "postgres://${POSTGRES_USER:-honeydue}:${POSTGRES_PASSWORD:-honeydue_dev_password}@db:5432/kratos?sslmode=disable"
LOG_LEVEL: "debug"
volumes:
- ./deploy/local/kratos:/etc/config/kratos:ro
depends_on:
kratos-migrate:
condition: service_completed_successfully
mailpit:
condition: service_started
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:4434/health/ready"]
interval: 10s
timeout: 5s
retries: 10
start_period: 10s
networks:
- honeydue-network
# Dozzle — lightweight real-time log viewer
dozzle:
image: amir20/dozzle:latest
+28 -23
View File
@@ -8,6 +8,13 @@ long-haul components, and dedicated service accounts with dropped
capabilities inside containers. This chapter documents each layer, the
rationale, and what's currently missing (and why).
> **Updated 2026-05-15 — security remediation.** The 2026-05 audits
> (`live_scan_5_12.md`, `k3_audit_5_12.md`, `security_scan_5_12.md`) drove a
> full remediation pass. **`deploy-k3s/SECURITY.md` is the authoritative,
> per-finding current-state record.** This chapter is corrected for the
> major items below; where any other detail conflicts with `SECURITY.md`,
> `SECURITY.md` wins.
## Threat model
Who we're defending against, in rough order of likelihood:
@@ -54,8 +61,8 @@ Cloudflare sits in front of every public request.
- **Authorize requests** — that's the app's job
- **Protect origin if origin IP leaks** — once someone knows a node IP
they can bypass CF. Mitigation: keep origin firewall strict (Chapter 4).
- **Encrypt between CF and origin** — we're on SSL=Flexible, so CF↔origin
is HTTP. This is in our TODO (Chapter 20, upgrade to Full-strict).
- **~~Encrypt between CF and origin~~** — done (2026-04-24): SSL mode is
Full (strict); CF↔origin is TLS with a Cloudflare Origin CA cert.
### The proxy-IP problem
@@ -75,8 +82,8 @@ This means a malicious request that bypasses CF (by hitting the node IP
directly) can't spoof headers — Traefik ignores `X-Forwarded-*` unless
the source IP is in CF's ranges.
**TODO** (Chapter 20): Enforce at UFW level — allow 80/tcp only from
CF IP ranges. Today any IP can reach the origin on port 80.
**Done (2026-04-24):** the node UFW allowlist permits `:443` only from
Cloudflare's IP ranges; the `Anywhere` rules on `:80`/`:443` were removed.
## Layer 2 — Node (OS, SSH, firewall)
@@ -297,15 +304,13 @@ The `deploy-k3s/manifests/network-policies.yaml` scaffold defines:
reach api pods on port 8000
- **allow-ingress-to-admin** — same, for admin:3000
**These are not currently applied.** Without them, our pods can freely
talk to anything — including, theoretically, malicious destinations if
an attacker gets RCE inside a pod.
**Applied.** `03-deploy.sh` applies
`deploy-k3s/manifests/network-policies.yaml` on every deploy — default-deny
plus the explicit per-app allows below. Traefik runs `hostNetwork`, so its
traffic is matched by node-IP `ipBlock`s plus the pod CIDR `10.42.0.0/16`,
not a `namespaceSelector`.
**TODO** (Chapter 20): Apply network policies. The scaffold is there; we
just need to `kubectl apply -f deploy-k3s/manifests/network-policies.yaml`
and test that nothing breaks.
### What network policies would prevent
### What network policies prevent
| Attack scenario | NetworkPolicy blocks |
|---|---|
@@ -324,13 +329,10 @@ renewed Let's Encrypt or CF-managed cert for `*.myhoneydue.com`.
### CF ↔ origin
**Plaintext HTTP** (SSL = Flexible). An attacker with access to the
Cloudflare-to-Hetzner path could read traffic. In practice nobody who
isn't Cloudflare or Hetzner sits on that path.
**TODO** (Chapter 20): Upgrade to SSL = Full (strict) with a Cloudflare
Origin CA certificate. This encrypts CF ↔ origin and verifies that
origin's cert is the CF-issued one (prevents MitM if DNS is compromised).
**TLS — SSL = Full (strict)** (since 2026-04-24). A Cloudflare Origin CA
certificate (`cloudflare-origin-cert` secret) is installed on all three
ingresses; Cloudflare validates it. Both user↔CF and CF↔origin are
encrypted, and a DNS-hijack MitM is defeated by the origin-cert check.
### API ↔ Neon Postgres
@@ -454,11 +456,14 @@ Mitigations:
- Gitea itself is behind login; PAT is scoped to read:packages +
write:packages only
- Gitea runs on the operator's infrastructure (same operator account)
- Image tags are SHA-pinned (`:237c6b8`) not `:latest` → attacker can't
replace an existing tag's image without us noticing the digest change
- Workloads deploy by immutable `@sha256:` digest, not by mutable tag
(`03-deploy.sh` resolves the digest after push; the redis/vmagent/node
base images are digest-pinned too) — a swapped tag cannot reach the
cluster.
**TODO** (Chapter 20): Add cosign signing at build time, verify at pull
time.
**TODO**: cosign signing is wired into `03-deploy.sh` (guarded — runs when
`cosign` + `COSIGN_KEY` are present); cluster-side admission verification
(Kyverno/Connaisseur) is still pending. See `deploy-k3s/SECURITY.md` → L5.
## Operator workstation security
+8
View File
@@ -1,5 +1,13 @@
# 06 — Traefik Ingress
> **Updated 2026-05-15 (security remediation):** the Traefik middleware set
> changed — `cloudflare-only` + `admin-auth` are now attached to the admin
> ingress, a strict `auth-rate-limit` middleware fronts the auth endpoints
> (via a dedicated `honeydue-api-auth` Ingress), and `security-headers`
> gained COOP/CORP + a 2-year preload HSTS and dropped the deprecated
> `X-XSS-Protection`. `deploy-k3s/SECURITY.md` is the authoritative
> current-state record.
## Summary
Traefik is the reverse proxy that routes external HTTP requests to the
+6
View File
@@ -1,5 +1,11 @@
# 07 — Services
> **Updated 2026-05-15 (security remediation):** Redis now requires a
> password (`config.yaml` `redis.password` → `honeydue-secrets`), all
> workloads deploy by immutable `@sha256:` digest, and the redis/vmagent
> base images are digest-pinned. `deploy-k3s/SECURITY.md` is the
> authoritative current-state record.
## Summary
Five workloads run in the `honeydue` namespace: **api** (Go REST API, 3
+100 -33
View File
@@ -150,18 +150,64 @@ Allowed MIME types: `image/jpeg`, `image/png`, `image/gif`, `image/webp`,
## Access control
### Upload flow
### Upload flow (current — direct-to-B2 with presigned POST)
1. Client POSTs to `/api/upload/`
2. Go API validates the user is authenticated and authorized for the
target resource
3. Go API streams the upload to B2 via minio-go's `PutObject`
4. B2 returns a key
5. Go API stores the key in Postgres
6. Returns the key to the client
Image and document uploads go **directly from the client to B2**. The
api server only signs a short-lived POST policy; the bytes never
traverse our cluster. This is the WhatsApp / Slack architecture and
sidesteps the api as a proxy bottleneck.
The B2 bucket is **private**. Clients can't GET directly; they always
go through the Go API.
1. Client `POST /api/uploads/presign` with `{category, content_type, content_length}`.
2. api validates auth, per-user quota (10 concurrent in-flight,
50/hour rate limit), allowed mime, and the 10 MB cap. On success it
creates a `pending_uploads` row, signs a B2 POST policy with a
`content-length-range` condition bound to the claimed length ±256
bytes, and returns `{id, upload_url, fields, key, expires_at}`.
3. Client multipart-POSTs the bytes directly to B2 using the returned
fields. **B2 enforces the size cap at the protocol level** — clients
can't bypass it by lying about Content-Length.
4. Client POSTs to the entity-creation endpoint (`/api/task-completions/`,
`/api/documents/`) with `upload_ids: [id]`. The service `HEAD`s each
B2 object, verifies size matches `expected_bytes`, marks the
`pending_uploads.claimed_at`, and writes the `task_completion_image`
/ `document_image` row referencing the upload.
The signed URL is valid for 15 minutes; presigns are not reusable.
The B2 bucket stays **private** — only the api ever holds the key
material. Clients can't list or GET directly without a presign.
```
┌──────────┐ 1) presign ┌────────┐
│ client │ ──────────────────► │ api │
│ │ ◄────────────────── │ │ POST policy + key
│ │ └────────┘
│ │ row in
│ │ pending_uploads
│ │ (claimed_at NULL)
│ │ 2) POST bytes ┌────────┐
│ │ ──────────────────► │ B2 │ enforces policy
│ │ ◄────────────────── │ │
│ │ └────────┘
│ │ 3) attach ┌────────┐
│ │ ──────────────────► │ api │ HEAD B2 object,
│ │ upload_ids: [id] │ │ mark claimed_at,
│ │ └────────┘ insert image row
└──────────┘
```
Server-side enforcement summary:
| Check | Where | Reject if |
|---|---|---|
| Auth | api middleware | unauthenticated |
| Mime allowlist | `upload_service.go:allowedContentTypes` | not in list for category |
| Size cap (10 MB) | api before signing + B2 policy | content_length > 10 MiB |
| Concurrency cap (10) | `CountUnclaimedActiveForUser` | already 10 unclaimed in-flight |
| Rate limit (50/hr) | Redis sliding window `upload:presign:<uid>:<bucket>` | 51st presign in the same hour |
| Size at upload time | B2 (signed policy) | bytes outside content-length-range |
| Ownership at attach | `FindUnclaimedForUser` | upload_id belongs to a different user |
| Bytes match claim | `s3.Stat()` + bytes comparison | actual size differs from expected ±256 |
### Download flow (current)
@@ -170,34 +216,55 @@ go through the Go API.
3. Go API fetches from B2 and streams back to the client
This proxies every download through the api. For high-traffic media
that's inefficient (api becomes an egress bottleneck).
### Future: signed URLs
We could generate time-limited signed URLs for B2 objects:
```go
url, err := s3Client.PresignedGetObject(ctx, bucket, key, 1*time.Hour, nil)
```
Returns a URL the client can GET directly from B2, scoped to a specific
object, valid for 1h. Saves api bandwidth and latency.
Not yet implemented. TODO (Chapter 20).
that's inefficient (api becomes an egress bottleneck) — could be
replaced with presigned GET URLs on the same bucket. Not yet shipped;
download volume is low enough that the proxy is fine for now.
## Lifecycle and retention
We have **no lifecycle rules** set on the bucket. Objects live forever
unless the app deletes them.
### Orphan cleanup (`pending_uploads`)
When a user deletes their account, the app should delete their B2
objects. This is currently not automated — a compliance gap for any
"right to be forgotten" request.
Every presign creates a row in `pending_uploads` with `expires_at =
now + 15 min`. If the client never finishes the upload, or finishes
but never calls the attach endpoint, the row stays unclaimed. An
hourly cron in the worker reaps them:
**TODO** (Chapter 20): Either:
- Implement explicit cleanup in the user deletion handler, or
- Add B2 lifecycle rule tied to object metadata (tag objects with
user ID; rule deletes tagged objects when user is soft-deleted)
- **`maintenance:upload_cleanup`** — cron `30 * * * *`. Selects
unclaimed rows past `expires_at`, deletes the corresponding B2
object, deletes the row. Up to 500 per tick; the next tick picks up
any overflow. Worker logs include `reaped` count.
The worker constructs a `StorageService` at startup; if storage init
fails (e.g. `B2_KEY_ID` / `B2_APP_KEY` not wired into the worker
deployment), the cleanup handler logs a warning and no-ops. See
`deploy-k3s/manifests/worker/deployment.yaml` — both B2 secrets are
required envs on this pod.
### Bucket lifecycle (backstop)
A B2 lifecycle rule on the `uploads/` prefix is the safety net if the
worker is offline for an extended period:
- Hide objects 7 days after upload.
- Delete 1 day after hidden.
This is configured manually via the Backblaze console (B2's S3
lifecycle API isn't fully implemented). See
`deploy-k3s/manifests/b2-lifecycle.md` for the exact rule and
`b2 bucket get-info` verification command.
### User-deletion cascade
When a user deletes their account, the app deletes their `task_*` /
`document` rows. The associated B2 objects survive — same compliance
gap as before, not yet automated. Two approaches:
- Walk the image rows on user delete and `RemoveObject` each (simple,
synchronous, slow for users with many uploads).
- Tag objects with a `user_id` metadata header at upload time, then
use a B2 lifecycle rule scoped to a deleted-users prefix.
Option 1 is the next item in the upload roadmap.
## Backup of B2
+6
View File
@@ -1,5 +1,11 @@
# 10 — Secrets & Config
> **Updated 2026-05-15 (security remediation):** `honeydue-secrets` now
> carries `REDIS_PASSWORD`; an `admin-basic-auth` Secret backs the admin
> ingress; rotation is documented in `docs/runbooks/secret-rotation.md`;
> and the Go config can read file-mounted secrets (`HONEYDUE_SECRETS_DIR`).
> `deploy-k3s/SECURITY.md` is the authoritative current-state record.
## Summary
Non-sensitive config (hostnames, ports, feature flags, etc.) lives in
+32
View File
@@ -247,6 +247,38 @@ kubectl patch secret honeydue-secrets -n honeydue \
kubectl rollout restart -n honeydue deployment/api deployment/worker
```
## One-time B2 bucket lifecycle (manual)
The `pending_uploads` cleanup cron (`30 * * * *` on the worker) handles
the common case of reaping orphaned uploads. The B2 bucket lifecycle
rule on the `uploads/` prefix is the **backstop** if the worker is
offline for >24 hours. It's configured once via the Backblaze web
console — B2's S3 lifecycle API isn't fully implemented, so this can't
be in the deploy script.
One-time setup:
1. Open https://secure.backblaze.com/b2_buckets.htm → bucket
`honeyDueProd`**Lifecycle Settings****Custom**
2. Add rule:
- File name prefix: `uploads/`
- Hide files older than: **7 days**
- Delete hidden files older than: **1 day**
Total maximum lifetime of an orphaned object after the rule fires: 8
days. The worker normally reaps within an hour, so the rule should
almost never trigger.
Verify:
```bash
# Requires the b2 CLI: brew install b2-tools
b2 bucket get-info honeyDueProd | jq '.lifecycleRules'
```
See `deploy-k3s/manifests/b2-lifecycle.md` for the canonical rule
definition and a curl-based fallback if the b2 CLI isn't available.
## Manifest changes
When you add/modify a deployment YAML:
+1 -1
View File
@@ -40,7 +40,7 @@ they do, and how to operate them.
- [07 — Services](./07-services.md) — api, admin, worker, redis per-service deep dive
- [08 — Database](./08-database.md) — Neon Postgres, advisory-lock migrations
- [09 — Storage](./09-storage.md) — Backblaze B2, minio-go client details
- [09 — Storage](./09-storage.md) — Backblaze B2, minio-go, presigned-URL direct uploads
- [10 — Secrets & Config](./10-secrets-config.md) — ConfigMap, Secret, env mapping
- [11 — Registry](./11-registry.md) — Gitea container registry, multi-arch builds
+146
View File
@@ -0,0 +1,146 @@
# Runbook — Secret Rotation
Closes audit finding `K3S-F12` (secrets unrotated since cluster bootstrap,
no rotation cadence). See `deploy-k3s/SECURITY.md` Stage 2.
**Cadence:** rotate every secret at least **annually**. Rotate
**immediately** on suspected exposure, on an operator-device loss, or when
anyone who has seen a secret leaves the project.
**Record keeping:** after each rotation, annotate the secret so the age is
visible:
```bash
kubectl -n honeydue annotate secret <name> \
honeydue.dev/last-rotated="$(date -u +%Y-%m-%d)" --overwrite
```
---
## How rotation works
Every secret has a **source of truth** on the operator workstation. The
deploy scripts read those sources and (re)create the Kubernetes Secrets.
Rotation is always: **update the source → re-run `02-setup-secrets.sh`
restart the pods that consume it → revoke the old credential at its
provider.**
`02-setup-secrets.sh` uses `kubectl apply` (via `--dry-run=client -o yaml`),
so re-running it is idempotent and only changes what you changed.
| Kubernetes Secret | Source of truth | Consumed by |
|---|---|---|
| `honeydue-secrets``POSTGRES_PASSWORD` | `deploy-k3s/secrets/postgres_password.txt` | api, worker |
| `honeydue-secrets``SECRET_KEY` | `deploy-k3s/secrets/secret_key.txt` | api, worker |
| `honeydue-secrets``EMAIL_HOST_PASSWORD` | `deploy-k3s/secrets/email_host_password.txt` | api, worker |
| `honeydue-secrets``FCM_SERVER_KEY` | `deploy-k3s/secrets/fcm_server_key.txt` | api, worker |
| `honeydue-secrets``REDIS_PASSWORD` | `config.yaml` key `redis.password` | api, worker, redis |
| `honeydue-secrets``OBS_INGEST_TOKEN` | `deploy/prod.env` | api, worker |
| `honeydue-apns-key``apns_auth_key.p8` | `deploy-k3s/secrets/apns_auth_key.p8` | api, worker |
| `cloudflare-origin-cert` | `deploy-k3s/secrets/cloudflare-origin.{crt,key}` | Traefik ingress |
| `ghcr-credentials` | `config.yaml` block `registry.*` | image pulls (all pods) |
| `admin-basic-auth` | `config.yaml` keys `admin.basic_auth_user` / `..._password` | Traefik `admin-auth` middleware |
The `deploy-k3s/secrets/` directory and `config.yaml` are **gitignored**
never commit them.
---
## Standard rotation procedure
```bash
cd honeyDueAPI-go
export KUBECONFIG="$(pwd)/deploy-k3s/kubeconfig"
# 1. Update the source (file under deploy-k3s/secrets/ or a config.yaml key)
# 2. Recreate the Kubernetes Secrets from sources
./deploy-k3s/scripts/02-setup-secrets.sh
# 3. Restart the consumers (see per-secret notes below for which)
kubectl -n honeydue rollout restart deploy/api deploy/worker
# 4. Confirm health
kubectl -n honeydue rollout status deploy/api
kubectl -n honeydue rollout status deploy/worker
# 5. Revoke the OLD credential at its provider (see per-secret notes)
# 6. Annotate the rotated secret with today's date
```
---
## Per-secret notes
### `POSTGRES_PASSWORD`
1. Rotate the role password in the Neon dashboard.
2. Write the new value to `deploy-k3s/secrets/postgres_password.txt`.
3. `02-setup-secrets.sh`, then `rollout restart deploy/api deploy/worker`.
4. Watch logs for connection errors; the old password stops working the
moment Neon applies the change, so do steps 23 promptly.
### `SECRET_KEY` ⚠️ user-visible
This signs auth tokens. **Rotating it logs every user out** — all existing
tokens become invalid and every client must re-authenticate.
1. Generate: `openssl rand -hex 32`.
2. Write to `deploy-k3s/secrets/secret_key.txt` (must be ≥32 chars — the
script enforces this; the app refuses to start in production without it).
3. `02-setup-secrets.sh`, then `rollout restart deploy/api deploy/worker`.
- Only rotate on a schedule or on suspected compromise — not casually.
- A future improvement (overlap window via a key-id header) would let old
tokens validate during the transition; not implemented today.
### `EMAIL_HOST_PASSWORD`
1. Generate a new app password in Fastmail; keep the old one alive briefly.
2. Write to `deploy-k3s/secrets/email_host_password.txt`.
3. `02-setup-secrets.sh`, `rollout restart deploy/api deploy/worker`.
4. Delete the old Fastmail app password.
### `FCM_SERVER_KEY`
1. Rotate the key in the Firebase console.
2. Write to `deploy-k3s/secrets/fcm_server_key.txt`.
3. `02-setup-secrets.sh`, `rollout restart deploy/api deploy/worker`.
### `REDIS_PASSWORD`
Source is `config.yaml` key `redis.password` (hex only — it is embedded in
the `REDIS_URL`, so non-hex characters would break URL parsing).
1. Generate: `openssl rand -hex 32`.
2. Set `redis.password` in `config.yaml`.
3. `02-setup-secrets.sh`.
4. Restart **redis as well as** api/worker so the new `--requirepass` and
the new `REDIS_URL` land together:
`kubectl -n honeydue rollout restart deploy/redis deploy/api deploy/worker`.
Expect a few seconds where api/worker reconnect.
### `apns_auth_key.p8`
1. Revoke the key in the Apple Developer console, generate a new `.p8`.
2. Replace `deploy-k3s/secrets/apns_auth_key.p8`.
3. `02-setup-secrets.sh`, `rollout restart deploy/api deploy/worker`.
4. If the Key ID changed, update `push.apns_key_id` in `config.yaml` too.
### `cloudflare-origin-cert`
1. Generate a new Origin CA certificate in the Cloudflare dashboard.
2. Replace `deploy-k3s/secrets/cloudflare-origin.crt` and `.key`.
3. `02-setup-secrets.sh`. Traefik picks up the new TLS secret; no app
restart needed. Verify the served cert with `openssl s_client`.
### `ghcr-credentials` (Gitea registry)
1. Generate a new PAT in Gitea (scope: `read:packages`).
2. Update the `registry.token` value in `config.yaml`.
3. `02-setup-secrets.sh`. No restart needed unless a pull is pending.
4. Revoke the old PAT in Gitea.
### `admin-basic-auth`
Source is `config.yaml` keys `admin.basic_auth_user` / `basic_auth_password`.
1. Set a new password (e.g. `openssl rand -hex 24`).
2. `02-setup-secrets.sh` regenerates the bcrypt htpasswd secret.
3. No app restart needed — Traefik reloads the `admin-auth` middleware.
4. Distribute the new credential to whoever uses the admin panel.
---
## After any rotation
- Run `./deploy-k3s/scripts/04-verify.sh` and confirm no `✗` lines.
- Annotate the rotated secret (see "Record keeping" above).
- If the rotation was due to a compromise, also follow the relevant
playbook in `deploy-k3s/SECURITY.md` → Appendix (Incident response).
+6 -6
View File
@@ -27,10 +27,10 @@ require (
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0
go.opentelemetry.io/otel/sdk v1.43.0
golang.org/x/crypto v0.49.0
golang.org/x/crypto v0.51.0
golang.org/x/oauth2 v0.35.0
golang.org/x/term v0.41.0
golang.org/x/text v0.35.0
golang.org/x/term v0.43.0
golang.org/x/text v0.37.0
golang.org/x/time v0.15.0
google.golang.org/api v0.257.0
gopkg.in/yaml.v3 v3.0.1
@@ -117,9 +117,9 @@ require (
go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0
golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0
golang.org/x/sys v0.44.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/grpc v1.80.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
+10 -10
View File
@@ -241,12 +241,12 @@ go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20170512130425-ab89591268e0/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220403103023-749bd193bc2b/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
@@ -262,16 +262,16 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -1,215 +1,30 @@
// apple_social_auth_handler is a stub — the user_applesocialauth table was
// dropped in the Ory Kratos migration (phase 2). Social sign-in is now
// handled by Kratos.
package handlers
import (
"net/http"
"strconv"
"github.com/labstack/echo/v4"
"gorm.io/gorm"
"github.com/treytartt/honeydue-api/internal/admin/dto"
"github.com/treytartt/honeydue-api/internal/models"
)
// AdminAppleSocialAuthHandler handles admin Apple social auth management endpoints
// AdminAppleSocialAuthHandler is a no-op stub.
type AdminAppleSocialAuthHandler struct {
db *gorm.DB
}
// NewAdminAppleSocialAuthHandler creates a new admin Apple social auth handler
func NewAdminAppleSocialAuthHandler(db *gorm.DB) *AdminAppleSocialAuthHandler {
return &AdminAppleSocialAuthHandler{db: db}
}
// AppleSocialAuthResponse represents the response for an Apple social auth entry
type AppleSocialAuthResponse struct {
ID uint `json:"id"`
UserID uint `json:"user_id"`
Username string `json:"username"`
UserEmail string `json:"user_email"`
AppleID string `json:"apple_id"`
Email string `json:"email"`
IsPrivateEmail bool `json:"is_private_email"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// UpdateAppleSocialAuthRequest represents the request to update an Apple social auth entry
type UpdateAppleSocialAuthRequest struct {
Email *string `json:"email"`
IsPrivateEmail *bool `json:"is_private_email"`
}
// List handles GET /api/admin/apple-social-auth
func (h *AdminAppleSocialAuthHandler) List(c echo.Context) error {
var filters dto.PaginationParams
if err := c.Bind(&filters); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid request body"})
}
var entries []models.AppleSocialAuth
var total int64
query := h.db.Model(&models.AppleSocialAuth{}).Preload("User")
// Apply search
if filters.Search != "" {
search := "%" + filters.Search + "%"
query = query.Joins("JOIN auth_user ON auth_user.id = user_applesocialauth.user_id").
Where("user_applesocialauth.apple_id ILIKE ? OR user_applesocialauth.email ILIKE ? OR auth_user.username ILIKE ? OR auth_user.email ILIKE ?",
search, search, search, search)
}
// Get total count
query.Count(&total)
// Apply sorting (allowlist prevents SQL injection via sort_by parameter)
sortBy := filters.GetSafeSortBy([]string{
"id", "user_id", "apple_id", "email", "is_private_email",
"created_at", "updated_at",
}, "created_at")
query = query.Order(sortBy + " " + filters.GetSortDir())
// Apply pagination
query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage())
if err := query.Find(&entries).Error; err != nil {
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch Apple social auth entries"})
}
// Build response
responses := make([]AppleSocialAuthResponse, len(entries))
for i, entry := range entries {
responses[i] = h.toResponse(&entry)
}
return c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
}
// Get handles GET /api/admin/apple-social-auth/:id
func (h *AdminAppleSocialAuthHandler) Get(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid ID"})
}
var entry models.AppleSocialAuth
if err := h.db.Preload("User").First(&entry, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Apple social auth entry not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch Apple social auth entry"})
}
return c.JSON(http.StatusOK, h.toResponse(&entry))
}
// GetByUser handles GET /api/admin/apple-social-auth/user/:user_id
func (h *AdminAppleSocialAuthHandler) GetByUser(c echo.Context) error {
userID, err := strconv.ParseUint(c.Param("user_id"), 10, 32)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid user ID"})
}
var entry models.AppleSocialAuth
if err := h.db.Preload("User").Where("user_id = ?", userID).First(&entry).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Apple social auth entry not found for user"})
}
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch Apple social auth entry"})
}
return c.JSON(http.StatusOK, h.toResponse(&entry))
}
// Update handles PUT /api/admin/apple-social-auth/:id
func (h *AdminAppleSocialAuthHandler) Update(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid ID"})
}
var entry models.AppleSocialAuth
if err := h.db.First(&entry, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Apple social auth entry not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch Apple social auth entry"})
}
var req UpdateAppleSocialAuthRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid request body"})
}
if req.Email != nil {
entry.Email = *req.Email
}
if req.IsPrivateEmail != nil {
entry.IsPrivateEmail = *req.IsPrivateEmail
}
if err := h.db.Save(&entry).Error; err != nil {
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to update Apple social auth entry"})
}
h.db.Preload("User").First(&entry, id)
return c.JSON(http.StatusOK, h.toResponse(&entry))
}
// Delete handles DELETE /api/admin/apple-social-auth/:id
func (h *AdminAppleSocialAuthHandler) Delete(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid ID"})
}
var entry models.AppleSocialAuth
if err := h.db.First(&entry, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Apple social auth entry not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch Apple social auth entry"})
}
if err := h.db.Delete(&entry).Error; err != nil {
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete Apple social auth entry"})
}
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Apple social auth entry deleted successfully"})
}
// BulkDelete handles DELETE /api/admin/apple-social-auth/bulk
func (h *AdminAppleSocialAuthHandler) BulkDelete(c echo.Context) error {
var req dto.BulkDeleteRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid request body"})
}
result := h.db.Where("id IN ?", req.IDs).Delete(&models.AppleSocialAuth{})
if result.Error != nil {
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete Apple social auth entries"})
}
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Apple social auth entries deleted successfully", "count": result.RowsAffected})
}
// toResponse converts an AppleSocialAuth model to AppleSocialAuthResponse
func (h *AdminAppleSocialAuthHandler) toResponse(entry *models.AppleSocialAuth) AppleSocialAuthResponse {
response := AppleSocialAuthResponse{
ID: entry.ID,
UserID: entry.UserID,
AppleID: entry.AppleID,
Email: entry.Email,
IsPrivateEmail: entry.IsPrivateEmail,
CreatedAt: entry.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: entry.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
if entry.User.ID != 0 {
response.Username = entry.User.Username
response.UserEmail = entry.User.Email
}
return response
func (h *AdminAppleSocialAuthHandler) gone(c echo.Context) error {
return c.JSON(http.StatusGone, map[string]string{"message": "Apple social auth is managed by Ory Kratos"})
}
func (h *AdminAppleSocialAuthHandler) List(c echo.Context) error { return h.gone(c) }
func (h *AdminAppleSocialAuthHandler) Get(c echo.Context) error { return h.gone(c) }
func (h *AdminAppleSocialAuthHandler) Delete(c echo.Context) error { return h.gone(c) }
func (h *AdminAppleSocialAuthHandler) BulkDelete(c echo.Context) error { return h.gone(c) }
func (h *AdminAppleSocialAuthHandler) Update(c echo.Context) error { return h.gone(c) }
func (h *AdminAppleSocialAuthHandler) GetByUser(c echo.Context) error { return h.gone(c) }
+9 -126
View File
@@ -1,144 +1,27 @@
// auth_token_handler is a stub — the user_authtoken table was dropped in the
// Ory Kratos migration (phase 2). Auth tokens are now Kratos sessions.
package handlers
import (
"net/http"
"strconv"
"github.com/labstack/echo/v4"
"gorm.io/gorm"
"github.com/treytartt/honeydue-api/internal/admin/dto"
"github.com/treytartt/honeydue-api/internal/models"
)
// AdminAuthTokenHandler handles admin auth token management endpoints
// AdminAuthTokenHandler is a no-op stub.
type AdminAuthTokenHandler struct {
db *gorm.DB
}
// NewAdminAuthTokenHandler creates a new admin auth token handler
func NewAdminAuthTokenHandler(db *gorm.DB) *AdminAuthTokenHandler {
return &AdminAuthTokenHandler{db: db}
}
// AuthTokenResponse represents an auth token in API responses
type AuthTokenResponse struct {
Key string `json:"key"`
UserID uint `json:"user_id"`
Username string `json:"username"`
Email string `json:"email"`
Created string `json:"created"`
}
// List handles GET /api/admin/auth-tokens
func (h *AdminAuthTokenHandler) List(c echo.Context) error {
var filters dto.PaginationParams
if err := c.Bind(&filters); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid request body"})
}
var tokens []models.AuthToken
var total int64
query := h.db.Model(&models.AuthToken{}).Preload("User")
// Apply search (search by user info)
if filters.Search != "" {
search := "%" + filters.Search + "%"
query = query.Joins("JOIN auth_user ON auth_user.id = user_authtoken.user_id").
Where(
"auth_user.username ILIKE ? OR auth_user.email ILIKE ? OR user_authtoken.key ILIKE ?",
search, search, search,
)
}
// Get total count
query.Count(&total)
// Apply sorting (allowlist prevents SQL injection via sort_by parameter)
sortBy := filters.GetSafeSortBy([]string{
"created", "user_id",
}, "created")
query = query.Order(sortBy + " " + filters.GetSortDir())
// Apply pagination
query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage())
if err := query.Find(&tokens).Error; err != nil {
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch auth tokens"})
}
// Build response
responses := make([]AuthTokenResponse, len(tokens))
for i, token := range tokens {
responses[i] = AuthTokenResponse{
Key: token.Key,
UserID: token.UserID,
Username: token.User.Username,
Email: token.User.Email,
Created: token.Created.Format("2006-01-02T15:04:05Z"),
}
}
return c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
}
// Get handles GET /api/admin/auth-tokens/:id (id is actually user_id)
func (h *AdminAuthTokenHandler) Get(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid user ID"})
}
var token models.AuthToken
if err := h.db.Preload("User").Where("user_id = ?", id).First(&token).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Auth token not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch auth token"})
}
response := AuthTokenResponse{
Key: token.Key,
UserID: token.UserID,
Username: token.User.Username,
Email: token.User.Email,
Created: token.Created.Format("2006-01-02T15:04:05Z"),
}
return c.JSON(http.StatusOK, response)
}
// Delete handles DELETE /api/admin/auth-tokens/:id (revoke token)
func (h *AdminAuthTokenHandler) Delete(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid user ID"})
}
result := h.db.Where("user_id = ?", id).Delete(&models.AuthToken{})
if result.Error != nil {
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to revoke token"})
}
if result.RowsAffected == 0 {
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Auth token not found"})
}
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Auth token revoked successfully"})
}
// BulkDelete handles DELETE /api/admin/auth-tokens/bulk
func (h *AdminAuthTokenHandler) BulkDelete(c echo.Context) error {
var req dto.BulkDeleteRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid request body"})
}
result := h.db.Where("user_id IN ?", req.IDs).Delete(&models.AuthToken{})
if result.Error != nil {
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to revoke tokens"})
}
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Auth tokens revoked successfully", "count": result.RowsAffected})
func (h *AdminAuthTokenHandler) gone(c echo.Context) error {
return c.JSON(http.StatusGone, map[string]string{"message": "auth tokens are managed by Ory Kratos"})
}
func (h *AdminAuthTokenHandler) List(c echo.Context) error { return h.gone(c) }
func (h *AdminAuthTokenHandler) Get(c echo.Context) error { return h.gone(c) }
func (h *AdminAuthTokenHandler) Delete(c echo.Context) error { return h.gone(c) }
func (h *AdminAuthTokenHandler) BulkDelete(c echo.Context) error { return h.gone(c) }
@@ -1,162 +1,28 @@
// confirmation_code_handler is a stub — the user_confirmationcode table was
// dropped in the Ory Kratos migration (phase 2). Email verification is now
// handled by Kratos.
package handlers
import (
"net/http"
"strconv"
"strings"
"github.com/labstack/echo/v4"
"gorm.io/gorm"
"github.com/treytartt/honeydue-api/internal/admin/dto"
"github.com/treytartt/honeydue-api/internal/models"
)
// maskCode masks a confirmation code, showing only the last 4 characters.
func maskCode(code string) string {
if len(code) <= 4 {
return strings.Repeat("*", len(code))
}
return strings.Repeat("*", len(code)-4) + code[len(code)-4:]
}
// AdminConfirmationCodeHandler handles admin confirmation code management endpoints
// AdminConfirmationCodeHandler is a no-op stub.
type AdminConfirmationCodeHandler struct {
db *gorm.DB
}
// NewAdminConfirmationCodeHandler creates a new admin confirmation code handler
func NewAdminConfirmationCodeHandler(db *gorm.DB) *AdminConfirmationCodeHandler {
return &AdminConfirmationCodeHandler{db: db}
}
// ConfirmationCodeResponse represents a confirmation code in API responses
type ConfirmationCodeResponse struct {
ID uint `json:"id"`
UserID uint `json:"user_id"`
Username string `json:"username"`
Email string `json:"email"`
Code string `json:"code"`
ExpiresAt string `json:"expires_at"`
IsUsed bool `json:"is_used"`
CreatedAt string `json:"created_at"`
}
// List handles GET /api/admin/confirmation-codes
func (h *AdminConfirmationCodeHandler) List(c echo.Context) error {
var filters dto.PaginationParams
if err := c.Bind(&filters); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid request body"})
}
var codes []models.ConfirmationCode
var total int64
query := h.db.Model(&models.ConfirmationCode{}).Preload("User")
// Apply search (search by user info or code)
if filters.Search != "" {
search := "%" + filters.Search + "%"
query = query.Joins("JOIN auth_user ON auth_user.id = user_confirmationcode.user_id").
Where(
"auth_user.username ILIKE ? OR auth_user.email ILIKE ? OR user_confirmationcode.code ILIKE ?",
search, search, search,
)
}
// Get total count
query.Count(&total)
// Apply sorting (allowlist prevents SQL injection via sort_by parameter)
sortBy := filters.GetSafeSortBy([]string{
"id", "user_id", "created_at", "expires_at", "is_used",
}, "created_at")
query = query.Order(sortBy + " " + filters.GetSortDir())
// Apply pagination
query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage())
if err := query.Find(&codes).Error; err != nil {
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch confirmation codes"})
}
// Build response
responses := make([]ConfirmationCodeResponse, len(codes))
for i, code := range codes {
responses[i] = ConfirmationCodeResponse{
ID: code.ID,
UserID: code.UserID,
Username: code.User.Username,
Email: code.User.Email,
Code: maskCode(code.Code),
ExpiresAt: code.ExpiresAt.Format("2006-01-02T15:04:05Z"),
IsUsed: code.IsUsed,
CreatedAt: code.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
}
return c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
}
// Get handles GET /api/admin/confirmation-codes/:id
func (h *AdminConfirmationCodeHandler) Get(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid ID"})
}
var code models.ConfirmationCode
if err := h.db.Preload("User").First(&code, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Confirmation code not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch confirmation code"})
}
response := ConfirmationCodeResponse{
ID: code.ID,
UserID: code.UserID,
Username: code.User.Username,
Email: code.User.Email,
Code: maskCode(code.Code),
ExpiresAt: code.ExpiresAt.Format("2006-01-02T15:04:05Z"),
IsUsed: code.IsUsed,
CreatedAt: code.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
return c.JSON(http.StatusOK, response)
}
// Delete handles DELETE /api/admin/confirmation-codes/:id
func (h *AdminConfirmationCodeHandler) Delete(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid ID"})
}
result := h.db.Delete(&models.ConfirmationCode{}, id)
if result.Error != nil {
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete confirmation code"})
}
if result.RowsAffected == 0 {
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Confirmation code not found"})
}
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Confirmation code deleted successfully"})
}
// BulkDelete handles DELETE /api/admin/confirmation-codes/bulk
func (h *AdminConfirmationCodeHandler) BulkDelete(c echo.Context) error {
var req dto.BulkDeleteRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid request body"})
}
result := h.db.Where("id IN ?", req.IDs).Delete(&models.ConfirmationCode{})
if result.Error != nil {
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete confirmation codes"})
}
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Confirmation codes deleted successfully", "count": result.RowsAffected})
func (h *AdminConfirmationCodeHandler) gone(c echo.Context) error {
return c.JSON(http.StatusGone, map[string]string{"message": "confirmation codes are managed by Ory Kratos"})
}
func (h *AdminConfirmationCodeHandler) List(c echo.Context) error { return h.gone(c) }
func (h *AdminConfirmationCodeHandler) Get(c echo.Context) error { return h.gone(c) }
func (h *AdminConfirmationCodeHandler) Delete(c echo.Context) error { return h.gone(c) }
func (h *AdminConfirmationCodeHandler) BulkDelete(c echo.Context) error { return h.gone(c) }
@@ -1,159 +1,28 @@
// password_reset_code_handler is a stub — the user_passwordresetcode table
// was dropped in the Ory Kratos migration (phase 2). Password resets are now
// handled by Kratos.
package handlers
import (
"net/http"
"strconv"
"github.com/labstack/echo/v4"
"gorm.io/gorm"
"github.com/treytartt/honeydue-api/internal/admin/dto"
"github.com/treytartt/honeydue-api/internal/models"
)
// AdminPasswordResetCodeHandler handles admin password reset code management endpoints
// AdminPasswordResetCodeHandler is a no-op stub.
type AdminPasswordResetCodeHandler struct {
db *gorm.DB
}
// NewAdminPasswordResetCodeHandler creates a new admin password reset code handler
func NewAdminPasswordResetCodeHandler(db *gorm.DB) *AdminPasswordResetCodeHandler {
return &AdminPasswordResetCodeHandler{db: db}
}
// PasswordResetCodeResponse represents a password reset code in API responses
type PasswordResetCodeResponse struct {
ID uint `json:"id"`
UserID uint `json:"user_id"`
Username string `json:"username"`
Email string `json:"email"`
ResetToken string `json:"reset_token"`
ExpiresAt string `json:"expires_at"`
Used bool `json:"used"`
Attempts int `json:"attempts"`
MaxAttempts int `json:"max_attempts"`
CreatedAt string `json:"created_at"`
}
// List handles GET /api/admin/password-reset-codes
func (h *AdminPasswordResetCodeHandler) List(c echo.Context) error {
var filters dto.PaginationParams
if err := c.Bind(&filters); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid request body"})
}
var codes []models.PasswordResetCode
var total int64
query := h.db.Model(&models.PasswordResetCode{}).Preload("User")
// Apply search (search by user info or token)
if filters.Search != "" {
search := "%" + filters.Search + "%"
query = query.Joins("JOIN auth_user ON auth_user.id = user_passwordresetcode.user_id").
Where(
"auth_user.username ILIKE ? OR auth_user.email ILIKE ? OR user_passwordresetcode.reset_token ILIKE ?",
search, search, search,
)
}
// Get total count
query.Count(&total)
// Apply sorting (allowlist prevents SQL injection via sort_by parameter)
sortBy := filters.GetSafeSortBy([]string{
"id", "user_id", "created_at", "expires_at", "used",
}, "created_at")
query = query.Order(sortBy + " " + filters.GetSortDir())
// Apply pagination
query = query.Offset(filters.GetOffset()).Limit(filters.GetPerPage())
if err := query.Find(&codes).Error; err != nil {
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch password reset codes"})
}
// Build response
responses := make([]PasswordResetCodeResponse, len(codes))
for i, code := range codes {
responses[i] = PasswordResetCodeResponse{
ID: code.ID,
UserID: code.UserID,
Username: code.User.Username,
Email: code.User.Email,
ResetToken: code.ResetToken[:8] + "..." + code.ResetToken[len(code.ResetToken)-4:], // Truncate for display
ExpiresAt: code.ExpiresAt.Format("2006-01-02T15:04:05Z"),
Used: code.Used,
Attempts: code.Attempts,
MaxAttempts: code.MaxAttempts,
CreatedAt: code.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
}
return c.JSON(http.StatusOK, dto.NewPaginatedResponse(responses, total, filters.GetPage(), filters.GetPerPage()))
}
// Get handles GET /api/admin/password-reset-codes/:id
func (h *AdminPasswordResetCodeHandler) Get(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid ID"})
}
var code models.PasswordResetCode
if err := h.db.Preload("User").First(&code, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Password reset code not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch password reset code"})
}
response := PasswordResetCodeResponse{
ID: code.ID,
UserID: code.UserID,
Username: code.User.Username,
Email: code.User.Email,
ResetToken: code.ResetToken[:8] + "..." + code.ResetToken[len(code.ResetToken)-4:],
ExpiresAt: code.ExpiresAt.Format("2006-01-02T15:04:05Z"),
Used: code.Used,
Attempts: code.Attempts,
MaxAttempts: code.MaxAttempts,
CreatedAt: code.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
return c.JSON(http.StatusOK, response)
}
// Delete handles DELETE /api/admin/password-reset-codes/:id
func (h *AdminPasswordResetCodeHandler) Delete(c echo.Context) error {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid ID"})
}
result := h.db.Delete(&models.PasswordResetCode{}, id)
if result.Error != nil {
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete password reset code"})
}
if result.RowsAffected == 0 {
return c.JSON(http.StatusNotFound, map[string]interface{}{"error": "Password reset code not found"})
}
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Password reset code deleted successfully"})
}
// BulkDelete handles DELETE /api/admin/password-reset-codes/bulk
func (h *AdminPasswordResetCodeHandler) BulkDelete(c echo.Context) error {
var req dto.BulkDeleteRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "Invalid request body"})
}
result := h.db.Where("id IN ?", req.IDs).Delete(&models.PasswordResetCode{})
if result.Error != nil {
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to delete password reset codes"})
}
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Password reset codes deleted successfully", "count": result.RowsAffected})
func (h *AdminPasswordResetCodeHandler) gone(c echo.Context) error {
return c.JSON(http.StatusGone, map[string]string{"message": "password reset codes are managed by Ory Kratos"})
}
func (h *AdminPasswordResetCodeHandler) List(c echo.Context) error { return h.gone(c) }
func (h *AdminPasswordResetCodeHandler) Get(c echo.Context) error { return h.gone(c) }
func (h *AdminPasswordResetCodeHandler) Delete(c echo.Context) error { return h.gone(c) }
func (h *AdminPasswordResetCodeHandler) BulkDelete(c echo.Context) error { return h.gone(c) }
+8 -125
View File
@@ -248,137 +248,20 @@ func (h *AdminSettingsHandler) cacheAllLookups(ctx context.Context) (bool, error
}
log.Debug().Int("count", len(taskTemplates)).Msg("Cached task templates")
// Build and cache the unified seeded data response
// Import the grouped response type
seededData := map[string]interface{}{
"residence_types": residenceTypes,
"task_categories": categories,
"task_priorities": priorities,
"task_frequencies": frequencies,
"contractor_specialties": specialties,
"task_templates": buildGroupedTemplates(taskTemplates),
// Invalidate the unified seeded-data cache for every locale. The combined
// response is localized (lookup display_name + home-profile options) and is
// rebuilt per-locale on demand by the static_data handler, so the correct
// action after a lookup change is to clear all language variants rather than
// pre-warm a single (non-localized) blob.
if err := cache.InvalidateSeededData(ctx); err != nil {
return false, fmt.Errorf("failed to invalidate seeded data: %w", err)
}
etag, err := cache.CacheSeededData(ctx, seededData)
if err != nil {
return false, fmt.Errorf("failed to cache seeded data: %w", err)
}
log.Debug().Str("etag", etag).Msg("Cached unified seeded data")
log.Debug().Msg("Invalidated per-locale seeded data cache")
log.Info().Msg("All lookup data cached in Redis successfully")
return true, nil
}
// buildGroupedTemplates groups task templates by category for the seeded data response
func buildGroupedTemplates(templates []models.TaskTemplate) map[string]interface{} {
type templateResponse struct {
ID uint `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
CategoryID *uint `json:"category_id"`
Category map[string]interface{} `json:"category,omitempty"`
FrequencyID *uint `json:"frequency_id"`
Frequency map[string]interface{} `json:"frequency,omitempty"`
IconIOS string `json:"icon_ios"`
IconAndroid string `json:"icon_android"`
Tags []string `json:"tags"`
DisplayOrder int `json:"display_order"`
IsActive bool `json:"is_active"`
}
type categoryGroup struct {
CategoryName string `json:"category_name"`
CategoryID *uint `json:"category_id"`
Templates []templateResponse `json:"templates"`
Count int `json:"count"`
}
categoryMap := make(map[string]*categoryGroup)
categoryOrder := []string{}
for _, t := range templates {
categoryName := "Uncategorized"
var categoryID *uint
if t.Category != nil {
categoryName = t.Category.Name
categoryID = &t.Category.ID
}
if _, exists := categoryMap[categoryName]; !exists {
categoryMap[categoryName] = &categoryGroup{
CategoryName: categoryName,
CategoryID: categoryID,
Templates: []templateResponse{},
}
categoryOrder = append(categoryOrder, categoryName)
}
resp := templateResponse{
ID: t.ID,
Title: t.Title,
Description: t.Description,
CategoryID: t.CategoryID,
FrequencyID: t.FrequencyID,
IconIOS: t.IconIOS,
IconAndroid: t.IconAndroid,
Tags: parseTags(t.Tags),
DisplayOrder: t.DisplayOrder,
IsActive: t.IsActive,
}
if t.Category != nil {
resp.Category = map[string]interface{}{
"id": t.Category.ID,
"name": t.Category.Name,
"description": t.Category.Description,
"icon": t.Category.Icon,
"color": t.Category.Color,
"display_order": t.Category.DisplayOrder,
}
}
if t.Frequency != nil {
resp.Frequency = map[string]interface{}{
"id": t.Frequency.ID,
"name": t.Frequency.Name,
"days": t.Frequency.Days,
"display_order": t.Frequency.DisplayOrder,
}
}
categoryMap[categoryName].Templates = append(categoryMap[categoryName].Templates, resp)
}
categories := make([]categoryGroup, len(categoryOrder))
totalCount := 0
for i, name := range categoryOrder {
group := categoryMap[name]
group.Count = len(group.Templates)
totalCount += group.Count
categories[i] = *group
}
return map[string]interface{}{
"categories": categories,
"total_count": totalCount,
}
}
// parseTags splits a comma-separated tags string into a slice
func parseTags(tags string) []string {
if tags == "" {
return []string{}
}
parts := strings.Split(tags, ",")
result := make([]string, 0, len(parts))
for _, p := range parts {
trimmed := strings.TrimSpace(p)
if trimmed != "" {
result = append(result, trimmed)
}
}
return result
}
// SeedTestData handles POST /api/admin/settings/seed-test-data
func (h *AdminSettingsHandler) SeedTestData(c echo.Context) error {
if err := h.runSeedFile("002_test_data.sql"); err != nil {
+3 -6
View File
@@ -207,9 +207,7 @@ func (h *AdminUserHandler) Create(c echo.Context) error {
user.IsSuperuser = *req.IsSuperuser
}
if err := user.SetPassword(req.Password); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to hash password"})
}
// Password management is handled by Ory Kratos; no local password hashing.
if err := h.db.Create(&user).Error; err != nil {
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to create user"})
@@ -284,10 +282,9 @@ func (h *AdminUserHandler) Update(c echo.Context) error {
if req.IsSuperuser != nil {
user.IsSuperuser = *req.IsSuperuser
}
// Password management is handled by Ory Kratos; local password update ignored.
if req.Password != nil {
if err := user.SetPassword(*req.Password); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to hash password"})
}
_ = req.Password // Password changes must go through Kratos admin API
}
if err := h.db.Save(&user).Error; err != nil {
+81 -5
View File
@@ -1,6 +1,7 @@
package config
import (
"crypto/rand"
"encoding/hex"
"fmt"
"net/url"
@@ -141,6 +142,13 @@ type SecurityConfig struct {
MaxPasswordResetRate int // per hour
TokenExpiryDays int // Number of days before auth tokens expire (default 90)
TokenRefreshDays int // Token must be at least this many days old before refresh (default 60)
// KratosPublicURL is the Ory Kratos public API base URL. The auth
// middleware validates sessions against {KratosPublicURL}/sessions/whoami.
KratosPublicURL string
// KratosAdminURL is the Ory Kratos admin API base URL. Account deletion
// removes the user's Kratos identity via
// {KratosAdminURL}/admin/identities/{id}.
KratosAdminURL string
}
// StorageConfig holds file storage settings.
@@ -216,6 +224,11 @@ func Load() (*Config, error) {
// Set defaults
setDefaults()
// Audit F8: overlay file-mounted secrets onto Viper. No-op when the
// directory is absent (local/dev), so this is safe to ship before the
// manifests mount honeydue-secrets as a volume.
loadFileSecrets()
// Parse DATABASE_URL if set (Dokku-style)
dbConfig := DatabaseConfig{
Host: viper.GetString("DB_HOST"),
@@ -298,6 +311,8 @@ func Load() (*Config, error) {
MaxPasswordResetRate: 3,
TokenExpiryDays: viper.GetInt("TOKEN_EXPIRY_DAYS"),
TokenRefreshDays: viper.GetInt("TOKEN_REFRESH_DAYS"),
KratosPublicURL: viper.GetString("KRATOS_PUBLIC_URL"),
KratosAdminURL: viper.GetString("KRATOS_ADMIN_URL"),
},
Storage: StorageConfig{
UploadDir: viper.GetString("STORAGE_UPLOAD_DIR"),
@@ -405,6 +420,8 @@ func setDefaults() {
// Token expiry defaults
viper.SetDefault("TOKEN_EXPIRY_DAYS", 90) // Tokens expire after 90 days
viper.SetDefault("KRATOS_PUBLIC_URL", "http://kratos:4433") // Ory Kratos public API
viper.SetDefault("KRATOS_ADMIN_URL", "http://kratos:4434") // Ory Kratos admin API
viper.SetDefault("TOKEN_REFRESH_DAYS", 60) // Tokens can be refreshed after 60 days
// Storage defaults
@@ -432,14 +449,67 @@ func isWeakSecretKey(key string) bool {
return knownWeakSecretKeys[strings.ToLower(strings.TrimSpace(key))]
}
// loadFileSecrets overlays file-mounted secrets onto Viper (audit F8). When
// the honeydue-secrets Secret is mounted as a volume at /etc/honeydue/secrets
// each key is a file; reading the value here and viper.Set-ing it (highest
// Viper precedence) keeps the secret out of the process environment
// (/proc/<pid>/environ), which plain env-var injection cannot. When the
// directory is absent it is a silent no-op and env vars are used as before.
func loadFileSecrets() {
dir := os.Getenv("HONEYDUE_SECRETS_DIR")
if dir == "" {
dir = "/etc/honeydue/secrets"
}
for _, k := range []string{
"POSTGRES_PASSWORD", "SECRET_KEY", "EMAIL_HOST_PASSWORD", "FCM_SERVER_KEY",
"REDIS_PASSWORD", "B2_KEY_ID", "B2_APP_KEY", "OBS_INGEST_TOKEN", "OBS_TRACES_URL",
} {
b, err := os.ReadFile(dir + "/" + k)
if err != nil {
continue
}
if v := strings.TrimSpace(string(b)); v != "" {
viper.Set(k, v)
}
}
}
// SecretValue resolves a configuration value that is not part of the typed
// Config struct. It reads through Viper, so a value supplied via a file-mounted
// secret (audit F8, loaded by loadFileSecrets) is found just like an env var.
//
// Must be called after Load(). Used by cmd/api and cmd/worker for the
// observability endpoints, which are needed before the full Config is wired
// and would otherwise be read with os.Getenv — which misses file-mounted
// secrets entirely once F8 removes them from the process environment.
func SecretValue(key string) string {
return viper.GetString(key)
}
// randomHexKey returns a cryptographically secure random hex string
// representing n random bytes (2n hex characters).
func randomHexKey(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func validate(cfg *Config) error {
// S-08: Validate SECRET_KEY against known weak defaults
// M8: SECRET_KEY validation — no static fallback secret in the binary.
if cfg.Security.SecretKey == "" {
if cfg.Server.Debug {
// In debug mode, use a default key with a warning for local development
cfg.Security.SecretKey = "change-me-in-production-secret-key-12345"
fmt.Println("WARNING: SECRET_KEY not set, using default (debug mode only)")
fmt.Println("WARNING: *** DO NOT USE THIS DEFAULT KEY IN PRODUCTION ***")
// Debug only: generate a random key per boot. Tokens signed with
// it do not survive a restart, which is acceptable for local dev
// and far safer than a well-known hardcoded fallback.
randomKey, err := randomHexKey(32)
if err != nil {
return fmt.Errorf("failed to generate ephemeral debug SECRET_KEY: %w", err)
}
cfg.Security.SecretKey = randomKey
fmt.Println("WARNING: SECRET_KEY not set, generated an ephemeral random key (debug mode only)")
fmt.Println("WARNING: tokens will not survive a restart — set SECRET_KEY for stable local sessions")
} else {
// In production, refuse to start without a proper secret key
return fmt.Errorf("FATAL: SECRET_KEY environment variable is required in production (DEBUG=false)")
@@ -452,6 +522,12 @@ func validate(cfg *Config) error {
}
}
// C4: fixed confirmation codes ("123456") must never be enabled outside
// debug — with DEBUG=false they are a full authentication bypass.
if cfg.Server.DebugFixedCodes && !cfg.Server.Debug {
return fmt.Errorf("FATAL: DEBUG_FIXED_CODES is enabled with DEBUG=false — fixed confirmation codes must never run in production")
}
// Database password might come from DATABASE_URL, don't require it separately
// The actual connection will fail if credentials are wrong
+31 -2
View File
@@ -106,8 +106,10 @@ func TestLoad_Validation_MissingSecretKey_DebugMode(t *testing.T) {
c, err := Load()
require.NoError(t, err)
// In debug mode, a default key is assigned
assert.Equal(t, "change-me-in-production-secret-key-12345", c.Security.SecretKey)
// Audit M8: in debug mode an ephemeral random key is generated per boot
// (no static fallback). It must be a non-empty 64-char hex string.
assert.Len(t, c.Security.SecretKey, 64)
assert.NotEqual(t, "change-me-in-production-secret-key-12345", c.Security.SecretKey)
}
func TestLoad_Validation_WeakSecretKey_Production(t *testing.T) {
@@ -133,6 +135,33 @@ func TestLoad_Validation_WeakSecretKey_DebugMode(t *testing.T) {
assert.Equal(t, "secret", c.Security.SecretKey)
}
// Audit C4: DEBUG_FIXED_CODES makes confirmation codes a fixed "123456" — a
// full authentication bypass. With DEBUG=false, validate() must refuse to boot
// rather than ship that bypass to production.
func TestLoad_Validation_DebugFixedCodes_Production(t *testing.T) {
// validate() directly — avoids the sync.Once issue Load() has on failure.
cfg := &Config{
Server: ServerConfig{Debug: false, DebugFixedCodes: true},
Security: SecurityConfig{SecretKey: "a-strong-secret-key-for-tests"},
}
err := validate(cfg)
require.Error(t, err)
assert.Contains(t, err.Error(), "DEBUG_FIXED_CODES")
}
// With DEBUG=true the fixed codes are an intended local-dev convenience, so
// the same combination must NOT error.
func TestLoad_Validation_DebugFixedCodes_DebugMode(t *testing.T) {
cfg := &Config{
Server: ServerConfig{Debug: true, DebugFixedCodes: true},
Security: SecurityConfig{SecretKey: "a-strong-secret-key-for-tests"},
}
err := validate(cfg)
require.NoError(t, err)
}
func TestLoad_Validation_EncryptionKey_Valid(t *testing.T) {
resetConfigState()
t.Setenv("SECRET_KEY", "a-strong-secret-key-for-tests")
-5
View File
@@ -244,12 +244,7 @@ func Migrate() error {
// User and auth tables
&models.User{},
&models.AuthToken{},
&models.UserProfile{},
&models.ConfirmationCode{},
&models.PasswordResetCode{},
&models.AppleSocialAuth{},
&models.GoogleSocialAuth{},
// Admin users (separate from app users)
&models.AdminUser{},
+6 -1
View File
@@ -25,7 +25,12 @@ type CreateDocumentRequest struct {
SerialNumber string `json:"serial_number" validate:"max=100"`
ModelNumber string `json:"model_number" validate:"max=100"`
TaskID *uint `json:"task_id"`
ImageURLs []string `json:"image_urls" validate:"omitempty,max=20,dive,max=500"` // Multiple image URLs
// UploadIDs claims pending_uploads rows produced by the presigned-URL
// upload flow and turns them into document_image rows. UploadIDs of
// category "document_file" attach to the document's main FileURL +
// FileName fields instead — the service infers placement from the
// row's category.
UploadIDs []uint `json:"upload_ids" validate:"omitempty,max=20"`
}
// UpdateDocumentRequest represents the request to update a document
+8 -3
View File
@@ -100,14 +100,20 @@ type UpdateTaskRequest struct {
ContractorID *uint `json:"contractor_id"`
}
// CreateTaskCompletionRequest represents the request to create a task completion
// CreateTaskCompletionRequest represents the request to create a task completion.
//
// Image attachments arrive via the presigned-URL flow: the client uploads
// each image directly to B2 (see /api/uploads/presign) and passes the
// resulting pending_uploads.id values in UploadIDs. The service claims
// those rows and creates the linked task_completion_image rows.
type CreateTaskCompletionRequest struct {
TaskID uint `json:"task_id" validate:"required"`
CompletedAt *time.Time `json:"completed_at"` // Defaults to now
Notes string `json:"notes" validate:"max=10000"`
ActualCost *decimal.Decimal `json:"actual_cost"`
Rating *int `json:"rating" validate:"omitempty,min=1,max=5"` // 1-5 star rating
ImageURLs []string `json:"image_urls" validate:"omitempty,max=20,dive,max=500"` // Multiple image URLs
UploadIDs []uint `json:"upload_ids" validate:"omitempty,max=20"`
}
// UpdateTaskCompletionRequest represents the request to update a task completion
@@ -115,7 +121,6 @@ type UpdateTaskCompletionRequest struct {
Notes *string `json:"notes" validate:"omitempty,max=10000"`
ActualCost *decimal.Decimal `json:"actual_cost"`
Rating *int `json:"rating" validate:"omitempty,min=1,max=5"`
ImageURLs []string `json:"image_urls" validate:"omitempty,max=20,dive,max=500"`
}
// CompletionImageInput represents an image to add to a completion
+22
View File
@@ -0,0 +1,22 @@
package requests
// PresignUploadRequest is the body for POST /api/uploads/presign. The client
// describes what it's about to upload; the server validates against quota,
// rate limits, and per-category caps before returning a signed POST policy.
type PresignUploadRequest struct {
// Category gates allowed mime types and the size cap. One of:
// "completion" — task completion photos
// "document_image" — image attached to a Document
// "document_file" — file (e.g. PDF) attached to a Document
Category string `json:"category" validate:"required,oneof=completion document_image document_file"`
// ContentType is the MIME type the client will upload (e.g. image/jpeg).
// Bound to the policy so the actual upload must match exactly.
ContentType string `json:"content_type" validate:"required,min=3,max=127"`
// ContentLength is the exact byte count the client intends to upload.
// The signed policy permits a small slack window around this value
// (server-side constant) so the client can encode in one pass without
// having to predict the byte count perfectly.
ContentLength int64 `json:"content_length" validate:"required,min=1"`
}
+3
View File
@@ -9,7 +9,10 @@ import (
// ContractorSpecialtyResponse represents a contractor specialty
type ContractorSpecialtyResponse struct {
ID uint `json:"id"`
// Name is the stable English identifier (clients match on this).
Name string `json:"name"`
// DisplayName is the localized label for the request's Accept-Language.
DisplayName string `json:"display_name"`
Description string `json:"description"`
Icon string `json:"icon"`
DisplayOrder int `json:"display_order"`
+3
View File
@@ -11,7 +11,10 @@ import (
// ResidenceTypeResponse represents a residence type in the API response
type ResidenceTypeResponse struct {
ID uint `json:"id"`
// Name is the stable English identifier (clients match on this).
Name string `json:"name"`
// DisplayName is the localized label for the request's Accept-Language.
DisplayName string `json:"display_name"`
}
// ResidenceUserResponse represents a user with access to a residence
+5
View File
@@ -14,7 +14,10 @@ import (
// TaskCategoryResponse represents a task category
type TaskCategoryResponse struct {
ID uint `json:"id"`
// Name is the stable English identifier (clients match on this).
Name string `json:"name"`
// DisplayName is the localized label for the request's Accept-Language.
DisplayName string `json:"display_name"`
Description string `json:"description"`
Icon string `json:"icon"`
Color string `json:"color"`
@@ -25,6 +28,7 @@ type TaskCategoryResponse struct {
type TaskPriorityResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Level int `json:"level"`
Color string `json:"color"`
DisplayOrder int `json:"display_order"`
@@ -34,6 +38,7 @@ type TaskPriorityResponse struct {
type TaskFrequencyResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
Days *int `json:"days"`
DisplayOrder int `json:"display_order"`
}
+38
View File
@@ -0,0 +1,38 @@
package responses
// PresignUploadResponse is what /api/uploads/presign returns to the client.
//
// Flow: the client makes one PUT request to URL with the raw object bytes
// as the body and Headers as the request headers (verbatim — the signature
// binds them). On success, the client passes ID back via upload_ids[] on
// POST /api/task-completions/ or POST /api/documents/ to claim and attach
// the object.
//
// We use PUT (not POST) because Backblaze B2's S3-compatible endpoint does
// not implement the S3 POST Object form upload — it returns HTTP 501 on
// every request style. PUT works against AWS S3, B2, and MinIO uniformly.
type PresignUploadResponse struct {
// ID is the pending_uploads.id the client passes back via upload_ids[].
ID uint `json:"id"`
// URL is the signed PUT URL. Includes all auth as query parameters.
URL string `json:"upload_url"`
// Method is always "PUT" — emitted explicitly so clients don't have to
// hardcode it. Reserved for the rare case we ever offer alternative
// upload mechanisms.
Method string `json:"method"`
// Headers must be sent verbatim on the PUT request. Currently includes
// Content-Type and Content-Length; both are signed, and B2 will reject
// any PUT whose headers don't match.
Headers map[string]string `json:"headers"`
// Key is the object key chosen by the server. Echoed for client logging
// and debugging; the canonical reference is via ID.
Key string `json:"key"`
// ExpiresAt is when the signed URL stops working. Clients should retry
// with a fresh presign rather than relying on long-lived URLs.
ExpiresAt string `json:"expires_at"`
}
+73 -401
View File
@@ -1,7 +1,6 @@
package handlers
import (
"errors"
"net/http"
"github.com/labstack/echo/v4"
@@ -13,20 +12,22 @@ import (
"github.com/treytartt/honeydue-api/internal/middleware"
"github.com/treytartt/honeydue-api/internal/services"
"github.com/treytartt/honeydue-api/internal/validator"
"github.com/treytartt/honeydue-api/internal/worker"
)
// AuthHandler handles authentication endpoints
// AuthHandler handles user profile and account management endpoints.
// Session lifecycle (login, register, logout, password reset) is delegated
// to Ory Kratos; this handler only deals with the honeyDue user record.
type AuthHandler struct {
authService *services.AuthService
emailService *services.EmailService
cache *services.CacheService
appleAuthService *services.AppleAuthService
googleAuthService *services.GoogleAuthService
storageService *services.StorageService
auditService *services.AuditService
enqueuer worker.Enqueuer
}
// NewAuthHandler creates a new auth handler
// NewAuthHandler creates a new auth handler.
func NewAuthHandler(authService *services.AuthService, emailService *services.EmailService, cache *services.CacheService) *AuthHandler {
return &AuthHandler{
authService: authService,
@@ -35,128 +36,78 @@ func NewAuthHandler(authService *services.AuthService, emailService *services.Em
}
}
// SetAppleAuthService sets the Apple auth service (called after initialization)
func (h *AuthHandler) SetAppleAuthService(appleAuth *services.AppleAuthService) {
h.appleAuthService = appleAuth
}
// SetGoogleAuthService sets the Google auth service (called after initialization)
func (h *AuthHandler) SetGoogleAuthService(googleAuth *services.GoogleAuthService) {
h.googleAuthService = googleAuth
}
// SetStorageService sets the storage service for file deletion during account deletion
// SetStorageService sets the storage service for file deletion during account deletion.
func (h *AuthHandler) SetStorageService(storageService *services.StorageService) {
h.storageService = storageService
}
// SetAuditService sets the audit service for logging security events
// SetAuditService sets the audit service for logging security events.
func (h *AuthHandler) SetAuditService(auditService *services.AuditService) {
h.auditService = auditService
}
// Login handles POST /api/auth/login/
func (h *AuthHandler) Login(c echo.Context) error {
var req requests.LoginRequest
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
}
response, err := h.authService.Login(c.Request().Context(), &req)
if err != nil {
log.Debug().Err(err).Str("identifier", req.Username).Msg("Login failed")
if h.auditService != nil {
h.auditService.LogEvent(c, nil, services.AuditEventLoginFailed, map[string]interface{}{
"identifier": req.Username,
})
}
return err
}
if h.auditService != nil {
userID := response.User.ID
h.auditService.LogEvent(c, &userID, services.AuditEventLogin, nil)
}
return c.JSON(http.StatusOK, response)
// SetEnqueuer sets the async task enqueuer (used by the GDPR data-export endpoint).
func (h *AuthHandler) SetEnqueuer(enqueuer worker.Enqueuer) {
h.enqueuer = enqueuer
}
// Register handles POST /api/auth/register/
// ExportData handles POST /api/auth/export/ — queues a GDPR data-export job that
// emails the user a zip of all their data. Async (202) because gathering,
// zipping, and emailing can take seconds; doing it inline would block the request.
func (h *AuthHandler) ExportData(c echo.Context) error {
noStore(c)
user, err := middleware.MustGetAuthUser(c)
if err != nil {
return err
}
if h.enqueuer == nil {
return echo.NewHTTPError(http.StatusServiceUnavailable, "data export is temporarily unavailable")
}
if err := h.enqueuer.EnqueueDataExport(user.ID); err != nil {
log.Error().Err(err).Uint("user_id", user.ID).Msg("Failed to enqueue data export")
return echo.NewHTTPError(http.StatusInternalServerError, "failed to queue data export")
}
if h.auditService != nil {
h.auditService.LogEvent(c, &user.ID, services.AuditEventDataExport, map[string]interface{}{
"user_id": user.ID,
"email": user.Email,
})
}
return c.JSON(http.StatusAccepted, map[string]string{
"message": "Your data export has been queued. You'll receive an email with your data shortly.",
})
}
// noStore marks a response as non-cacheable.
func noStore(c echo.Context) {
c.Response().Header().Set("Cache-Control", "no-store")
}
// Register handles POST /api/auth/register/ — creates a new password account.
//
// The identity is admin-created in Kratos with an unverified email and no
// auto-sent code (see services.AuthService.Register). The client logs in right
// after to get a session, then completes email verification. Returns 201 with
// no token; 409 if the email is taken; 400 on a weak password.
func (h *AuthHandler) Register(c echo.Context) error {
var req requests.RegisterRequest
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
return apperrors.BadRequest("error.invalid_request_body")
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
}
response, confirmationCode, err := h.authService.Register(c.Request().Context(), &req)
if err != nil {
log.Debug().Err(err).Msg("Registration failed")
if err := h.authService.Register(c.Request().Context(), &req); err != nil {
return err
}
if h.auditService != nil {
userID := response.User.ID
h.auditService.LogEvent(c, &userID, services.AuditEventRegister, map[string]interface{}{
"username": req.Username,
"email": req.Email,
return c.JSON(http.StatusCreated, map[string]string{
"message": "Account created. Please verify your email.",
})
}
// Send welcome email with confirmation code (async)
if h.emailService != nil && confirmationCode != "" {
go func() {
defer func() {
if r := recover(); r != nil {
log.Error().Interface("panic", r).Str("email", req.Email).Msg("Panic in welcome email goroutine")
}
}()
if err := h.emailService.SendWelcomeEmail(req.Email, req.FirstName, confirmationCode); err != nil {
log.Error().Err(err).Str("email", req.Email).Msg("Failed to send welcome email")
}
}()
}
return c.JSON(http.StatusCreated, response)
}
// Logout handles POST /api/auth/logout/
func (h *AuthHandler) Logout(c echo.Context) error {
token := middleware.GetAuthToken(c)
if token == "" {
return apperrors.Unauthorized("error.not_authenticated")
}
// Log audit event before invalidating the token
if h.auditService != nil {
user := middleware.GetAuthUser(c)
if user != nil {
h.auditService.LogEvent(c, &user.ID, services.AuditEventLogout, nil)
}
}
// Invalidate token in database
if err := h.authService.Logout(c.Request().Context(), token); err != nil {
log.Warn().Err(err).Msg("Failed to delete token from database")
}
// Invalidate token in cache
if h.cache != nil {
if err := h.cache.InvalidateAuthToken(c.Request().Context(), token); err != nil {
log.Warn().Err(err).Msg("Failed to invalidate token in cache")
}
}
return c.JSON(http.StatusOK, responses.MessageResponse{Message: "Logged out successfully"})
}
// CurrentUser handles GET /api/auth/me/
func (h *AuthHandler) CurrentUser(c echo.Context) error {
noStore(c)
user, err := middleware.MustGetAuthUser(c)
if err != nil {
return err
@@ -168,6 +119,25 @@ func (h *AuthHandler) CurrentUser(c echo.Context) error {
return err
}
// user_profile.verified is a one-time mirror set at provision time
// (see middleware/kratos_auth.go::provision). Kratos remains the source
// of truth for email-verification state — it can flip from false → true
// the instant the user completes the verification flow, and nothing
// updates the local column. Override the response with the live value
// the Kratos auth middleware already stashed in context so /auth/me
// reflects current reality. Also opportunistically sync the DB mirror
// (best-effort, ignore error) so background queries that read the
// column see the same answer.
if verified, ok := c.Get(middleware.AuthVerifiedKey).(bool); ok {
mirrorStale := response.Profile != nil && response.Profile.Verified != verified
if response.Profile != nil {
response.Profile.Verified = verified
}
if verified && mirrorStale {
_ = h.authService.MarkUserVerified(c.Request().Context(), user.ID)
}
}
return c.JSON(http.StatusOK, response)
}
@@ -195,296 +165,6 @@ func (h *AuthHandler) UpdateProfile(c echo.Context) error {
return c.JSON(http.StatusOK, response)
}
// VerifyEmail handles POST /api/auth/verify-email/
func (h *AuthHandler) VerifyEmail(c echo.Context) error {
user, err := middleware.MustGetAuthUser(c)
if err != nil {
return err
}
var req requests.VerifyEmailRequest
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
}
err = h.authService.VerifyEmail(c.Request().Context(), user.ID, req.Code)
if err != nil {
log.Debug().Err(err).Uint("user_id", user.ID).Msg("Email verification failed")
return err
}
// Send post-verification welcome email with tips (async)
if h.emailService != nil {
go func() {
defer func() {
if r := recover(); r != nil {
log.Error().Interface("panic", r).Str("email", user.Email).Msg("Panic in post-verification email goroutine")
}
}()
if err := h.emailService.SendPostVerificationEmail(user.Email, user.FirstName); err != nil {
log.Error().Err(err).Str("email", user.Email).Msg("Failed to send post-verification email")
}
}()
}
return c.JSON(http.StatusOK, responses.VerifyEmailResponse{
Message: "Email verified successfully",
Verified: true,
})
}
// ResendVerification handles POST /api/auth/resend-verification/
func (h *AuthHandler) ResendVerification(c echo.Context) error {
user, err := middleware.MustGetAuthUser(c)
if err != nil {
return err
}
code, err := h.authService.ResendVerificationCode(c.Request().Context(), user.ID)
if err != nil {
log.Debug().Err(err).Uint("user_id", user.ID).Msg("Failed to resend verification")
return err
}
// Send verification email (async)
if h.emailService != nil {
go func() {
defer func() {
if r := recover(); r != nil {
log.Error().Interface("panic", r).Str("email", user.Email).Msg("Panic in verification email goroutine")
}
}()
if err := h.emailService.SendVerificationEmail(user.Email, user.FirstName, code); err != nil {
log.Error().Err(err).Str("email", user.Email).Msg("Failed to send verification email")
}
}()
}
return c.JSON(http.StatusOK, responses.MessageResponse{Message: "Verification email sent"})
}
// ForgotPassword handles POST /api/auth/forgot-password/
func (h *AuthHandler) ForgotPassword(c echo.Context) error {
var req requests.ForgotPasswordRequest
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
}
code, user, err := h.authService.ForgotPassword(c.Request().Context(), req.Email)
if err != nil {
var appErr *apperrors.AppError
if errors.As(err, &appErr) && appErr.Code == http.StatusTooManyRequests {
// Only reveal rate limit errors
return err
}
log.Error().Err(err).Str("email", req.Email).Msg("Forgot password failed")
// Don't reveal other errors to prevent email enumeration
}
// Send password reset email (async) - only if user found
if h.emailService != nil && code != "" && user != nil {
go func() {
defer func() {
if r := recover(); r != nil {
log.Error().Interface("panic", r).Str("email", user.Email).Msg("Panic in password reset email goroutine")
}
}()
if err := h.emailService.SendPasswordResetEmail(user.Email, user.FirstName, code); err != nil {
log.Error().Err(err).Str("email", user.Email).Msg("Failed to send password reset email")
}
}()
}
if h.auditService != nil {
h.auditService.LogEvent(c, nil, services.AuditEventPasswordReset, map[string]interface{}{
"email": req.Email,
})
}
// Always return success to prevent email enumeration
return c.JSON(http.StatusOK, responses.ForgotPasswordResponse{
Message: "Password reset email sent",
})
}
// VerifyResetCode handles POST /api/auth/verify-reset-code/
func (h *AuthHandler) VerifyResetCode(c echo.Context) error {
var req requests.VerifyResetCodeRequest
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
}
resetToken, err := h.authService.VerifyResetCode(c.Request().Context(), req.Email, req.Code)
if err != nil {
log.Debug().Err(err).Str("email", req.Email).Msg("Verify reset code failed")
return err
}
return c.JSON(http.StatusOK, responses.VerifyResetCodeResponse{
Message: "Reset code verified",
ResetToken: resetToken,
})
}
// ResetPassword handles POST /api/auth/reset-password/
func (h *AuthHandler) ResetPassword(c echo.Context) error {
var req requests.ResetPasswordRequest
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
}
err := h.authService.ResetPassword(c.Request().Context(), req.ResetToken, req.NewPassword)
if err != nil {
log.Debug().Err(err).Msg("Password reset failed")
return err
}
if h.auditService != nil {
h.auditService.LogEvent(c, nil, services.AuditEventPasswordChanged, map[string]interface{}{
"method": "reset_token",
})
}
return c.JSON(http.StatusOK, responses.ResetPasswordResponse{
Message: "Password reset successful",
})
}
// AppleSignIn handles POST /api/auth/apple-sign-in/
func (h *AuthHandler) AppleSignIn(c echo.Context) error {
var req requests.AppleSignInRequest
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
}
if h.appleAuthService == nil {
log.Error().Msg("Apple auth service not configured")
return &apperrors.AppError{
Code: 500,
MessageKey: "error.apple_signin_not_configured",
}
}
response, err := h.authService.AppleSignIn(c.Request().Context(), h.appleAuthService, &req)
if err != nil {
// Check for legacy Apple Sign In error (not yet migrated)
if errors.Is(err, services.ErrAppleSignInFailed) {
log.Debug().Err(err).Msg("Apple Sign In failed (legacy error)")
return apperrors.Unauthorized("error.invalid_apple_token")
}
log.Debug().Err(err).Msg("Apple Sign In failed")
return err
}
// Send welcome email for new users (async)
if response.IsNewUser && h.emailService != nil && response.User.Email != "" {
go func() {
defer func() {
if r := recover(); r != nil {
log.Error().Interface("panic", r).Str("email", response.User.Email).Msg("Panic in Apple welcome email goroutine")
}
}()
if err := h.emailService.SendAppleWelcomeEmail(response.User.Email, response.User.FirstName); err != nil {
log.Error().Err(err).Str("email", response.User.Email).Msg("Failed to send Apple welcome email")
}
}()
}
return c.JSON(http.StatusOK, response)
}
// GoogleSignIn handles POST /api/auth/google-sign-in/
func (h *AuthHandler) GoogleSignIn(c echo.Context) error {
var req requests.GoogleSignInRequest
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, validator.FormatValidationErrors(err))
}
if h.googleAuthService == nil {
log.Error().Msg("Google auth service not configured")
return &apperrors.AppError{
Code: 500,
MessageKey: "error.google_signin_not_configured",
}
}
response, err := h.authService.GoogleSignIn(c.Request().Context(), h.googleAuthService, &req)
if err != nil {
// Check for legacy Google Sign In error (not yet migrated)
if errors.Is(err, services.ErrGoogleSignInFailed) {
log.Debug().Err(err).Msg("Google Sign In failed (legacy error)")
return apperrors.Unauthorized("error.invalid_google_token")
}
log.Debug().Err(err).Msg("Google Sign In failed")
return err
}
// Send welcome email for new users (async)
if response.IsNewUser && h.emailService != nil && response.User.Email != "" {
go func() {
defer func() {
if r := recover(); r != nil {
log.Error().Interface("panic", r).Str("email", response.User.Email).Msg("Panic in Google welcome email goroutine")
}
}()
if err := h.emailService.SendGoogleWelcomeEmail(response.User.Email, response.User.FirstName); err != nil {
log.Error().Err(err).Str("email", response.User.Email).Msg("Failed to send Google welcome email")
}
}()
}
return c.JSON(http.StatusOK, response)
}
// RefreshToken handles POST /api/auth/refresh/
func (h *AuthHandler) RefreshToken(c echo.Context) error {
user, err := middleware.MustGetAuthUser(c)
if err != nil {
return err
}
token := middleware.GetAuthToken(c)
if token == "" {
return apperrors.Unauthorized("error.not_authenticated")
}
response, err := h.authService.RefreshToken(c.Request().Context(), token, user.ID)
if err != nil {
log.Debug().Err(err).Uint("user_id", user.ID).Msg("Token refresh failed")
return err
}
// If the token was refreshed (new token), invalidate the old one from cache
if response.Token != token && h.cache != nil {
if cacheErr := h.cache.InvalidateAuthToken(c.Request().Context(), token); cacheErr != nil {
log.Warn().Err(cacheErr).Msg("Failed to invalidate old token from cache during refresh")
}
}
return c.JSON(http.StatusOK, response)
}
// DeleteAccount handles DELETE /api/auth/account/
func (h *AuthHandler) DeleteAccount(c echo.Context) error {
user, err := middleware.MustGetAuthUser(c)
@@ -527,13 +207,5 @@ func (h *AuthHandler) DeleteAccount(c echo.Context) error {
}()
}
// Invalidate auth token from cache
token := middleware.GetAuthToken(c)
if h.cache != nil && token != "" {
if err := h.cache.InvalidateAuthToken(c.Request().Context(), token); err != nil {
log.Warn().Err(err).Msg("Failed to invalidate token in cache after account deletion")
}
}
return c.JSON(http.StatusOK, responses.MessageResponse{Message: "Account deleted successfully"})
}
+15 -106
View File
@@ -35,26 +35,25 @@ func setupDeleteAccountHandler(t *testing.T) (*AuthHandler, *echo.Echo, *gorm.DB
return handler, e, db
}
func TestAuthHandler_DeleteAccount_EmailUser(t *testing.T) {
// TestAuthHandler_DeleteAccount_WithConfirmation verifies that DELETE /account/
// succeeds when the user sends confirmation: "DELETE".
// Post-Kratos: all users (regardless of provider) must confirm with "DELETE".
func TestAuthHandler_DeleteAccount_WithConfirmation(t *testing.T) {
handler, e, db := setupDeleteAccountHandler(t)
user := testutil.CreateTestUser(t, db, "deletetest", "delete@test.com", "Password123")
user := testutil.CreateTestUser(t, db, "deletetest", "delete@test.com", "ignored")
// Create profile for the user
profile := &models.UserProfile{UserID: user.ID, Verified: true}
require.NoError(t, db.Create(profile).Error)
// Create auth token
testutil.CreateTestToken(t, db, user.ID)
authGroup := e.Group("/api/auth")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.DELETE("/account/", handler.DeleteAccount)
t.Run("successful deletion with correct password", func(t *testing.T) {
password := "Password123"
t.Run("successful deletion with DELETE confirmation", func(t *testing.T) {
req := map[string]interface{}{
"password": password,
"confirmation": "DELETE",
}
w := testutil.MakeRequest(e, "DELETE", "/api/auth/account/", req, "test-token")
@@ -74,106 +73,15 @@ func TestAuthHandler_DeleteAccount_EmailUser(t *testing.T) {
// Verify profile is deleted
db.Model(&models.UserProfile{}).Where("user_id = ?", user.ID).Count(&count)
assert.Equal(t, int64(0), count)
// Verify auth token is deleted
db.Model(&models.AuthToken{}).Where("user_id = ?", user.ID).Count(&count)
assert.Equal(t, int64(0), count)
})
}
func TestAuthHandler_DeleteAccount_WrongPassword(t *testing.T) {
// TestAuthHandler_DeleteAccount_MissingConfirmation verifies that a missing
// confirmation string is rejected with 400.
func TestAuthHandler_DeleteAccount_MissingConfirmation(t *testing.T) {
handler, e, db := setupDeleteAccountHandler(t)
user := testutil.CreateTestUser(t, db, "wrongpw", "wrongpw@test.com", "Password123")
authGroup := e.Group("/api/auth")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.DELETE("/account/", handler.DeleteAccount)
t.Run("wrong password returns 401", func(t *testing.T) {
wrongPw := "wrongpassword"
req := map[string]interface{}{
"password": wrongPw,
}
w := testutil.MakeRequest(e, "DELETE", "/api/auth/account/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusUnauthorized)
})
}
func TestAuthHandler_DeleteAccount_MissingPassword(t *testing.T) {
handler, e, db := setupDeleteAccountHandler(t)
user := testutil.CreateTestUser(t, db, "nopw", "nopw@test.com", "Password123")
authGroup := e.Group("/api/auth")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.DELETE("/account/", handler.DeleteAccount)
t.Run("missing password returns 400", func(t *testing.T) {
req := map[string]interface{}{}
w := testutil.MakeRequest(e, "DELETE", "/api/auth/account/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
func TestAuthHandler_DeleteAccount_SocialAuthUser(t *testing.T) {
handler, e, db := setupDeleteAccountHandler(t)
user := testutil.CreateTestUser(t, db, "appleuser", "apple@test.com", "randompassword")
// Create Apple social auth record
appleAuth := &models.AppleSocialAuth{
UserID: user.ID,
AppleID: "apple_sub_123",
Email: "apple@test.com",
}
require.NoError(t, db.Create(appleAuth).Error)
// Create profile
profile := &models.UserProfile{UserID: user.ID, Verified: true}
require.NoError(t, db.Create(profile).Error)
authGroup := e.Group("/api/auth")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.DELETE("/account/", handler.DeleteAccount)
t.Run("successful deletion with DELETE confirmation", func(t *testing.T) {
confirmation := "DELETE"
req := map[string]interface{}{
"confirmation": confirmation,
}
w := testutil.MakeRequest(e, "DELETE", "/api/auth/account/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
// Verify user is deleted
var count int64
db.Model(&models.User{}).Where("id = ?", user.ID).Count(&count)
assert.Equal(t, int64(0), count)
// Verify apple auth is deleted
db.Model(&models.AppleSocialAuth{}).Where("user_id = ?", user.ID).Count(&count)
assert.Equal(t, int64(0), count)
})
}
func TestAuthHandler_DeleteAccount_SocialAuthMissingConfirmation(t *testing.T) {
handler, e, db := setupDeleteAccountHandler(t)
user := testutil.CreateTestUser(t, db, "googleuser", "google@test.com", "randompassword")
// Create Google social auth record
googleAuth := &models.GoogleSocialAuth{
UserID: user.ID,
GoogleID: "google_sub_456",
Email: "google@test.com",
}
require.NoError(t, db.Create(googleAuth).Error)
user := testutil.CreateTestUser(t, db, "nopw", "nopw@test.com", "ignored")
authGroup := e.Group("/api/auth")
authGroup.Use(testutil.MockAuthMiddleware(user))
@@ -188,9 +96,8 @@ func TestAuthHandler_DeleteAccount_SocialAuthMissingConfirmation(t *testing.T) {
})
t.Run("wrong confirmation returns 400", func(t *testing.T) {
wrongConfirmation := "delete"
req := map[string]interface{}{
"confirmation": wrongConfirmation,
"confirmation": "delete", // lowercase — must be exact "DELETE"
}
w := testutil.MakeRequest(e, "DELETE", "/api/auth/account/", req, "test-token")
@@ -199,6 +106,8 @@ func TestAuthHandler_DeleteAccount_SocialAuthMissingConfirmation(t *testing.T) {
})
}
// TestAuthHandler_DeleteAccount_Unauthenticated verifies that 401 is returned
// when no auth middleware is set.
func TestAuthHandler_DeleteAccount_Unauthenticated(t *testing.T) {
handler, e, _ := setupDeleteAccountHandler(t)
@@ -207,7 +116,7 @@ func TestAuthHandler_DeleteAccount_Unauthenticated(t *testing.T) {
t.Run("unauthenticated request returns 401", func(t *testing.T) {
req := map[string]interface{}{
"password": "Password123",
"confirmation": "DELETE",
}
w := testutil.MakeRequest(e, "DELETE", "/api/auth/account/", req, "")
+33 -325
View File
@@ -1,3 +1,7 @@
// auth_handler_test.go tests the auth handler endpoints that survived the
// Ory Kratos migration: GET /me/ and PUT/PATCH /profile/.
// Login, register, logout, forgot-password, and social sign-in are now
// handled by Kratos.
package handlers
import (
@@ -34,204 +38,32 @@ func setupAuthHandler(t *testing.T) (*AuthHandler, *echo.Echo, *repositories.Use
return handler, e, userRepo
}
func TestAuthHandler_Register(t *testing.T) {
handler, e, _ := setupAuthHandler(t)
e.POST("/api/auth/register/", handler.Register)
t.Run("successful registration", func(t *testing.T) {
req := requests.RegisterRequest{
Username: "newuser",
Email: "new@test.com",
Password: "Password123",
FirstName: "New",
LastName: "User",
}
w := testutil.MakeRequest(e, "POST", "/api/auth/register/", req, "")
testutil.AssertStatusCode(t, w, http.StatusCreated)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
testutil.AssertJSONFieldExists(t, response, "token")
testutil.AssertJSONFieldExists(t, response, "user")
testutil.AssertJSONFieldExists(t, response, "message")
user := response["user"].(map[string]interface{})
assert.Equal(t, "newuser", user["username"])
assert.Equal(t, "new@test.com", user["email"])
assert.Equal(t, "New", user["first_name"])
assert.Equal(t, "User", user["last_name"])
})
t.Run("registration with missing fields", func(t *testing.T) {
req := map[string]string{
"username": "test",
// Missing email and password
}
w := testutil.MakeRequest(e, "POST", "/api/auth/register/", req, "")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
response := testutil.ParseJSON(t, w.Body.Bytes())
testutil.AssertJSONFieldExists(t, response, "error")
})
t.Run("registration with short password", func(t *testing.T) {
req := requests.RegisterRequest{
Username: "testuser",
Email: "test@test.com",
Password: "short", // Less than 8 chars
}
w := testutil.MakeRequest(e, "POST", "/api/auth/register/", req, "")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
t.Run("registration with duplicate username", func(t *testing.T) {
// First registration
req := requests.RegisterRequest{
Username: "duplicate",
Email: "unique1@test.com",
Password: "Password123",
}
w := testutil.MakeRequest(e, "POST", "/api/auth/register/", req, "")
testutil.AssertStatusCode(t, w, http.StatusCreated)
// Try to register again with same username
req.Email = "unique2@test.com"
w = testutil.MakeRequest(e, "POST", "/api/auth/register/", req, "")
testutil.AssertStatusCode(t, w, http.StatusConflict) // 409 for duplicate resource
response := testutil.ParseJSON(t, w.Body.Bytes())
assert.Contains(t, response["error"], "Username already taken")
})
t.Run("registration with duplicate email", func(t *testing.T) {
// First registration
req := requests.RegisterRequest{
Username: "user1",
Email: "duplicate@test.com",
Password: "Password123",
}
w := testutil.MakeRequest(e, "POST", "/api/auth/register/", req, "")
testutil.AssertStatusCode(t, w, http.StatusCreated)
// Try to register again with same email
req.Username = "user2"
w = testutil.MakeRequest(e, "POST", "/api/auth/register/", req, "")
testutil.AssertStatusCode(t, w, http.StatusConflict) // 409 for duplicate resource
response := testutil.ParseJSON(t, w.Body.Bytes())
assert.Contains(t, response["error"], "Email already registered")
})
}
func TestAuthHandler_Login(t *testing.T) {
handler, e, _ := setupAuthHandler(t)
e.POST("/api/auth/register/", handler.Register)
e.POST("/api/auth/login/", handler.Login)
// Create a test user
registerReq := requests.RegisterRequest{
Username: "logintest",
Email: "login@test.com",
Password: "Password123",
FirstName: "Test",
LastName: "User",
}
w := testutil.MakeRequest(e, "POST", "/api/auth/register/", registerReq, "")
testutil.AssertStatusCode(t, w, http.StatusCreated)
t.Run("successful login with username", func(t *testing.T) {
req := requests.LoginRequest{
Username: "logintest",
Password: "Password123",
}
w := testutil.MakeRequest(e, "POST", "/api/auth/login/", req, "")
testutil.AssertStatusCode(t, w, http.StatusOK)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
testutil.AssertJSONFieldExists(t, response, "token")
testutil.AssertJSONFieldExists(t, response, "user")
user := response["user"].(map[string]interface{})
assert.Equal(t, "logintest", user["username"])
assert.Equal(t, "login@test.com", user["email"])
})
t.Run("successful login with email", func(t *testing.T) {
req := requests.LoginRequest{
Username: "login@test.com", // Using email as username
Password: "Password123",
}
w := testutil.MakeRequest(e, "POST", "/api/auth/login/", req, "")
testutil.AssertStatusCode(t, w, http.StatusOK)
})
t.Run("login with wrong password", func(t *testing.T) {
req := requests.LoginRequest{
Username: "logintest",
Password: "wrongpassword",
}
w := testutil.MakeRequest(e, "POST", "/api/auth/login/", req, "")
testutil.AssertStatusCode(t, w, http.StatusUnauthorized)
response := testutil.ParseJSON(t, w.Body.Bytes())
assert.Contains(t, response["error"], "Invalid credentials")
})
t.Run("login with non-existent user", func(t *testing.T) {
req := requests.LoginRequest{
Username: "nonexistent",
Password: "Password123",
}
w := testutil.MakeRequest(e, "POST", "/api/auth/login/", req, "")
testutil.AssertStatusCode(t, w, http.StatusUnauthorized)
})
t.Run("login with missing fields", func(t *testing.T) {
req := map[string]string{
"username": "logintest",
// Missing password
}
w := testutil.MakeRequest(e, "POST", "/api/auth/login/", req, "")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
func TestAuthHandler_CurrentUser(t *testing.T) {
handler, e, userRepo := setupAuthHandler(t)
handler, e, _ := setupAuthHandler(t)
db := testutil.SetupTestDB(t)
user := testutil.CreateTestUser(t, db, "metest", "me@test.com", "Password123")
user := testutil.CreateTestUser(t, db, "metest", "me@test.com", "")
user.FirstName = "Test"
user.LastName = "User"
userRepo.Update(user)
// Use the userRepo from setupAuthHandler's DB, but since we need the user
// in the same DB we re-create it there.
db2 := testutil.SetupTestDB(t)
user2 := testutil.CreateTestUser(t, db2, "metest2", "me2@test.com", "")
user2.FirstName = "Test"
user2.LastName = "User"
userRepo2 := repositories.NewUserRepository(db2)
require.NoError(t, userRepo2.Update(user2))
// Build handler against db2
cfg := &config.Config{}
authService2 := services.NewAuthService(userRepo2, cfg)
handler2 := NewAuthHandler(authService2, nil, nil)
// Set up route with mock auth middleware
authGroup := e.Group("/api/auth")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.GET("/me/", handler.CurrentUser)
authGroup.Use(testutil.MockAuthMiddleware(user2))
authGroup.GET("/me/", handler2.CurrentUser)
_ = handler // avoid unused
t.Run("get current user", func(t *testing.T) {
w := testutil.MakeRequest(e, "GET", "/api/auth/me/", nil, "test-token")
@@ -242,23 +74,26 @@ func TestAuthHandler_CurrentUser(t *testing.T) {
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, "metest", response["username"])
assert.Equal(t, "me@test.com", response["email"])
assert.Equal(t, "metest2", response["username"])
assert.Equal(t, "me2@test.com", response["email"])
})
}
func TestAuthHandler_UpdateProfile(t *testing.T) {
handler, e, userRepo := setupAuthHandler(t)
db := testutil.SetupTestDB(t)
user := testutil.CreateTestUser(t, db, "updatetest", "update@test.com", "Password123")
userRepo.Update(user)
userRepo := repositories.NewUserRepository(db)
cfg := &config.Config{}
authService := services.NewAuthService(userRepo, cfg)
handler := NewAuthHandler(authService, nil, nil)
e := testutil.SetupTestRouter()
user := testutil.CreateTestUser(t, db, "updatetest", "update@test.com", "")
authGroup := e.Group("/api/auth")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.PUT("/profile/", handler.UpdateProfile)
t.Run("update profile", func(t *testing.T) {
t.Run("update first and last name", func(t *testing.T) {
firstName := "Updated"
lastName := "Name"
req := requests.UpdateProfileRequest{
@@ -278,130 +113,3 @@ func TestAuthHandler_UpdateProfile(t *testing.T) {
assert.Equal(t, "Name", response["last_name"])
})
}
func TestAuthHandler_ForgotPassword(t *testing.T) {
handler, e, _ := setupAuthHandler(t)
e.POST("/api/auth/register/", handler.Register)
e.POST("/api/auth/forgot-password/", handler.ForgotPassword)
// Create a test user
registerReq := requests.RegisterRequest{
Username: "forgottest",
Email: "forgot@test.com",
Password: "Password123",
}
testutil.MakeRequest(e, "POST", "/api/auth/register/", registerReq, "")
t.Run("forgot password with valid email", func(t *testing.T) {
req := requests.ForgotPasswordRequest{
Email: "forgot@test.com",
}
w := testutil.MakeRequest(e, "POST", "/api/auth/forgot-password/", req, "")
// Always returns 200 to prevent email enumeration
testutil.AssertStatusCode(t, w, http.StatusOK)
response := testutil.ParseJSON(t, w.Body.Bytes())
testutil.AssertJSONFieldExists(t, response, "message")
})
t.Run("forgot password with invalid email", func(t *testing.T) {
req := requests.ForgotPasswordRequest{
Email: "nonexistent@test.com",
}
w := testutil.MakeRequest(e, "POST", "/api/auth/forgot-password/", req, "")
// Still returns 200 to prevent email enumeration
testutil.AssertStatusCode(t, w, http.StatusOK)
})
}
func TestAuthHandler_Logout(t *testing.T) {
handler, e, userRepo := setupAuthHandler(t)
db := testutil.SetupTestDB(t)
user := testutil.CreateTestUser(t, db, "logouttest", "logout@test.com", "Password123")
userRepo.Update(user)
authGroup := e.Group("/api/auth")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/logout/", handler.Logout)
t.Run("successful logout", func(t *testing.T) {
w := testutil.MakeRequest(e, "POST", "/api/auth/logout/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
response := testutil.ParseJSON(t, w.Body.Bytes())
assert.Contains(t, response["message"], "Logged out successfully")
})
}
func TestAuthHandler_JSONResponses(t *testing.T) {
handler, e, _ := setupAuthHandler(t)
e.POST("/api/auth/register/", handler.Register)
e.POST("/api/auth/login/", handler.Login)
t.Run("register response has correct JSON structure", func(t *testing.T) {
req := requests.RegisterRequest{
Username: "jsontest",
Email: "json@test.com",
Password: "Password123",
FirstName: "JSON",
LastName: "Test",
}
w := testutil.MakeRequest(e, "POST", "/api/auth/register/", req, "")
testutil.AssertStatusCode(t, w, http.StatusCreated)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
// Verify top-level structure
assert.Contains(t, response, "token")
assert.Contains(t, response, "user")
assert.Contains(t, response, "message")
// Verify token is not empty
assert.NotEmpty(t, response["token"])
// Verify user structure
user := response["user"].(map[string]interface{})
assert.Contains(t, user, "id")
assert.Contains(t, user, "username")
assert.Contains(t, user, "email")
assert.Contains(t, user, "first_name")
assert.Contains(t, user, "last_name")
assert.Contains(t, user, "is_active")
assert.Contains(t, user, "date_joined")
// Verify types
assert.IsType(t, float64(0), user["id"]) // JSON numbers are float64
assert.IsType(t, "", user["username"])
assert.IsType(t, "", user["email"])
assert.IsType(t, true, user["is_active"])
})
t.Run("error response has correct JSON structure", func(t *testing.T) {
req := map[string]string{
"username": "test",
}
w := testutil.MakeRequest(e, "POST", "/api/auth/register/", req, "")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Contains(t, response, "error")
assert.IsType(t, "", response["error"])
})
}
+5 -265
View File
@@ -506,232 +506,6 @@ func TestTaskHandler_CreateCompletion_NoTaskID(t *testing.T) {
})
}
// =============================================================================
// Auth Handler - Additional Coverage
// =============================================================================
func TestAuthHandler_AppleSignIn_NotConfigured(t *testing.T) {
handler, e, _ := setupAuthHandler(t)
e.POST("/api/auth/apple-sign-in/", handler.AppleSignIn)
t.Run("returns 500 when apple auth not configured", func(t *testing.T) {
req := map[string]interface{}{
"id_token": "fake-token",
"user_id": "fake-user-id",
}
w := testutil.MakeRequest(e, "POST", "/api/auth/apple-sign-in/", req, "")
testutil.AssertStatusCode(t, w, http.StatusInternalServerError)
})
t.Run("missing identity_token returns 400", func(t *testing.T) {
req := map[string]interface{}{}
w := testutil.MakeRequest(e, "POST", "/api/auth/apple-sign-in/", req, "")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
func TestAuthHandler_GoogleSignIn_NotConfigured(t *testing.T) {
handler, e, _ := setupAuthHandler(t)
e.POST("/api/auth/google-sign-in/", handler.GoogleSignIn)
t.Run("returns 500 when google auth not configured", func(t *testing.T) {
req := map[string]interface{}{
"id_token": "fake-token",
}
w := testutil.MakeRequest(e, "POST", "/api/auth/google-sign-in/", req, "")
testutil.AssertStatusCode(t, w, http.StatusInternalServerError)
})
t.Run("missing id_token returns 400", func(t *testing.T) {
req := map[string]interface{}{}
w := testutil.MakeRequest(e, "POST", "/api/auth/google-sign-in/", req, "")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
// setupAuthHandlerWithDB is like setupAuthHandler but also returns the underlying *gorm.DB
// for tests that need to create records like ConfirmationCode directly.
func setupAuthHandlerWithDB(t *testing.T) (*AuthHandler, *echo.Echo, *gorm.DB) {
db := testutil.SetupTestDB(t)
userRepo := repositories.NewUserRepository(db)
cfg := &config.Config{
Security: config.SecurityConfig{
SecretKey: "test-secret-key",
PasswordResetExpiry: 15 * time.Minute,
ConfirmationExpiry: 24 * time.Hour,
MaxPasswordResetRate: 3,
},
}
authService := services.NewAuthService(userRepo, cfg)
handler := NewAuthHandler(authService, nil, nil)
e := testutil.SetupTestRouter()
return handler, e, db
}
func TestAuthHandler_VerifyEmail(t *testing.T) {
handler, e, db := setupAuthHandlerWithDB(t)
user := testutil.CreateTestUser(t, db, "verifytest", "verify@test.com", "Password123")
// Create confirmation code
confirmCode := &models.ConfirmationCode{
UserID: user.ID,
Code: "123456",
ExpiresAt: time.Now().Add(24 * time.Hour),
IsUsed: false,
}
require.NoError(t, db.Create(confirmCode).Error)
authGroup := e.Group("/api/auth")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/verify-email/", handler.VerifyEmail)
t.Run("successful verification", func(t *testing.T) {
req := requests.VerifyEmailRequest{
Code: "123456",
}
w := testutil.MakeRequest(e, "POST", "/api/auth/verify-email/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, true, response["verified"])
})
t.Run("wrong code returns error", func(t *testing.T) {
req := requests.VerifyEmailRequest{
Code: "999999",
}
w := testutil.MakeRequest(e, "POST", "/api/auth/verify-email/", req, "test-token")
// Code already used or wrong code
assert.True(t, w.Code == http.StatusBadRequest || w.Code == http.StatusNotFound,
"expected 400 or 404, got %d", w.Code)
})
t.Run("missing code returns 400", func(t *testing.T) {
req := map[string]interface{}{}
w := testutil.MakeRequest(e, "POST", "/api/auth/verify-email/", req, "test-token")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
func TestAuthHandler_ResendVerification(t *testing.T) {
handler, e, db := setupAuthHandlerWithDB(t)
user := testutil.CreateTestUser(t, db, "resendtest", "resend@test.com", "Password123")
authGroup := e.Group("/api/auth")
authGroup.Use(testutil.MockAuthMiddleware(user))
authGroup.POST("/resend-verification/", handler.ResendVerification)
t.Run("successful resend", func(t *testing.T) {
w := testutil.MakeRequest(e, "POST", "/api/auth/resend-verification/", nil, "test-token")
testutil.AssertStatusCode(t, w, http.StatusOK)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Contains(t, response, "message")
})
}
func TestAuthHandler_RefreshToken(t *testing.T) {
handler, e, db := setupAuthHandlerWithDB(t)
user := testutil.CreateTestUser(t, db, "refreshtest", "refresh@test.com", "Password123")
// Create auth token and use its actual key in the middleware
authToken := testutil.CreateTestToken(t, db, user.ID)
authGroup := e.Group("/api/auth")
authGroup.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
c.Set("auth_user", user)
c.Set("auth_token", authToken.Key)
return next(c)
}
})
authGroup.POST("/refresh/", handler.RefreshToken)
t.Run("successful refresh", func(t *testing.T) {
w := testutil.MakeRequest(e, "POST", "/api/auth/refresh/", nil, authToken.Key)
testutil.AssertStatusCode(t, w, http.StatusOK)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Contains(t, response, "token")
})
}
func TestAuthHandler_VerifyResetCode(t *testing.T) {
handler, e, _ := setupAuthHandler(t)
e.POST("/api/auth/register/", handler.Register)
e.POST("/api/auth/verify-reset-code/", handler.VerifyResetCode)
t.Run("invalid code returns error", func(t *testing.T) {
req := requests.VerifyResetCodeRequest{
Email: "nonexistent@test.com",
Code: "999999",
}
w := testutil.MakeRequest(e, "POST", "/api/auth/verify-reset-code/", req, "")
// Should not be 200 since no valid code exists
assert.NotEqual(t, http.StatusOK, w.Code)
})
t.Run("missing fields returns 400", func(t *testing.T) {
req := map[string]interface{}{}
w := testutil.MakeRequest(e, "POST", "/api/auth/verify-reset-code/", req, "")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
func TestAuthHandler_ResetPassword(t *testing.T) {
handler, e, _ := setupAuthHandler(t)
e.POST("/api/auth/reset-password/", handler.ResetPassword)
t.Run("invalid reset token returns error", func(t *testing.T) {
req := requests.ResetPasswordRequest{
ResetToken: "invalid-token",
NewPassword: "NewPassword123",
}
w := testutil.MakeRequest(e, "POST", "/api/auth/reset-password/", req, "")
assert.NotEqual(t, http.StatusOK, w.Code)
})
t.Run("missing fields returns 400", func(t *testing.T) {
req := map[string]interface{}{}
w := testutil.MakeRequest(e, "POST", "/api/auth/reset-password/", req, "")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
t.Run("short password returns 400", func(t *testing.T) {
req := requests.ResetPasswordRequest{
ResetToken: "some-token",
NewPassword: "short",
}
w := testutil.MakeRequest(e, "POST", "/api/auth/reset-password/", req, "")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
func TestAuthHandler_ForgotPassword_MissingEmail(t *testing.T) {
handler, e, _ := setupAuthHandler(t)
e.POST("/api/auth/forgot-password/", handler.ForgotPassword)
t.Run("missing email returns 400", func(t *testing.T) {
req := map[string]interface{}{}
w := testutil.MakeRequest(e, "POST", "/api/auth/forgot-password/", req, "")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
// =============================================================================
// Residence Handler - Additional Error Paths
// =============================================================================
@@ -1781,45 +1555,11 @@ func TestStaticDataHandler_RefreshStaticData(t *testing.T) {
// =============================================================================
// Upload Handler - Additional Error Paths
// =============================================================================
func TestUploadHandler_UploadImage_NoFile(t *testing.T) {
storageSvc := newTestStorageService("/var/uploads")
handler := NewUploadHandler(storageSvc, nil)
e := testutil.SetupTestRouter()
e.POST("/api/uploads/image", handler.UploadImage)
t.Run("no file returns 400", func(t *testing.T) {
w := testutil.MakeRequest(e, "POST", "/api/uploads/image", nil, "")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
func TestUploadHandler_UploadDocument_NoFile(t *testing.T) {
storageSvc := newTestStorageService("/var/uploads")
handler := NewUploadHandler(storageSvc, nil)
e := testutil.SetupTestRouter()
e.POST("/api/uploads/document", handler.UploadDocument)
t.Run("no file returns 400", func(t *testing.T) {
w := testutil.MakeRequest(e, "POST", "/api/uploads/document", nil, "")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
func TestUploadHandler_UploadCompletion_NoFile(t *testing.T) {
storageSvc := newTestStorageService("/var/uploads")
handler := NewUploadHandler(storageSvc, nil)
e := testutil.SetupTestRouter()
e.POST("/api/uploads/completion", handler.UploadCompletion)
t.Run("no file returns 400", func(t *testing.T) {
w := testutil.MakeRequest(e, "POST", "/api/uploads/completion", nil, "")
testutil.AssertStatusCode(t, w, http.StatusBadRequest)
})
}
//
// Multipart upload handlers (UploadImage / UploadDocument / UploadCompletion)
// were removed alongside the legacy /api/uploads/{image,document,completion}
// routes. The presigned-URL flow (POST /api/uploads/presign) is exercised by
// integration tests that hit the full pipeline.
func TestUploadHandler_DeleteFile_OwnershipDenied(t *testing.T) {
storageSvc := newTestStorageService("/var/uploads")
+20 -3
View File
@@ -37,6 +37,23 @@ func NewMediaHandler(
}
}
// safeContentDisposition builds an inline Content-Disposition header value
// with a sanitized filename (audit M1). Control characters (including CR/LF),
// double-quote and backslash are stripped so an attacker-controlled upload
// filename cannot inject additional response headers (CWE-113).
func safeContentDisposition(filename string) string {
cleaned := strings.Map(func(r rune) rune {
if r < 0x20 || r == 0x7f || r == '"' || r == '\\' {
return -1
}
return r
}, filename)
if cleaned == "" {
cleaned = "download"
}
return `inline; filename="` + cleaned + `"`
}
// ServeDocument serves a document file with access control
// GET /api/media/document/:id
func (h *MediaHandler) ServeDocument(c echo.Context) error {
@@ -71,7 +88,7 @@ func (h *MediaHandler) ServeDocument(c echo.Context) error {
// Set caching and disposition headers
c.Response().Header().Set("Cache-Control", "private, max-age=3600")
if doc.FileName != "" {
c.Response().Header().Set("Content-Disposition", "inline; filename=\""+doc.FileName+"\"")
c.Response().Header().Set("Content-Disposition", safeContentDisposition(doc.FileName))
}
return c.Blob(http.StatusOK, mimeType, data)
}
@@ -114,7 +131,7 @@ func (h *MediaHandler) ServeDocumentImage(c echo.Context) error {
}
c.Response().Header().Set("Cache-Control", "private, max-age=3600")
c.Response().Header().Set("Content-Disposition", "inline; filename=\""+filepath.Base(img.ImageURL)+"\"")
c.Response().Header().Set("Content-Disposition", safeContentDisposition(filepath.Base(img.ImageURL)))
return c.Blob(http.StatusOK, mimeType, data)
}
@@ -162,7 +179,7 @@ func (h *MediaHandler) ServeCompletionImage(c echo.Context) error {
}
c.Response().Header().Set("Cache-Control", "private, max-age=3600")
c.Response().Header().Set("Content-Disposition", "inline; filename=\""+filepath.Base(img.ImageURL)+"\"")
c.Response().Header().Set("Content-Disposition", safeContentDisposition(filepath.Base(img.ImageURL)))
return c.Blob(http.StatusOK, mimeType, data)
}
+19 -5
View File
@@ -21,6 +21,9 @@ type SeededDataResponse struct {
TaskFrequencies interface{} `json:"task_frequencies"`
ContractorSpecialties interface{} `json:"contractor_specialties"`
TaskTemplates responses.TaskTemplatesGroupedResponse `json:"task_templates"`
HomeProfileOptions map[string][]services.HomeProfileOption `json:"home_profile_options"`
DocumentTypes []services.HomeProfileOption `json:"document_types"`
DocumentCategories []services.HomeProfileOption `json:"document_categories"`
}
// StaticDataHandler handles static/lookup data endpoints
@@ -54,13 +57,18 @@ func NewStaticDataHandler(
func (h *StaticDataHandler) GetStaticData(c echo.Context) error {
ctx := c.Request().Context()
// Lookup display labels and home-profile options are localized for the
// request's language, so the cache + ETag are keyed by locale.
locale := i18n.GetLocale(c)
localizer := i18n.GetLocalizer(c)
// Check If-None-Match header for conditional request
// Strip W/ prefix if present (added by reverse proxy, but we store without it)
clientETag := strings.TrimPrefix(c.Request().Header.Get("If-None-Match"), "W/")
// Try to get cached ETag first (fast path for 304 responses)
if h.cache != nil && clientETag != "" {
cachedETag, err := h.cache.GetSeededDataETag(ctx)
cachedETag, err := h.cache.GetSeededDataETag(ctx, locale)
if err == nil && cachedETag == clientETag {
// Client has the latest data, return 304 Not Modified
return c.NoContent(http.StatusNotModified)
@@ -70,10 +78,10 @@ func (h *StaticDataHandler) GetStaticData(c echo.Context) error {
// Try to get cached seeded data
if h.cache != nil {
var cachedData SeededDataResponse
err := h.cache.GetCachedSeededData(ctx, &cachedData)
err := h.cache.GetCachedSeededData(ctx, locale, &cachedData)
if err == nil {
// Cache hit - get the ETag and return data
etag, etagErr := h.cache.GetSeededDataETag(ctx)
etag, etagErr := h.cache.GetSeededDataETag(ctx, locale)
if etagErr == nil {
c.Response().Header().Set("ETag", etag)
c.Response().Header().Set("Cache-Control", "private, max-age=3600")
@@ -116,6 +124,9 @@ func (h *StaticDataHandler) GetStaticData(c echo.Context) error {
return err
}
// Localize the lookup display_name fields in place for this request's locale.
services.LocalizeLookups(localizer, residenceTypes, taskCategories, taskPriorities, taskFrequencies, contractorSpecialties)
// Build response
seededData := SeededDataResponse{
ResidenceTypes: residenceTypes,
@@ -124,11 +135,14 @@ func (h *StaticDataHandler) GetStaticData(c echo.Context) error {
TaskFrequencies: taskFrequencies,
ContractorSpecialties: contractorSpecialties,
TaskTemplates: taskTemplates,
HomeProfileOptions: services.BuildHomeProfileOptions(localizer),
DocumentTypes: services.BuildDocumentTypes(localizer),
DocumentCategories: services.BuildDocumentCategories(localizer),
}
// Cache the data and get ETag
// Cache the data and get ETag (per-locale)
if h.cache != nil {
etag, cacheErr := h.cache.CacheSeededData(ctx, seededData)
etag, cacheErr := h.cache.CacheSeededData(ctx, locale, seededData)
if cacheErr != nil {
log.Warn().Err(cacheErr).Msg("Failed to cache seeded data")
} else {
@@ -8,6 +8,7 @@ import (
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"math/big"
@@ -20,6 +21,7 @@ import (
"github.com/golang-jwt/jwt/v5"
"github.com/labstack/echo/v4"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
"github.com/treytartt/honeydue-api/internal/config"
"github.com/treytartt/honeydue-api/internal/models"
@@ -165,9 +167,13 @@ func (h *SubscriptionWebhookHandler) HandleAppleWebhook(c echo.Context) error {
if notification.NotificationUUID != "" {
alreadyProcessed, err := h.webhookEventRepo.HasProcessed("apple", notification.NotificationUUID)
if err != nil {
log.Error().Err(err).Msg("Apple Webhook: Failed to check dedup")
// Continue processing on dedup check failure (fail-open)
} else if alreadyProcessed {
// Audit H6: fail closed. A dedup-check failure must not let a
// possibly-duplicate event through (duplicate refunds/grants).
// Return 500 so Apple retries once the DB is healthy.
log.Error().Err(err).Msg("Apple Webhook: dedup check failed — returning 500 for retry")
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "dedup check failed"})
}
if alreadyProcessed {
log.Info().Str("uuid", notification.NotificationUUID).Msg("Apple Webhook: Duplicate event, skipping")
return c.JSON(http.StatusOK, map[string]interface{}{"status": "duplicate"})
}
@@ -352,11 +358,25 @@ func (h *SubscriptionWebhookHandler) processAppleNotification(
}
func (h *SubscriptionWebhookHandler) findUserByAppleTransaction(originalTransactionID string) (*models.User, error) {
// Look up user subscription by stored receipt data
subscription, err := h.subscriptionRepo.FindByAppleReceiptContains(originalTransactionID)
// Audit C13: exact match on the indexed apple_original_transaction_id
// column. Falls back to the legacy escaped-LIKE scan over
// apple_receipt_data only for subscriptions created before that column
// existed (and thus not yet populated).
subscription, err := h.subscriptionRepo.FindByAppleOriginalTransactionID(originalTransactionID)
if err != nil {
// Only fall back to the legacy substring scan when the exact-match
// column genuinely had no row (a subscription created before the
// column existed). A real DB error must propagate — masking it as
// "not found" could bind the webhook to the wrong account via the
// LIKE scan, or silently drop a legitimate event.
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
subscription, err = h.subscriptionRepo.FindByAppleReceiptContains(originalTransactionID)
if err != nil {
return nil, err
}
}
user, err := h.userRepo.FindByID(subscription.UserID)
if err != nil {
@@ -566,9 +586,12 @@ func (h *SubscriptionWebhookHandler) HandleGoogleWebhook(c echo.Context) error {
if messageID != "" {
alreadyProcessed, err := h.webhookEventRepo.HasProcessed("google", messageID)
if err != nil {
log.Error().Err(err).Msg("Google Webhook: Failed to check dedup")
// Continue processing on dedup check failure (fail-open)
} else if alreadyProcessed {
// Audit H6: fail closed — see the Apple handler. Return 500 so
// Google Pub/Sub redelivers once the DB is healthy.
log.Error().Err(err).Msg("Google Webhook: dedup check failed — returning 500 for retry")
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "dedup check failed"})
}
if alreadyProcessed {
log.Info().Str("message_id", messageID).Msg("Google Webhook: Duplicate event, skipping")
return c.JSON(http.StatusOK, map[string]interface{}{"status": "duplicate"})
}
+2 -1
View File
@@ -7,6 +7,7 @@ import (
"github.com/labstack/echo/v4"
"github.com/treytartt/honeydue-api/internal/apperrors"
"github.com/treytartt/honeydue-api/internal/i18n"
"github.com/treytartt/honeydue-api/internal/middleware"
"github.com/treytartt/honeydue-api/internal/services"
)
@@ -41,7 +42,7 @@ func (h *SuggestionHandler) GetSuggestions(c echo.Context) error {
return apperrors.BadRequest("error.invalid_id")
}
resp, err := h.suggestionService.GetSuggestions(uint(residenceID), user.ID)
resp, err := h.suggestionService.GetSuggestions(uint(residenceID), user.ID, i18n.GetLocalizer(c))
if err != nil {
return err
}
+12 -60
View File
@@ -3,11 +3,8 @@ package handlers
import (
"net/http"
"strconv"
"strings"
"time"
"github.com/labstack/echo/v4"
"github.com/shopspring/decimal"
"github.com/treytartt/honeydue-api/internal/apperrors"
"github.com/treytartt/honeydue-api/internal/dto/requests"
@@ -393,7 +390,18 @@ func (h *TaskHandler) GetCompletion(c echo.Context) error {
}
// CreateCompletion handles POST /api/task-completions/
// Supports both JSON and multipart form data (for image uploads)
//
// JSON-only. Image attachments arrive via the presigned-URL flow:
//
// 1. Client POSTs /api/uploads/presign for each image and uploads bytes
// directly to B2 using the returned policy.
// 2. Client POSTs the resulting upload_ids[] in this request body.
// 3. The service claims those pending_uploads rows and creates the
// associated TaskCompletionImage rows.
//
// The legacy multipart path (with the API server proxying image bytes)
// was removed alongside the 1 MB BodyLimit middleware that was rejecting
// it anyway. See deploy-k3s/manifests/b2-lifecycle.md.
func (h *TaskHandler) CreateCompletion(c echo.Context) error {
user, err := middleware.MustGetAuthUser(c)
if err != nil {
@@ -402,65 +410,9 @@ func (h *TaskHandler) CreateCompletion(c echo.Context) error {
userNow := middleware.GetUserNow(c)
var req requests.CreateTaskCompletionRequest
contentType := c.Request().Header.Get("Content-Type")
// Check if this is a multipart form request (image upload)
if strings.HasPrefix(contentType, "multipart/form-data") {
// Parse multipart form
if err := c.Request().ParseMultipartForm(32 << 20); err != nil { // 32MB max
return apperrors.BadRequest("error.failed_to_parse_form")
}
// Parse task_id (required)
taskIDStr := c.FormValue("task_id")
if taskIDStr == "" {
return apperrors.BadRequest("error.task_id_required")
}
taskID, err := strconv.ParseUint(taskIDStr, 10, 32)
if err != nil {
return apperrors.BadRequest("error.invalid_task_id_value")
}
req.TaskID = uint(taskID)
// Parse notes (optional)
req.Notes = c.FormValue("notes")
// Parse actual_cost (optional)
if costStr := c.FormValue("actual_cost"); costStr != "" {
cost, err := decimal.NewFromString(costStr)
if err == nil {
req.ActualCost = &cost
}
}
// Parse completed_at (optional)
if completedAtStr := c.FormValue("completed_at"); completedAtStr != "" {
if t, err := time.Parse(time.RFC3339, completedAtStr); err == nil {
req.CompletedAt = &t
}
}
// Handle multiple image uploads from various field names
if h.storageService != nil && c.Request().MultipartForm != nil {
for _, fieldName := range []string{"images", "image", "photo", "files"} {
files := c.Request().MultipartForm.File[fieldName]
for _, file := range files {
result, err := h.storageService.Upload(c.Request().Context(), file, "completions")
if err != nil {
return apperrors.BadRequest("error.failed_to_upload_image")
}
req.ImageURLs = append(req.ImageURLs, result.URL)
}
}
}
} else {
// Standard JSON request
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
}
if err := c.Validate(&req); err != nil {
return err
}
+44 -52
View File
@@ -7,9 +7,11 @@ import (
"github.com/rs/zerolog/log"
"github.com/treytartt/honeydue-api/internal/apperrors"
"github.com/treytartt/honeydue-api/internal/dto/requests"
"github.com/treytartt/honeydue-api/internal/dto/responses"
"github.com/treytartt/honeydue-api/internal/i18n"
"github.com/treytartt/honeydue-api/internal/middleware"
"github.com/treytartt/honeydue-api/internal/models"
"github.com/treytartt/honeydue-api/internal/services"
)
@@ -23,6 +25,7 @@ type FileOwnershipChecker interface {
// UploadHandler handles file upload endpoints
type UploadHandler struct {
storageService *services.StorageService
uploadService *services.UploadService // optional — only set when S3 storage is configured
fileOwnershipChecker FileOwnershipChecker
}
@@ -34,58 +37,11 @@ func NewUploadHandler(storageService *services.StorageService, fileOwnershipChec
}
}
// UploadImage handles POST /api/uploads/image
// Accepts multipart/form-data with "file" field
func (h *UploadHandler) UploadImage(c echo.Context) error {
file, err := c.FormFile("file")
if err != nil {
return apperrors.BadRequest("error.no_file_provided")
}
// Get category from query param (default: images)
category := c.QueryParam("category")
if category == "" {
category = "images"
}
result, err := h.storageService.Upload(c.Request().Context(), file, category)
if err != nil {
return err
}
return c.JSON(http.StatusOK, result)
}
// UploadDocument handles POST /api/uploads/document
// Accepts multipart/form-data with "file" field
func (h *UploadHandler) UploadDocument(c echo.Context) error {
file, err := c.FormFile("file")
if err != nil {
return apperrors.BadRequest("error.no_file_provided")
}
result, err := h.storageService.Upload(c.Request().Context(), file, "documents")
if err != nil {
return err
}
return c.JSON(http.StatusOK, result)
}
// UploadCompletion handles POST /api/uploads/completion
// For task completion photos
func (h *UploadHandler) UploadCompletion(c echo.Context) error {
file, err := c.FormFile("file")
if err != nil {
return apperrors.BadRequest("error.no_file_provided")
}
result, err := h.storageService.Upload(c.Request().Context(), file, "completions")
if err != nil {
return err
}
return c.JSON(http.StatusOK, result)
// SetUploadService wires the presigned-URL upload service. Called from the
// router only when S3 storage is configured; with local-disk storage the
// presign endpoint is unsupported and returns 503.
func (h *UploadHandler) SetUploadService(s *services.UploadService) {
h.uploadService = s
}
// DeleteFileRequest is the request body for deleting a file.
@@ -138,3 +94,39 @@ func (h *UploadHandler) DeleteFile(c echo.Context) error {
return c.JSON(http.StatusOK, responses.MessageResponse{Message: i18n.LocalizedMessage(c, "message.file_deleted")})
}
// PresignUpload handles POST /api/uploads/presign.
//
// Returns a short-lived signed POST policy that the client uses to upload an
// image or document directly to B2, bypassing the API entirely for the byte
// transfer. The returned `id` is later passed in `upload_ids[]` on the
// task-completion or document creation endpoints to attach the object.
func (h *UploadHandler) PresignUpload(c echo.Context) error {
if h.uploadService == nil {
return apperrors.Internal(nil)
}
user, err := middleware.MustGetAuthUser(c)
if err != nil {
return err
}
var req requests.PresignUploadRequest
if err := c.Bind(&req); err != nil {
return apperrors.BadRequest("error.invalid_request")
}
if err := c.Validate(&req); err != nil {
return err
}
resp, err := h.uploadService.Presign(
c.Request().Context(),
user.ID,
models.UploadCategory(req.Category),
req.ContentType,
req.ContentLength,
)
if err != nil {
return err
}
return c.JSON(http.StatusCreated, resp)
}
+93 -37
View File
@@ -25,7 +25,6 @@
"error.google_signin_not_configured": "Google-Anmeldung ist nicht konfiguriert",
"error.google_signin_failed": "Google-Anmeldung fehlgeschlagen",
"error.invalid_google_token": "Ungultiger Google-Identitats-Token",
"error.invalid_task_id": "Ungultige Aufgaben-ID",
"error.invalid_residence_id": "Ungultige Immobilien-ID",
"error.invalid_contractor_id": "Ungultige Dienstleister-ID",
@@ -34,7 +33,6 @@
"error.invalid_user_id": "Ungultige Benutzer-ID",
"error.invalid_notification_id": "Ungultige Benachrichtigungs-ID",
"error.invalid_device_id": "Ungultige Gerate-ID",
"error.task_not_found": "Aufgabe nicht gefunden",
"error.residence_not_found": "Immobilie nicht gefunden",
"error.contractor_not_found": "Dienstleister nicht gefunden",
@@ -43,7 +41,6 @@
"error.user_not_found": "Benutzer nicht gefunden",
"error.share_code_invalid": "Ungultiger Freigabecode",
"error.share_code_expired": "Der Freigabecode ist abgelaufen",
"error.task_access_denied": "Sie haben keinen Zugriff auf diese Aufgabe",
"error.residence_access_denied": "Sie haben keinen Zugriff auf diese Immobilie",
"error.contractor_access_denied": "Sie haben keinen Zugriff auf diesen Dienstleister",
@@ -52,10 +49,8 @@
"error.cannot_remove_owner": "Der Eigentumer kann nicht entfernt werden",
"error.user_already_member": "Der Benutzer ist bereits Mitglied dieser Immobilie",
"error.properties_limit_reached": "Sie haben die maximale Anzahl an Immobilien fur Ihr Abonnement erreicht",
"error.task_already_cancelled": "Die Aufgabe ist bereits storniert",
"error.task_already_archived": "Die Aufgabe ist bereits archiviert",
"error.failed_to_parse_form": "Formular konnte nicht analysiert werden",
"error.task_id_required": "task_id ist erforderlich",
"error.invalid_task_id_value": "Ungultige task_id",
@@ -64,14 +59,12 @@
"error.invalid_residence_id_value": "Ungultige residence_id",
"error.title_required": "Titel ist erforderlich",
"error.failed_to_upload_file": "Datei konnte nicht hochgeladen werden",
"message.logged_out": "Erfolgreich abgemeldet",
"message.email_verified": "E-Mail erfolgreich verifiziert",
"message.verification_email_sent": "Verifizierungs-E-Mail gesendet",
"message.password_reset_email_sent": "Wenn ein Konto mit dieser E-Mail existiert, wurde ein Zurucksetzungscode gesendet.",
"message.reset_code_verified": "Code erfolgreich verifiziert",
"message.password_reset_success": "Passwort erfolgreich zuruckgesetzt. Bitte melden Sie sich mit Ihrem neuen Passwort an.",
"message.task_deleted": "Aufgabe erfolgreich geloscht",
"message.task_in_progress": "Aufgabe als in Bearbeitung markiert",
"message.task_cancelled": "Aufgabe storniert",
@@ -79,46 +72,35 @@
"message.task_archived": "Aufgabe archiviert",
"message.task_unarchived": "Aufgabe dearchiviert",
"message.completion_deleted": "Abschluss erfolgreich geloscht",
"message.residence_deleted": "Immobilie erfolgreich geloscht",
"message.user_removed": "Benutzer von der Immobilie entfernt",
"message.tasks_report_generated": "Aufgabenbericht erfolgreich erstellt",
"message.tasks_report_sent": "Aufgabenbericht erstellt und an {{.Email}} gesendet",
"message.tasks_report_email_failed": "Aufgabenbericht erstellt, aber E-Mail konnte nicht gesendet werden",
"message.contractor_deleted": "Dienstleister erfolgreich geloscht",
"message.document_deleted": "Dokument erfolgreich geloscht",
"message.document_activated": "Dokument aktiviert",
"message.document_deactivated": "Dokument deaktiviert",
"message.notification_marked_read": "Benachrichtigung als gelesen markiert",
"message.all_notifications_marked_read": "Alle Benachrichtigungen als gelesen markiert",
"message.device_removed": "Gerät entfernt",
"message.subscription_upgraded": "Abonnement erfolgreich aktualisiert",
"message.subscription_cancelled": "Abonnement gekündigt. Sie behalten die Pro-Vorteile bis zum Ende Ihres Abrechnungszeitraums.",
"message.subscription_restored": "Abonnement erfolgreich wiederhergestellt",
"message.file_deleted": "Datei erfolgreich gelöscht",
"message.static_data_refreshed": "Statische Daten aktualisiert",
"error.notification_not_found": "Benachrichtigung nicht gefunden",
"error.invalid_platform": "Ungültige Plattform",
"error.upgrade_trigger_not_found": "Upgrade-Trigger nicht gefunden",
"error.receipt_data_required": "receipt_data ist für iOS erforderlich",
"error.purchase_token_required": "purchase_token ist für Android erforderlich",
"error.no_file_provided": "Keine Datei bereitgestellt",
"error.failed_to_fetch_residence_types": "Fehler beim Abrufen der Immobilientypen",
"error.failed_to_fetch_task_categories": "Fehler beim Abrufen der Aufgabenkategorien",
"error.failed_to_fetch_task_priorities": "Fehler beim Abrufen der Aufgabenprioritäten",
"error.failed_to_fetch_task_frequencies": "Fehler beim Abrufen der Aufgabenfrequenzen",
"error.failed_to_fetch_task_statuses": "Fehler beim Abrufen der Aufgabenstatus",
"error.failed_to_fetch_contractor_specialties": "Fehler beim Abrufen der Dienstleister-Spezialitäten",
"push.task_due_soon.title": "Aufgabe Bald Fallig",
"push.task_due_soon.body": "{{.TaskTitle}} ist fallig am {{.DueDate}}",
"push.task_overdue.title": "Uberfällige Aufgabe",
@@ -129,63 +111,137 @@
"push.task_assigned.body": "Ihnen wurde {{.TaskTitle}} zugewiesen",
"push.residence_shared.title": "Immobilie Geteilt",
"push.residence_shared.body": "{{.UserName}} hat {{.ResidenceName}} mit Ihnen geteilt",
"email.welcome.subject": "Willkommen bei honeyDue!",
"email.verification.subject": "Bestatigen Sie Ihre E-Mail",
"email.password_reset.subject": "Passwort-Zurucksetzungscode",
"email.tasks_report.subject": "Aufgabenbericht fur {{.ResidenceName}}",
"lookup.residence_type.house": "Haus",
"lookup.residence_type.apartment": "Wohnung",
"lookup.residence_type.condo": "Eigentumswohnung",
"lookup.residence_type.townhouse": "Reihenhaus",
"lookup.residence_type.mobile_home": "Mobilheim",
"lookup.residence_type.other": "Sonstiges",
"lookup.task_category.plumbing": "Sanitär",
"lookup.task_category.electrical": "Elektrik",
"lookup.task_category.hvac": "Heizung/Klimaanlage",
"lookup.task_category.appliances": "Gerate",
"lookup.task_category.exterior": "Aussenbereich",
"lookup.task_category.hvac": "HLK",
"lookup.task_category.appliances": "Haushaltsgeräte",
"lookup.task_category.exterior": "Außenbereich",
"lookup.task_category.interior": "Innenbereich",
"lookup.task_category.landscaping": "Gartenpflege",
"lookup.task_category.safety": "Sicherheit",
"lookup.task_category.cleaning": "Reinigung",
"lookup.task_category.pest_control": "Schadlingsbekampfung",
"lookup.task_category.pest_control": "Schädlingsbekämpfung",
"lookup.task_category.seasonal": "Saisonal",
"lookup.task_category.other": "Sonstiges",
"lookup.task_priority.low": "Niedrig",
"lookup.task_priority.medium": "Mittel",
"lookup.task_priority.high": "Hoch",
"lookup.task_priority.urgent": "Dringend",
"lookup.task_status.pending": "Ausstehend",
"lookup.task_status.in_progress": "In Bearbeitung",
"lookup.task_status.completed": "Abgeschlossen",
"lookup.task_status.cancelled": "Storniert",
"lookup.task_status.archived": "Archiviert",
"lookup.task_frequency.once": "Einmalig",
"lookup.task_frequency.daily": "Taglich",
"lookup.task_frequency.weekly": "Wochentlich",
"lookup.task_frequency.daily": "Täglich",
"lookup.task_frequency.weekly": "Wöchentlich",
"lookup.task_frequency.biweekly": "Alle 2 Wochen",
"lookup.task_frequency.monthly": "Monatlich",
"lookup.task_frequency.quarterly": "Vierteljahrlich",
"lookup.task_frequency.quarterly": "Vierteljährlich",
"lookup.task_frequency.semiannually": "Halbjahrlich",
"lookup.task_frequency.annually": "Jahrlich",
"lookup.task_frequency.annually": "Jährlich",
"lookup.contractor_specialty.plumber": "Klempner",
"lookup.contractor_specialty.electrician": "Elektriker",
"lookup.contractor_specialty.hvac_technician": "HLK-Techniker",
"lookup.contractor_specialty.handyman": "Handwerker",
"lookup.contractor_specialty.landscaper": "Landschaftsgartner",
"lookup.contractor_specialty.landscaper": "Landschaftsgärtner",
"lookup.contractor_specialty.roofer": "Dachdecker",
"lookup.contractor_specialty.painter": "Maler",
"lookup.contractor_specialty.carpenter": "Schreiner",
"lookup.contractor_specialty.pest_control": "Schadlingsbekampfung",
"lookup.contractor_specialty.pest_control": "Schädlingsbekämpfung",
"lookup.contractor_specialty.cleaning": "Reinigung",
"lookup.contractor_specialty.pool_service": "Pool-Service",
"lookup.contractor_specialty.pool_service": "Poolservice",
"lookup.contractor_specialty.general_contractor": "Generalunternehmer",
"lookup.contractor_specialty.other": "Sonstiges"
"lookup.contractor_specialty.other": "Sonstiges",
"suggestion.reason.has_pool": "Ihr Zuhause hat einen Pool",
"suggestion.reason.has_sprinkler_system": "Ihr Zuhause hat eine Bewässerungsanlage",
"suggestion.reason.has_septic": "Ihr Zuhause hat eine Klärgrube",
"suggestion.reason.has_fireplace": "Ihr Zuhause hat einen Kamin",
"suggestion.reason.has_garage": "Ihr Zuhause hat eine Garage",
"suggestion.reason.has_basement": "Ihr Zuhause hat einen Keller",
"suggestion.reason.has_attic": "Ihr Zuhause hat einen Dachboden",
"suggestion.reason.heating_type": "Passt zu Ihrer Heizung",
"suggestion.reason.cooling_type": "Passt zu Ihrer Kühlung",
"suggestion.reason.water_heater_type": "Passt zu Ihrem Warmwasserbereiter",
"suggestion.reason.roof_type": "Passt zu Ihrem Dach",
"suggestion.reason.exterior_type": "Passt zu Ihrer Fassade",
"suggestion.reason.flooring_primary": "Passt zu Ihrem Bodenbelag",
"suggestion.reason.landscaping_type": "Passt zu Ihrer Gartengestaltung",
"suggestion.reason.property_type": "Empfohlen für Ihren Immobilientyp",
"suggestion.reason.climate_region": "Empfohlen für Ihr Klima",
"lookup.residence_type.duplex": "Doppelhaus",
"lookup.residence_type.vacation_home": "Ferienhaus",
"lookup.task_category.general": "Allgemein",
"lookup.task_frequency.bi_weekly": "Zweiwöchentlich",
"lookup.task_frequency.semi_annually": "Halbjährlich",
"lookup.task_frequency.custom": "Benutzerdefiniert",
"lookup.contractor_specialty.appliance_repair": "Gerätereparatur",
"lookup.contractor_specialty.cleaner": "Reinigungskraft",
"lookup.contractor_specialty.locksmith": "Schlosser",
"lookup.home_profile.gas_furnace": "Gasheizung",
"lookup.home_profile.electric_furnace": "Elektroheizung",
"lookup.home_profile.heat_pump": "Wärmepumpe",
"lookup.home_profile.boiler": "Heizkessel",
"lookup.home_profile.radiant": "Strahlungsheizung",
"lookup.home_profile.other": "Sonstiges",
"lookup.home_profile.central_ac": "Zentrale Klimaanlage",
"lookup.home_profile.window_ac": "Fensterklimagerät",
"lookup.home_profile.evaporative": "Verdunstung",
"lookup.home_profile.none": "Keine",
"lookup.home_profile.tank_gas": "Speicher (Gas)",
"lookup.home_profile.tank_electric": "Speicher (Elektro)",
"lookup.home_profile.tankless_gas": "Durchlauf (Gas)",
"lookup.home_profile.tankless_electric": "Durchlauf (Elektro)",
"lookup.home_profile.solar": "Solar",
"lookup.home_profile.asphalt_shingle": "Asphaltschindel",
"lookup.home_profile.metal": "Metall",
"lookup.home_profile.tile": "Ziegel",
"lookup.home_profile.slate": "Schiefer",
"lookup.home_profile.wood_shake": "Holzschindel",
"lookup.home_profile.flat": "Flach",
"lookup.home_profile.brick": "Backstein",
"lookup.home_profile.vinyl_siding": "Vinylverkleidung",
"lookup.home_profile.wood_siding": "Holzverkleidung",
"lookup.home_profile.stucco": "Putz",
"lookup.home_profile.stone": "Stein",
"lookup.home_profile.fiber_cement": "Faserzement",
"lookup.home_profile.hardwood": "Hartholz",
"lookup.home_profile.laminate": "Laminat",
"lookup.home_profile.carpet": "Teppich",
"lookup.home_profile.vinyl": "Vinyl",
"lookup.home_profile.concrete": "Beton",
"lookup.home_profile.lawn": "Rasen",
"lookup.home_profile.desert": "Wüste",
"lookup.home_profile.xeriscape": "Xeriscaping",
"lookup.home_profile.garden": "Garten",
"lookup.home_profile.mixed": "Gemischt",
"lookup.document_type.warranty": "Garantie",
"lookup.document_type.manual": "Benutzerhandbuch",
"lookup.document_type.receipt": "Beleg/Rechnung",
"lookup.document_type.inspection": "Inspektionsbericht",
"lookup.document_type.permit": "Genehmigung",
"lookup.document_type.deed": "Urkunde/Titel",
"lookup.document_type.insurance": "Versicherung",
"lookup.document_type.contract": "Vertrag",
"lookup.document_type.photo": "Foto",
"lookup.document_type.other": "Sonstiges",
"lookup.document_category.appliance": "Haushaltsgerät",
"lookup.document_category.hvac": "HLK",
"lookup.document_category.plumbing": "Sanitär",
"lookup.document_category.electrical": "Elektrik",
"lookup.document_category.roofing": "Dach",
"lookup.document_category.structural": "Struktur",
"lookup.document_category.landscaping": "Gartengestaltung",
"lookup.document_category.general": "Allgemein",
"lookup.document_category.other": "Sonstiges"
}
+82 -26
View File
@@ -28,7 +28,6 @@
"error.google_signin_not_configured": "Google Sign In is not configured",
"error.google_signin_failed": "Google Sign In failed",
"error.invalid_google_token": "Invalid Google identity token",
"error.invalid_task_id": "Invalid task ID",
"error.invalid_residence_id": "Invalid residence ID",
"error.invalid_contractor_id": "Invalid contractor ID",
@@ -37,7 +36,6 @@
"error.invalid_user_id": "Invalid user ID",
"error.invalid_notification_id": "Invalid notification ID",
"error.invalid_device_id": "Invalid device ID",
"error.task_not_found": "Task not found",
"error.residence_not_found": "Residence not found",
"error.contractor_not_found": "Contractor not found",
@@ -46,7 +44,6 @@
"error.user_not_found": "User not found",
"error.share_code_invalid": "Invalid share code",
"error.share_code_expired": "Share code has expired",
"error.task_access_denied": "You don't have access to this task",
"error.residence_access_denied": "You don't have access to this property",
"error.contractor_access_denied": "You don't have access to this contractor",
@@ -55,10 +52,8 @@
"error.cannot_remove_owner": "Cannot remove the property owner",
"error.user_already_member": "User is already a member of this property",
"error.properties_limit_reached": "You have reached the maximum number of properties for your subscription",
"error.task_already_cancelled": "Task is already cancelled",
"error.task_already_archived": "Task is already archived",
"error.failed_to_parse_form": "Failed to parse multipart form",
"error.task_id_required": "task_id is required",
"error.invalid_task_id_value": "Invalid task_id",
@@ -67,14 +62,12 @@
"error.invalid_residence_id_value": "Invalid residence_id",
"error.title_required": "title is required",
"error.failed_to_upload_file": "Failed to upload file",
"message.logged_out": "Logged out successfully",
"message.email_verified": "Email verified successfully",
"message.verification_email_sent": "Verification email sent",
"message.password_reset_email_sent": "If an account with that email exists, a password reset code has been sent.",
"message.reset_code_verified": "Code verified successfully",
"message.password_reset_success": "Password reset successfully. Please log in with your new password.",
"message.task_deleted": "Task deleted successfully",
"message.task_in_progress": "Task marked as in progress",
"message.task_cancelled": "Task cancelled",
@@ -82,44 +75,34 @@
"message.task_archived": "Task archived",
"message.task_unarchived": "Task unarchived",
"message.completion_deleted": "Completion deleted successfully",
"message.residence_deleted": "Residence deleted successfully",
"message.user_removed": "User removed from residence",
"message.tasks_report_generated": "Tasks report generated successfully",
"message.tasks_report_sent": "Tasks report generated and sent to {{.Email}}",
"message.tasks_report_email_failed": "Tasks report generated but email could not be sent",
"message.contractor_deleted": "Contractor deleted successfully",
"message.document_deleted": "Document deleted successfully",
"message.document_activated": "Document activated",
"message.document_deactivated": "Document deactivated",
"message.notification_marked_read": "Notification marked as read",
"message.all_notifications_marked_read": "All notifications marked as read",
"message.device_removed": "Device removed",
"message.subscription_upgraded": "Subscription upgraded successfully",
"message.subscription_cancelled": "Subscription cancelled. You will retain Pro benefits until the end of your billing period.",
"message.subscription_restored": "Subscription restored successfully",
"message.file_deleted": "File deleted successfully",
"message.static_data_refreshed": "Static data refreshed",
"error.notification_not_found": "Notification not found",
"error.invalid_platform": "Invalid platform",
"error.upgrade_trigger_not_found": "Upgrade trigger not found",
"error.receipt_data_required": "receipt_data is required for iOS",
"error.purchase_token_required": "purchase_token is required for Android",
"error.no_file_provided": "No file provided",
"error.url_required": "File URL is required",
"error.file_access_denied": "You don't have access to this file",
"error.days_out_of_range": "Days parameter must be between 1 and 3650",
"error.platform_required": "Platform is required (ios or android)",
"error.registration_id_required": "Registration ID is required",
"error.failed_to_fetch_residence_types": "Failed to fetch residence types",
"error.failed_to_fetch_task_categories": "Failed to fetch task categories",
"error.failed_to_fetch_task_priorities": "Failed to fetch task priorities",
@@ -129,7 +112,6 @@
"error.failed_to_fetch_templates": "Failed to fetch task templates",
"error.failed_to_search_templates": "Failed to search task templates",
"error.template_not_found": "Task template not found",
"push.task_due_soon.title": "Task Due Soon",
"push.task_due_soon.body": "{{.TaskTitle}} is due {{.DueDate}}",
"push.task_overdue.title": "Overdue Task",
@@ -140,19 +122,16 @@
"push.task_assigned.body": "You have been assigned to {{.TaskTitle}}",
"push.residence_shared.title": "Property Shared",
"push.residence_shared.body": "{{.UserName}} shared {{.ResidenceName}} with you",
"email.welcome.subject": "Welcome to honeyDue!",
"email.verification.subject": "Verify Your Email",
"email.password_reset.subject": "Password Reset Code",
"email.tasks_report.subject": "Tasks Report for {{.ResidenceName}}",
"lookup.residence_type.house": "House",
"lookup.residence_type.apartment": "Apartment",
"lookup.residence_type.condo": "Condo",
"lookup.residence_type.townhouse": "Townhouse",
"lookup.residence_type.mobile_home": "Mobile Home",
"lookup.residence_type.other": "Other",
"lookup.task_category.plumbing": "Plumbing",
"lookup.task_category.electrical": "Electrical",
"lookup.task_category.hvac": "HVAC",
@@ -165,18 +144,15 @@
"lookup.task_category.pest_control": "Pest Control",
"lookup.task_category.seasonal": "Seasonal",
"lookup.task_category.other": "Other",
"lookup.task_priority.low": "Low",
"lookup.task_priority.medium": "Medium",
"lookup.task_priority.high": "High",
"lookup.task_priority.urgent": "Urgent",
"lookup.task_status.pending": "Pending",
"lookup.task_status.in_progress": "In Progress",
"lookup.task_status.completed": "Completed",
"lookup.task_status.cancelled": "Cancelled",
"lookup.task_status.archived": "Archived",
"lookup.task_frequency.once": "Once",
"lookup.task_frequency.daily": "Daily",
"lookup.task_frequency.weekly": "Weekly",
@@ -185,7 +161,6 @@
"lookup.task_frequency.quarterly": "Quarterly",
"lookup.task_frequency.semiannually": "Every 6 Months",
"lookup.task_frequency.annually": "Annually",
"lookup.contractor_specialty.plumber": "Plumber",
"lookup.contractor_specialty.electrician": "Electrician",
"lookup.contractor_specialty.hvac_technician": "HVAC Technician",
@@ -198,5 +173,86 @@
"lookup.contractor_specialty.cleaning": "Cleaning",
"lookup.contractor_specialty.pool_service": "Pool Service",
"lookup.contractor_specialty.general_contractor": "General Contractor",
"lookup.contractor_specialty.other": "Other"
"lookup.contractor_specialty.other": "Other",
"suggestion.reason.has_pool": "Your home has a pool",
"suggestion.reason.has_sprinkler_system": "Your home has a sprinkler system",
"suggestion.reason.has_septic": "Your home has a septic system",
"suggestion.reason.has_fireplace": "Your home has a fireplace",
"suggestion.reason.has_garage": "Your home has a garage",
"suggestion.reason.has_basement": "Your home has a basement",
"suggestion.reason.has_attic": "Your home has an attic",
"suggestion.reason.heating_type": "Matches your heating system",
"suggestion.reason.cooling_type": "Matches your cooling system",
"suggestion.reason.water_heater_type": "Matches your water heater",
"suggestion.reason.roof_type": "Matches your roof",
"suggestion.reason.exterior_type": "Matches your exterior",
"suggestion.reason.flooring_primary": "Matches your flooring",
"suggestion.reason.landscaping_type": "Matches your landscaping",
"suggestion.reason.property_type": "Recommended for your property type",
"suggestion.reason.climate_region": "Recommended for your climate",
"lookup.residence_type.duplex": "Duplex",
"lookup.residence_type.vacation_home": "Vacation Home",
"lookup.task_category.general": "General",
"lookup.task_frequency.bi_weekly": "Bi-Weekly",
"lookup.task_frequency.semi_annually": "Semi-Annually",
"lookup.task_frequency.custom": "Custom",
"lookup.contractor_specialty.appliance_repair": "Appliance Repair",
"lookup.contractor_specialty.cleaner": "Cleaner",
"lookup.contractor_specialty.locksmith": "Locksmith",
"lookup.home_profile.gas_furnace": "Gas Furnace",
"lookup.home_profile.electric_furnace": "Electric Furnace",
"lookup.home_profile.heat_pump": "Heat Pump",
"lookup.home_profile.boiler": "Boiler",
"lookup.home_profile.radiant": "Radiant",
"lookup.home_profile.other": "Other",
"lookup.home_profile.central_ac": "Central AC",
"lookup.home_profile.window_ac": "Window AC",
"lookup.home_profile.evaporative": "Evaporative",
"lookup.home_profile.none": "None",
"lookup.home_profile.tank_gas": "Tank (Gas)",
"lookup.home_profile.tank_electric": "Tank (Electric)",
"lookup.home_profile.tankless_gas": "Tankless (Gas)",
"lookup.home_profile.tankless_electric": "Tankless (Electric)",
"lookup.home_profile.solar": "Solar",
"lookup.home_profile.asphalt_shingle": "Asphalt Shingle",
"lookup.home_profile.metal": "Metal",
"lookup.home_profile.tile": "Tile",
"lookup.home_profile.slate": "Slate",
"lookup.home_profile.wood_shake": "Wood Shake",
"lookup.home_profile.flat": "Flat",
"lookup.home_profile.brick": "Brick",
"lookup.home_profile.vinyl_siding": "Vinyl Siding",
"lookup.home_profile.wood_siding": "Wood Siding",
"lookup.home_profile.stucco": "Stucco",
"lookup.home_profile.stone": "Stone",
"lookup.home_profile.fiber_cement": "Fiber Cement",
"lookup.home_profile.hardwood": "Hardwood",
"lookup.home_profile.laminate": "Laminate",
"lookup.home_profile.carpet": "Carpet",
"lookup.home_profile.vinyl": "Vinyl",
"lookup.home_profile.concrete": "Concrete",
"lookup.home_profile.lawn": "Lawn",
"lookup.home_profile.desert": "Desert",
"lookup.home_profile.xeriscape": "Xeriscape",
"lookup.home_profile.garden": "Garden",
"lookup.home_profile.mixed": "Mixed",
"lookup.document_type.warranty": "Warranty",
"lookup.document_type.manual": "User Manual",
"lookup.document_type.receipt": "Receipt/Invoice",
"lookup.document_type.inspection": "Inspection Report",
"lookup.document_type.permit": "Permit",
"lookup.document_type.deed": "Deed/Title",
"lookup.document_type.insurance": "Insurance",
"lookup.document_type.contract": "Contract",
"lookup.document_type.photo": "Photo",
"lookup.document_type.other": "Other",
"lookup.document_category.appliance": "Appliance",
"lookup.document_category.hvac": "HVAC",
"lookup.document_category.plumbing": "Plumbing",
"lookup.document_category.electrical": "Electrical",
"lookup.document_category.roofing": "Roofing",
"lookup.document_category.structural": "Structural",
"lookup.document_category.landscaping": "Landscaping",
"lookup.document_category.general": "General",
"lookup.document_category.other": "Other"
}
+95 -39
View File
@@ -25,7 +25,6 @@
"error.google_signin_not_configured": "El inicio de sesion con Google no esta configurado",
"error.google_signin_failed": "Error en el inicio de sesion con Google",
"error.invalid_google_token": "Token de identidad de Google no valido",
"error.invalid_task_id": "ID de tarea no valido",
"error.invalid_residence_id": "ID de propiedad no valido",
"error.invalid_contractor_id": "ID de contratista no valido",
@@ -34,7 +33,6 @@
"error.invalid_user_id": "ID de usuario no valido",
"error.invalid_notification_id": "ID de notificacion no valido",
"error.invalid_device_id": "ID de dispositivo no valido",
"error.task_not_found": "Tarea no encontrada",
"error.residence_not_found": "Propiedad no encontrada",
"error.contractor_not_found": "Contratista no encontrado",
@@ -43,7 +41,6 @@
"error.user_not_found": "Usuario no encontrado",
"error.share_code_invalid": "Codigo de compartir no valido",
"error.share_code_expired": "El codigo de compartir ha expirado",
"error.task_access_denied": "No tienes acceso a esta tarea",
"error.residence_access_denied": "No tienes acceso a esta propiedad",
"error.contractor_access_denied": "No tienes acceso a este contratista",
@@ -52,10 +49,8 @@
"error.cannot_remove_owner": "No se puede eliminar al propietario de la propiedad",
"error.user_already_member": "El usuario ya es miembro de esta propiedad",
"error.properties_limit_reached": "Has alcanzado el numero maximo de propiedades para tu suscripcion",
"error.task_already_cancelled": "La tarea ya esta cancelada",
"error.task_already_archived": "La tarea ya esta archivada",
"error.failed_to_parse_form": "Error al analizar el formulario",
"error.task_id_required": "Se requiere task_id",
"error.invalid_task_id_value": "task_id no valido",
@@ -64,14 +59,12 @@
"error.invalid_residence_id_value": "residence_id no valido",
"error.title_required": "Se requiere el titulo",
"error.failed_to_upload_file": "Error al subir el archivo",
"message.logged_out": "Sesion cerrada correctamente",
"message.email_verified": "Correo electronico verificado correctamente",
"message.verification_email_sent": "Correo de verificacion enviado",
"message.password_reset_email_sent": "Si existe una cuenta con ese correo electronico, se ha enviado un codigo de restablecimiento de contrasena.",
"message.reset_code_verified": "Codigo verificado correctamente",
"message.password_reset_success": "Contrasena restablecida correctamente. Por favor, inicia sesion con tu nueva contrasena.",
"message.task_deleted": "Tarea eliminada correctamente",
"message.task_in_progress": "Tarea marcada como en progreso",
"message.task_cancelled": "Tarea cancelada",
@@ -79,46 +72,35 @@
"message.task_archived": "Tarea archivada",
"message.task_unarchived": "Tarea desarchivada",
"message.completion_deleted": "Finalizacion eliminada correctamente",
"message.residence_deleted": "Propiedad eliminada correctamente",
"message.user_removed": "Usuario eliminado de la propiedad",
"message.tasks_report_generated": "Informe de tareas generado correctamente",
"message.tasks_report_sent": "Informe de tareas generado y enviado a {{.Email}}",
"message.tasks_report_email_failed": "Informe de tareas generado pero no se pudo enviar el correo",
"message.contractor_deleted": "Contratista eliminado correctamente",
"message.document_deleted": "Documento eliminado correctamente",
"message.document_activated": "Documento activado",
"message.document_deactivated": "Documento desactivado",
"message.notification_marked_read": "Notificación marcada como leída",
"message.all_notifications_marked_read": "Todas las notificaciones marcadas como leídas",
"message.device_removed": "Dispositivo eliminado",
"message.subscription_upgraded": "Suscripción actualizada correctamente",
"message.subscription_cancelled": "Suscripción cancelada. Mantendrás los beneficios Pro hasta el final de tu período de facturación.",
"message.subscription_restored": "Suscripción restaurada correctamente",
"message.file_deleted": "Archivo eliminado correctamente",
"message.static_data_refreshed": "Datos estáticos actualizados",
"error.notification_not_found": "Notificación no encontrada",
"error.invalid_platform": "Plataforma no válida",
"error.upgrade_trigger_not_found": "Trigger de actualización no encontrado",
"error.receipt_data_required": "Se requiere receipt_data para iOS",
"error.purchase_token_required": "Se requiere purchase_token para Android",
"error.no_file_provided": "No se proporcionó ningún archivo",
"error.failed_to_fetch_residence_types": "Error al obtener los tipos de propiedad",
"error.failed_to_fetch_task_categories": "Error al obtener las categorías de tareas",
"error.failed_to_fetch_task_priorities": "Error al obtener las prioridades de tareas",
"error.failed_to_fetch_task_frequencies": "Error al obtener las frecuencias de tareas",
"error.failed_to_fetch_task_statuses": "Error al obtener los estados de tareas",
"error.failed_to_fetch_contractor_specialties": "Error al obtener las especialidades de contratistas",
"push.task_due_soon.title": "Tarea Proxima a Vencer",
"push.task_due_soon.body": "{{.TaskTitle}} vence {{.DueDate}}",
"push.task_overdue.title": "Tarea Vencida",
@@ -129,44 +111,38 @@
"push.task_assigned.body": "Se te ha asignado {{.TaskTitle}}",
"push.residence_shared.title": "Propiedad Compartida",
"push.residence_shared.body": "{{.UserName}} compartio {{.ResidenceName}} contigo",
"email.welcome.subject": "Bienvenido a honeyDue!",
"email.verification.subject": "Verifica Tu Correo Electronico",
"email.password_reset.subject": "Codigo de Restablecimiento de Contrasena",
"email.tasks_report.subject": "Informe de Tareas para {{.ResidenceName}}",
"lookup.residence_type.house": "Casa",
"lookup.residence_type.apartment": "Apartamento",
"lookup.residence_type.condo": "Condominio",
"lookup.residence_type.townhouse": "Casa Adosada",
"lookup.residence_type.mobile_home": "Casa Movil",
"lookup.residence_type.townhouse": "Casa adosada",
"lookup.residence_type.mobile_home": "Casa vil",
"lookup.residence_type.other": "Otro",
"lookup.task_category.plumbing": "Plomeria",
"lookup.task_category.electrical": "Electricidad",
"lookup.task_category.hvac": "Climatizacion",
"lookup.task_category.appliances": "Electrodomesticos",
"lookup.task_category.plumbing": "Fontanería",
"lookup.task_category.electrical": "Eléctrico",
"lookup.task_category.hvac": "Climatización",
"lookup.task_category.appliances": "Electrodomésticos",
"lookup.task_category.exterior": "Exterior",
"lookup.task_category.interior": "Interior",
"lookup.task_category.landscaping": "Jardineria",
"lookup.task_category.safety": "Seguridad",
"lookup.task_category.cleaning": "Limpieza",
"lookup.task_category.pest_control": "Control de Plagas",
"lookup.task_category.pest_control": "Control de plagas",
"lookup.task_category.seasonal": "Estacional",
"lookup.task_category.other": "Otro",
"lookup.task_priority.low": "Baja",
"lookup.task_priority.medium": "Media",
"lookup.task_priority.high": "Alta",
"lookup.task_priority.urgent": "Urgente",
"lookup.task_status.pending": "Pendiente",
"lookup.task_status.in_progress": "En Progreso",
"lookup.task_status.completed": "Completada",
"lookup.task_status.cancelled": "Cancelada",
"lookup.task_status.archived": "Archivada",
"lookup.task_frequency.once": "Una Vez",
"lookup.task_frequency.once": "Una vez",
"lookup.task_frequency.daily": "Diario",
"lookup.task_frequency.weekly": "Semanal",
"lookup.task_frequency.biweekly": "Cada 2 Semanas",
@@ -174,18 +150,98 @@
"lookup.task_frequency.quarterly": "Trimestral",
"lookup.task_frequency.semiannually": "Cada 6 Meses",
"lookup.task_frequency.annually": "Anual",
"lookup.contractor_specialty.plumber": "Plomero",
"lookup.contractor_specialty.plumber": "Fontanero",
"lookup.contractor_specialty.electrician": "Electricista",
"lookup.contractor_specialty.hvac_technician": "Tecnico de Climatizacion",
"lookup.contractor_specialty.hvac_technician": "Técnico de climatización",
"lookup.contractor_specialty.handyman": "Manitas",
"lookup.contractor_specialty.landscaper": "Jardinero",
"lookup.contractor_specialty.roofer": "Techador",
"lookup.contractor_specialty.painter": "Pintor",
"lookup.contractor_specialty.carpenter": "Carpintero",
"lookup.contractor_specialty.pest_control": "Control de Plagas",
"lookup.contractor_specialty.pest_control": "Control de plagas",
"lookup.contractor_specialty.cleaning": "Limpieza",
"lookup.contractor_specialty.pool_service": "Servicio de Piscina",
"lookup.contractor_specialty.general_contractor": "Contratista General",
"lookup.contractor_specialty.other": "Otro"
"lookup.contractor_specialty.pool_service": "Servicio de piscina",
"lookup.contractor_specialty.general_contractor": "Contratista general",
"lookup.contractor_specialty.other": "Otro",
"suggestion.reason.has_pool": "Tu casa tiene piscina",
"suggestion.reason.has_sprinkler_system": "Tu casa tiene sistema de riego",
"suggestion.reason.has_septic": "Tu casa tiene fosa séptica",
"suggestion.reason.has_fireplace": "Tu casa tiene chimenea",
"suggestion.reason.has_garage": "Tu casa tiene garaje",
"suggestion.reason.has_basement": "Tu casa tiene sótano",
"suggestion.reason.has_attic": "Tu casa tiene ático",
"suggestion.reason.heating_type": "Coincide con tu sistema de calefacción",
"suggestion.reason.cooling_type": "Coincide con tu sistema de refrigeración",
"suggestion.reason.water_heater_type": "Coincide con tu calentador de agua",
"suggestion.reason.roof_type": "Coincide con tu tejado",
"suggestion.reason.exterior_type": "Coincide con tu exterior",
"suggestion.reason.flooring_primary": "Coincide con tu suelo",
"suggestion.reason.landscaping_type": "Coincide con tu jardín",
"suggestion.reason.property_type": "Recomendado para tu tipo de propiedad",
"suggestion.reason.climate_region": "Recomendado para tu clima",
"lookup.residence_type.duplex": "Dúplex",
"lookup.residence_type.vacation_home": "Casa de vacaciones",
"lookup.task_category.general": "General",
"lookup.task_frequency.bi_weekly": "Quincenal",
"lookup.task_frequency.semi_annually": "Semestral",
"lookup.task_frequency.custom": "Personalizado",
"lookup.contractor_specialty.appliance_repair": "Reparación de electrodomésticos",
"lookup.contractor_specialty.cleaner": "Limpiador",
"lookup.contractor_specialty.locksmith": "Cerrajero",
"lookup.home_profile.gas_furnace": "Calefactor de gas",
"lookup.home_profile.electric_furnace": "Calefactor eléctrico",
"lookup.home_profile.heat_pump": "Bomba de calor",
"lookup.home_profile.boiler": "Caldera",
"lookup.home_profile.radiant": "Radiante",
"lookup.home_profile.other": "Otro",
"lookup.home_profile.central_ac": "AC central",
"lookup.home_profile.window_ac": "AC de ventana",
"lookup.home_profile.evaporative": "Evaporativo",
"lookup.home_profile.none": "Ninguno",
"lookup.home_profile.tank_gas": "Tanque (gas)",
"lookup.home_profile.tank_electric": "Tanque (eléctrico)",
"lookup.home_profile.tankless_gas": "Sin tanque (gas)",
"lookup.home_profile.tankless_electric": "Sin tanque (eléctrico)",
"lookup.home_profile.solar": "Solar",
"lookup.home_profile.asphalt_shingle": "Teja asfáltica",
"lookup.home_profile.metal": "Metal",
"lookup.home_profile.tile": "Teja",
"lookup.home_profile.slate": "Pizarra",
"lookup.home_profile.wood_shake": "Tablilla de madera",
"lookup.home_profile.flat": "Plano",
"lookup.home_profile.brick": "Ladrillo",
"lookup.home_profile.vinyl_siding": "Revestimiento de vinilo",
"lookup.home_profile.wood_siding": "Revestimiento de madera",
"lookup.home_profile.stucco": "Estuco",
"lookup.home_profile.stone": "Piedra",
"lookup.home_profile.fiber_cement": "Fibrocemento",
"lookup.home_profile.hardwood": "Madera dura",
"lookup.home_profile.laminate": "Laminado",
"lookup.home_profile.carpet": "Alfombra",
"lookup.home_profile.vinyl": "Vinilo",
"lookup.home_profile.concrete": "Hormigón",
"lookup.home_profile.lawn": "Césped",
"lookup.home_profile.desert": "Desierto",
"lookup.home_profile.xeriscape": "Xerojardinería",
"lookup.home_profile.garden": "Jardín",
"lookup.home_profile.mixed": "Mixto",
"lookup.document_type.warranty": "Garantía",
"lookup.document_type.manual": "Manual de usuario",
"lookup.document_type.receipt": "Recibo/Factura",
"lookup.document_type.inspection": "Informe de inspección",
"lookup.document_type.permit": "Permiso",
"lookup.document_type.deed": "Escritura/Título",
"lookup.document_type.insurance": "Seguro",
"lookup.document_type.contract": "Contrato",
"lookup.document_type.photo": "Foto",
"lookup.document_type.other": "Otro",
"lookup.document_category.appliance": "Electrodoméstico",
"lookup.document_category.hvac": "Climatización",
"lookup.document_category.plumbing": "Fontanería",
"lookup.document_category.electrical": "Eléctrico",
"lookup.document_category.roofing": "Tejado",
"lookup.document_category.structural": "Estructural",
"lookup.document_category.landscaping": "Jardinería",
"lookup.document_category.general": "General",
"lookup.document_category.other": "Otro"
}
+98 -42
View File
@@ -25,7 +25,6 @@
"error.google_signin_not_configured": "La connexion Google n'est pas configuree",
"error.google_signin_failed": "Echec de la connexion Google",
"error.invalid_google_token": "Jeton d'identite Google non valide",
"error.invalid_task_id": "ID de tache non valide",
"error.invalid_residence_id": "ID de propriete non valide",
"error.invalid_contractor_id": "ID de prestataire non valide",
@@ -34,7 +33,6 @@
"error.invalid_user_id": "ID d'utilisateur non valide",
"error.invalid_notification_id": "ID de notification non valide",
"error.invalid_device_id": "ID d'appareil non valide",
"error.task_not_found": "Tache non trouvee",
"error.residence_not_found": "Propriete non trouvee",
"error.contractor_not_found": "Prestataire non trouve",
@@ -43,7 +41,6 @@
"error.user_not_found": "Utilisateur non trouve",
"error.share_code_invalid": "Code de partage non valide",
"error.share_code_expired": "Le code de partage a expire",
"error.task_access_denied": "Vous n'avez pas acces a cette tache",
"error.residence_access_denied": "Vous n'avez pas acces a cette propriete",
"error.contractor_access_denied": "Vous n'avez pas acces a ce prestataire",
@@ -52,10 +49,8 @@
"error.cannot_remove_owner": "Impossible de retirer le proprietaire",
"error.user_already_member": "L'utilisateur est deja membre de cette propriete",
"error.properties_limit_reached": "Vous avez atteint le nombre maximum de proprietes pour votre abonnement",
"error.task_already_cancelled": "La tache est deja annulee",
"error.task_already_archived": "La tache est deja archivee",
"error.failed_to_parse_form": "Echec de l'analyse du formulaire",
"error.task_id_required": "task_id est requis",
"error.invalid_task_id_value": "task_id non valide",
@@ -64,14 +59,12 @@
"error.invalid_residence_id_value": "residence_id non valide",
"error.title_required": "Le titre est requis",
"error.failed_to_upload_file": "Echec du telechargement du fichier",
"message.logged_out": "Deconnexion reussie",
"message.email_verified": "Email verifie avec succes",
"message.verification_email_sent": "Email de verification envoye",
"message.password_reset_email_sent": "Si un compte existe avec cet email, un code de reinitialisation a ete envoye.",
"message.reset_code_verified": "Code verifie avec succes",
"message.password_reset_success": "Mot de passe reinitialise avec succes. Veuillez vous connecter avec votre nouveau mot de passe.",
"message.task_deleted": "Tache supprimee avec succes",
"message.task_in_progress": "Tache marquee comme en cours",
"message.task_cancelled": "Tache annulee",
@@ -79,46 +72,35 @@
"message.task_archived": "Tache archivee",
"message.task_unarchived": "Tache desarchivee",
"message.completion_deleted": "Completion supprimee avec succes",
"message.residence_deleted": "Propriete supprimee avec succes",
"message.user_removed": "Utilisateur retire de la propriete",
"message.tasks_report_generated": "Rapport de taches genere avec succes",
"message.tasks_report_sent": "Rapport de taches genere et envoye a {{.Email}}",
"message.tasks_report_email_failed": "Rapport de taches genere mais l'email n'a pas pu etre envoye",
"message.contractor_deleted": "Prestataire supprime avec succes",
"message.document_deleted": "Document supprime avec succes",
"message.document_activated": "Document active",
"message.document_deactivated": "Document desactive",
"message.notification_marked_read": "Notification marquée comme lue",
"message.all_notifications_marked_read": "Toutes les notifications marquées comme lues",
"message.device_removed": "Appareil supprimé",
"message.subscription_upgraded": "Abonnement mis à niveau avec succès",
"message.subscription_cancelled": "Abonnement annulé. Vous conserverez les avantages Pro jusqu'à la fin de votre période de facturation.",
"message.subscription_restored": "Abonnement restauré avec succès",
"message.file_deleted": "Fichier supprimé avec succès",
"message.static_data_refreshed": "Données statiques actualisées",
"error.notification_not_found": "Notification non trouvée",
"error.invalid_platform": "Plateforme non valide",
"error.upgrade_trigger_not_found": "Déclencheur de mise à niveau non trouvé",
"error.receipt_data_required": "receipt_data est requis pour iOS",
"error.purchase_token_required": "purchase_token est requis pour Android",
"error.no_file_provided": "Aucun fichier fourni",
"error.failed_to_fetch_residence_types": "Échec de la récupération des types de propriété",
"error.failed_to_fetch_task_categories": "Échec de la récupération des catégories de tâches",
"error.failed_to_fetch_task_priorities": "Échec de la récupération des priorités de tâches",
"error.failed_to_fetch_task_frequencies": "Échec de la récupération des fréquences de tâches",
"error.failed_to_fetch_task_statuses": "Échec de la récupération des statuts de tâches",
"error.failed_to_fetch_contractor_specialties": "Échec de la récupération des spécialités des prestataires",
"push.task_due_soon.title": "Tache Bientot Due",
"push.task_due_soon.body": "{{.TaskTitle}} est due le {{.DueDate}}",
"push.task_overdue.title": "Tache en Retard",
@@ -129,44 +111,38 @@
"push.task_assigned.body": "{{.TaskTitle}} vous a ete assignee",
"push.residence_shared.title": "Propriete Partagee",
"push.residence_shared.body": "{{.UserName}} a partage {{.ResidenceName}} avec vous",
"email.welcome.subject": "Bienvenue sur honeyDue !",
"email.verification.subject": "Verifiez Votre Email",
"email.password_reset.subject": "Code de Reinitialisation de Mot de Passe",
"email.tasks_report.subject": "Rapport de Taches pour {{.ResidenceName}}",
"lookup.residence_type.house": "Maison",
"lookup.residence_type.apartment": "Appartement",
"lookup.residence_type.condo": "Copropriete",
"lookup.residence_type.townhouse": "Maison de Ville",
"lookup.residence_type.mobile_home": "Mobil-home",
"lookup.residence_type.condo": "Copropriété",
"lookup.residence_type.townhouse": "Maison de ville",
"lookup.residence_type.mobile_home": "Maison mobile",
"lookup.residence_type.other": "Autre",
"lookup.task_category.plumbing": "Plomberie",
"lookup.task_category.electrical": "Electricite",
"lookup.task_category.hvac": "Climatisation",
"lookup.task_category.appliances": "Electromenager",
"lookup.task_category.exterior": "Exterieur",
"lookup.task_category.interior": "Interieur",
"lookup.task_category.electrical": "Électricité",
"lookup.task_category.hvac": "CVC",
"lookup.task_category.appliances": "Électroménager",
"lookup.task_category.exterior": "Extérieur",
"lookup.task_category.interior": "Intérieur",
"lookup.task_category.landscaping": "Jardinage",
"lookup.task_category.safety": "Securite",
"lookup.task_category.safety": "Sécurité",
"lookup.task_category.cleaning": "Nettoyage",
"lookup.task_category.pest_control": "Lutte Antiparasitaire",
"lookup.task_category.pest_control": "Lutte antiparasitaire",
"lookup.task_category.seasonal": "Saisonnier",
"lookup.task_category.other": "Autre",
"lookup.task_priority.low": "Basse",
"lookup.task_priority.medium": "Moyenne",
"lookup.task_priority.high": "Haute",
"lookup.task_priority.urgent": "Urgente",
"lookup.task_status.pending": "En Attente",
"lookup.task_status.in_progress": "En Cours",
"lookup.task_status.completed": "Terminee",
"lookup.task_status.cancelled": "Annulee",
"lookup.task_status.archived": "Archivee",
"lookup.task_frequency.once": "Une Fois",
"lookup.task_frequency.once": "Une fois",
"lookup.task_frequency.daily": "Quotidien",
"lookup.task_frequency.weekly": "Hebdomadaire",
"lookup.task_frequency.biweekly": "Toutes les 2 Semaines",
@@ -174,18 +150,98 @@
"lookup.task_frequency.quarterly": "Trimestriel",
"lookup.task_frequency.semiannually": "Tous les 6 Mois",
"lookup.task_frequency.annually": "Annuel",
"lookup.contractor_specialty.plumber": "Plombier",
"lookup.contractor_specialty.electrician": "Electricien",
"lookup.contractor_specialty.electrician": "Électricien",
"lookup.contractor_specialty.hvac_technician": "Technicien CVC",
"lookup.contractor_specialty.handyman": "Bricoleur",
"lookup.contractor_specialty.landscaper": "Paysagiste",
"lookup.contractor_specialty.roofer": "Couvreur",
"lookup.contractor_specialty.painter": "Peintre",
"lookup.contractor_specialty.carpenter": "Menuisier",
"lookup.contractor_specialty.pest_control": "Desinsectisation",
"lookup.contractor_specialty.carpenter": "Charpentier",
"lookup.contractor_specialty.pest_control": "Lutte antiparasitaire",
"lookup.contractor_specialty.cleaning": "Nettoyage",
"lookup.contractor_specialty.pool_service": "Service Piscine",
"lookup.contractor_specialty.general_contractor": "Entrepreneur General",
"lookup.contractor_specialty.other": "Autre"
"lookup.contractor_specialty.pool_service": "Service de piscine",
"lookup.contractor_specialty.general_contractor": "Entrepreneur général",
"lookup.contractor_specialty.other": "Autre",
"suggestion.reason.has_pool": "Votre logement a une piscine",
"suggestion.reason.has_sprinkler_system": "Votre logement a un système d'arrosage",
"suggestion.reason.has_septic": "Votre logement a une fosse septique",
"suggestion.reason.has_fireplace": "Votre logement a une cheminée",
"suggestion.reason.has_garage": "Votre logement a un garage",
"suggestion.reason.has_basement": "Votre logement a un sous-sol",
"suggestion.reason.has_attic": "Votre logement a des combles",
"suggestion.reason.heating_type": "Correspond à votre système de chauffage",
"suggestion.reason.cooling_type": "Correspond à votre système de climatisation",
"suggestion.reason.water_heater_type": "Correspond à votre chauffe-eau",
"suggestion.reason.roof_type": "Correspond à votre toiture",
"suggestion.reason.exterior_type": "Correspond à votre extérieur",
"suggestion.reason.flooring_primary": "Correspond à votre revêtement de sol",
"suggestion.reason.landscaping_type": "Correspond à votre aménagement paysager",
"suggestion.reason.property_type": "Recommandé pour votre type de logement",
"suggestion.reason.climate_region": "Recommandé pour votre climat",
"lookup.residence_type.duplex": "Duplex",
"lookup.residence_type.vacation_home": "Maison de vacances",
"lookup.task_category.general": "Général",
"lookup.task_frequency.bi_weekly": "Bimensuel",
"lookup.task_frequency.semi_annually": "Semestriel",
"lookup.task_frequency.custom": "Personnalisé",
"lookup.contractor_specialty.appliance_repair": "Réparation d'électroménager",
"lookup.contractor_specialty.cleaner": "Agent de nettoyage",
"lookup.contractor_specialty.locksmith": "Serrurier",
"lookup.home_profile.gas_furnace": "Fournaise au gaz",
"lookup.home_profile.electric_furnace": "Fournaise électrique",
"lookup.home_profile.heat_pump": "Pompe à chaleur",
"lookup.home_profile.boiler": "Chaudière",
"lookup.home_profile.radiant": "Rayonnant",
"lookup.home_profile.other": "Autre",
"lookup.home_profile.central_ac": "Climatisation centrale",
"lookup.home_profile.window_ac": "Climatiseur de fenêtre",
"lookup.home_profile.evaporative": "Évaporatif",
"lookup.home_profile.none": "Aucun",
"lookup.home_profile.tank_gas": "Réservoir (gaz)",
"lookup.home_profile.tank_electric": "Réservoir (électrique)",
"lookup.home_profile.tankless_gas": "Sans réservoir (gaz)",
"lookup.home_profile.tankless_electric": "Sans réservoir (électrique)",
"lookup.home_profile.solar": "Solaire",
"lookup.home_profile.asphalt_shingle": "Bardeau d'asphalte",
"lookup.home_profile.metal": "Métal",
"lookup.home_profile.tile": "Tuile",
"lookup.home_profile.slate": "Ardoise",
"lookup.home_profile.wood_shake": "Bardeau de bois",
"lookup.home_profile.flat": "Plat",
"lookup.home_profile.brick": "Brique",
"lookup.home_profile.vinyl_siding": "Revêtement vinyle",
"lookup.home_profile.wood_siding": "Revêtement bois",
"lookup.home_profile.stucco": "Stuc",
"lookup.home_profile.stone": "Pierre",
"lookup.home_profile.fiber_cement": "Fibrociment",
"lookup.home_profile.hardwood": "Bois franc",
"lookup.home_profile.laminate": "Stratifié",
"lookup.home_profile.carpet": "Moquette",
"lookup.home_profile.vinyl": "Vinyle",
"lookup.home_profile.concrete": "Béton",
"lookup.home_profile.lawn": "Pelouse",
"lookup.home_profile.desert": "Désert",
"lookup.home_profile.xeriscape": "Xéropaysagisme",
"lookup.home_profile.garden": "Jardin",
"lookup.home_profile.mixed": "Mixte",
"lookup.document_type.warranty": "Garantie",
"lookup.document_type.manual": "Manuel d'utilisation",
"lookup.document_type.receipt": "Reçu/Facture",
"lookup.document_type.inspection": "Rapport d'inspection",
"lookup.document_type.permit": "Permis",
"lookup.document_type.deed": "Acte/Titre",
"lookup.document_type.insurance": "Assurance",
"lookup.document_type.contract": "Contrat",
"lookup.document_type.photo": "Photo",
"lookup.document_type.other": "Autre",
"lookup.document_category.appliance": "Électroménager",
"lookup.document_category.hvac": "CVC",
"lookup.document_category.plumbing": "Plomberie",
"lookup.document_category.electrical": "Électricité",
"lookup.document_category.roofing": "Toiture",
"lookup.document_category.structural": "Structure",
"lookup.document_category.landscaping": "Aménagement paysager",
"lookup.document_category.general": "Général",
"lookup.document_category.other": "Autre"
}
+92 -36
View File
@@ -25,7 +25,6 @@
"error.google_signin_not_configured": "L'accesso con Google non è configurato",
"error.google_signin_failed": "Accesso con Google fallito",
"error.invalid_google_token": "Token di identità Google non valido",
"error.invalid_task_id": "ID attività non valido",
"error.invalid_residence_id": "ID immobile non valido",
"error.invalid_contractor_id": "ID fornitore non valido",
@@ -34,7 +33,6 @@
"error.invalid_user_id": "ID utente non valido",
"error.invalid_notification_id": "ID notifica non valido",
"error.invalid_device_id": "ID dispositivo non valido",
"error.task_not_found": "Attività non trovata",
"error.residence_not_found": "Immobile non trovato",
"error.contractor_not_found": "Fornitore non trovato",
@@ -43,7 +41,6 @@
"error.user_not_found": "Utente non trovato",
"error.share_code_invalid": "Codice di condivisione non valido",
"error.share_code_expired": "Il codice di condivisione è scaduto",
"error.task_access_denied": "Non hai accesso a questa attività",
"error.residence_access_denied": "Non hai accesso a questo immobile",
"error.contractor_access_denied": "Non hai accesso a questo fornitore",
@@ -52,10 +49,8 @@
"error.cannot_remove_owner": "Impossibile rimuovere il proprietario dell'immobile",
"error.user_already_member": "L'utente è già membro di questo immobile",
"error.properties_limit_reached": "Hai raggiunto il numero massimo di immobili per il tuo abbonamento",
"error.task_already_cancelled": "L'attività è già stata annullata",
"error.task_already_archived": "L'attività è già stata archiviata",
"error.failed_to_parse_form": "Impossibile analizzare il modulo multipart",
"error.task_id_required": "task_id è obbligatorio",
"error.invalid_task_id_value": "task_id non valido",
@@ -64,14 +59,12 @@
"error.invalid_residence_id_value": "residence_id non valido",
"error.title_required": "title è obbligatorio",
"error.failed_to_upload_file": "Impossibile caricare il file",
"message.logged_out": "Disconnessione avvenuta con successo",
"message.email_verified": "Email verificata con successo",
"message.verification_email_sent": "Email di verifica inviata",
"message.password_reset_email_sent": "Se esiste un account con quell'email, è stato inviato un codice di reimpostazione password.",
"message.reset_code_verified": "Codice verificato con successo",
"message.password_reset_success": "Password reimpostata con successo. Accedi con la tua nuova password.",
"message.task_deleted": "Attività eliminata con successo",
"message.task_in_progress": "Attività contrassegnata come in corso",
"message.task_cancelled": "Attività annullata",
@@ -79,46 +72,35 @@
"message.task_archived": "Attività archiviata",
"message.task_unarchived": "Attività ripristinata dall'archivio",
"message.completion_deleted": "Completamento eliminato con successo",
"message.residence_deleted": "Immobile eliminato con successo",
"message.user_removed": "Utente rimosso dall'immobile",
"message.tasks_report_generated": "Report attività generato con successo",
"message.tasks_report_sent": "Report attività generato e inviato a {{.Email}}",
"message.tasks_report_email_failed": "Report attività generato ma l'email non è stata inviata",
"message.contractor_deleted": "Fornitore eliminato con successo",
"message.document_deleted": "Documento eliminato con successo",
"message.document_activated": "Documento attivato",
"message.document_deactivated": "Documento disattivato",
"message.notification_marked_read": "Notifica contrassegnata come letta",
"message.all_notifications_marked_read": "Tutte le notifiche contrassegnate come lette",
"message.device_removed": "Dispositivo rimosso",
"message.subscription_upgraded": "Abbonamento aggiornato con successo",
"message.subscription_cancelled": "Abbonamento annullato. Manterrai i vantaggi Pro fino alla fine del periodo di fatturazione.",
"message.subscription_restored": "Abbonamento ripristinato con successo",
"message.file_deleted": "File eliminato con successo",
"message.static_data_refreshed": "Dati statici aggiornati",
"error.notification_not_found": "Notifica non trovata",
"error.invalid_platform": "Piattaforma non valida",
"error.upgrade_trigger_not_found": "Trigger di aggiornamento non trovato",
"error.receipt_data_required": "receipt_data è obbligatorio per iOS",
"error.purchase_token_required": "purchase_token è obbligatorio per Android",
"error.no_file_provided": "Nessun file fornito",
"error.failed_to_fetch_residence_types": "Impossibile recuperare i tipi di immobile",
"error.failed_to_fetch_task_categories": "Impossibile recuperare le categorie di attività",
"error.failed_to_fetch_task_priorities": "Impossibile recuperare le priorità delle attività",
"error.failed_to_fetch_task_frequencies": "Impossibile recuperare le frequenze delle attività",
"error.failed_to_fetch_task_statuses": "Impossibile recuperare gli stati delle attività",
"error.failed_to_fetch_contractor_specialties": "Impossibile recuperare le specializzazioni dei fornitori",
"push.task_due_soon.title": "Attività in Scadenza",
"push.task_due_soon.body": "{{.TaskTitle}} scade {{.DueDate}}",
"push.task_overdue.title": "Attività Scaduta",
@@ -129,22 +111,19 @@
"push.task_assigned.body": "Ti è stata assegnata {{.TaskTitle}}",
"push.residence_shared.title": "Immobile Condiviso",
"push.residence_shared.body": "{{.UserName}} ha condiviso {{.ResidenceName}} con te",
"email.welcome.subject": "Benvenuto su honeyDue!",
"email.verification.subject": "Verifica la Tua Email",
"email.password_reset.subject": "Codice di Reimpostazione Password",
"email.tasks_report.subject": "Report Attività per {{.ResidenceName}}",
"lookup.residence_type.house": "Casa",
"lookup.residence_type.apartment": "Appartamento",
"lookup.residence_type.condo": "Condominio",
"lookup.residence_type.townhouse": "Villetta a Schiera",
"lookup.residence_type.mobile_home": "Casa Mobile",
"lookup.residence_type.townhouse": "Villetta a schiera",
"lookup.residence_type.mobile_home": "Casa mobile",
"lookup.residence_type.other": "Altro",
"lookup.task_category.plumbing": "Idraulica",
"lookup.task_category.electrical": "Elettricità",
"lookup.task_category.hvac": "Climatizzazione",
"lookup.task_category.electrical": "Elettrico",
"lookup.task_category.hvac": "HVAC",
"lookup.task_category.appliances": "Elettrodomestici",
"lookup.task_category.exterior": "Esterno",
"lookup.task_category.interior": "Interno",
@@ -154,38 +133,115 @@
"lookup.task_category.pest_control": "Disinfestazione",
"lookup.task_category.seasonal": "Stagionale",
"lookup.task_category.other": "Altro",
"lookup.task_priority.low": "Bassa",
"lookup.task_priority.medium": "Media",
"lookup.task_priority.high": "Alta",
"lookup.task_priority.urgent": "Urgente",
"lookup.task_status.pending": "In Attesa",
"lookup.task_status.in_progress": "In Corso",
"lookup.task_status.completed": "Completata",
"lookup.task_status.cancelled": "Annullata",
"lookup.task_status.archived": "Archiviata",
"lookup.task_frequency.once": "Una Volta",
"lookup.task_frequency.daily": "Giornaliera",
"lookup.task_frequency.once": "Una volta",
"lookup.task_frequency.daily": "Giornaliero",
"lookup.task_frequency.weekly": "Settimanale",
"lookup.task_frequency.biweekly": "Ogni 2 Settimane",
"lookup.task_frequency.monthly": "Mensile",
"lookup.task_frequency.quarterly": "Trimestrale",
"lookup.task_frequency.semiannually": "Ogni 6 Mesi",
"lookup.task_frequency.annually": "Annuale",
"lookup.contractor_specialty.plumber": "Idraulico",
"lookup.contractor_specialty.electrician": "Elettricista",
"lookup.contractor_specialty.hvac_technician": "Tecnico Climatizzazione",
"lookup.contractor_specialty.hvac_technician": "Tecnico HVAC",
"lookup.contractor_specialty.handyman": "Tuttofare",
"lookup.contractor_specialty.landscaper": "Giardiniere",
"lookup.contractor_specialty.roofer": "Lattoniere",
"lookup.contractor_specialty.roofer": "Conciatetti",
"lookup.contractor_specialty.painter": "Imbianchino",
"lookup.contractor_specialty.carpenter": "Falegname",
"lookup.contractor_specialty.pest_control": "Disinfestazione",
"lookup.contractor_specialty.cleaning": "Pulizia",
"lookup.contractor_specialty.pool_service": "Manutenzione Piscine",
"lookup.contractor_specialty.general_contractor": "Impresa Generale",
"lookup.contractor_specialty.other": "Altro"
"lookup.contractor_specialty.pool_service": "Servizio piscina",
"lookup.contractor_specialty.general_contractor": "Imprenditore generale",
"lookup.contractor_specialty.other": "Altro",
"suggestion.reason.has_pool": "La tua casa ha una piscina",
"suggestion.reason.has_sprinkler_system": "La tua casa ha un impianto di irrigazione",
"suggestion.reason.has_septic": "La tua casa ha una fossa settica",
"suggestion.reason.has_fireplace": "La tua casa ha un camino",
"suggestion.reason.has_garage": "La tua casa ha un garage",
"suggestion.reason.has_basement": "La tua casa ha un seminterrato",
"suggestion.reason.has_attic": "La tua casa ha una soffitta",
"suggestion.reason.heating_type": "Corrisponde al tuo impianto di riscaldamento",
"suggestion.reason.cooling_type": "Corrisponde al tuo impianto di raffreddamento",
"suggestion.reason.water_heater_type": "Corrisponde al tuo scaldabagno",
"suggestion.reason.roof_type": "Corrisponde al tuo tetto",
"suggestion.reason.exterior_type": "Corrisponde al tuo esterno",
"suggestion.reason.flooring_primary": "Corrisponde alla tua pavimentazione",
"suggestion.reason.landscaping_type": "Corrisponde al tuo giardino",
"suggestion.reason.property_type": "Consigliato per il tuo tipo di immobile",
"suggestion.reason.climate_region": "Consigliato per il tuo clima",
"lookup.residence_type.duplex": "Bifamiliare",
"lookup.residence_type.vacation_home": "Casa vacanze",
"lookup.task_category.general": "Generale",
"lookup.task_frequency.bi_weekly": "Bisettimanale",
"lookup.task_frequency.semi_annually": "Semestrale",
"lookup.task_frequency.custom": "Personalizzato",
"lookup.contractor_specialty.appliance_repair": "Riparazione elettrodomestici",
"lookup.contractor_specialty.cleaner": "Addetto alle pulizie",
"lookup.contractor_specialty.locksmith": "Fabbro",
"lookup.home_profile.gas_furnace": "Caldaia a gas",
"lookup.home_profile.electric_furnace": "Caldaia elettrica",
"lookup.home_profile.heat_pump": "Pompa di calore",
"lookup.home_profile.boiler": "Caldaia",
"lookup.home_profile.radiant": "Radiante",
"lookup.home_profile.other": "Altro",
"lookup.home_profile.central_ac": "Climatizzatore centrale",
"lookup.home_profile.window_ac": "Climatizzatore a finestra",
"lookup.home_profile.evaporative": "Evaporativo",
"lookup.home_profile.none": "Nessuno",
"lookup.home_profile.tank_gas": "Serbatoio (gas)",
"lookup.home_profile.tank_electric": "Serbatoio (elettrico)",
"lookup.home_profile.tankless_gas": "Senza serbatoio (gas)",
"lookup.home_profile.tankless_electric": "Senza serbatoio (elettrico)",
"lookup.home_profile.solar": "Solare",
"lookup.home_profile.asphalt_shingle": "Tegola bituminosa",
"lookup.home_profile.metal": "Metallo",
"lookup.home_profile.tile": "Tegola",
"lookup.home_profile.slate": "Ardesia",
"lookup.home_profile.wood_shake": "Scandola di legno",
"lookup.home_profile.flat": "Piatto",
"lookup.home_profile.brick": "Mattone",
"lookup.home_profile.vinyl_siding": "Rivestimento in vinile",
"lookup.home_profile.wood_siding": "Rivestimento in legno",
"lookup.home_profile.stucco": "Stucco",
"lookup.home_profile.stone": "Pietra",
"lookup.home_profile.fiber_cement": "Fibrocemento",
"lookup.home_profile.hardwood": "Legno duro",
"lookup.home_profile.laminate": "Laminato",
"lookup.home_profile.carpet": "Moquette",
"lookup.home_profile.vinyl": "Vinile",
"lookup.home_profile.concrete": "Cemento",
"lookup.home_profile.lawn": "Prato",
"lookup.home_profile.desert": "Deserto",
"lookup.home_profile.xeriscape": "Xeriscaping",
"lookup.home_profile.garden": "Giardino",
"lookup.home_profile.mixed": "Misto",
"lookup.document_type.warranty": "Garanzia",
"lookup.document_type.manual": "Manuale d'uso",
"lookup.document_type.receipt": "Ricevuta/Fattura",
"lookup.document_type.inspection": "Rapporto di ispezione",
"lookup.document_type.permit": "Permesso",
"lookup.document_type.deed": "Atto/Titolo",
"lookup.document_type.insurance": "Assicurazione",
"lookup.document_type.contract": "Contratto",
"lookup.document_type.photo": "Foto",
"lookup.document_type.other": "Altro",
"lookup.document_category.appliance": "Elettrodomestico",
"lookup.document_category.hvac": "HVAC",
"lookup.document_category.plumbing": "Idraulica",
"lookup.document_category.electrical": "Elettrico",
"lookup.document_category.roofing": "Tetto",
"lookup.document_category.structural": "Strutturale",
"lookup.document_category.landscaping": "Giardinaggio",
"lookup.document_category.general": "Generale",
"lookup.document_category.other": "Altro"
}
+90 -34
View File
@@ -25,7 +25,6 @@
"error.google_signin_not_configured": "Google サインインが設定されていません",
"error.google_signin_failed": "Google サインインに失敗しました",
"error.invalid_google_token": "無効な Google ID トークンです",
"error.invalid_task_id": "無効なタスクIDです",
"error.invalid_residence_id": "無効な物件IDです",
"error.invalid_contractor_id": "無効な業者IDです",
@@ -34,7 +33,6 @@
"error.invalid_user_id": "無効なユーザーIDです",
"error.invalid_notification_id": "無効な通知IDです",
"error.invalid_device_id": "無効なデバイスIDです",
"error.task_not_found": "タスクが見つかりません",
"error.residence_not_found": "物件が見つかりません",
"error.contractor_not_found": "業者が見つかりません",
@@ -43,7 +41,6 @@
"error.user_not_found": "ユーザーが見つかりません",
"error.share_code_invalid": "無効な共有コードです",
"error.share_code_expired": "共有コードの有効期限が切れています",
"error.task_access_denied": "このタスクへのアクセス権がありません",
"error.residence_access_denied": "この物件へのアクセス権がありません",
"error.contractor_access_denied": "この業者へのアクセス権がありません",
@@ -52,10 +49,8 @@
"error.cannot_remove_owner": "物件のオーナーを削除することはできません",
"error.user_already_member": "このユーザーは既にこの物件のメンバーです",
"error.properties_limit_reached": "サブスクリプションで許可されている物件の最大数に達しました",
"error.task_already_cancelled": "タスクは既にキャンセルされています",
"error.task_already_archived": "タスクは既にアーカイブされています",
"error.failed_to_parse_form": "マルチパートフォームの解析に失敗しました",
"error.task_id_required": "task_id は必須です",
"error.invalid_task_id_value": "無効な task_id です",
@@ -64,14 +59,12 @@
"error.invalid_residence_id_value": "無効な residence_id です",
"error.title_required": "タイトルは必須です",
"error.failed_to_upload_file": "ファイルのアップロードに失敗しました",
"message.logged_out": "ログアウトしました",
"message.email_verified": "メールアドレスの認証が完了しました",
"message.verification_email_sent": "認証メールを送信しました",
"message.password_reset_email_sent": "該当するアカウントが存在する場合、パスワードリセットコードが送信されました。",
"message.reset_code_verified": "コードの認証が完了しました",
"message.password_reset_success": "パスワードのリセットが完了しました。新しいパスワードでログインしてください。",
"message.task_deleted": "タスクを削除しました",
"message.task_in_progress": "タスクを進行中に設定しました",
"message.task_cancelled": "タスクをキャンセルしました",
@@ -79,46 +72,35 @@
"message.task_archived": "タスクをアーカイブしました",
"message.task_unarchived": "タスクのアーカイブを解除しました",
"message.completion_deleted": "完了記録を削除しました",
"message.residence_deleted": "物件を削除しました",
"message.user_removed": "ユーザーを物件から削除しました",
"message.tasks_report_generated": "タスクレポートを生成しました",
"message.tasks_report_sent": "タスクレポートを生成し、{{.Email}} に送信しました",
"message.tasks_report_email_failed": "タスクレポートは生成されましたが、メールの送信に失敗しました",
"message.contractor_deleted": "業者を削除しました",
"message.document_deleted": "書類を削除しました",
"message.document_activated": "書類を有効化しました",
"message.document_deactivated": "書類を無効化しました",
"message.notification_marked_read": "通知を既読にしました",
"message.all_notifications_marked_read": "すべての通知を既読にしました",
"message.device_removed": "デバイスを削除しました",
"message.subscription_upgraded": "サブスクリプションをアップグレードしました",
"message.subscription_cancelled": "サブスクリプションをキャンセルしました。請求期間終了まで Pro 機能をご利用いただけます。",
"message.subscription_restored": "サブスクリプションを復元しました",
"message.file_deleted": "ファイルを削除しました",
"message.static_data_refreshed": "静的データを更新しました",
"error.notification_not_found": "通知が見つかりません",
"error.invalid_platform": "無効なプラットフォームです",
"error.upgrade_trigger_not_found": "アップグレードトリガーが見つかりません",
"error.receipt_data_required": "iOS の場合、receipt_data は必須です",
"error.purchase_token_required": "Android の場合、purchase_token は必須です",
"error.no_file_provided": "ファイルが提供されていません",
"error.failed_to_fetch_residence_types": "物件タイプの取得に失敗しました",
"error.failed_to_fetch_task_categories": "タスクカテゴリの取得に失敗しました",
"error.failed_to_fetch_task_priorities": "タスク優先度の取得に失敗しました",
"error.failed_to_fetch_task_frequencies": "タスク頻度の取得に失敗しました",
"error.failed_to_fetch_task_statuses": "タスクステータスの取得に失敗しました",
"error.failed_to_fetch_contractor_specialties": "業者専門分野の取得に失敗しました",
"push.task_due_soon.title": "タスクの期限が近づいています",
"push.task_due_soon.body": "{{.TaskTitle}} の期限は {{.DueDate}} です",
"push.task_overdue.title": "期限切れのタスク",
@@ -129,19 +111,16 @@
"push.task_assigned.body": "{{.TaskTitle}} に割り当てられました",
"push.residence_shared.title": "物件が共有されました",
"push.residence_shared.body": "{{.UserName}} が {{.ResidenceName}} を共有しました",
"email.welcome.subject": "honeyDue へようこそ!",
"email.verification.subject": "メールアドレスの認証",
"email.password_reset.subject": "パスワードリセットコード",
"email.tasks_report.subject": "{{.ResidenceName}} のタスクレポート",
"lookup.residence_type.house": "一戸建て",
"lookup.residence_type.apartment": "アパート",
"lookup.residence_type.condo": "マンション",
"lookup.residence_type.condo": "分譲マンション",
"lookup.residence_type.townhouse": "タウンハウス",
"lookup.residence_type.mobile_home": "移動式住宅",
"lookup.residence_type.mobile_home": "モバイルホーム",
"lookup.residence_type.other": "その他",
"lookup.task_category.plumbing": "配管",
"lookup.task_category.electrical": "電気",
"lookup.task_category.hvac": "空調",
@@ -154,19 +133,16 @@
"lookup.task_category.pest_control": "害虫駆除",
"lookup.task_category.seasonal": "季節",
"lookup.task_category.other": "その他",
"lookup.task_priority.low": "低",
"lookup.task_priority.medium": "中",
"lookup.task_priority.high": "高",
"lookup.task_priority.urgent": "緊急",
"lookup.task_status.pending": "保留中",
"lookup.task_status.in_progress": "進行中",
"lookup.task_status.completed": "完了",
"lookup.task_status.cancelled": "キャンセル",
"lookup.task_status.archived": "アーカイブ",
"lookup.task_frequency.once": "一度のみ",
"lookup.task_frequency.once": "1回",
"lookup.task_frequency.daily": "毎日",
"lookup.task_frequency.weekly": "毎週",
"lookup.task_frequency.biweekly": "2週間ごと",
@@ -174,18 +150,98 @@
"lookup.task_frequency.quarterly": "四半期ごと",
"lookup.task_frequency.semiannually": "半年ごと",
"lookup.task_frequency.annually": "毎年",
"lookup.contractor_specialty.plumber": "配管工",
"lookup.contractor_specialty.electrician": "電気工事士",
"lookup.contractor_specialty.hvac_technician": "空調技術者",
"lookup.contractor_specialty.electrician": "電気技師",
"lookup.contractor_specialty.hvac_technician": "空調技",
"lookup.contractor_specialty.handyman": "便利屋",
"lookup.contractor_specialty.landscaper": "造園業者",
"lookup.contractor_specialty.roofer": "屋根職人",
"lookup.contractor_specialty.painter": "塗装",
"lookup.contractor_specialty.painter": "塗装業者",
"lookup.contractor_specialty.carpenter": "大工",
"lookup.contractor_specialty.pest_control": "害虫駆除業者",
"lookup.contractor_specialty.pest_control": "害虫駆除",
"lookup.contractor_specialty.cleaning": "清掃業者",
"lookup.contractor_specialty.pool_service": "プールサービス",
"lookup.contractor_specialty.general_contractor": "総合建設業者",
"lookup.contractor_specialty.other": "その他"
"lookup.contractor_specialty.general_contractor": "総合請負業者",
"lookup.contractor_specialty.other": "その他",
"suggestion.reason.has_pool": "ご自宅にプールがあります",
"suggestion.reason.has_sprinkler_system": "ご自宅にスプリンクラーがあります",
"suggestion.reason.has_septic": "ご自宅に浄化槽があります",
"suggestion.reason.has_fireplace": "ご自宅に暖炉があります",
"suggestion.reason.has_garage": "ご自宅にガレージがあります",
"suggestion.reason.has_basement": "ご自宅に地下室があります",
"suggestion.reason.has_attic": "ご自宅に屋根裏があります",
"suggestion.reason.heating_type": "暖房設備に合っています",
"suggestion.reason.cooling_type": "冷房設備に合っています",
"suggestion.reason.water_heater_type": "給湯器に合っています",
"suggestion.reason.roof_type": "屋根に合っています",
"suggestion.reason.exterior_type": "外装に合っています",
"suggestion.reason.flooring_primary": "床材に合っています",
"suggestion.reason.landscaping_type": "造園に合っています",
"suggestion.reason.property_type": "ご自宅の種類におすすめです",
"suggestion.reason.climate_region": "お住まいの気候におすすめです",
"lookup.residence_type.duplex": "二世帯住宅",
"lookup.residence_type.vacation_home": "別荘",
"lookup.task_category.general": "一般",
"lookup.task_frequency.bi_weekly": "隔週",
"lookup.task_frequency.semi_annually": "半年ごと",
"lookup.task_frequency.custom": "カスタム",
"lookup.contractor_specialty.appliance_repair": "家電修理",
"lookup.contractor_specialty.cleaner": "清掃業者",
"lookup.contractor_specialty.locksmith": "錠前師",
"lookup.home_profile.gas_furnace": "ガス炉",
"lookup.home_profile.electric_furnace": "電気炉",
"lookup.home_profile.heat_pump": "ヒートポンプ",
"lookup.home_profile.boiler": "ボイラー",
"lookup.home_profile.radiant": "放射式",
"lookup.home_profile.other": "その他",
"lookup.home_profile.central_ac": "セントラルエアコン",
"lookup.home_profile.window_ac": "窓用エアコン",
"lookup.home_profile.evaporative": "気化式",
"lookup.home_profile.none": "なし",
"lookup.home_profile.tank_gas": "タンク式(ガス)",
"lookup.home_profile.tank_electric": "タンク式(電気)",
"lookup.home_profile.tankless_gas": "タンクレス(ガス)",
"lookup.home_profile.tankless_electric": "タンクレス(電気)",
"lookup.home_profile.solar": "ソーラー",
"lookup.home_profile.asphalt_shingle": "アスファルトシングル",
"lookup.home_profile.metal": "金属",
"lookup.home_profile.tile": "タイル",
"lookup.home_profile.slate": "スレート",
"lookup.home_profile.wood_shake": "木製シェイク",
"lookup.home_profile.flat": "平型",
"lookup.home_profile.brick": "レンガ",
"lookup.home_profile.vinyl_siding": "ビニールサイディング",
"lookup.home_profile.wood_siding": "木製サイディング",
"lookup.home_profile.stucco": "スタッコ",
"lookup.home_profile.stone": "石",
"lookup.home_profile.fiber_cement": "繊維強化セメント",
"lookup.home_profile.hardwood": "無垢材",
"lookup.home_profile.laminate": "ラミネート",
"lookup.home_profile.carpet": "カーペット",
"lookup.home_profile.vinyl": "ビニール",
"lookup.home_profile.concrete": "コンクリート",
"lookup.home_profile.lawn": "芝生",
"lookup.home_profile.desert": "砂漠",
"lookup.home_profile.xeriscape": "ゼリスケープ",
"lookup.home_profile.garden": "庭園",
"lookup.home_profile.mixed": "混合",
"lookup.document_type.warranty": "保証",
"lookup.document_type.manual": "ユーザーマニュアル",
"lookup.document_type.receipt": "領収書/請求書",
"lookup.document_type.inspection": "点検報告書",
"lookup.document_type.permit": "許可証",
"lookup.document_type.deed": "権利証/証書",
"lookup.document_type.insurance": "保険",
"lookup.document_type.contract": "契約",
"lookup.document_type.photo": "写真",
"lookup.document_type.other": "その他",
"lookup.document_category.appliance": "家電",
"lookup.document_category.hvac": "空調",
"lookup.document_category.plumbing": "配管",
"lookup.document_category.electrical": "電気",
"lookup.document_category.roofing": "屋根",
"lookup.document_category.structural": "構造",
"lookup.document_category.landscaping": "造園",
"lookup.document_category.general": "一般",
"lookup.document_category.other": "その他"
}
+89 -33
View File
@@ -25,7 +25,6 @@
"error.google_signin_not_configured": "Google 로그인이 설정되지 않았습니다",
"error.google_signin_failed": "Google 로그인에 실패했습니다",
"error.invalid_google_token": "유효하지 않은 Google 인증 토큰입니다",
"error.invalid_task_id": "유효하지 않은 작업 ID입니다",
"error.invalid_residence_id": "유효하지 않은 주거지 ID입니다",
"error.invalid_contractor_id": "유효하지 않은 계약업체 ID입니다",
@@ -34,7 +33,6 @@
"error.invalid_user_id": "유효하지 않은 사용자 ID입니다",
"error.invalid_notification_id": "유효하지 않은 알림 ID입니다",
"error.invalid_device_id": "유효하지 않은 기기 ID입니다",
"error.task_not_found": "작업을 찾을 수 없습니다",
"error.residence_not_found": "주거지를 찾을 수 없습니다",
"error.contractor_not_found": "계약업체를 찾을 수 없습니다",
@@ -43,7 +41,6 @@
"error.user_not_found": "사용자를 찾을 수 없습니다",
"error.share_code_invalid": "유효하지 않은 공유 코드입니다",
"error.share_code_expired": "공유 코드가 만료되었습니다",
"error.task_access_denied": "이 작업에 접근할 권한이 없습니다",
"error.residence_access_denied": "이 주거지에 접근할 권한이 없습니다",
"error.contractor_access_denied": "이 계약업체에 접근할 권한이 없습니다",
@@ -52,10 +49,8 @@
"error.cannot_remove_owner": "주거지 소유자는 삭제할 수 없습니다",
"error.user_already_member": "이미 이 주거지의 멤버입니다",
"error.properties_limit_reached": "구독 플랜의 최대 주거지 수에 도달했습니다",
"error.task_already_cancelled": "이미 취소된 작업입니다",
"error.task_already_archived": "이미 보관된 작업입니다",
"error.failed_to_parse_form": "멀티파트 폼 파싱에 실패했습니다",
"error.task_id_required": "task_id가 필요합니다",
"error.invalid_task_id_value": "유효하지 않은 task_id 값입니다",
@@ -64,14 +59,12 @@
"error.invalid_residence_id_value": "유효하지 않은 residence_id 값입니다",
"error.title_required": "제목이 필요합니다",
"error.failed_to_upload_file": "파일 업로드에 실패했습니다",
"message.logged_out": "로그아웃되었습니다",
"message.email_verified": "이메일이 인증되었습니다",
"message.verification_email_sent": "인증 이메일이 발송되었습니다",
"message.password_reset_email_sent": "해당 이메일로 등록된 계정이 있는 경우 비밀번호 재설정 코드가 발송되었습니다.",
"message.reset_code_verified": "코드가 인증되었습니다",
"message.password_reset_success": "비밀번호가 재설정되었습니다. 새 비밀번호로 로그인해주세요.",
"message.task_deleted": "작업이 삭제되었습니다",
"message.task_in_progress": "작업이 진행 중으로 표시되었습니다",
"message.task_cancelled": "작업이 취소되었습니다",
@@ -79,46 +72,35 @@
"message.task_archived": "작업이 보관되었습니다",
"message.task_unarchived": "작업 보관이 해제되었습니다",
"message.completion_deleted": "완료 기록이 삭제되었습니다",
"message.residence_deleted": "주거지가 삭제되었습니다",
"message.user_removed": "주거지에서 사용자가 제거되었습니다",
"message.tasks_report_generated": "작업 보고서가 생성되었습니다",
"message.tasks_report_sent": "작업 보고서가 생성되어 {{.Email}}로 발송되었습니다",
"message.tasks_report_email_failed": "작업 보고서가 생성되었지만 이메일 발송에 실패했습니다",
"message.contractor_deleted": "계약업체가 삭제되었습니다",
"message.document_deleted": "문서가 삭제되었습니다",
"message.document_activated": "문서가 활성화되었습니다",
"message.document_deactivated": "문서가 비활성화되었습니다",
"message.notification_marked_read": "알림이 읽음으로 표시되었습니다",
"message.all_notifications_marked_read": "모든 알림이 읽음으로 표시되었습니다",
"message.device_removed": "기기가 제거되었습니다",
"message.subscription_upgraded": "구독이 업그레이드되었습니다",
"message.subscription_cancelled": "구독이 취소되었습니다. 결제 기간이 종료될 때까지 Pro 혜택을 유지하실 수 있습니다.",
"message.subscription_restored": "구독이 복원되었습니다",
"message.file_deleted": "파일이 삭제되었습니다",
"message.static_data_refreshed": "정적 데이터가 새로고침되었습니다",
"error.notification_not_found": "알림을 찾을 수 없습니다",
"error.invalid_platform": "유효하지 않은 플랫폼입니다",
"error.upgrade_trigger_not_found": "업그레이드 트리거를 찾을 수 없습니다",
"error.receipt_data_required": "iOS의 경우 receipt_data가 필요합니다",
"error.purchase_token_required": "Android의 경우 purchase_token이 필요합니다",
"error.no_file_provided": "파일이 제공되지 않았습니다",
"error.failed_to_fetch_residence_types": "주거지 유형을 가져오는데 실패했습니다",
"error.failed_to_fetch_task_categories": "작업 카테고리를 가져오는데 실패했습니다",
"error.failed_to_fetch_task_priorities": "작업 우선순위를 가져오는데 실패했습니다",
"error.failed_to_fetch_task_frequencies": "작업 빈도를 가져오는데 실패했습니다",
"error.failed_to_fetch_task_statuses": "작업 상태를 가져오는데 실패했습니다",
"error.failed_to_fetch_contractor_specialties": "계약업체 전문 분야를 가져오는데 실패했습니다",
"push.task_due_soon.title": "작업 마감 임박",
"push.task_due_soon.body": "{{.TaskTitle}}의 마감일은 {{.DueDate}}입니다",
"push.task_overdue.title": "지연된 작업",
@@ -129,23 +111,20 @@
"push.task_assigned.body": "{{.TaskTitle}}이(가) 할당되었습니다",
"push.residence_shared.title": "주거지 공유",
"push.residence_shared.body": "{{.UserName}}님이 {{.ResidenceName}}을(를) 공유했습니다",
"email.welcome.subject": "honeyDue에 오신 것을 환영합니다!",
"email.verification.subject": "이메일 인증",
"email.password_reset.subject": "비밀번호 재설정 코드",
"email.tasks_report.subject": "{{.ResidenceName}} 작업 보고서",
"lookup.residence_type.house": "단독주택",
"lookup.residence_type.house": "주택",
"lookup.residence_type.apartment": "아파트",
"lookup.residence_type.condo": "콘도",
"lookup.residence_type.townhouse": "타운하우스",
"lookup.residence_type.mobile_home": "이동식 주택",
"lookup.residence_type.other": "기타",
"lookup.task_category.plumbing": "배관",
"lookup.task_category.electrical": "전기",
"lookup.task_category.hvac": "냉난방",
"lookup.task_category.appliances": "가전제품",
"lookup.task_category.appliances": "가전",
"lookup.task_category.exterior": "외부",
"lookup.task_category.interior": "내부",
"lookup.task_category.landscaping": "조경",
@@ -154,18 +133,15 @@
"lookup.task_category.pest_control": "해충 방제",
"lookup.task_category.seasonal": "계절별",
"lookup.task_category.other": "기타",
"lookup.task_priority.low": "낮음",
"lookup.task_priority.medium": "보통",
"lookup.task_priority.high": "높음",
"lookup.task_priority.urgent": "긴급",
"lookup.task_status.pending": "대기 중",
"lookup.task_status.in_progress": "진행 중",
"lookup.task_status.completed": "완료",
"lookup.task_status.cancelled": "취소됨",
"lookup.task_status.archived": "보관됨",
"lookup.task_frequency.once": "한 번",
"lookup.task_frequency.daily": "매일",
"lookup.task_frequency.weekly": "매주",
@@ -174,18 +150,98 @@
"lookup.task_frequency.quarterly": "분기별",
"lookup.task_frequency.semiannually": "6개월마다",
"lookup.task_frequency.annually": "매년",
"lookup.contractor_specialty.plumber": "배관공",
"lookup.contractor_specialty.electrician": "전기 기사",
"lookup.contractor_specialty.hvac_technician": "냉난방 기사",
"lookup.contractor_specialty.handyman": "리공",
"lookup.contractor_specialty.handyman": "리공",
"lookup.contractor_specialty.landscaper": "조경사",
"lookup.contractor_specialty.roofer": "지붕",
"lookup.contractor_specialty.painter": "도배공",
"lookup.contractor_specialty.roofer": "지붕 기술자",
"lookup.contractor_specialty.painter": "페인터",
"lookup.contractor_specialty.carpenter": "목수",
"lookup.contractor_specialty.pest_control": "해충 방제",
"lookup.contractor_specialty.cleaning": "청소",
"lookup.contractor_specialty.pool_service": "수영장 관리",
"lookup.contractor_specialty.general_contractor": "종합 건설업",
"lookup.contractor_specialty.other": "기타"
"lookup.contractor_specialty.pool_service": "수영장 서비스",
"lookup.contractor_specialty.general_contractor": "종합 건설업",
"lookup.contractor_specialty.other": "기타",
"suggestion.reason.has_pool": "집에 수영장이 있습니다",
"suggestion.reason.has_sprinkler_system": "집에 스프링클러 시스템이 있습니다",
"suggestion.reason.has_septic": "집에 정화조가 있습니다",
"suggestion.reason.has_fireplace": "집에 벽난로가 있습니다",
"suggestion.reason.has_garage": "집에 차고가 있습니다",
"suggestion.reason.has_basement": "집에 지하실이 있습니다",
"suggestion.reason.has_attic": "집에 다락방이 있습니다",
"suggestion.reason.heating_type": "난방 시스템과 일치합니다",
"suggestion.reason.cooling_type": "냉방 시스템과 일치합니다",
"suggestion.reason.water_heater_type": "온수기와 일치합니다",
"suggestion.reason.roof_type": "지붕과 일치합니다",
"suggestion.reason.exterior_type": "외장과 일치합니다",
"suggestion.reason.flooring_primary": "바닥재와 일치합니다",
"suggestion.reason.landscaping_type": "조경과 일치합니다",
"suggestion.reason.property_type": "주택 유형에 추천됩니다",
"suggestion.reason.climate_region": "거주 지역 기후에 추천됩니다",
"lookup.residence_type.duplex": "듀플렉스",
"lookup.residence_type.vacation_home": "별장",
"lookup.task_category.general": "일반",
"lookup.task_frequency.bi_weekly": "격주",
"lookup.task_frequency.semi_annually": "반기별",
"lookup.task_frequency.custom": "사용자 지정",
"lookup.contractor_specialty.appliance_repair": "가전 수리",
"lookup.contractor_specialty.cleaner": "청소부",
"lookup.contractor_specialty.locksmith": "열쇠공",
"lookup.home_profile.gas_furnace": "가스 난로",
"lookup.home_profile.electric_furnace": "전기 난로",
"lookup.home_profile.heat_pump": "열펌프",
"lookup.home_profile.boiler": "보일러",
"lookup.home_profile.radiant": "복사식",
"lookup.home_profile.other": "기타",
"lookup.home_profile.central_ac": "중앙 에어컨",
"lookup.home_profile.window_ac": "창문형 에어컨",
"lookup.home_profile.evaporative": "증발식",
"lookup.home_profile.none": "없음",
"lookup.home_profile.tank_gas": "탱크식(가스)",
"lookup.home_profile.tank_electric": "탱크식(전기)",
"lookup.home_profile.tankless_gas": "탱크리스(가스)",
"lookup.home_profile.tankless_electric": "탱크리스(전기)",
"lookup.home_profile.solar": "태양광",
"lookup.home_profile.asphalt_shingle": "아스팔트 슁글",
"lookup.home_profile.metal": "금속",
"lookup.home_profile.tile": "타일",
"lookup.home_profile.slate": "슬레이트",
"lookup.home_profile.wood_shake": "목재 셰이크",
"lookup.home_profile.flat": "평지붕",
"lookup.home_profile.brick": "벽돌",
"lookup.home_profile.vinyl_siding": "비닐 사이딩",
"lookup.home_profile.wood_siding": "목재 사이딩",
"lookup.home_profile.stucco": "스투코",
"lookup.home_profile.stone": "석재",
"lookup.home_profile.fiber_cement": "섬유 시멘트",
"lookup.home_profile.hardwood": "원목",
"lookup.home_profile.laminate": "라미네이트",
"lookup.home_profile.carpet": "카펫",
"lookup.home_profile.vinyl": "비닐",
"lookup.home_profile.concrete": "콘크리트",
"lookup.home_profile.lawn": "잔디",
"lookup.home_profile.desert": "사막",
"lookup.home_profile.xeriscape": "제리스케이프",
"lookup.home_profile.garden": "정원",
"lookup.home_profile.mixed": "혼합",
"lookup.document_type.warranty": "보증",
"lookup.document_type.manual": "사용 설명서",
"lookup.document_type.receipt": "영수증/송장",
"lookup.document_type.inspection": "점검 보고서",
"lookup.document_type.permit": "허가증",
"lookup.document_type.deed": "증서/권리증",
"lookup.document_type.insurance": "보험",
"lookup.document_type.contract": "계약",
"lookup.document_type.photo": "사진",
"lookup.document_type.other": "기타",
"lookup.document_category.appliance": "가전",
"lookup.document_category.hvac": "냉난방",
"lookup.document_category.plumbing": "배관",
"lookup.document_category.electrical": "전기",
"lookup.document_category.roofing": "지붕",
"lookup.document_category.structural": "구조",
"lookup.document_category.landscaping": "조경",
"lookup.document_category.general": "일반",
"lookup.document_category.other": "기타"
}
+93 -37
View File
@@ -25,7 +25,6 @@
"error.google_signin_not_configured": "Google Sign In is niet geconfigureerd",
"error.google_signin_failed": "Google Sign In mislukt",
"error.invalid_google_token": "Ongeldig Google identiteitstoken",
"error.invalid_task_id": "Ongeldig taak-ID",
"error.invalid_residence_id": "Ongeldig woning-ID",
"error.invalid_contractor_id": "Ongeldig aannemer-ID",
@@ -34,7 +33,6 @@
"error.invalid_user_id": "Ongeldig gebruikers-ID",
"error.invalid_notification_id": "Ongeldig notificatie-ID",
"error.invalid_device_id": "Ongeldig apparaat-ID",
"error.task_not_found": "Taak niet gevonden",
"error.residence_not_found": "Woning niet gevonden",
"error.contractor_not_found": "Aannemer niet gevonden",
@@ -43,7 +41,6 @@
"error.user_not_found": "Gebruiker niet gevonden",
"error.share_code_invalid": "Ongeldige deelcode",
"error.share_code_expired": "Deelcode is verlopen",
"error.task_access_denied": "U heeft geen toegang tot deze taak",
"error.residence_access_denied": "U heeft geen toegang tot deze woning",
"error.contractor_access_denied": "U heeft geen toegang tot deze aannemer",
@@ -52,10 +49,8 @@
"error.cannot_remove_owner": "Kan de woningeigenaar niet verwijderen",
"error.user_already_member": "Gebruiker is al lid van deze woning",
"error.properties_limit_reached": "U heeft het maximale aantal woningen voor uw abonnement bereikt",
"error.task_already_cancelled": "Taak is al geannuleerd",
"error.task_already_archived": "Taak is al gearchiveerd",
"error.failed_to_parse_form": "Multipart formulier parsen mislukt",
"error.task_id_required": "task_id is verplicht",
"error.invalid_task_id_value": "Ongeldig task_id",
@@ -64,14 +59,12 @@
"error.invalid_residence_id_value": "Ongeldig residence_id",
"error.title_required": "titel is verplicht",
"error.failed_to_upload_file": "Bestand uploaden mislukt",
"message.logged_out": "Succesvol uitgelogd",
"message.email_verified": "E-mailadres succesvol geverifieerd",
"message.verification_email_sent": "Verificatie e-mail verzonden",
"message.password_reset_email_sent": "Als er een account met dat e-mailadres bestaat, is er een wachtwoord resetcode verzonden.",
"message.reset_code_verified": "Code succesvol geverifieerd",
"message.password_reset_success": "Wachtwoord succesvol gereset. Log in met uw nieuwe wachtwoord.",
"message.task_deleted": "Taak succesvol verwijderd",
"message.task_in_progress": "Taak gemarkeerd als in uitvoering",
"message.task_cancelled": "Taak geannuleerd",
@@ -79,46 +72,35 @@
"message.task_archived": "Taak gearchiveerd",
"message.task_unarchived": "Taak gearchiveerd ongedaan gemaakt",
"message.completion_deleted": "Voltooiing succesvol verwijderd",
"message.residence_deleted": "Woning succesvol verwijderd",
"message.user_removed": "Gebruiker verwijderd van woning",
"message.tasks_report_generated": "Takenrapport succesvol gegenereerd",
"message.tasks_report_sent": "Takenrapport gegenereerd en verzonden naar {{.Email}}",
"message.tasks_report_email_failed": "Takenrapport gegenereerd maar e-mail kon niet worden verzonden",
"message.contractor_deleted": "Aannemer succesvol verwijderd",
"message.document_deleted": "Document succesvol verwijderd",
"message.document_activated": "Document geactiveerd",
"message.document_deactivated": "Document gedeactiveerd",
"message.notification_marked_read": "Notificatie gemarkeerd als gelezen",
"message.all_notifications_marked_read": "Alle notificaties gemarkeerd als gelezen",
"message.device_removed": "Apparaat verwijderd",
"message.subscription_upgraded": "Abonnement succesvol geüpgraded",
"message.subscription_cancelled": "Abonnement geannuleerd. U behoudt Pro voordelen tot het einde van uw factureringsperiode.",
"message.subscription_restored": "Abonnement succesvol hersteld",
"message.file_deleted": "Bestand succesvol verwijderd",
"message.static_data_refreshed": "Statische gegevens vernieuwd",
"error.notification_not_found": "Notificatie niet gevonden",
"error.invalid_platform": "Ongeldig platform",
"error.upgrade_trigger_not_found": "Upgrade trigger niet gevonden",
"error.receipt_data_required": "receipt_data is verplicht voor iOS",
"error.purchase_token_required": "purchase_token is verplicht voor Android",
"error.no_file_provided": "Geen bestand aangeleverd",
"error.failed_to_fetch_residence_types": "Woningtypes ophalen mislukt",
"error.failed_to_fetch_task_categories": "Taakcategorieën ophalen mislukt",
"error.failed_to_fetch_task_priorities": "Taakprioriteiten ophalen mislukt",
"error.failed_to_fetch_task_frequencies": "Taakfrequenties ophalen mislukt",
"error.failed_to_fetch_task_statuses": "Taakstatussen ophalen mislukt",
"error.failed_to_fetch_contractor_specialties": "Aannemer specialiteiten ophalen mislukt",
"push.task_due_soon.title": "Taak Vervalt Binnenkort",
"push.task_due_soon.body": "{{.TaskTitle}} vervalt {{.DueDate}}",
"push.task_overdue.title": "Verlopen Taak",
@@ -129,55 +111,48 @@
"push.task_assigned.body": "U bent toegewezen aan {{.TaskTitle}}",
"push.residence_shared.title": "Woning Gedeeld",
"push.residence_shared.body": "{{.UserName}} heeft {{.ResidenceName}} met u gedeeld",
"email.welcome.subject": "Welkom bij honeyDue!",
"email.verification.subject": "Verifieer Uw E-mailadres",
"email.password_reset.subject": "Wachtwoord Resetcode",
"email.tasks_report.subject": "Takenrapport voor {{.ResidenceName}}",
"lookup.residence_type.house": "Huis",
"lookup.residence_type.apartment": "Appartement",
"lookup.residence_type.condo": "Appartement met eigen grond",
"lookup.residence_type.condo": "Koopflat",
"lookup.residence_type.townhouse": "Rijtjeshuis",
"lookup.residence_type.mobile_home": "Stacaravan",
"lookup.residence_type.other": "Anders",
"lookup.task_category.plumbing": "Loodgieterij",
"lookup.task_category.electrical": "Elektriciteit",
"lookup.task_category.hvac": "Verwarming en Ventilatie",
"lookup.residence_type.other": "Overig",
"lookup.task_category.plumbing": "Loodgieterswerk",
"lookup.task_category.electrical": "Elektrisch",
"lookup.task_category.hvac": "HVAC",
"lookup.task_category.appliances": "Apparaten",
"lookup.task_category.exterior": "Buitenkant",
"lookup.task_category.interior": "Binnenkant",
"lookup.task_category.exterior": "Buiten",
"lookup.task_category.interior": "Binnen",
"lookup.task_category.landscaping": "Tuinonderhoud",
"lookup.task_category.safety": "Veiligheid",
"lookup.task_category.cleaning": "Schoonmaak",
"lookup.task_category.pest_control": "Ongediertebestrijding",
"lookup.task_category.seasonal": "Seizoensgebonden",
"lookup.task_category.other": "Anders",
"lookup.task_priority.low": "Laag",
"lookup.task_priority.medium": "Gemiddeld",
"lookup.task_priority.high": "Hoog",
"lookup.task_priority.urgent": "Urgent",
"lookup.task_status.pending": "In afwachting",
"lookup.task_status.in_progress": "In uitvoering",
"lookup.task_status.completed": "Voltooid",
"lookup.task_status.cancelled": "Geannuleerd",
"lookup.task_status.archived": "Gearchiveerd",
"lookup.task_frequency.once": "Eenmalig",
"lookup.task_frequency.daily": "Dagelijks",
"lookup.task_frequency.weekly": "Wekelijks",
"lookup.task_frequency.biweekly": "Om de 2 Weken",
"lookup.task_frequency.monthly": "Maandelijks",
"lookup.task_frequency.quarterly": "Per Kwartaal",
"lookup.task_frequency.quarterly": "Per kwartaal",
"lookup.task_frequency.semiannually": "Om de 6 Maanden",
"lookup.task_frequency.annually": "Jaarlijks",
"lookup.contractor_specialty.plumber": "Loodgieter",
"lookup.contractor_specialty.electrician": "Elektricien",
"lookup.contractor_specialty.hvac_technician": "HVAC Monteur",
"lookup.contractor_specialty.hvac_technician": "HVAC-technicus",
"lookup.contractor_specialty.handyman": "Klusjesman",
"lookup.contractor_specialty.landscaper": "Hovenier",
"lookup.contractor_specialty.roofer": "Dakdekker",
@@ -185,7 +160,88 @@
"lookup.contractor_specialty.carpenter": "Timmerman",
"lookup.contractor_specialty.pest_control": "Ongediertebestrijding",
"lookup.contractor_specialty.cleaning": "Schoonmaak",
"lookup.contractor_specialty.pool_service": "Zwembadonderhoud",
"lookup.contractor_specialty.general_contractor": "Algemeen Aannemer",
"lookup.contractor_specialty.other": "Anders"
"lookup.contractor_specialty.pool_service": "Zwembadservice",
"lookup.contractor_specialty.general_contractor": "Hoofdaannemer",
"lookup.contractor_specialty.other": "Anders",
"suggestion.reason.has_pool": "Je woning heeft een zwembad",
"suggestion.reason.has_sprinkler_system": "Je woning heeft een sproei-installatie",
"suggestion.reason.has_septic": "Je woning heeft een septische tank",
"suggestion.reason.has_fireplace": "Je woning heeft een open haard",
"suggestion.reason.has_garage": "Je woning heeft een garage",
"suggestion.reason.has_basement": "Je woning heeft een kelder",
"suggestion.reason.has_attic": "Je woning heeft een zolder",
"suggestion.reason.heating_type": "Past bij je verwarmingssysteem",
"suggestion.reason.cooling_type": "Past bij je koelsysteem",
"suggestion.reason.water_heater_type": "Past bij je boiler",
"suggestion.reason.roof_type": "Past bij je dak",
"suggestion.reason.exterior_type": "Past bij je gevel",
"suggestion.reason.flooring_primary": "Past bij je vloer",
"suggestion.reason.landscaping_type": "Past bij je tuin",
"suggestion.reason.property_type": "Aanbevolen voor je type woning",
"suggestion.reason.climate_region": "Aanbevolen voor je klimaat",
"lookup.residence_type.duplex": "Twee-onder-een-kap",
"lookup.residence_type.vacation_home": "Vakantiehuis",
"lookup.task_category.general": "Algemeen",
"lookup.task_frequency.bi_weekly": "Tweewekelijks",
"lookup.task_frequency.semi_annually": "Halfjaarlijks",
"lookup.task_frequency.custom": "Aangepast",
"lookup.contractor_specialty.appliance_repair": "Apparaatreparatie",
"lookup.contractor_specialty.cleaner": "Schoonmaker",
"lookup.contractor_specialty.locksmith": "Slotenmaker",
"lookup.home_profile.gas_furnace": "Gasketel",
"lookup.home_profile.electric_furnace": "Elektrische ketel",
"lookup.home_profile.heat_pump": "Warmtepomp",
"lookup.home_profile.boiler": "CV-ketel",
"lookup.home_profile.radiant": "Stralingsverwarming",
"lookup.home_profile.other": "Overig",
"lookup.home_profile.central_ac": "Centrale airco",
"lookup.home_profile.window_ac": "Raamairco",
"lookup.home_profile.evaporative": "Verdampings",
"lookup.home_profile.none": "Geen",
"lookup.home_profile.tank_gas": "Boiler (gas)",
"lookup.home_profile.tank_electric": "Boiler (elektrisch)",
"lookup.home_profile.tankless_gas": "Doorstroom (gas)",
"lookup.home_profile.tankless_electric": "Doorstroom (elektrisch)",
"lookup.home_profile.solar": "Zonne-energie",
"lookup.home_profile.asphalt_shingle": "Asfaltshingle",
"lookup.home_profile.metal": "Metaal",
"lookup.home_profile.tile": "Dakpan",
"lookup.home_profile.slate": "Leisteen",
"lookup.home_profile.wood_shake": "Houten shingle",
"lookup.home_profile.flat": "Plat",
"lookup.home_profile.brick": "Baksteen",
"lookup.home_profile.vinyl_siding": "Vinyl gevelbekleding",
"lookup.home_profile.wood_siding": "Houten gevelbekleding",
"lookup.home_profile.stucco": "Stucwerk",
"lookup.home_profile.stone": "Steen",
"lookup.home_profile.fiber_cement": "Vezelcement",
"lookup.home_profile.hardwood": "Hardhout",
"lookup.home_profile.laminate": "Laminaat",
"lookup.home_profile.carpet": "Tapijt",
"lookup.home_profile.vinyl": "Vinyl",
"lookup.home_profile.concrete": "Beton",
"lookup.home_profile.lawn": "Gazon",
"lookup.home_profile.desert": "Woestijn",
"lookup.home_profile.xeriscape": "Xeriscaping",
"lookup.home_profile.garden": "Tuin",
"lookup.home_profile.mixed": "Gemengd",
"lookup.document_type.warranty": "Garantie",
"lookup.document_type.manual": "Handleiding",
"lookup.document_type.receipt": "Bon/Factuur",
"lookup.document_type.inspection": "Inspectierapport",
"lookup.document_type.permit": "Vergunning",
"lookup.document_type.deed": "Akte/Eigendomsbewijs",
"lookup.document_type.insurance": "Verzekering",
"lookup.document_type.contract": "Contract",
"lookup.document_type.photo": "Foto",
"lookup.document_type.other": "Overig",
"lookup.document_category.appliance": "Apparaat",
"lookup.document_category.hvac": "HVAC",
"lookup.document_category.plumbing": "Loodgieterswerk",
"lookup.document_category.electrical": "Elektrisch",
"lookup.document_category.roofing": "Dak",
"lookup.document_category.structural": "Constructie",
"lookup.document_category.landscaping": "Tuinaanleg",
"lookup.document_category.general": "Algemeen",
"lookup.document_category.other": "Overig"
}
+97 -41
View File
@@ -25,7 +25,6 @@
"error.google_signin_not_configured": "O login com Google nao esta configurado",
"error.google_signin_failed": "Falha no login com Google",
"error.invalid_google_token": "Token de identidade Google invalido",
"error.invalid_task_id": "ID da tarefa invalido",
"error.invalid_residence_id": "ID da propriedade invalido",
"error.invalid_contractor_id": "ID do prestador invalido",
@@ -34,7 +33,6 @@
"error.invalid_user_id": "ID do usuario invalido",
"error.invalid_notification_id": "ID da notificacao invalido",
"error.invalid_device_id": "ID do dispositivo invalido",
"error.task_not_found": "Tarefa nao encontrada",
"error.residence_not_found": "Propriedade nao encontrada",
"error.contractor_not_found": "Prestador nao encontrado",
@@ -43,7 +41,6 @@
"error.user_not_found": "Usuario nao encontrado",
"error.share_code_invalid": "Codigo de compartilhamento invalido",
"error.share_code_expired": "O codigo de compartilhamento expirou",
"error.task_access_denied": "Voce nao tem acesso a esta tarefa",
"error.residence_access_denied": "Voce nao tem acesso a esta propriedade",
"error.contractor_access_denied": "Voce nao tem acesso a este prestador",
@@ -52,10 +49,8 @@
"error.cannot_remove_owner": "Nao e possivel remover o proprietario",
"error.user_already_member": "O usuario ja e membro desta propriedade",
"error.properties_limit_reached": "Voce atingiu o numero maximo de propriedades para sua assinatura",
"error.task_already_cancelled": "A tarefa ja esta cancelada",
"error.task_already_archived": "A tarefa ja esta arquivada",
"error.failed_to_parse_form": "Falha ao analisar o formulario",
"error.task_id_required": "task_id e obrigatorio",
"error.invalid_task_id_value": "task_id invalido",
@@ -64,14 +59,12 @@
"error.invalid_residence_id_value": "residence_id invalido",
"error.title_required": "Titulo e obrigatorio",
"error.failed_to_upload_file": "Falha ao enviar arquivo",
"message.logged_out": "Logout realizado com sucesso",
"message.email_verified": "Email verificado com sucesso",
"message.verification_email_sent": "Email de verificacao enviado",
"message.password_reset_email_sent": "Se existir uma conta com este email, um codigo de redefinicao foi enviado.",
"message.reset_code_verified": "Codigo verificado com sucesso",
"message.password_reset_success": "Senha redefinida com sucesso. Por favor, faca login com sua nova senha.",
"message.task_deleted": "Tarefa excluida com sucesso",
"message.task_in_progress": "Tarefa marcada como em andamento",
"message.task_cancelled": "Tarefa cancelada",
@@ -79,46 +72,35 @@
"message.task_archived": "Tarefa arquivada",
"message.task_unarchived": "Tarefa desarquivada",
"message.completion_deleted": "Conclusao excluida com sucesso",
"message.residence_deleted": "Propriedade excluida com sucesso",
"message.user_removed": "Usuario removido da propriedade",
"message.tasks_report_generated": "Relatorio de tarefas gerado com sucesso",
"message.tasks_report_sent": "Relatorio de tarefas gerado e enviado para {{.Email}}",
"message.tasks_report_email_failed": "Relatorio de tarefas gerado mas o email nao pode ser enviado",
"message.contractor_deleted": "Prestador excluido com sucesso",
"message.document_deleted": "Documento excluido com sucesso",
"message.document_activated": "Documento ativado",
"message.document_deactivated": "Documento desativado",
"message.notification_marked_read": "Notificação marcada como lida",
"message.all_notifications_marked_read": "Todas as notificações marcadas como lidas",
"message.device_removed": "Dispositivo removido",
"message.subscription_upgraded": "Assinatura atualizada com sucesso",
"message.subscription_cancelled": "Assinatura cancelada. Você manterá os benefícios Pro até o final do seu período de faturamento.",
"message.subscription_restored": "Assinatura restaurada com sucesso",
"message.file_deleted": "Arquivo excluído com sucesso",
"message.static_data_refreshed": "Dados estáticos atualizados",
"error.notification_not_found": "Notificação não encontrada",
"error.invalid_platform": "Plataforma inválida",
"error.upgrade_trigger_not_found": "Gatilho de atualização não encontrado",
"error.receipt_data_required": "receipt_data é obrigatório para iOS",
"error.purchase_token_required": "purchase_token é obrigatório para Android",
"error.no_file_provided": "Nenhum arquivo fornecido",
"error.failed_to_fetch_residence_types": "Falha ao buscar tipos de propriedade",
"error.failed_to_fetch_task_categories": "Falha ao buscar categorias de tarefas",
"error.failed_to_fetch_task_priorities": "Falha ao buscar prioridades de tarefas",
"error.failed_to_fetch_task_frequencies": "Falha ao buscar frequências de tarefas",
"error.failed_to_fetch_task_statuses": "Falha ao buscar status de tarefas",
"error.failed_to_fetch_contractor_specialties": "Falha ao buscar especialidades de prestadores",
"push.task_due_soon.title": "Tarefa Proxima do Vencimento",
"push.task_due_soon.body": "{{.TaskTitle}} vence em {{.DueDate}}",
"push.task_overdue.title": "Tarefa Atrasada",
@@ -129,63 +111,137 @@
"push.task_assigned.body": "{{.TaskTitle}} foi atribuida a voce",
"push.residence_shared.title": "Propriedade Compartilhada",
"push.residence_shared.body": "{{.UserName}} compartilhou {{.ResidenceName}} com voce",
"email.welcome.subject": "Bem-vindo ao honeyDue!",
"email.verification.subject": "Verifique Seu Email",
"email.password_reset.subject": "Codigo de Redefinicao de Senha",
"email.tasks_report.subject": "Relatorio de Tarefas para {{.ResidenceName}}",
"lookup.residence_type.house": "Casa",
"lookup.residence_type.apartment": "Apartamento",
"lookup.residence_type.condo": "Condominio",
"lookup.residence_type.condo": "Condomínio",
"lookup.residence_type.townhouse": "Sobrado",
"lookup.residence_type.mobile_home": "Casa Movel",
"lookup.residence_type.mobile_home": "Casa vel",
"lookup.residence_type.other": "Outro",
"lookup.task_category.plumbing": "Encanamento",
"lookup.task_category.electrical": "Eletrica",
"lookup.task_category.hvac": "Climatizacao",
"lookup.task_category.appliances": "Eletrodomesticos",
"lookup.task_category.electrical": "Elétrica",
"lookup.task_category.hvac": "AVAC",
"lookup.task_category.appliances": "Eletrodomésticos",
"lookup.task_category.exterior": "Exterior",
"lookup.task_category.interior": "Interior",
"lookup.task_category.landscaping": "Paisagismo",
"lookup.task_category.safety": "Seguranca",
"lookup.task_category.safety": "Segurança",
"lookup.task_category.cleaning": "Limpeza",
"lookup.task_category.pest_control": "Controle de Pragas",
"lookup.task_category.pest_control": "Controle de pragas",
"lookup.task_category.seasonal": "Sazonal",
"lookup.task_category.other": "Outro",
"lookup.task_priority.low": "Baixa",
"lookup.task_priority.medium": "Media",
"lookup.task_priority.medium": "Média",
"lookup.task_priority.high": "Alta",
"lookup.task_priority.urgent": "Urgente",
"lookup.task_status.pending": "Pendente",
"lookup.task_status.in_progress": "Em Andamento",
"lookup.task_status.completed": "Concluida",
"lookup.task_status.cancelled": "Cancelada",
"lookup.task_status.archived": "Arquivada",
"lookup.task_frequency.once": "Uma Vez",
"lookup.task_frequency.daily": "Diario",
"lookup.task_frequency.once": "Uma vez",
"lookup.task_frequency.daily": "Diário",
"lookup.task_frequency.weekly": "Semanal",
"lookup.task_frequency.biweekly": "Quinzenal",
"lookup.task_frequency.monthly": "Mensal",
"lookup.task_frequency.quarterly": "Trimestral",
"lookup.task_frequency.semiannually": "Semestral",
"lookup.task_frequency.annually": "Anual",
"lookup.contractor_specialty.plumber": "Encanador",
"lookup.contractor_specialty.electrician": "Eletricista",
"lookup.contractor_specialty.hvac_technician": "Tecnico de Climatizacao",
"lookup.contractor_specialty.hvac_technician": "Técnico de AVAC",
"lookup.contractor_specialty.handyman": "Faz-tudo",
"lookup.contractor_specialty.landscaper": "Paisagista",
"lookup.contractor_specialty.landscaper": "Jardineiro",
"lookup.contractor_specialty.roofer": "Telhadista",
"lookup.contractor_specialty.painter": "Pintor",
"lookup.contractor_specialty.carpenter": "Carpinteiro",
"lookup.contractor_specialty.pest_control": "Controle de Pragas",
"lookup.contractor_specialty.pest_control": "Controle de pragas",
"lookup.contractor_specialty.cleaning": "Limpeza",
"lookup.contractor_specialty.pool_service": "Servico de Piscina",
"lookup.contractor_specialty.general_contractor": "Empreiteiro Geral",
"lookup.contractor_specialty.other": "Outro"
"lookup.contractor_specialty.pool_service": "Serviço de piscina",
"lookup.contractor_specialty.general_contractor": "Empreiteiro geral",
"lookup.contractor_specialty.other": "Outro",
"suggestion.reason.has_pool": "Sua casa tem piscina",
"suggestion.reason.has_sprinkler_system": "Sua casa tem sistema de irrigação",
"suggestion.reason.has_septic": "Sua casa tem fossa séptica",
"suggestion.reason.has_fireplace": "Sua casa tem lareira",
"suggestion.reason.has_garage": "Sua casa tem garagem",
"suggestion.reason.has_basement": "Sua casa tem porão",
"suggestion.reason.has_attic": "Sua casa tem sótão",
"suggestion.reason.heating_type": "Combina com seu sistema de aquecimento",
"suggestion.reason.cooling_type": "Combina com seu sistema de refrigeração",
"suggestion.reason.water_heater_type": "Combina com seu aquecedor de água",
"suggestion.reason.roof_type": "Combina com seu telhado",
"suggestion.reason.exterior_type": "Combina com seu exterior",
"suggestion.reason.flooring_primary": "Combina com seu piso",
"suggestion.reason.landscaping_type": "Combina com seu paisagismo",
"suggestion.reason.property_type": "Recomendado para seu tipo de imóvel",
"suggestion.reason.climate_region": "Recomendado para seu clima",
"lookup.residence_type.duplex": "Duplex",
"lookup.residence_type.vacation_home": "Casa de férias",
"lookup.task_category.general": "Geral",
"lookup.task_frequency.bi_weekly": "Quinzenal",
"lookup.task_frequency.semi_annually": "Semestral",
"lookup.task_frequency.custom": "Personalizado",
"lookup.contractor_specialty.appliance_repair": "Reparo de eletrodomésticos",
"lookup.contractor_specialty.cleaner": "Faxineiro",
"lookup.contractor_specialty.locksmith": "Chaveiro",
"lookup.home_profile.gas_furnace": "Aquecedor a gás",
"lookup.home_profile.electric_furnace": "Aquecedor elétrico",
"lookup.home_profile.heat_pump": "Bomba de calor",
"lookup.home_profile.boiler": "Caldeira",
"lookup.home_profile.radiant": "Radiante",
"lookup.home_profile.other": "Outro",
"lookup.home_profile.central_ac": "AC central",
"lookup.home_profile.window_ac": "AC de janela",
"lookup.home_profile.evaporative": "Evaporativo",
"lookup.home_profile.none": "Nenhum",
"lookup.home_profile.tank_gas": "Tanque (gás)",
"lookup.home_profile.tank_electric": "Tanque (elétrico)",
"lookup.home_profile.tankless_gas": "Sem tanque (gás)",
"lookup.home_profile.tankless_electric": "Sem tanque (elétrico)",
"lookup.home_profile.solar": "Solar",
"lookup.home_profile.asphalt_shingle": "Telha asfáltica",
"lookup.home_profile.metal": "Metal",
"lookup.home_profile.tile": "Telha",
"lookup.home_profile.slate": "Ardósia",
"lookup.home_profile.wood_shake": "Telha de madeira",
"lookup.home_profile.flat": "Plano",
"lookup.home_profile.brick": "Tijolo",
"lookup.home_profile.vinyl_siding": "Revestimento de vinil",
"lookup.home_profile.wood_siding": "Revestimento de madeira",
"lookup.home_profile.stucco": "Estuque",
"lookup.home_profile.stone": "Pedra",
"lookup.home_profile.fiber_cement": "Cimento reforçado",
"lookup.home_profile.hardwood": "Madeira de lei",
"lookup.home_profile.laminate": "Laminado",
"lookup.home_profile.carpet": "Carpete",
"lookup.home_profile.vinyl": "Vinil",
"lookup.home_profile.concrete": "Concreto",
"lookup.home_profile.lawn": "Gramado",
"lookup.home_profile.desert": "Deserto",
"lookup.home_profile.xeriscape": "Xeropaisagismo",
"lookup.home_profile.garden": "Jardim",
"lookup.home_profile.mixed": "Misto",
"lookup.document_type.warranty": "Garantia",
"lookup.document_type.manual": "Manual do usuário",
"lookup.document_type.receipt": "Recibo/Fatura",
"lookup.document_type.inspection": "Relatório de inspeção",
"lookup.document_type.permit": "Licença",
"lookup.document_type.deed": "Escritura/Título",
"lookup.document_type.insurance": "Seguro",
"lookup.document_type.contract": "Contrato",
"lookup.document_type.photo": "Foto",
"lookup.document_type.other": "Outro",
"lookup.document_category.appliance": "Eletrodoméstico",
"lookup.document_category.hvac": "AVAC",
"lookup.document_category.plumbing": "Encanamento",
"lookup.document_category.electrical": "Elétrica",
"lookup.document_category.roofing": "Telhado",
"lookup.document_category.structural": "Estrutural",
"lookup.document_category.landscaping": "Paisagismo",
"lookup.document_category.general": "Geral",
"lookup.document_category.other": "Outro"
}
+85 -29
View File
@@ -25,7 +25,6 @@
"error.google_signin_not_configured": "未配置 Google 登录",
"error.google_signin_failed": "Google 登录失败",
"error.invalid_google_token": "Google 身份令牌无效",
"error.invalid_task_id": "任务 ID 无效",
"error.invalid_residence_id": "房产 ID 无效",
"error.invalid_contractor_id": "承包商 ID 无效",
@@ -34,7 +33,6 @@
"error.invalid_user_id": "用户 ID 无效",
"error.invalid_notification_id": "通知 ID 无效",
"error.invalid_device_id": "设备 ID 无效",
"error.task_not_found": "未找到任务",
"error.residence_not_found": "未找到房产",
"error.contractor_not_found": "未找到承包商",
@@ -43,7 +41,6 @@
"error.user_not_found": "未找到用户",
"error.share_code_invalid": "分享码无效",
"error.share_code_expired": "分享码已过期",
"error.task_access_denied": "您无权访问此任务",
"error.residence_access_denied": "您无权访问此房产",
"error.contractor_access_denied": "您无权访问此承包商",
@@ -52,10 +49,8 @@
"error.cannot_remove_owner": "无法移除房产所有者",
"error.user_already_member": "用户已是此房产的成员",
"error.properties_limit_reached": "您已达到订阅计划的房产数量上限",
"error.task_already_cancelled": "任务已取消",
"error.task_already_archived": "任务已归档",
"error.failed_to_parse_form": "解析多部分表单失败",
"error.task_id_required": "需要 task_id",
"error.invalid_task_id_value": "task_id 无效",
@@ -64,14 +59,12 @@
"error.invalid_residence_id_value": "residence_id 无效",
"error.title_required": "需要标题",
"error.failed_to_upload_file": "上传文件失败",
"message.logged_out": "已成功退出",
"message.email_verified": "邮箱验证成功",
"message.verification_email_sent": "验证邮件已发送",
"message.password_reset_email_sent": "如果该邮箱存在账户,密码重置验证码已发送。",
"message.reset_code_verified": "验证码验证成功",
"message.password_reset_success": "密码重置成功,请使用新密码登录。",
"message.task_deleted": "任务删除成功",
"message.task_in_progress": "任务已标记为进行中",
"message.task_cancelled": "任务已取消",
@@ -79,46 +72,35 @@
"message.task_archived": "任务已归档",
"message.task_unarchived": "任务已取消归档",
"message.completion_deleted": "完成记录删除成功",
"message.residence_deleted": "房产删除成功",
"message.user_removed": "用户已从房产中移除",
"message.tasks_report_generated": "任务报告生成成功",
"message.tasks_report_sent": "任务报告已生成并发送至 {{.Email}}",
"message.tasks_report_email_failed": "任务报告已生成但无法发送邮件",
"message.contractor_deleted": "承包商删除成功",
"message.document_deleted": "文档删除成功",
"message.document_activated": "文档已激活",
"message.document_deactivated": "文档已停用",
"message.notification_marked_read": "通知已标记为已读",
"message.all_notifications_marked_read": "所有通知已标记为已读",
"message.device_removed": "设备已移除",
"message.subscription_upgraded": "订阅升级成功",
"message.subscription_cancelled": "订阅已取消。您将保留专业版权益至当前账单周期结束。",
"message.subscription_restored": "订阅恢复成功",
"message.file_deleted": "文件删除成功",
"message.static_data_refreshed": "静态数据已刷新",
"error.notification_not_found": "未找到通知",
"error.invalid_platform": "平台无效",
"error.upgrade_trigger_not_found": "未找到升级触发器",
"error.receipt_data_required": "iOS 需要 receipt_data",
"error.purchase_token_required": "Android 需要 purchase_token",
"error.no_file_provided": "未提供文件",
"error.failed_to_fetch_residence_types": "获取房产类型失败",
"error.failed_to_fetch_task_categories": "获取任务分类失败",
"error.failed_to_fetch_task_priorities": "获取任务优先级失败",
"error.failed_to_fetch_task_frequencies": "获取任务频率失败",
"error.failed_to_fetch_task_statuses": "获取任务状态失败",
"error.failed_to_fetch_contractor_specialties": "获取承包商专业类别失败",
"push.task_due_soon.title": "任务即将到期",
"push.task_due_soon.body": "{{.TaskTitle}} 将于 {{.DueDate}} 到期",
"push.task_overdue.title": "任务已逾期",
@@ -129,19 +111,16 @@
"push.task_assigned.body": "您已被分配到 {{.TaskTitle}}",
"push.residence_shared.title": "房产已分享",
"push.residence_shared.body": "{{.UserName}} 与您分享了 {{.ResidenceName}}",
"email.welcome.subject": "欢迎使用 honeyDue",
"email.verification.subject": "验证您的邮箱",
"email.password_reset.subject": "密码重置验证码",
"email.tasks_report.subject": "{{.ResidenceName}} 的任务报告",
"lookup.residence_type.house": "独立屋",
"lookup.residence_type.house": "独栋房屋",
"lookup.residence_type.apartment": "公寓",
"lookup.residence_type.condo": "共管公寓",
"lookup.residence_type.townhouse": "联排别墅",
"lookup.residence_type.mobile_home": "移动房屋",
"lookup.residence_type.other": "其他",
"lookup.task_category.plumbing": "管道",
"lookup.task_category.electrical": "电气",
"lookup.task_category.hvac": "暖通空调",
@@ -154,18 +133,15 @@
"lookup.task_category.pest_control": "害虫防治",
"lookup.task_category.seasonal": "季节性",
"lookup.task_category.other": "其他",
"lookup.task_priority.low": "低",
"lookup.task_priority.medium": "中",
"lookup.task_priority.high": "高",
"lookup.task_priority.urgent": "紧急",
"lookup.task_status.pending": "待处理",
"lookup.task_status.in_progress": "进行中",
"lookup.task_status.completed": "已完成",
"lookup.task_status.cancelled": "已取消",
"lookup.task_status.archived": "已归档",
"lookup.task_frequency.once": "一次",
"lookup.task_frequency.daily": "每天",
"lookup.task_frequency.weekly": "每周",
@@ -174,12 +150,11 @@
"lookup.task_frequency.quarterly": "每季度",
"lookup.task_frequency.semiannually": "每半年",
"lookup.task_frequency.annually": "每年",
"lookup.contractor_specialty.plumber": "水管工",
"lookup.contractor_specialty.plumber": "管道工",
"lookup.contractor_specialty.electrician": "电工",
"lookup.contractor_specialty.hvac_technician": "暖通空调技师",
"lookup.contractor_specialty.handyman": "杂工",
"lookup.contractor_specialty.landscaper": "园林工",
"lookup.contractor_specialty.landscaper": "园艺师",
"lookup.contractor_specialty.roofer": "屋顶工",
"lookup.contractor_specialty.painter": "油漆工",
"lookup.contractor_specialty.carpenter": "木工",
@@ -187,5 +162,86 @@
"lookup.contractor_specialty.cleaning": "清洁",
"lookup.contractor_specialty.pool_service": "泳池服务",
"lookup.contractor_specialty.general_contractor": "总承包商",
"lookup.contractor_specialty.other": "其他"
"lookup.contractor_specialty.other": "其他",
"suggestion.reason.has_pool": "您的住宅有游泳池",
"suggestion.reason.has_sprinkler_system": "您的住宅有喷灌系统",
"suggestion.reason.has_septic": "您的住宅有化粪池",
"suggestion.reason.has_fireplace": "您的住宅有壁炉",
"suggestion.reason.has_garage": "您的住宅有车库",
"suggestion.reason.has_basement": "您的住宅有地下室",
"suggestion.reason.has_attic": "您的住宅有阁楼",
"suggestion.reason.heating_type": "与您的供暖系统匹配",
"suggestion.reason.cooling_type": "与您的制冷系统匹配",
"suggestion.reason.water_heater_type": "与您的热水器匹配",
"suggestion.reason.roof_type": "与您的屋顶匹配",
"suggestion.reason.exterior_type": "与您的外墙匹配",
"suggestion.reason.flooring_primary": "与您的地板匹配",
"suggestion.reason.landscaping_type": "与您的庭院匹配",
"suggestion.reason.property_type": "为您的房产类型推荐",
"suggestion.reason.climate_region": "为您所在气候推荐",
"lookup.residence_type.duplex": "双拼住宅",
"lookup.residence_type.vacation_home": "度假屋",
"lookup.task_category.general": "通用",
"lookup.task_frequency.bi_weekly": "每两周",
"lookup.task_frequency.semi_annually": "每半年",
"lookup.task_frequency.custom": "自定义",
"lookup.contractor_specialty.appliance_repair": "家电维修",
"lookup.contractor_specialty.cleaner": "清洁工",
"lookup.contractor_specialty.locksmith": "锁匠",
"lookup.home_profile.gas_furnace": "燃气炉",
"lookup.home_profile.electric_furnace": "电炉",
"lookup.home_profile.heat_pump": "热泵",
"lookup.home_profile.boiler": "锅炉",
"lookup.home_profile.radiant": "辐射式",
"lookup.home_profile.other": "其他",
"lookup.home_profile.central_ac": "中央空调",
"lookup.home_profile.window_ac": "窗式空调",
"lookup.home_profile.evaporative": "蒸发式",
"lookup.home_profile.none": "无",
"lookup.home_profile.tank_gas": "储水式(燃气)",
"lookup.home_profile.tank_electric": "储水式(电)",
"lookup.home_profile.tankless_gas": "即热式(燃气)",
"lookup.home_profile.tankless_electric": "即热式(电)",
"lookup.home_profile.solar": "太阳能",
"lookup.home_profile.asphalt_shingle": "沥青瓦",
"lookup.home_profile.metal": "金属",
"lookup.home_profile.tile": "瓦片",
"lookup.home_profile.slate": "石板",
"lookup.home_profile.wood_shake": "木瓦",
"lookup.home_profile.flat": "平顶",
"lookup.home_profile.brick": "砖",
"lookup.home_profile.vinyl_siding": "乙烯基壁板",
"lookup.home_profile.wood_siding": "木壁板",
"lookup.home_profile.stucco": "灰泥",
"lookup.home_profile.stone": "石材",
"lookup.home_profile.fiber_cement": "纤维水泥",
"lookup.home_profile.hardwood": "硬木",
"lookup.home_profile.laminate": "复合地板",
"lookup.home_profile.carpet": "地毯",
"lookup.home_profile.vinyl": "乙烯基",
"lookup.home_profile.concrete": "混凝土",
"lookup.home_profile.lawn": "草坪",
"lookup.home_profile.desert": "沙漠",
"lookup.home_profile.xeriscape": "旱生园艺",
"lookup.home_profile.garden": "花园",
"lookup.home_profile.mixed": "混合",
"lookup.document_type.warranty": "保修",
"lookup.document_type.manual": "用户手册",
"lookup.document_type.receipt": "收据/发票",
"lookup.document_type.inspection": "检查报告",
"lookup.document_type.permit": "许可证",
"lookup.document_type.deed": "契据/产权",
"lookup.document_type.insurance": "保险",
"lookup.document_type.contract": "合同",
"lookup.document_type.photo": "照片",
"lookup.document_type.other": "其他",
"lookup.document_category.appliance": "家电",
"lookup.document_category.hvac": "暖通空调",
"lookup.document_category.plumbing": "管道",
"lookup.document_category.electrical": "电气",
"lookup.document_category.roofing": "屋顶",
"lookup.document_category.structural": "结构",
"lookup.document_category.landscaping": "园艺",
"lookup.document_category.general": "通用",
"lookup.document_category.other": "其他"
}
+21
View File
@@ -190,6 +190,27 @@ func shouldSkipSpecRoute(path string) bool {
if strings.HasPrefix(path, "/uploads/") || strings.HasPrefix(path, "/media/") {
return true
}
// Auth routes delegated to Ory Kratos (phase 2 auth refactor).
// These endpoints are no longer served by the Go API; the spec is retained
// as documentation of the Kratos-facing contract.
kratosRoutes := map[string]bool{
"/auth/login/": true,
"/auth/register/": true,
"/auth/logout/": true,
"/auth/refresh/": true,
"/auth/forgot-password/": true,
"/auth/verify-reset-code/": true,
"/auth/reset-password/": true,
"/auth/verify-email/": true,
"/auth/resend-verification/": true,
"/auth/apple-sign-in/": true,
"/auth/google-sign-in/": true,
}
if kratosRoutes[path] {
return true
}
return false
}
+96 -280
View File
@@ -6,9 +6,11 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -17,6 +19,7 @@ import (
"github.com/treytartt/honeydue-api/internal/config"
"github.com/treytartt/honeydue-api/internal/handlers"
"github.com/treytartt/honeydue-api/internal/middleware"
"github.com/treytartt/honeydue-api/internal/models"
"github.com/treytartt/honeydue-api/internal/repositories"
"github.com/treytartt/honeydue-api/internal/services"
"github.com/treytartt/honeydue-api/internal/testutil"
@@ -105,11 +108,40 @@ type TestApp struct {
TaskRepo *repositories.TaskRepository
ContractorRepo *repositories.ContractorRepository
AuthService *services.AuthService
// tokenStore maps fake token strings to users for the test-auth middleware.
tokenStore map[string]*models.User
tokenStoreMu sync.RWMutex
}
// fakeAuthMiddleware replaces the real Kratos middleware in integration tests.
// It looks up the "Authorization: Token <tok>" value in app.tokenStore.
func (app *TestApp) fakeAuthMiddleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
ah := c.Request().Header.Get("Authorization")
if ah == "" {
return apperrors.Unauthorized("error.not_authenticated")
}
tok := ah
if len(ah) > 6 && ah[:6] == "Token " {
tok = ah[6:]
} else if len(ah) > 7 && ah[:7] == "Bearer " {
tok = ah[7:]
}
app.tokenStoreMu.RLock()
user, ok := app.tokenStore[tok]
app.tokenStoreMu.RUnlock()
if !ok {
return apperrors.Unauthorized("error.not_authenticated")
}
c.Set("auth_user", user)
c.Set("auth_token", tok)
return next(c)
}
}
}
func setupIntegrationTest(t *testing.T) *TestApp {
// Echo does not need test mode
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
@@ -123,9 +155,6 @@ func setupIntegrationTest(t *testing.T) *TestApp {
cfg := &config.Config{
Security: config.SecurityConfig{
SecretKey: "test-secret-key-for-integration-tests",
PasswordResetExpiry: 15 * time.Minute,
ConfirmationExpiry: 24 * time.Hour,
MaxPasswordResetRate: 3,
},
}
@@ -141,28 +170,33 @@ func setupIntegrationTest(t *testing.T) *TestApp {
taskHandler := handlers.NewTaskHandler(taskService, nil)
contractorHandler := handlers.NewContractorHandler(contractorService)
// Create router with real middleware
e := echo.New()
app := &TestApp{
DB: db,
Router: echo.New(),
AuthHandler: authHandler,
ResidenceHandler: residenceHandler,
TaskHandler: taskHandler,
ContractorHandler: contractorHandler,
UserRepo: userRepo,
ResidenceRepo: residenceRepo,
TaskRepo: taskRepo,
ContractorRepo: contractorRepo,
AuthService: authService,
tokenStore: make(map[string]*models.User),
}
e := app.Router
e.Validator = validator.NewCustomValidator()
e.HTTPErrorHandler = apperrors.HTTPErrorHandler
// Add timezone middleware globally so X-Timezone header is processed
// Timezone middleware processes X-Timezone header
e.Use(middleware.TimezoneMiddleware())
// Public routes
auth := e.Group("/api/auth")
{
auth.POST("/register", authHandler.Register)
auth.POST("/login", authHandler.Login)
}
// Protected routes - use AuthMiddleware without Redis cache for testing
authMiddleware := middleware.NewAuthMiddleware(db, nil)
// Protected routes — guarded by the fake token middleware
api := e.Group("/api")
api.Use(authMiddleware.TokenAuth())
api.Use(app.fakeAuthMiddleware())
{
api.GET("/auth/me", authHandler.CurrentUser)
api.POST("/auth/logout", authHandler.Logout)
residences := api.Group("/residences")
{
@@ -216,19 +250,7 @@ func setupIntegrationTest(t *testing.T) *TestApp {
api.GET("/contractors/by-residence/:residence_id", contractorHandler.ListContractorsByResidence)
}
return &TestApp{
DB: db,
Router: e,
AuthHandler: authHandler,
ResidenceHandler: residenceHandler,
TaskHandler: taskHandler,
ContractorHandler: contractorHandler,
UserRepo: userRepo,
ResidenceRepo: residenceRepo,
TaskRepo: taskRepo,
ContractorRepo: contractorRepo,
AuthService: authService,
}
return app
}
// Helper to make authenticated requests
@@ -251,156 +273,16 @@ func (app *TestApp) makeAuthenticatedRequest(t *testing.T, method, path string,
return w
}
// Helper to register and login a user, returns token
func (app *TestApp) registerAndLogin(t *testing.T, username, email, password string) string {
// Register
registerBody := map[string]string{
"username": username,
"email": email,
"password": password,
}
w := app.makeAuthenticatedRequest(t, "POST", "/api/auth/register", registerBody, "")
require.Equal(t, http.StatusCreated, w.Code)
// Login
loginBody := map[string]string{
"username": username,
"password": password,
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/auth/login", loginBody, "")
require.Equal(t, http.StatusOK, w.Code)
var loginResp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &loginResp)
require.NoError(t, err)
return loginResp["token"].(string)
}
// ============ Authentication Flow Tests ============
func TestIntegration_AuthenticationFlow(t *testing.T) {
app := setupIntegrationTest(t)
// 1. Register a new user
registerBody := map[string]string{
"username": "testuser",
"email": "test@example.com",
"password": "SecurePass123!",
}
w := app.makeAuthenticatedRequest(t, "POST", "/api/auth/register", registerBody, "")
assert.Equal(t, http.StatusCreated, w.Code)
var registerResp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &registerResp)
require.NoError(t, err)
assert.NotEmpty(t, registerResp["token"])
assert.NotNil(t, registerResp["user"])
// 2. Login with the same credentials
loginBody := map[string]string{
"username": "testuser",
"password": "SecurePass123!",
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/auth/login", loginBody, "")
assert.Equal(t, http.StatusOK, w.Code)
var loginResp map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &loginResp)
require.NoError(t, err)
token := loginResp["token"].(string)
assert.NotEmpty(t, token)
// 3. Get current user with token
w = app.makeAuthenticatedRequest(t, "GET", "/api/auth/me", nil, token)
assert.Equal(t, http.StatusOK, w.Code)
var meResp map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &meResp)
require.NoError(t, err)
assert.Equal(t, "testuser", meResp["username"])
assert.Equal(t, "test@example.com", meResp["email"])
// 4. Access protected route without token should fail
w = app.makeAuthenticatedRequest(t, "GET", "/api/auth/me", nil, "")
assert.Equal(t, http.StatusUnauthorized, w.Code)
// 5. Access protected route with invalid token should fail
w = app.makeAuthenticatedRequest(t, "GET", "/api/auth/me", nil, "invalid-token")
assert.Equal(t, http.StatusUnauthorized, w.Code)
// 6. Logout
w = app.makeAuthenticatedRequest(t, "POST", "/api/auth/logout", nil, token)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestIntegration_RegistrationValidation(t *testing.T) {
app := setupIntegrationTest(t)
tests := []struct {
name string
body map[string]string
expectedStatus int
}{
{
name: "missing username",
body: map[string]string{"email": "test@example.com", "password": "pass123"},
expectedStatus: http.StatusBadRequest,
},
{
name: "missing email",
body: map[string]string{"username": "testuser", "password": "pass123"},
expectedStatus: http.StatusBadRequest,
},
{
name: "missing password",
body: map[string]string{"username": "testuser", "email": "test@example.com"},
expectedStatus: http.StatusBadRequest,
},
{
name: "invalid email",
body: map[string]string{"username": "testuser", "email": "invalid", "password": "pass123"},
expectedStatus: http.StatusBadRequest,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := app.makeAuthenticatedRequest(t, "POST", "/api/auth/register", tt.body, "")
assert.Equal(t, tt.expectedStatus, w.Code)
})
}
}
func TestIntegration_DuplicateRegistration(t *testing.T) {
app := setupIntegrationTest(t)
// Register first user (password must be >= 8 chars)
registerBody := map[string]string{
"username": "testuser",
"email": "test@example.com",
"password": "Password123",
}
w := app.makeAuthenticatedRequest(t, "POST", "/api/auth/register", registerBody, "")
assert.Equal(t, http.StatusCreated, w.Code)
// Try to register with same username - returns 409 (Conflict)
registerBody2 := map[string]string{
"username": "testuser",
"email": "different@example.com",
"password": "Password123",
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/auth/register", registerBody2, "")
assert.Equal(t, http.StatusConflict, w.Code)
// Try to register with same email - returns 409 (Conflict)
registerBody3 := map[string]string{
"username": "differentuser",
"email": "test@example.com",
"password": "Password123",
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/auth/register", registerBody3, "")
assert.Equal(t, http.StatusConflict, w.Code)
// registerAndLogin creates a user directly in the DB and returns a synthetic token
// that the fake auth middleware will accept. No HTTP register/login endpoints are called.
func (app *TestApp) registerAndLogin(t *testing.T, username, email, _ string) string {
t.Helper()
user := testutil.CreateTestUser(t, app.DB, username, email, "")
tok := uuid.NewString()
app.tokenStoreMu.Lock()
app.tokenStore[tok] = user
app.tokenStoreMu.Unlock()
return tok
}
// ============ Residence Flow Tests ============
@@ -827,48 +709,16 @@ func TestIntegration_ResponseStructure(t *testing.T) {
func TestIntegration_ComprehensiveE2E(t *testing.T) {
app := setupIntegrationTest(t)
// ============ Phase 1: Authentication ============
t.Log("Phase 1: Testing authentication flow")
// ============ Phase 1: User Setup ============
t.Log("Phase 1: Setting up test user")
// Register new user
registerBody := map[string]string{
"username": "e2e_testuser",
"email": "e2e@example.com",
"password": "SecurePass123!",
}
w := app.makeAuthenticatedRequest(t, "POST", "/api/auth/register", registerBody, "")
require.Equal(t, http.StatusCreated, w.Code, "Registration should succeed")
var registerResp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &registerResp)
require.NoError(t, err)
assert.NotEmpty(t, registerResp["token"], "Registration should return token")
assert.NotNil(t, registerResp["user"], "Registration should return user")
// Verify login with same credentials
loginBody := map[string]string{
"username": "e2e_testuser",
"password": "SecurePass123!",
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/auth/login", loginBody, "")
require.Equal(t, http.StatusOK, w.Code, "Login should succeed")
var loginResp map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &loginResp)
require.NoError(t, err)
token := loginResp["token"].(string)
assert.NotEmpty(t, token, "Login should return token")
token := app.registerAndLogin(t, "e2e_testuser", "e2e@example.com", "")
// Verify authenticated access
w = app.makeAuthenticatedRequest(t, "GET", "/api/auth/me", nil, token)
w := app.makeAuthenticatedRequest(t, "GET", "/api/auth/me", nil, token)
require.Equal(t, http.StatusOK, w.Code, "Should access protected route with valid token")
var meResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &meResp)
assert.Equal(t, "e2e_testuser", meResp["username"])
assert.Equal(t, "e2e@example.com", meResp["email"])
t.Log("✓ Authentication flow verified")
t.Log("✓ User setup verified")
// ============ Phase 2: Create 5 Residences ============
t.Log("Phase 2: Creating 5 residences")
@@ -1244,29 +1094,9 @@ func TestIntegration_ComprehensiveE2E(t *testing.T) {
t.Logf("✓ All %d visible tasks verified in correct columns by ID", expectedVisibleTasks)
// ============ Phase 9: Create User B ============
t.Log("Phase 9: Creating User B and verifying login")
t.Log("Phase 9: Creating User B")
// Register User B
registerBodyB := map[string]string{
"username": "e2e_userb",
"email": "e2e_userb@example.com",
"password": "SecurePass456!",
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/auth/register", registerBodyB, "")
require.Equal(t, http.StatusCreated, w.Code, "User B registration should succeed")
// Login as User B
loginBodyB := map[string]string{
"username": "e2e_userb",
"password": "SecurePass456!",
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/auth/login", loginBodyB, "")
require.Equal(t, http.StatusOK, w.Code, "User B login should succeed")
var loginRespB map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &loginRespB)
tokenB := loginRespB["token"].(string)
assert.NotEmpty(t, tokenB, "User B should have a token")
tokenB := app.registerAndLogin(t, "e2e_userb", "e2e_userb@example.com", "")
// Verify User B can access their own profile
w = app.makeAuthenticatedRequest(t, "GET", "/api/auth/me", nil, tokenB)
@@ -1592,8 +1422,6 @@ func formatID(id float64) string {
// setupContractorTest sets up a test environment including contractor routes
func setupContractorTest(t *testing.T) *TestApp {
// Echo does not need test mode
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
@@ -1607,9 +1435,6 @@ func setupContractorTest(t *testing.T) *TestApp {
cfg := &config.Config{
Security: config.SecurityConfig{
SecretKey: "test-secret-key-for-integration-tests",
PasswordResetExpiry: 15 * time.Minute,
ConfirmationExpiry: 24 * time.Hour,
MaxPasswordResetRate: 3,
},
}
@@ -1625,29 +1450,32 @@ func setupContractorTest(t *testing.T) *TestApp {
taskHandler := handlers.NewTaskHandler(taskService, nil)
contractorHandler := handlers.NewContractorHandler(contractorService)
// Create router with real middleware
e := echo.New()
app := &TestApp{
DB: db,
Router: echo.New(),
AuthHandler: authHandler,
ResidenceHandler: residenceHandler,
TaskHandler: taskHandler,
ContractorHandler: contractorHandler,
UserRepo: userRepo,
ResidenceRepo: residenceRepo,
TaskRepo: taskRepo,
ContractorRepo: contractorRepo,
AuthService: authService,
tokenStore: make(map[string]*models.User),
}
e := app.Router
e.Validator = validator.NewCustomValidator()
e.HTTPErrorHandler = apperrors.HTTPErrorHandler
// Add timezone middleware globally so X-Timezone header is processed
// Timezone middleware
e.Use(middleware.TimezoneMiddleware())
// Public routes
auth := e.Group("/api/auth")
{
auth.POST("/register", authHandler.Register)
auth.POST("/login", authHandler.Login)
}
// Protected routes
authMiddleware := middleware.NewAuthMiddleware(db, nil)
api := e.Group("/api")
api.Use(authMiddleware.TokenAuth())
api.Use(app.fakeAuthMiddleware())
{
api.GET("/auth/me", authHandler.CurrentUser)
api.POST("/auth/logout", authHandler.Logout)
residences := api.Group("/residences")
{
residences.GET("", residenceHandler.ListResidences)
@@ -1680,19 +1508,7 @@ func setupContractorTest(t *testing.T) *TestApp {
}
}
return &TestApp{
DB: db,
Router: e,
AuthHandler: authHandler,
ResidenceHandler: residenceHandler,
TaskHandler: taskHandler,
ContractorHandler: contractorHandler,
UserRepo: userRepo,
ResidenceRepo: residenceRepo,
TaskRepo: taskRepo,
ContractorRepo: contractorRepo,
AuthService: authService,
}
return app
}
// ============ Test 1: Recurring Task Lifecycle ============
@@ -2045,12 +1861,12 @@ func TestIntegration_MultiUserSharing(t *testing.T) {
// Phase 9: Remove User B from residence 3
t.Log("Phase 9: Remove User B from residence 3")
// Get User B's ID
w = app.makeAuthenticatedRequest(t, "GET", "/api/auth/me", nil, tokenB)
require.Equal(t, http.StatusOK, w.Code)
var userBInfo map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &userBInfo)
userBID := uint(userBInfo["id"].(float64))
// Get User B's ID from the token store
app.tokenStoreMu.RLock()
userBModel := app.tokenStore[tokenB]
app.tokenStoreMu.RUnlock()
require.NotNil(t, userBModel, "User B should be in token store")
userBID := userBModel.ID
// Remove User B from residence 3
w = app.makeAuthenticatedRequest(t, "DELETE", fmt.Sprintf("/api/residences/%d/users/%d", residenceIDs[2], userBID), nil, tokenA)
@@ -6,9 +6,11 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -35,6 +37,48 @@ type SecurityTestApp struct {
Router *echo.Echo
SubscriptionService *services.SubscriptionService
SubscriptionRepo *repositories.SubscriptionRepository
tokenStore map[string]*models.User
tokenStoreMu sync.RWMutex
}
// fakeAuthMiddleware returns an Echo middleware that authenticates requests using
// the in-process tokenStore instead of calling the real Kratos session endpoint.
func (app *SecurityTestApp) fakeAuthMiddleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
ah := c.Request().Header.Get("Authorization")
if ah == "" {
return apperrors.Unauthorized("error.not_authenticated")
}
tok := ah
if len(ah) > 6 && ah[:6] == "Token " {
tok = ah[6:]
} else if len(ah) > 7 && ah[:7] == "Bearer " {
tok = ah[7:]
}
app.tokenStoreMu.RLock()
user, ok := app.tokenStore[tok]
app.tokenStoreMu.RUnlock()
if !ok {
return apperrors.Unauthorized("error.not_authenticated")
}
c.Set("auth_user", user)
c.Set("auth_token", tok)
return next(c)
}
}
}
// registerAndLoginSec creates a user directly in the DB and returns a fake token
// that the fakeAuthMiddleware will accept. No HTTP register/login calls are made.
func (app *SecurityTestApp) registerAndLoginSec(t *testing.T, username, email, _ string) (string, uint) {
t.Helper()
user := testutil.CreateTestUser(t, app.DB, username, email, "")
tok := uuid.NewString()
app.tokenStoreMu.Lock()
app.tokenStore[tok] = user
app.tokenStoreMu.Unlock()
return tok, user.ID
}
func setupSecurityTest(t *testing.T) *SecurityTestApp {
@@ -78,27 +122,25 @@ func setupSecurityTest(t *testing.T) *SecurityTestApp {
notificationHandler := handlers.NewNotificationHandler(notificationService)
subscriptionHandler := handlers.NewSubscriptionHandler(subscriptionService, nil)
// Create router with real middleware
app := &SecurityTestApp{
DB: db,
SubscriptionService: subscriptionService,
SubscriptionRepo: subscriptionRepo,
tokenStore: make(map[string]*models.User),
}
// Create router with fake auth middleware
e := echo.New()
e.Validator = validator.NewCustomValidator()
e.HTTPErrorHandler = apperrors.HTTPErrorHandler
e.Use(middleware.TimezoneMiddleware())
// Public routes
auth := e.Group("/api/auth")
{
auth.POST("/register", authHandler.Register)
auth.POST("/login", authHandler.Login)
}
// Protected routes
authMiddleware := middleware.NewAuthMiddleware(db, nil)
api := e.Group("/api")
api.Use(authMiddleware.TokenAuth())
api.Use(app.fakeAuthMiddleware())
{
api.GET("/auth/me", authHandler.CurrentUser)
api.POST("/auth/logout", authHandler.Logout)
residences := api.Group("/residences")
{
@@ -146,42 +188,8 @@ func setupSecurityTest(t *testing.T) *SecurityTestApp {
}
}
return &SecurityTestApp{
DB: db,
Router: e,
SubscriptionService: subscriptionService,
SubscriptionRepo: subscriptionRepo,
}
}
// registerAndLoginSec registers and logs in a user, returns token and user ID.
func (app *SecurityTestApp) registerAndLoginSec(t *testing.T, username, email, password string) (string, uint) {
// Register
registerBody := map[string]string{
"username": username,
"email": email,
"password": password,
}
w := app.makeAuthReq(t, "POST", "/api/auth/register", registerBody, "")
require.Equal(t, http.StatusCreated, w.Code, "Registration should succeed for %s", username)
// Login
loginBody := map[string]string{
"username": username,
"password": password,
}
w = app.makeAuthReq(t, "POST", "/api/auth/login", loginBody, "")
require.Equal(t, http.StatusOK, w.Code, "Login should succeed for %s", username)
var loginResp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &loginResp)
require.NoError(t, err)
token := loginResp["token"].(string)
userMap := loginResp["user"].(map[string]interface{})
userID := uint(userMap["id"].(float64))
return token, userID
app.Router = e
return app
}
// makeAuthReq creates and sends an HTTP request through the router.
@@ -6,12 +6,15 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/treytartt/honeydue-api/internal/apperrors"
"github.com/treytartt/honeydue-api/internal/config"
@@ -22,7 +25,6 @@ import (
"github.com/treytartt/honeydue-api/internal/services"
"github.com/treytartt/honeydue-api/internal/testutil"
"github.com/treytartt/honeydue-api/internal/validator"
"gorm.io/gorm"
)
// SubscriptionTestApp holds components for subscription integration testing
@@ -31,11 +33,51 @@ type SubscriptionTestApp struct {
Router *echo.Echo
SubscriptionService *services.SubscriptionService
SubscriptionRepo *repositories.SubscriptionRepository
tokenStore map[string]*models.User
tokenStoreMu sync.RWMutex
}
// fakeAuthMiddleware returns an Echo middleware that authenticates requests using
// the in-process tokenStore instead of calling the real Kratos session endpoint.
func (app *SubscriptionTestApp) fakeAuthMiddleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
ah := c.Request().Header.Get("Authorization")
if ah == "" {
return apperrors.Unauthorized("error.not_authenticated")
}
tok := ah
if len(ah) > 6 && ah[:6] == "Token " {
tok = ah[6:]
} else if len(ah) > 7 && ah[:7] == "Bearer " {
tok = ah[7:]
}
app.tokenStoreMu.RLock()
user, ok := app.tokenStore[tok]
app.tokenStoreMu.RUnlock()
if !ok {
return apperrors.Unauthorized("error.not_authenticated")
}
c.Set("auth_user", user)
c.Set("auth_token", tok)
return next(c)
}
}
}
// registerAndLogin creates a user directly in the DB and returns a fake token
// and user ID. No HTTP register/login calls are made.
func (app *SubscriptionTestApp) registerAndLogin(t *testing.T, username, email, _ string) (string, uint) {
t.Helper()
user := testutil.CreateTestUser(t, app.DB, username, email, "")
tok := uuid.NewString()
app.tokenStoreMu.Lock()
app.tokenStore[tok] = user
app.tokenStoreMu.Unlock()
return tok, user.ID
}
func setupSubscriptionTest(t *testing.T) *SubscriptionTestApp {
// Echo does not need test mode
db := testutil.SetupTestDB(t)
testutil.SeedLookupData(t, db)
@@ -67,22 +109,23 @@ func setupSubscriptionTest(t *testing.T) *SubscriptionTestApp {
residenceHandler := handlers.NewResidenceHandler(residenceService, nil, nil, true)
subscriptionHandler := handlers.NewSubscriptionHandler(subscriptionService, nil)
// Create router
app := &SubscriptionTestApp{
DB: db,
SubscriptionService: subscriptionService,
SubscriptionRepo: subscriptionRepo,
tokenStore: make(map[string]*models.User),
}
// Create router with fake auth middleware
e := echo.New()
e.Validator = validator.NewCustomValidator()
e.HTTPErrorHandler = apperrors.HTTPErrorHandler
// Public routes
auth := e.Group("/api/auth")
{
auth.POST("/register", authHandler.Register)
auth.POST("/login", authHandler.Login)
}
e.Use(middleware.TimezoneMiddleware())
// Protected routes
authMiddleware := middleware.NewAuthMiddleware(db, nil)
api := e.Group("/api")
api.Use(authMiddleware.TokenAuth())
api.Use(app.fakeAuthMiddleware())
{
api.GET("/auth/me", authHandler.CurrentUser)
@@ -98,12 +141,8 @@ func setupSubscriptionTest(t *testing.T) *SubscriptionTestApp {
}
}
return &SubscriptionTestApp{
DB: db,
Router: e,
SubscriptionService: subscriptionService,
SubscriptionRepo: subscriptionRepo,
}
app.Router = e
return app
}
// Helper to make authenticated requests
@@ -129,39 +168,14 @@ func (app *SubscriptionTestApp) makeAuthenticatedRequest(t *testing.T, method, p
return w
}
// Helper to register and login a user, returns token and user ID
func (app *SubscriptionTestApp) registerAndLogin(t *testing.T, username, email, password string) (string, uint) {
// Register
registerBody := map[string]string{
"username": username,
"email": email,
"password": password,
}
w := app.makeAuthenticatedRequest(t, "POST", "/api/auth/register", registerBody, "")
require.Equal(t, http.StatusCreated, w.Code)
// Login
loginBody := map[string]string{
"username": username,
"password": password,
}
w = app.makeAuthenticatedRequest(t, "POST", "/api/auth/login", loginBody, "")
require.Equal(t, http.StatusOK, w.Code)
var loginResp map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &loginResp)
require.NoError(t, err)
token := loginResp["token"].(string)
userMap := loginResp["user"].(map[string]interface{})
userID := uint(userMap["id"].(float64))
return token, userID
}
// TestIntegration_IsFreeBypassesLimitations tests that users with IsFree=true
// see limitations_enabled=false regardless of global settings
func TestIntegration_IsFreeBypassesLimitations(t *testing.T) {
// TEMPORARILY DISABLED — Subscriptions: GetSubscriptionStatus now returns
// a limitations_enabled=false stub for everyone, so the assertion that a
// normal user sees limitations_enabled=true (per EnableLimitations setting)
// no longer holds. Remove this skip when GetSubscriptionStatus is restored.
t.Skip("subscription feature disabled — see subscription_service.go TEMPORARILY DISABLED")
app := setupSubscriptionTest(t)
// Register and login a user
@@ -280,6 +294,10 @@ func TestIntegration_IsFreeBypassesCheckLimit(t *testing.T) {
// TestIntegration_IsFreeIndependentOfTier tests that IsFree works regardless of
// the user's subscription tier
func TestIntegration_IsFreeIndependentOfTier(t *testing.T) {
// TEMPORARILY DISABLED — Subscriptions: GetSubscriptionStatus is stubbed,
// so the Pro+!IsFree case (which would normally return limitations_enabled=true)
// is no longer reachable. Remove this skip when the feature is restored.
t.Skip("subscription feature disabled — see subscription_service.go TEMPORARILY DISABLED")
app := setupSubscriptionTest(t)
// Register and login a user
+245
View File
@@ -0,0 +1,245 @@
// Package kratos is a thin client for the Ory Kratos APIs. honeyDue
// delegates all identity concerns (credentials, sessions, verification,
// recovery, social sign-in) to Kratos; this client validates sessions
// against the public API and deletes identities via the admin API.
package kratos
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// ErrUnauthorized is returned by Whoami when the session is missing, invalid,
// or inactive — the caller should respond 401.
var ErrUnauthorized = errors.New("kratos: session invalid or inactive")
// ErrIdentityExists is returned by CreateIdentity when an identity with the
// same credential identifier (email) already exists — caller should respond 409.
var ErrIdentityExists = errors.New("kratos: identity already exists")
// ErrInvalidCredentials is returned by CreateIdentity when Kratos rejects the
// password against its policy (too short, breached, etc.) — caller responds 400.
type ErrInvalidCredentials struct{ Reason string }
func (e *ErrInvalidCredentials) Error() string { return "kratos: " + e.Reason }
// Client talks to the Ory Kratos public and admin APIs.
type Client struct {
publicURL string
adminURL string
http *http.Client
}
// NewClient builds a Kratos client. publicURL is the public-API base used for
// session validation (e.g. http://kratos:4433 in-cluster); adminURL is the
// admin-API base used for identity management (e.g. http://kratos:4434).
// Either may be empty when the corresponding API is unused.
func NewClient(publicURL, adminURL string) *Client {
return &Client{
publicURL: strings.TrimRight(publicURL, "/"),
adminURL: strings.TrimRight(adminURL, "/"),
http: &http.Client{Timeout: 5 * time.Second},
}
}
// Identity is the subset of a Kratos identity honeyDue consumes. It mirrors
// the identity schema in deploy-k3s/manifests/kratos/configmap.yaml.
type Identity struct {
ID string `json:"id"` // UUID — the stable identity identifier
Traits struct {
Email string `json:"email"`
Name struct {
First string `json:"first"`
Last string `json:"last"`
} `json:"name"`
} `json:"traits"`
VerifiableAddresses []struct {
Value string `json:"value"`
Verified bool `json:"verified"`
} `json:"verifiable_addresses"`
}
// Session is a Kratos session as returned by GET /sessions/whoami.
type Session struct {
ID string `json:"id"`
Active bool `json:"active"`
Identity Identity `json:"identity"`
}
// EmailVerified reports whether any of the identity's email addresses is
// verified — the source of truth for honeyDue's RequireVerified gate.
func (s *Session) EmailVerified() bool {
for _, a := range s.Identity.VerifiableAddresses {
if a.Verified {
return true
}
}
return false
}
// Whoami validates a session against Kratos. Supply the mobile session token
// (sessionToken) OR the browser cookie header (cookie) — whichever is
// non-empty is forwarded to Kratos. Returns ErrUnauthorized for an invalid or
// inactive session.
func (c *Client) Whoami(ctx context.Context, sessionToken, cookie string) (*Session, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.publicURL+"/sessions/whoami", nil)
if err != nil {
return nil, err
}
if sessionToken != "" {
req.Header.Set("X-Session-Token", sessionToken)
}
if cookie != "" {
req.Header.Set("Cookie", cookie)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("kratos whoami: %w", err)
}
defer resp.Body.Close()
switch {
case resp.StatusCode == http.StatusUnauthorized, resp.StatusCode == http.StatusForbidden:
return nil, ErrUnauthorized
case resp.StatusCode != http.StatusOK:
return nil, fmt.Errorf("kratos whoami: unexpected status %d", resp.StatusCode)
}
var s Session
if err := json.NewDecoder(resp.Body).Decode(&s); err != nil {
return nil, fmt.Errorf("kratos whoami: decode: %w", err)
}
if !s.Active || s.Identity.ID == "" {
return nil, ErrUnauthorized
}
return &s, nil
}
// CreateIdentity admin-creates a Kratos identity with a password credential and
// an UNVERIFIED email, returning the created identity (with its UUID).
//
// Why admin-create instead of the self-service registration flow: self-service
// registration always auto-emails a verification code to a flow whose id Kratos
// never returns to the client (verified 2026-06-03 — true with and without the
// session after-hook), so the client can never submit the user's code to the
// right flow. Admin creation runs no registration hooks and sends no email, so
// honeyDue drives verification explicitly afterward: the client starts its own
// verification flow (the single code) and the user enters it. The new identity
// is fully usable immediately — the client logs in right after to get a session
// (Kratos permits login for unverified identities; app access is gated on the
// verified flag, not on Kratos login).
//
// password is sent in cleartext over the in-cluster admin API (never exposed
// publicly); Kratos hashes it with the configured bcrypt cost and applies the
// password policy, returning 400 on a weak/breached password and 409 on a
// duplicate email.
func (c *Client) CreateIdentity(ctx context.Context, email, firstName, lastName, password string) (*Identity, error) {
if c.adminURL == "" {
return nil, errors.New("kratos: admin URL not configured")
}
payload := map[string]any{
"schema_id": "honeydue",
"traits": map[string]any{
"email": email,
"name": map[string]any{"first": firstName, "last": lastName},
},
"credentials": map[string]any{
"password": map[string]any{
"config": map[string]any{"password": password},
},
},
}
buf, err := json.Marshal(payload)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.adminURL+"/admin/identities", bytes.NewReader(buf))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("kratos create identity: %w", err)
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusCreated, http.StatusOK:
var id Identity
if err := json.NewDecoder(resp.Body).Decode(&id); err != nil {
return nil, fmt.Errorf("kratos create identity: decode: %w", err)
}
return &id, nil
case http.StatusConflict:
return nil, ErrIdentityExists
case http.StatusBadRequest:
body, _ := io.ReadAll(resp.Body)
return nil, &ErrInvalidCredentials{Reason: kratosReason(body)}
default:
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("kratos create identity: unexpected status %d: %s", resp.StatusCode, string(body))
}
}
// kratosReason pulls a human-readable reason out of a Kratos error body
// ({"error":{"reason":"...","message":"..."}}), falling back to the raw body.
func kratosReason(body []byte) string {
var env struct {
Error struct {
Reason string `json:"reason"`
Message string `json:"message"`
} `json:"error"`
}
if json.Unmarshal(body, &env) == nil {
if env.Error.Reason != "" {
return env.Error.Reason
}
if env.Error.Message != "" {
return env.Error.Message
}
}
return strings.TrimSpace(string(body))
}
// DeleteIdentity permanently removes a Kratos identity by its UUID via the
// admin API (DELETE /admin/identities/{id}). A 404 is treated as success —
// the identity is already gone, which is the desired end state, so the call
// is idempotent across retries. Called when a honeyDue account is deleted so
// no orphaned, still-loginable identity is left behind.
func (c *Client) DeleteIdentity(ctx context.Context, identityID string) error {
if c.adminURL == "" {
return errors.New("kratos: admin URL not configured")
}
if identityID == "" {
return errors.New("kratos: empty identity id")
}
endpoint := c.adminURL + "/admin/identities/" + url.PathEscape(identityID)
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil)
if err != nil {
return err
}
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("kratos delete identity: %w", err)
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusNoContent, http.StatusNotFound:
return nil
default:
return fmt.Errorf("kratos delete identity: unexpected status %d", resp.StatusCode)
}
}
-410
View File
@@ -1,410 +0,0 @@
package middleware
import (
"context"
"fmt"
"strings"
"time"
"github.com/labstack/echo/v4"
"github.com/redis/go-redis/v9"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
"github.com/treytartt/honeydue-api/internal/apperrors"
"github.com/treytartt/honeydue-api/internal/config"
"github.com/treytartt/honeydue-api/internal/models"
"github.com/treytartt/honeydue-api/internal/services"
)
const (
// AuthUserKey is the key used to store the authenticated user in the context
AuthUserKey = "auth_user"
// AuthTokenKey is the key used to store the token in the context
AuthTokenKey = "auth_token"
// TokenCacheTTL is the duration to cache tokens in Redis. Tokens are
// valid for DefaultTokenExpiryDays (90), and explicit logout invalidates
// the cache, so a long TTL here just means most authed requests skip the
// auth-token SQL query entirely.
TokenCacheTTL = 1 * time.Hour
// TokenCachePrefix is the prefix for token cache keys
TokenCachePrefix = "auth_token_"
// UserCacheTTL is how long full user records are cached in memory to
// avoid hitting the database on every authenticated request. Bumped from
// 30s — at 30s the trace showed a SELECT auth_user query on most warm
// requests because users aren't in cache long enough to hit twice.
UserCacheTTL = 5 * time.Minute
// UserCacheMaxSize bounds the per-pod in-memory user cache. With ~1KB
// per User struct, 5000 entries = ~5MB per pod. Older entries are
// evicted LRU before the limit is exceeded.
UserCacheMaxSize = 5000
// DefaultTokenExpiryDays is the default number of days before a token expires.
DefaultTokenExpiryDays = 90
)
// AuthMiddleware provides token authentication middleware
type AuthMiddleware struct {
db *gorm.DB
cache *services.CacheService
userCache *UserCache
tokenExpiryDays int
}
// NewAuthMiddleware creates a new auth middleware instance
func NewAuthMiddleware(db *gorm.DB, cache *services.CacheService) *AuthMiddleware {
return &AuthMiddleware{
db: db,
cache: cache,
userCache: NewUserCache(UserCacheTTL, UserCacheMaxSize),
tokenExpiryDays: DefaultTokenExpiryDays,
}
}
// NewAuthMiddlewareWithConfig creates a new auth middleware instance with configuration
func NewAuthMiddlewareWithConfig(db *gorm.DB, cache *services.CacheService, cfg *config.Config) *AuthMiddleware {
expiryDays := DefaultTokenExpiryDays
if cfg != nil && cfg.Security.TokenExpiryDays > 0 {
expiryDays = cfg.Security.TokenExpiryDays
}
return &AuthMiddleware{
db: db,
cache: cache,
userCache: NewUserCache(UserCacheTTL, UserCacheMaxSize),
tokenExpiryDays: expiryDays,
}
}
// TokenExpiryDuration returns the token expiry duration.
func (m *AuthMiddleware) TokenExpiryDuration() time.Duration {
return time.Duration(m.tokenExpiryDays) * 24 * time.Hour
}
// isTokenExpired checks if a token's created timestamp indicates expiry.
func (m *AuthMiddleware) isTokenExpired(created time.Time) bool {
if created.IsZero() {
return false // Legacy tokens without created time are not expired
}
return time.Since(created) > m.TokenExpiryDuration()
}
// TokenAuth returns an Echo middleware that validates token authentication
func (m *AuthMiddleware) TokenAuth() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// Extract token from Authorization header
token, err := extractToken(c)
if err != nil {
return apperrors.Unauthorized("error.not_authenticated")
}
// Try to get user from cache first (includes expiry check)
user, err := m.getUserFromCache(c.Request().Context(), token)
if err == nil && user != nil {
// Cache hit - set user in context and continue
c.Set(AuthUserKey, user)
c.Set(AuthTokenKey, token)
return next(c)
}
// Check if the cache indicated token expiry
if err != nil && err.Error() == "token expired" {
return apperrors.Unauthorized("error.token_expired")
}
// Cache miss - look up token in database
user, authToken, err := m.getUserFromDatabaseWithToken(c.Request().Context(), token)
if err != nil {
log.Debug().Err(err).Str("token", truncateToken(token)).Msg("Token authentication failed")
return apperrors.Unauthorized("error.invalid_token")
}
// Check token expiry
if m.isTokenExpired(authToken.Created) {
log.Debug().Str("token", truncateToken(token)).Time("created", authToken.Created).Msg("Token expired")
return apperrors.Unauthorized("error.token_expired")
}
// Cache the user ID and token creation time for future requests
if cacheErr := m.cacheTokenInfo(c.Request().Context(), token, user.ID, authToken.Created); cacheErr != nil {
log.Warn().Err(cacheErr).Msg("Failed to cache token info")
}
// Set user in context
c.Set(AuthUserKey, user)
c.Set(AuthTokenKey, token)
return next(c)
}
}
}
// OptionalTokenAuth returns middleware that authenticates if token is present but doesn't require it
func (m *AuthMiddleware) OptionalTokenAuth() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
token, err := extractToken(c)
if err != nil {
// No token or invalid format - continue without user
return next(c)
}
// Try cache first
user, err := m.getUserFromCache(c.Request().Context(), token)
if err == nil && user != nil {
c.Set(AuthUserKey, user)
c.Set(AuthTokenKey, token)
return next(c)
}
// Try database
user, authToken, err := m.getUserFromDatabaseWithToken(c.Request().Context(), token)
if err == nil && !m.isTokenExpired(authToken.Created) {
m.cacheTokenInfo(c.Request().Context(), token, user.ID, authToken.Created)
c.Set(AuthUserKey, user)
c.Set(AuthTokenKey, token)
}
return next(c)
}
}
}
// extractToken extracts the token from the Authorization header
func extractToken(c echo.Context) (string, error) {
authHeader := c.Request().Header.Get("Authorization")
if authHeader == "" {
return "", fmt.Errorf("authorization header required")
}
// Support both "Token xxx" (Django style) and "Bearer xxx" formats
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 {
return "", fmt.Errorf("invalid authorization header format")
}
scheme := parts[0]
token := parts[1]
if scheme != "Token" && scheme != "Bearer" {
return "", fmt.Errorf("invalid authorization scheme: %s", scheme)
}
if token == "" {
return "", fmt.Errorf("token is empty")
}
return token, nil
}
// getUserFromCache tries to get user from Redis cache, then from the
// in-memory user cache, before falling back to the database.
// Returns a "token expired" error if the cached creation time indicates expiry.
func (m *AuthMiddleware) getUserFromCache(ctx context.Context, token string) (*models.User, error) {
if m.cache == nil {
return nil, fmt.Errorf("cache not available")
}
userID, createdUnix, err := m.cache.GetCachedAuthTokenWithCreated(ctx, token)
if err != nil {
if err == redis.Nil {
return nil, fmt.Errorf("token not in cache")
}
return nil, err
}
// Check token expiry from cached creation time
if createdUnix > 0 {
created := time.Unix(createdUnix, 0)
if m.isTokenExpired(created) {
m.cache.InvalidateAuthToken(ctx, token)
return nil, fmt.Errorf("token expired")
}
}
// Try in-memory user cache first to avoid a DB round-trip
if cached := m.userCache.Get(userID); cached != nil {
if !cached.IsActive {
m.cache.InvalidateAuthToken(ctx, token)
m.userCache.Invalidate(userID)
return nil, fmt.Errorf("user is inactive")
}
return cached, nil
}
// In-memory cache miss — fetch from database
var user models.User
if err := m.db.WithContext(ctx).First(&user, userID).Error; err != nil {
// User was deleted - invalidate caches
m.cache.InvalidateAuthToken(ctx, token)
return nil, err
}
// Check if user is active
if !user.IsActive {
m.cache.InvalidateAuthToken(ctx, token)
return nil, fmt.Errorf("user is inactive")
}
// Store in in-memory cache for subsequent requests
m.userCache.Set(&user)
return &user, nil
}
// getUserFromDatabaseWithToken looks up the token in the database and returns
// both the user and the auth token record (for expiry checking). The ctx is
// threaded into the GORM session so the SQL span attaches to the request trace.
//
// Uses a single JOIN query instead of GORM's Preload (which issues 2 SELECTs).
// Over a transatlantic link this saves ~110ms RTT per cache miss.
func (m *AuthMiddleware) getUserFromDatabaseWithToken(ctx context.Context, token string) (*models.User, *models.AuthToken, error) {
// Flat result row: every column from auth_user prefixed `u_`, every
// column from user_authtoken left in its native shape. Mapping to two
// structs is mechanical so we don't need a struct tag soup.
type joinedRow struct {
// AuthToken columns
Key string `gorm:"column:key"`
Created time.Time `gorm:"column:created"`
UserID uint `gorm:"column:user_id"`
// User columns (prefixed to avoid collision with UserID)
UID uint `gorm:"column:u_id"`
UUsername string `gorm:"column:u_username"`
UEmail string `gorm:"column:u_email"`
UFirstName string `gorm:"column:u_first_name"`
ULastName string `gorm:"column:u_last_name"`
UPassword string `gorm:"column:u_password"`
UIsActive bool `gorm:"column:u_is_active"`
UIsStaff bool `gorm:"column:u_is_staff"`
UIsSuper bool `gorm:"column:u_is_superuser"`
UDateJoined time.Time `gorm:"column:u_date_joined"`
ULastLogin *time.Time `gorm:"column:u_last_login"`
}
var row joinedRow
err := m.db.WithContext(ctx).
Table("user_authtoken AS t").
Select(`
t.key, t.created, t.user_id,
u.id AS u_id,
u.username AS u_username,
u.email AS u_email,
u.first_name AS u_first_name,
u.last_name AS u_last_name,
u.password AS u_password,
u.is_active AS u_is_active,
u.is_staff AS u_is_staff,
u.is_superuser AS u_is_superuser,
u.date_joined AS u_date_joined,
u.last_login AS u_last_login
`).
Joins("INNER JOIN auth_user u ON u.id = t.user_id").
Where("t.key = ?", token).
Limit(1).
Scan(&row).Error
if err != nil || row.Key == "" {
return nil, nil, fmt.Errorf("token not found")
}
user := models.User{
ID: row.UID,
Username: row.UUsername,
Email: row.UEmail,
FirstName: row.UFirstName,
LastName: row.ULastName,
Password: row.UPassword,
IsActive: row.UIsActive,
IsStaff: row.UIsStaff,
IsSuperuser: row.UIsSuper,
DateJoined: row.UDateJoined,
LastLogin: row.ULastLogin,
}
authToken := models.AuthToken{
Key: row.Key,
Created: row.Created,
UserID: row.UserID,
User: user,
}
if !user.IsActive {
return nil, nil, fmt.Errorf("user is inactive")
}
m.userCache.Set(&user)
return &user, &authToken, nil
}
// getUserFromDatabase looks up the token in the database and caches the
// resulting user record in memory.
// Deprecated: Use getUserFromDatabaseWithToken for new code paths that need expiry checking.
func (m *AuthMiddleware) getUserFromDatabase(ctx context.Context, token string) (*models.User, error) {
user, _, err := m.getUserFromDatabaseWithToken(ctx, token)
return user, err
}
// cacheTokenInfo caches the user ID and token creation time for a token
func (m *AuthMiddleware) cacheTokenInfo(ctx context.Context, token string, userID uint, created time.Time) error {
if m.cache == nil {
return nil
}
return m.cache.CacheAuthTokenWithCreated(ctx, token, userID, created.Unix())
}
// cacheUserID caches the user ID for a token
func (m *AuthMiddleware) cacheUserID(ctx context.Context, token string, userID uint) error {
if m.cache == nil {
return nil
}
return m.cache.CacheAuthToken(ctx, token, userID)
}
// InvalidateToken removes a token from the cache
func (m *AuthMiddleware) InvalidateToken(ctx context.Context, token string) error {
if m.cache == nil {
return nil
}
return m.cache.InvalidateAuthToken(ctx, token)
}
// GetAuthUser retrieves the authenticated user from the Echo context.
// Returns nil if the context value is missing or not of the expected type.
func GetAuthUser(c echo.Context) *models.User {
val := c.Get(AuthUserKey)
if val == nil {
return nil
}
user, ok := val.(*models.User)
if !ok {
return nil
}
return user
}
// GetAuthToken retrieves the auth token from the Echo context
func GetAuthToken(c echo.Context) string {
token := c.Get(AuthTokenKey)
if token == nil {
return ""
}
tokenStr, ok := token.(string)
if !ok {
return ""
}
return tokenStr
}
// MustGetAuthUser retrieves the authenticated user or returns error with 401
func MustGetAuthUser(c echo.Context) (*models.User, error) {
user := GetAuthUser(c)
if user == nil {
return nil, apperrors.Unauthorized("error.not_authenticated")
}
return user, nil
}
// truncateToken safely truncates a token string for logging.
// Returns at most the first 8 characters followed by "...".
func truncateToken(token string) string {
if len(token) > 8 {
return token[:8] + "..."
}
return token + "..."
}
-165
View File
@@ -1,165 +0,0 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/treytartt/honeydue-api/internal/models"
)
// setupTestDB creates a temporary in-memory SQLite database with the required
// tables for auth middleware tests.
func setupTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
require.NoError(t, err)
err = db.AutoMigrate(&models.User{}, &models.AuthToken{})
require.NoError(t, err)
return db
}
// createTestUserAndToken creates a user and an auth token, then backdates the
// token's Created timestamp by the specified number of days.
func createTestUserAndToken(t *testing.T, db *gorm.DB, username string, ageDays int) (*models.User, *models.AuthToken) {
t.Helper()
user := &models.User{
Username: username,
Email: username + "@test.com",
IsActive: true,
}
require.NoError(t, user.SetPassword("Password123"))
require.NoError(t, db.Create(user).Error)
token := &models.AuthToken{
UserID: user.ID,
}
require.NoError(t, db.Create(token).Error)
// Backdate the token's Created timestamp after creation to bypass autoCreateTime
backdated := time.Now().UTC().AddDate(0, 0, -ageDays)
require.NoError(t, db.Model(token).Update("created", backdated).Error)
token.Created = backdated
return user, token
}
func TestTokenAuth_RejectsExpiredToken(t *testing.T) {
db := setupTestDB(t)
_, token := createTestUserAndToken(t, db, "expired_user", 91) // 91 days old > 90 day expiry
m := NewAuthMiddleware(db, nil) // No Redis cache for these tests
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/api/test/", nil)
req.Header.Set("Authorization", "Token "+token.Key)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
handler := m.TokenAuth()(func(c echo.Context) error {
return c.String(http.StatusOK, "ok")
})
err := handler(c)
require.Error(t, err)
assert.Contains(t, err.Error(), "error.token_expired")
}
func TestTokenAuth_AcceptsValidToken(t *testing.T) {
db := setupTestDB(t)
_, token := createTestUserAndToken(t, db, "valid_user", 30) // 30 days old < 90 day expiry
m := NewAuthMiddleware(db, nil)
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/api/test/", nil)
req.Header.Set("Authorization", "Token "+token.Key)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
handler := m.TokenAuth()(func(c echo.Context) error {
return c.String(http.StatusOK, "ok")
})
err := handler(c)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, rec.Code)
// Verify user was set in context
user := GetAuthUser(c)
require.NotNil(t, user)
assert.Equal(t, "valid_user", user.Username)
}
func TestTokenAuth_AcceptsTokenAtBoundary(t *testing.T) {
db := setupTestDB(t)
_, token := createTestUserAndToken(t, db, "boundary_user", 89) // 89 days old, just under 90 day expiry
m := NewAuthMiddleware(db, nil)
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/api/test/", nil)
req.Header.Set("Authorization", "Token "+token.Key)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
handler := m.TokenAuth()(func(c echo.Context) error {
return c.String(http.StatusOK, "ok")
})
err := handler(c)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, rec.Code)
}
func TestTokenAuth_RejectsInvalidToken(t *testing.T) {
db := setupTestDB(t)
m := NewAuthMiddleware(db, nil)
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/api/test/", nil)
req.Header.Set("Authorization", "Token nonexistent-token")
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
handler := m.TokenAuth()(func(c echo.Context) error {
return c.String(http.StatusOK, "ok")
})
err := handler(c)
require.Error(t, err)
assert.Contains(t, err.Error(), "error.invalid_token")
}
func TestTokenAuth_RejectsNoAuthHeader(t *testing.T) {
db := setupTestDB(t)
m := NewAuthMiddleware(db, nil)
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/api/test/", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
handler := m.TokenAuth()(func(c echo.Context) error {
return c.String(http.StatusOK, "ok")
})
err := handler(c)
require.Error(t, err)
assert.Contains(t, err.Error(), "error.not_authenticated")
}
+74
View File
@@ -9,6 +9,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/treytartt/honeydue-api/internal/apperrors"
"github.com/treytartt/honeydue-api/internal/config"
"github.com/treytartt/honeydue-api/internal/models"
)
@@ -117,3 +118,76 @@ func TestAdminAuth_QueryParamToken_Rejected(t *testing.T) {
assert.Equal(t, http.StatusUnauthorized, rec.Code, "query param token must be rejected")
assert.Contains(t, rec.Body.String(), "Authorization required")
}
// requireVerifiedContext builds an Echo context primed as the Authenticate
// middleware would leave it: an auth_user and the verified flag.
func requireVerifiedContext(user *models.User, verified bool) (echo.Context, *httptest.ResponseRecorder) {
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/api/residences/", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
if user != nil {
c.Set(AuthUserKey, user)
}
c.Set(AuthVerifiedKey, verified)
return c, rec
}
// TestRequireVerified_VerifiedUser_Passes confirms a verified user reaches the
// wrapped handler. This is the default tier for all app-data routes now that
// RequireVerified is applied at the `verified` group level in the router.
func TestRequireVerified_VerifiedUser_Passes(t *testing.T) {
m := &KratosAuth{}
c, _ := requireVerifiedContext(&models.User{Username: "v"}, true)
reached := false
handler := m.RequireVerified()(func(c echo.Context) error {
reached = true
return c.NoContent(http.StatusOK)
})
err := handler(c)
assert.NoError(t, err)
assert.True(t, reached, "verified user should reach the handler")
}
// TestRequireVerified_UnverifiedUser_403 is the core gating assertion for the
// new policy: an authenticated-but-unverified user is rejected with 403 on a
// data route, NOT allowed through.
func TestRequireVerified_UnverifiedUser_403(t *testing.T) {
m := &KratosAuth{}
c, _ := requireVerifiedContext(&models.User{Username: "u"}, false)
reached := false
handler := m.RequireVerified()(func(c echo.Context) error {
reached = true
return c.NoContent(http.StatusOK)
})
err := handler(c)
require.Error(t, err)
assert.False(t, reached, "unverified user must NOT reach the handler")
var appErr *apperrors.AppError
require.ErrorAs(t, err, &appErr)
assert.Equal(t, http.StatusForbidden, appErr.Code)
}
// TestRequireVerified_NoUser_401 confirms RequireVerified rejects an
// unauthenticated request with 401 (defense-in-depth even though Authenticate
// runs first in the router).
func TestRequireVerified_NoUser_401(t *testing.T) {
m := &KratosAuth{}
c, _ := requireVerifiedContext(nil, false)
handler := m.RequireVerified()(func(c echo.Context) error {
return c.NoContent(http.StatusOK)
})
err := handler(c)
require.Error(t, err)
var appErr *apperrors.AppError
require.ErrorAs(t, err, &appErr)
assert.Equal(t, http.StatusUnauthorized, appErr.Code)
}
-337
View File
@@ -1,337 +0,0 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/treytartt/honeydue-api/internal/config"
"github.com/treytartt/honeydue-api/internal/models"
)
func TestTokenAuth_BearerScheme_Accepted(t *testing.T) {
db := setupTestDB(t)
_, token := createTestUserAndToken(t, db, "bearer_user", 10)
m := NewAuthMiddleware(db, nil)
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/api/test/", nil)
req.Header.Set("Authorization", "Bearer "+token.Key)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
handler := m.TokenAuth()(func(c echo.Context) error {
return c.String(http.StatusOK, "ok")
})
err := handler(c)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, rec.Code)
user := GetAuthUser(c)
require.NotNil(t, user)
assert.Equal(t, "bearer_user", user.Username)
}
func TestTokenAuth_InvalidScheme_Rejected(t *testing.T) {
db := setupTestDB(t)
_, token := createTestUserAndToken(t, db, "scheme_user", 10)
m := NewAuthMiddleware(db, nil)
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/api/test/", nil)
req.Header.Set("Authorization", "Basic "+token.Key)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
handler := m.TokenAuth()(func(c echo.Context) error {
return c.String(http.StatusOK, "ok")
})
err := handler(c)
require.Error(t, err)
assert.Contains(t, err.Error(), "error.not_authenticated")
}
func TestTokenAuth_MalformedHeader_Rejected(t *testing.T) {
db := setupTestDB(t)
m := NewAuthMiddleware(db, nil)
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/api/test/", nil)
req.Header.Set("Authorization", "JustATokenWithNoScheme")
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
handler := m.TokenAuth()(func(c echo.Context) error {
return c.String(http.StatusOK, "ok")
})
err := handler(c)
require.Error(t, err)
assert.Contains(t, err.Error(), "error.not_authenticated")
}
func TestTokenAuth_EmptyToken_Rejected(t *testing.T) {
db := setupTestDB(t)
m := NewAuthMiddleware(db, nil)
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/api/test/", nil)
req.Header.Set("Authorization", "Token ")
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
handler := m.TokenAuth()(func(c echo.Context) error {
return c.String(http.StatusOK, "ok")
})
err := handler(c)
require.Error(t, err)
assert.Contains(t, err.Error(), "error.not_authenticated")
}
func TestTokenAuth_InactiveUser_Rejected(t *testing.T) {
db := setupTestDB(t)
user, token := createTestUserAndToken(t, db, "inactive_user", 10)
// Deactivate the user
require.NoError(t, db.Model(user).Update("is_active", false).Error)
m := NewAuthMiddleware(db, nil)
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/api/test/", nil)
req.Header.Set("Authorization", "Token "+token.Key)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
handler := m.TokenAuth()(func(c echo.Context) error {
return c.String(http.StatusOK, "ok")
})
err := handler(c)
require.Error(t, err)
assert.Contains(t, err.Error(), "error.invalid_token")
}
func TestOptionalTokenAuth_NoToken_PassesThrough(t *testing.T) {
db := setupTestDB(t)
m := NewAuthMiddleware(db, nil)
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/api/test/", nil)
// No Authorization header
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
handler := m.OptionalTokenAuth()(func(c echo.Context) error {
user := GetAuthUser(c)
if user == nil {
return c.String(http.StatusOK, "no-user")
}
return c.String(http.StatusOK, user.Username)
})
err := handler(c)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "no-user", rec.Body.String())
}
func TestOptionalTokenAuth_ValidToken_SetsUser(t *testing.T) {
db := setupTestDB(t)
_, token := createTestUserAndToken(t, db, "opt_user", 10)
m := NewAuthMiddleware(db, nil)
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/api/test/", nil)
req.Header.Set("Authorization", "Token "+token.Key)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
handler := m.OptionalTokenAuth()(func(c echo.Context) error {
user := GetAuthUser(c)
if user == nil {
return c.String(http.StatusOK, "no-user")
}
return c.String(http.StatusOK, user.Username)
})
err := handler(c)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "opt_user", rec.Body.String())
}
func TestOptionalTokenAuth_ExpiredToken_IgnoresUser(t *testing.T) {
db := setupTestDB(t)
_, token := createTestUserAndToken(t, db, "expired_opt_user", 91)
m := NewAuthMiddleware(db, nil)
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/api/test/", nil)
req.Header.Set("Authorization", "Token "+token.Key)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
handler := m.OptionalTokenAuth()(func(c echo.Context) error {
user := GetAuthUser(c)
if user == nil {
return c.String(http.StatusOK, "no-user")
}
return c.String(http.StatusOK, user.Username)
})
err := handler(c)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "no-user", rec.Body.String())
}
func TestOptionalTokenAuth_InvalidToken_IgnoresUser(t *testing.T) {
db := setupTestDB(t)
m := NewAuthMiddleware(db, nil)
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/api/test/", nil)
req.Header.Set("Authorization", "Token nonexistent-token")
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
handler := m.OptionalTokenAuth()(func(c echo.Context) error {
user := GetAuthUser(c)
if user == nil {
return c.String(http.StatusOK, "no-user")
}
return c.String(http.StatusOK, user.Username)
})
err := handler(c)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "no-user", rec.Body.String())
}
func TestNewAuthMiddlewareWithConfig_CustomExpiryDays(t *testing.T) {
db := setupTestDB(t)
cfg := &config.Config{
Security: config.SecurityConfig{
TokenExpiryDays: 30,
},
}
m := NewAuthMiddlewareWithConfig(db, nil, cfg)
assert.NotNil(t, m)
assert.Equal(t, 30, m.tokenExpiryDays)
// Token at 29 days should be valid
_, token := createTestUserAndToken(t, db, "short_expiry_user", 29)
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/api/test/", nil)
req.Header.Set("Authorization", "Token "+token.Key)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
handler := m.TokenAuth()(func(c echo.Context) error {
return c.String(http.StatusOK, "ok")
})
err := handler(c)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, rec.Code)
}
func TestNewAuthMiddlewareWithConfig_ExpiredWithCustomExpiry(t *testing.T) {
db := setupTestDB(t)
cfg := &config.Config{
Security: config.SecurityConfig{
TokenExpiryDays: 30,
},
}
m := NewAuthMiddlewareWithConfig(db, nil, cfg)
// Token at 31 days should be expired with 30-day config
_, token := createTestUserAndToken(t, db, "custom_expired_user", 31)
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/api/test/", nil)
req.Header.Set("Authorization", "Token "+token.Key)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
handler := m.TokenAuth()(func(c echo.Context) error {
return c.String(http.StatusOK, "ok")
})
err := handler(c)
require.Error(t, err)
assert.Contains(t, err.Error(), "error.token_expired")
}
func TestNewAuthMiddlewareWithConfig_NilConfig_UsesDefault(t *testing.T) {
db := setupTestDB(t)
m := NewAuthMiddlewareWithConfig(db, nil, nil)
assert.Equal(t, DefaultTokenExpiryDays, m.tokenExpiryDays)
}
func TestGetAuthToken_ReturnsToken(t *testing.T) {
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.Set(AuthTokenKey, "test-token-value")
assert.Equal(t, "test-token-value", GetAuthToken(c))
}
func TestGetAuthToken_NilContext_ReturnsEmpty(t *testing.T) {
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
// No token set
assert.Equal(t, "", GetAuthToken(c))
}
func TestGetAuthToken_WrongType_ReturnsEmpty(t *testing.T) {
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.Set(AuthTokenKey, 12345) // Wrong type
assert.Equal(t, "", GetAuthToken(c))
}
func TestIsTokenExpired_ZeroTime_NotExpired(t *testing.T) {
db := setupTestDB(t)
m := NewAuthMiddleware(db, nil)
// Legacy tokens without created time should not be expired
assert.False(t, m.isTokenExpired(models.AuthToken{}.Created))
}
func TestInvalidateToken_NilCache_NoError(t *testing.T) {
db := setupTestDB(t)
m := NewAuthMiddleware(db, nil) // nil cache
err := m.InvalidateToken(nil, "some-token")
assert.NoError(t, err)
}
+292
View File
@@ -0,0 +1,292 @@
package middleware
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"github.com/labstack/echo/v4"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
"github.com/treytartt/honeydue-api/internal/apperrors"
"github.com/treytartt/honeydue-api/internal/kratos"
"github.com/treytartt/honeydue-api/internal/models"
"github.com/treytartt/honeydue-api/internal/services"
)
const (
// AuthUserKey stores the authenticated *models.User in the echo context.
AuthUserKey = "auth_user"
// AuthTokenKey stores the raw session credential in the echo context.
AuthTokenKey = "auth_token"
// AuthVerifiedKey stores the Kratos email-verified flag in the context.
// Handlers can read this to override stale local mirrors like
// user_profile.verified with the live Kratos truth.
AuthVerifiedKey = "auth_email_verified"
// UserCacheTTL / UserCacheMaxSize bound the in-memory local-user cache.
UserCacheTTL = 5 * time.Minute
UserCacheMaxSize = 5000
// kratosSessionCacheTTL is how long a validated session is cached in
// Redis, so most authed requests skip the Kratos /whoami round trip.
//
// PRODUCTION CAVEAT (2026-06-03): until Kratos is deployed in-cluster,
// the Whoami fallback ALWAYS fails (no kratos Service). That means every
// cache miss = 401 = forced re-login. We mitigate by (a) using a long
// TTL and (b) refreshing the TTL on every cache hit (see resolve()).
// This is a short-term workaround — restore to a few minutes once Kratos
// is live and the runbook §11 #7 prerequisites are done.
kratosSessionCacheTTL = 24 * time.Hour
kratosSessionPrefix = "kratos_sess:"
)
// KratosAuth authenticates requests against an Ory Kratos session. It
// replaces the hand-rolled token auth: the session is validated via Kratos
// /sessions/whoami (Redis-cached), and the matching local auth_user row is
// lazily provisioned on first sight of a Kratos identity.
type KratosAuth struct {
kratos *kratos.Client
cache *services.CacheService
db *gorm.DB
userCache *UserCache
}
// NewKratosAuth builds the Kratos auth middleware.
func NewKratosAuth(k *kratos.Client, cache *services.CacheService, db *gorm.DB) *KratosAuth {
return &KratosAuth{
kratos: k,
cache: cache,
db: db,
userCache: NewUserCache(UserCacheTTL, UserCacheMaxSize),
}
}
// Authenticate validates the Kratos session and requires it.
func (m *KratosAuth) Authenticate() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
user, verified, cred, err := m.resolve(c)
if err != nil {
log.Debug().Err(err).Msg("Kratos authentication failed")
return apperrors.Unauthorized("error.not_authenticated")
}
c.Set(AuthUserKey, user)
c.Set(AuthTokenKey, cred)
c.Set(AuthVerifiedKey, verified)
return next(c)
}
}
}
// OptionalAuthenticate authenticates if a session is present, else continues
// unauthenticated.
func (m *KratosAuth) OptionalAuthenticate() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if user, verified, cred, err := m.resolve(c); err == nil {
c.Set(AuthUserKey, user)
c.Set(AuthTokenKey, cred)
c.Set(AuthVerifiedKey, verified)
}
return next(c)
}
}
}
// RequireVerified rejects users whose Kratos email address is not verified.
// Apply after Authenticate.
func (m *KratosAuth) RequireVerified() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if GetAuthUser(c) == nil {
return apperrors.Unauthorized("error.not_authenticated")
}
if verified, _ := c.Get(AuthVerifiedKey).(bool); !verified {
return apperrors.Forbidden("error.email_not_verified")
}
return next(c)
}
}
}
// resolve validates the request's session and returns the local user.
func (m *KratosAuth) resolve(c echo.Context) (*models.User, bool, string, error) {
token, cookie := extractSession(c)
if token == "" && cookie == "" {
return nil, false, "", errors.New("no session credential")
}
cred := token
if cred == "" {
cred = cookie
}
ctx := c.Request().Context()
// Redis cache: kratos_sess:<hash(cred)> -> "<userID>|<0|1>"
cacheKey := kratosSessionPrefix + hashCredential(cred)
if m.cache != nil {
if v, err := m.cache.GetString(ctx, cacheKey); err == nil && v != "" {
// Only a cached `verified=true` is authoritative — email verification
// is sticky (it never reverts), so we can safely short-circuit.
// A cached `verified=false` is deliberately NOT trusted: the user may
// have verified their email since this entry was written, and a stale
// false would lock a just-verified user out of every verified-gated
// route until the 24h TTL expired (e.g. sign up -> verify -> create a
// residence immediately). On a cached false we fall through and
// re-resolve the live status from Kratos /whoami below.
if user, verified, ok := m.userFromCacheValue(ctx, v); ok && verified {
// Sliding-window refresh: extend the TTL on every successful hit
// so active (verified) users aren't bounced when their entry
// would otherwise expire. Best-effort.
_ = m.cache.SetString(ctx, cacheKey, v, kratosSessionCacheTTL)
return user, true, cred, nil
}
}
}
sess, err := m.kratos.Whoami(ctx, token, cookie)
if err != nil {
return nil, false, "", err
}
user, err := m.provision(ctx, sess)
if err != nil {
return nil, false, "", err
}
if m.cache != nil {
_ = m.cache.SetString(ctx, cacheKey,
fmt.Sprintf("%d|%s", user.ID, boolDigit(sess.EmailVerified())), kratosSessionCacheTTL)
}
return user, sess.EmailVerified(), cred, nil
}
// provision finds the local auth_user row for a Kratos identity, creating it
// (and a UserProfile) on first sight. Concurrent first requests are handled
// by re-reading after a unique-constraint conflict.
func (m *KratosAuth) provision(ctx context.Context, sess *kratos.Session) (*models.User, error) {
var user models.User
err := m.db.WithContext(ctx).Where("kratos_id = ?", sess.Identity.ID).First(&user).Error
if err == nil {
return &user, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
user = models.User{
KratosID: sess.Identity.ID,
Email: sess.Identity.Traits.Email,
Username: sess.Identity.Traits.Email,
FirstName: sess.Identity.Traits.Name.First,
LastName: sess.Identity.Traits.Name.Last,
IsActive: true,
DateJoined: time.Now().UTC(),
}
txErr := m.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&user).Error; err != nil {
return err
}
return tx.Create(&models.UserProfile{
UserID: user.ID,
Verified: sess.EmailVerified(),
}).Error
})
if txErr != nil {
// Likely a concurrent provision of the same identity — re-read.
if e := m.db.WithContext(ctx).Where("kratos_id = ?", sess.Identity.ID).First(&user).Error; e == nil {
return &user, nil
}
return nil, txErr
}
log.Info().Str("kratos_id", sess.Identity.ID).Uint("user_id", user.ID).
Msg("provisioned local user from Kratos identity")
return &user, nil
}
// userFromCacheValue resolves a cached "<userID>|<0|1>" value to a user.
func (m *KratosAuth) userFromCacheValue(ctx context.Context, v string) (*models.User, bool, bool) {
parts := strings.SplitN(v, "|", 2)
if len(parts) != 2 {
return nil, false, false
}
var id uint
if _, err := fmt.Sscanf(parts[0], "%d", &id); err != nil || id == 0 {
return nil, false, false
}
verified := parts[1] == "1"
if cached := m.userCache.Get(id); cached != nil {
return cached, verified, true
}
var user models.User
if err := m.db.WithContext(ctx).First(&user, id).Error; err != nil {
return nil, false, false
}
m.userCache.Set(&user)
return &user, verified, true
}
// extractSession pulls the session credential from the request: the
// X-Session-Token header or Authorization bearer (mobile clients), or the
// ory_kratos_session cookie (web).
func extractSession(c echo.Context) (token, cookie string) {
if t := c.Request().Header.Get("X-Session-Token"); t != "" {
token = t
} else if ah := c.Request().Header.Get("Authorization"); ah != "" {
parts := strings.SplitN(ah, " ", 2)
if len(parts) == 2 && (parts[0] == "Bearer" || parts[0] == "Token") {
token = parts[1]
}
}
if token == "" {
if ck := c.Request().Header.Get("Cookie"); strings.Contains(ck, "ory_kratos_session") {
cookie = ck
}
}
return token, cookie
}
func hashCredential(cred string) string {
sum := sha256.Sum256([]byte(cred))
return hex.EncodeToString(sum[:])
}
func boolDigit(b bool) string {
if b {
return "1"
}
return "0"
}
// truncateToken returns the first 8 characters of a credential followed by
// "..." for safe inclusion in log lines.
func truncateToken(tok string) string {
if len(tok) <= 8 {
return tok + "..."
}
return tok[:8] + "..."
}
// GetAuthUser retrieves the authenticated user from the echo context.
func GetAuthUser(c echo.Context) *models.User {
user, _ := c.Get(AuthUserKey).(*models.User)
return user
}
// GetAuthToken retrieves the session credential from the echo context.
func GetAuthToken(c echo.Context) string {
tok, _ := c.Get(AuthTokenKey).(string)
return tok
}
// MustGetAuthUser retrieves the authenticated user or returns a 401 error.
func MustGetAuthUser(c echo.Context) (*models.User, error) {
user := GetAuthUser(c)
if user == nil {
return nil, apperrors.Unauthorized("error.not_authenticated")
}
return user, nil
}
+12 -10
View File
@@ -99,22 +99,24 @@ func parseTimezone(tz string) *time.Location {
return loc
}
// Try parsing as UTC offset (e.g., "-08:00", "+05:30")
// We parse a reference time with the given offset to extract the offset value
t, err := time.Parse("-07:00", tz)
if err == nil {
// time.Parse returns a time, we need to extract the offset
// The parsed time will have the offset embedded
_, offset := t.Zone()
// Try parsing as a UTC offset (e.g., "-08:00", "+05:30"). Audit H8:
// reject absurd offsets — real timezones are within ±14h of UTC — so a
// crafted X-Timezone header cannot shift date math arbitrarily.
const maxOffsetSeconds = 14 * 3600
if t, err := time.Parse("-07:00", tz); err == nil {
if _, offset := t.Zone(); offset >= -maxOffsetSeconds && offset <= maxOffsetSeconds {
return time.FixedZone(tz, offset)
}
return time.UTC
}
// Also try without colon (e.g., "-0800")
t, err = time.Parse("-0700", tz)
if err == nil {
_, offset := t.Zone()
if t, err := time.Parse("-0700", tz); err == nil {
if _, offset := t.Zone(); offset >= -maxOffsetSeconds && offset <= maxOffsetSeconds {
return time.FixedZone(tz, offset)
}
return time.UTC
}
// Default to UTC
return time.UTC
+2
View File
@@ -91,6 +91,8 @@ type DocumentImage struct {
DocumentID uint `gorm:"column:document_id;index;not null" json:"document_id"`
ImageURL string `gorm:"column:image_url;size:500;not null" json:"image_url"`
Caption string `gorm:"column:caption;size:255" json:"caption"`
// PendingUploadID — see TaskCompletionImage.PendingUploadID.
PendingUploadID *uint `gorm:"column:pending_upload_id" json:"pending_upload_id,omitempty"`
}
// TableName returns the table name for GORM
+1 -124
View File
@@ -19,7 +19,7 @@ func setupModelsTestDB(t *testing.T) *gorm.DB {
Logger: logger.Default.LogMode(logger.Silent),
})
require.NoError(t, err)
err = db.AutoMigrate(&User{}, &AuthToken{}, &UserProfile{})
err = db.AutoMigrate(&User{}, &UserProfile{})
require.NoError(t, err)
return db
}
@@ -233,104 +233,6 @@ func TestNotificationType_Constants(t *testing.T) {
assert.Equal(t, NotificationType("warranty_expiring"), NotificationWarrantyExpiring)
}
// === AuthToken model tests ===
func TestAuthToken_BeforeCreate_GeneratesKey(t *testing.T) {
db := setupModelsTestDB(t)
user := &User{
Username: "tokenuser",
Email: "token@test.com",
Password: "dummy",
IsActive: true,
}
err := db.Create(user).Error
require.NoError(t, err)
token := &AuthToken{UserID: user.ID}
err = db.Create(token).Error
require.NoError(t, err)
assert.NotEmpty(t, token.Key)
assert.Len(t, token.Key, 40) // 20 bytes = 40 hex chars
assert.False(t, token.Created.IsZero())
}
func TestAuthToken_BeforeCreate_PreservesExistingKey(t *testing.T) {
db := setupModelsTestDB(t)
user := &User{
Username: "tokenuser",
Email: "token@test.com",
Password: "dummy",
IsActive: true,
}
err := db.Create(user).Error
require.NoError(t, err)
existingKey := "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
token := &AuthToken{
Key: existingKey,
UserID: user.ID,
}
err = db.Create(token).Error
require.NoError(t, err)
assert.Equal(t, existingKey, token.Key)
}
func TestGetOrCreateToken_CreatesNew(t *testing.T) {
db := setupModelsTestDB(t)
user := &User{
Username: "newtoken",
Email: "newtoken@test.com",
Password: "dummy",
IsActive: true,
}
err := db.Create(user).Error
require.NoError(t, err)
token, err := GetOrCreateToken(db, user.ID)
require.NoError(t, err)
assert.NotEmpty(t, token.Key)
assert.Equal(t, user.ID, token.UserID)
}
func TestGetOrCreateToken_ReturnsExisting(t *testing.T) {
db := setupModelsTestDB(t)
user := &User{
Username: "existingtoken",
Email: "existingtoken@test.com",
Password: "dummy",
IsActive: true,
}
err := db.Create(user).Error
require.NoError(t, err)
token1, err := GetOrCreateToken(db, user.ID)
require.NoError(t, err)
token2, err := GetOrCreateToken(db, user.ID)
require.NoError(t, err)
assert.Equal(t, token1.Key, token2.Key)
}
// === User model additional tests ===
func TestUser_SetPassword_And_CheckPassword_Integration(t *testing.T) {
user := &User{}
err := user.SetPassword("Password123")
require.NoError(t, err)
assert.True(t, user.CheckPassword("Password123"))
assert.False(t, user.CheckPassword("WrongPassword"))
assert.False(t, user.CheckPassword(""))
assert.False(t, user.CheckPassword("password123")) // case sensitive
}
// === Task model additional tests ===
func TestTask_IsOverdue_CancelledNotOverdue(t *testing.T) {
@@ -564,31 +466,6 @@ func TestGetDefaultProLimits(t *testing.T) {
assert.Nil(t, limits.DocumentsLimit)
}
// === ConfirmationCode additional tests ===
func TestConfirmationCode_TableName(t *testing.T) {
cc := ConfirmationCode{}
assert.Equal(t, "user_confirmationcode", cc.TableName())
}
// === PasswordResetCode additional tests ===
func TestPasswordResetCode_TableName(t *testing.T) {
prc := PasswordResetCode{}
assert.Equal(t, "user_passwordresetcode", prc.TableName())
}
// === Social Auth TableName tests ===
func TestAppleSocialAuth_TableName(t *testing.T) {
a := AppleSocialAuth{}
assert.Equal(t, "user_applesocialauth", a.TableName())
}
func TestGoogleSocialAuth_TableName(t *testing.T) {
g := GoogleSocialAuth{}
assert.Equal(t, "user_googlesocialauth", g.TableName())
}
// === BaseModel tests ===
+53
View File
@@ -0,0 +1,53 @@
package models
import "time"
// UploadCategory enumerates the kinds of objects that can be uploaded via the
// presigned-URL flow. Each category has its own size cap and mime-type
// allow-list enforced at the service layer.
type UploadCategory string
const (
UploadCategoryCompletion UploadCategory = "completion"
UploadCategoryDocumentImage UploadCategory = "document_image"
UploadCategoryDocumentFile UploadCategory = "document_file"
)
// PendingUpload is a short-lived upload session created when the client asks
// for a presigned POST policy. The row tracks the intent so the server can
// validate quota / rate-limit / size up front, then attach the resulting B2
// object to a task_completion_image or document_image once the upload lands.
//
// Lifecycle:
//
// created → upload to B2 → attach via /api/task-completions/ or /documents/
// ↑ │
// └─ if not claimed before expires_at, the cleanup worker (see
// internal/worker/jobs) deletes the B2 object and the row.
type PendingUpload struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"column:user_id;not null;index:idx_pending_uploads_user_created,priority:1" json:"user_id"`
Category UploadCategory `gorm:"column:category;size:32;not null" json:"category"`
B2Key string `gorm:"column:b2_key;size:255;uniqueIndex" json:"b2_key"`
ContentType string `gorm:"column:content_type;size:127;not null" json:"content_type"`
ExpectedBytes int64 `gorm:"column:expected_bytes;not null" json:"expected_bytes"`
ActualBytes *int64 `gorm:"column:actual_bytes" json:"actual_bytes,omitempty"`
ClaimedAt *time.Time `gorm:"column:claimed_at" json:"claimed_at,omitempty"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index:idx_pending_uploads_user_created,priority:2,sort:desc" json:"created_at"`
ExpiresAt time.Time `gorm:"column:expires_at;not null" json:"expires_at"`
}
// TableName matches the goose migration.
func (PendingUpload) TableName() string {
return "pending_uploads"
}
// IsClaimed reports whether the upload has been linked to a real entity.
func (p *PendingUpload) IsClaimed() bool {
return p.ClaimedAt != nil
}
// IsExpired reports whether the upload session has passed its TTL.
func (p *PendingUpload) IsExpired(now time.Time) bool {
return now.After(p.ExpiresAt)
}
+3
View File
@@ -43,6 +43,9 @@ type UserSubscription struct {
// In-App Purchase data (Apple / Google)
AppleReceiptData *string `gorm:"column:apple_receipt_data;type:text" json:"-"`
GooglePurchaseToken *string `gorm:"column:google_purchase_token;type:text" json:"-"`
// AppleOriginalTransactionID binds an Apple subscription to one account
// (audit C5/C13). A partial unique index enforces one-account-per-txn.
AppleOriginalTransactionID *string `gorm:"column:apple_original_transaction_id;type:text" json:"-"`
// Stripe data (web subscriptions)
StripeCustomerID *string `gorm:"column:stripe_customer_id;size:255" json:"-"`

Some files were not shown because too many files have changed in this diff Show More