Files
Claude 5579006e79 Abonnemang: gratis start, månadsfaktura, och en låsning som aldrig rör historiken
Kontot startar på Free och stannar där tills någon väljer annat. En
provperiod som tyst börjar kosta är samma sorts fälla produkten finns för
att undvika. Fakturan skapas ändå vid registreringen — på noll kronor —
därför att den etablerar serien och visar kunden exakt vad den inte
betalar för.

Fyra nivåer: Free (hela metoden, två konton), Standard, Premium,
Enterprise. Enterprise har inget listpris i stället för ett påhittat.
Basavgift plus per aktiv användare, och nivån är en HÄNDELSE — vilken
nivå som gällde när en faktura utfärdades måste gå att svara på i
efterhand, och en kolumn som skrivits över kan inte svara.

Månadsjobbet är idempotent på perioden: kör det två gånger samma dygn och
den andra körningen fakturerar ingenting. Ett fakturajobb som
dubbelfakturerar vid en omstart är värre än ett som inte kör alls.
Perioden räknas från registreringsdagen, inte från den första i månaden —
den som registrerar sig den 20:e ska inte få en faktura på elva dagar som
sin första upplevelse av produkten.

PDF utan beroenden. Kravet var inga beroenden, och ett PDF-bibliotek drar
in fem till för att rita text i två spalter. PDF är ett textformat;
generatorn är tvåhundra rader som går att läsa. Renderad och verifierad i
Chromium. Kunden hämtar fakturan själv eller anger en adress — bägge
vägar leder till samma dokument, och e-post är ett tillägg, inte den enda
vägen till sitt eget underlag.

---- Låsningen och dess gräns ------------------------------------------

Vi litar på kunden: inget kort, ingen förskottsbetalning, ingen spärr
innan någon fått veta. Förfallen faktura ger synlig nedräkning i 14 dagar
och sedan stopp för nya ärenden.

Men läsning och export är sanna i ALLA tillstånd, och det är inte
generositet. Ärendeloggen är verkstadens underlag i en garanti- eller
försäkringstvist som gäller DERAS kund — en tredje part utan del i vår
obetalda faktura. Att göra det oåtkomligt vore att använda någon annans
rättsliga ställning som påtryckningsmedel. Prövat från flera håll,
eftersom det är en regel som är lätt att tumma på under press.

En nollfaktura kan aldrig låsa någon. Free fakturerar noll, och en
obetald nollfaktura är en bokföringspost, inte en skuld.

---- Tre fynd som testerna grävde fram ----------------------------------

1. En kuverterad nyckel gjorde HELA organisationens övriga ärenden
   oläsbara när huvudnyckeln saknades — nycklarFor laddar alla nycklar,
   och en enda som inte gick att öppna kastade. En delvis migrerad
   organisation blev alltså helt stum av ett fel som gällde ett ärende.
   Nu hoppas den över, med varning i loggen, och fälten maskeras — vilket
   dessutom är rätt utfall i sak: det är precis vad krypto-shredding
   lämnar efter sig.

2. Integrationstestet försökte backdatera abonnemangets registrering och
   stoppades av min egen trigger. Att testet fick böja sig för regeln och
   inte tvärtom är poängen med regeln.

3. Palettestet räknade upp färgerna som en andra lista — samma dubblering
   som M-7 handlade om. Det läser dem nu ur FARG, med ett tak på åtta
   värden som motvikt: en palett som växer förbi det är en färglåda.

491 enhetstester · 179 integrationskontroller · genomgången 4/4 ·
portalspärren 18/18.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
2026-08-06 15:55:13 +00:00

364 lines
9.6 KiB
Terraform

# Plattformstjänsten — systemets mitt.
#
# Äger: inloggning (bcrypt via pgcrypto, HS256-JWT med roll + organisation
# i anspråken), append-only händelse-API, Live Share med serverstyrd
# filtrering, organisationsinställningar, ECM-regelpaketet och de
# märkesspecifika kopplingarna.
#
# Ser tre hemligheter och inga fler: JWT-hemligheten (delas med
# orkestern), databaslösenordet och krypteringsnyckeln för kundernas
# leverantörsuppgifter.
resource "kubernetes_deployment_v1" "plattform" {
metadata {
name = "plattform"
namespace = kubernetes_namespace_v1.denna.metadata[0].name
labels = merge(local.etiketter, { app = "plattform" })
}
spec {
replicas = var.repliker.plattform
selector {
match_labels = { app = "plattform" }
}
template {
metadata {
labels = merge(local.etiketter, { app = "plattform" })
}
spec {
# Behöver token: IRSA hämtar AWS-uppgifter genom det.
service_account_name = kubernetes_service_account_v1.plattform.metadata[0].name
security_context {
run_as_non_root = true
seccomp_profile {
type = "RuntimeDefault"
}
}
container {
name = "plattform"
image = local.tjanster.plattform.bild
port {
container_port = local.port_container
}
# Alla hemligheter kommer ur den spegling External Secrets
# gör från Secrets Manager. Ingen av dem finns i Terraforms
# tillstånd eller i något manifest.
env_from {
secret_ref {
name = "felsokning-hemligheter"
}
}
# Klienten serveras från samma domän som API:t, så CORS behöver
# inte vara öppet.
env {
name = "TILLATNA_URSPRUNG"
value = "https://${local.aws.doman}"
}
env {
name = "REGISTRERING_OPPEN"
value = var.registrering_oppen ? "true" : "false"
}
env {
name = "TILLAT_INTERNA_UPPSLAG"
value = var.tillat_interna_uppslag ? "true" : "false"
}
# Bilagorna ligger i S3. Inga nycklar behövs — tjänsten
# signerar med tjänstekontots roll via IRSA.
env {
name = "BILAGE_LAGE"
value = "s3"
}
env {
name = "S3_HINK"
value = local.aws.bilage_hink
}
env {
name = "S3_REGION"
value = local.aws.region
}
env {
name = "S3_ENDPOINT"
value = "https://s3.${local.aws.region}.amazonaws.com"
}
resources {
requests = { cpu = "100m", memory = "128Mi" }
limits = { cpu = "1", memory = "512Mi" }
}
readiness_probe {
http_get {
path = "/halsa"
port = local.port_container
}
initial_delay_seconds = 3
period_seconds = 10
}
liveness_probe {
http_get {
path = "/halsa"
port = local.port_container
}
period_seconds = 15
}
security_context {
allow_privilege_escalation = false
read_only_root_filesystem = true
capabilities {
drop = ["ALL"]
}
}
}
}
}
}
}
resource "kubernetes_service_v1" "plattform" {
metadata {
name = "plattform"
namespace = kubernetes_namespace_v1.denna.metadata[0].name
labels = merge(local.etiketter, { app = "plattform" })
}
spec {
selector = { app = "plattform" }
port {
port = local.port_tjanst
target_port = local.port_container
}
}
}
resource "kubernetes_horizontal_pod_autoscaler_v2" "plattform" {
metadata {
name = "plattform"
namespace = kubernetes_namespace_v1.denna.metadata[0].name
labels = local.etiketter
}
spec {
min_replicas = var.repliker.plattform
max_replicas = var.max_repliker.plattform
scale_target_ref {
api_version = "apps/v1"
kind = "Deployment"
name = kubernetes_deployment_v1.plattform.metadata[0].name
}
metric {
type = "Resource"
resource {
name = "cpu"
target {
type = "Utilization"
average_utilization = 70
}
}
}
}
}
resource "kubernetes_pod_disruption_budget_v1" "plattform" {
metadata {
name = "plattform"
namespace = kubernetes_namespace_v1.denna.metadata[0].name
labels = local.etiketter
}
spec {
min_available = 1
selector {
match_labels = { app = "plattform" }
}
}
}
# ---- Gallringen (ALVA-PROC-0040 · TÜV T-4) -----------------------------
#
# Gallringsdatumet räknades fram vid avslut och lästes sedan aldrig av
# någon. Lagringsbegränsningen var en nedskriven avsikt, inte en
# verkställd kontroll — och eftersom ingenting konsumerade värdet märkte
# heller ingen att det under en period inte ens gick att skriva.
#
# Egen körning och inte en timer inne i webbtjänsten: en gallring som
# avbryts av en omstart mitt i ska kunna köras om, och den ska synas som
# en egen körning i driften i stället för att gömma sig i en podd som
# samtidigt betjänar trafik.
resource "kubernetes_cron_job_v1" "gallring" {
metadata {
name = "gallring"
namespace = kubernetes_namespace_v1.denna.metadata[0].name
labels = merge(local.etiketter, { app = "gallring" })
}
spec {
# Nattetid, en gång per dygn. Gallring är aldrig brådskande — ett
# dygns fördröjning är oproblematiskt, en för tidig gallring är
# oåterkallelig.
schedule = "17 2 * * *"
concurrency_policy = "Forbid"
successful_jobs_history_limit = 3
failed_jobs_history_limit = 3
starting_deadline_seconds = 3600
job_template {
metadata {
labels = merge(local.etiketter, { app = "gallring" })
}
spec {
# Misslyckas den lämnas nycklarna kvar. Det försiktiga utfallet:
# för tidig gallring går inte att ångra, en försenad gör det.
backoff_limit = 2
template {
metadata {
labels = merge(local.etiketter, { app = "gallring" })
}
spec {
restart_policy = "Never"
service_account_name = kubernetes_service_account_v1.plattform.metadata[0].name
security_context {
run_as_non_root = true
seccomp_profile {
type = "RuntimeDefault"
}
}
container {
name = "gallring"
image = local.tjanster.plattform.bild
command = ["node", "gallring.mjs"]
# Samma hemligheter som tjänsten: DATABASE_URL och
# PERSONNYCKEL_HUVUD behövs, inget annat.
env_from {
secret_ref {
name = "felsokning-hemligheter"
}
}
resources {
requests = {
cpu = "50m"
memory = "128Mi"
}
limits = {
memory = "256Mi"
}
}
}
}
}
}
}
}
}
# ---- Månadsfaktureringen (ALVA-PROC-0002) ------------------------------
#
# Egen körning av samma skäl som gallringen: en körning som avbryts mitt i
# ska kunna köras om, och den ska synas som en egen körning i driften.
#
# Jobbet är idempotent på perioden — andra körningen samma dygn fakturerar
# ingenting. Det är viktigare än det låter: ett fakturajobb som
# dubbelfakturerar vid en omstart är värre än ett som inte kör alls.
resource "kubernetes_cron_job_v1" "manadsfakturering" {
metadata {
name = "manadsfakturering"
namespace = kubernetes_namespace_v1.denna.metadata[0].name
labels = merge(local.etiketter, { app = "manadsfakturering" })
}
spec {
# Dagligen. Perioden avgör vem som faktureras, inte schemat — en
# organisation som registrerade sig den 20:e faktureras den 20:e.
schedule = "40 3 * * *"
concurrency_policy = "Forbid"
successful_jobs_history_limit = 7
failed_jobs_history_limit = 7
starting_deadline_seconds = 3600
job_template {
metadata {
labels = merge(local.etiketter, { app = "manadsfakturering" })
}
spec {
backoff_limit = 2
template {
metadata {
labels = merge(local.etiketter, { app = "manadsfakturering" })
}
spec {
restart_policy = "Never"
service_account_name = kubernetes_service_account_v1.plattform.metadata[0].name
security_context {
run_as_non_root = true
seccomp_profile {
type = "RuntimeDefault"
}
}
container {
name = "manadsfakturering"
image = local.tjanster.plattform.bild
command = ["node", "manadsfakturering.mjs"]
env_from {
secret_ref {
name = "felsokning-hemligheter"
}
}
resources {
requests = {
cpu = "50m"
memory = "128Mi"
}
limits = {
memory = "256Mi"
}
}
}
}
}
}
}
}
}