From 71aad7fc55da887b3f952a9b3db1abbbae77a94a Mon Sep 17 00:00:00 2001 From: Jelle Spreeuwenberg Date: Sat, 10 Oct 2026 15:58:44 +0200 Subject: [PATCH] install wardrowbe on orion --- README.md | 4 + docs/current-work.md | 19 ++ docs/design.md | 4 + modules/capabilities/wardrowbe/README.md | 131 ++++++++++++ modules/capabilities/wardrowbe/default.nix | 234 +++++++++++++++++++++ modules/facts.nix | 8 + modules/hosts/orion/default.nix | 1 + modules/secrets/secrets.yaml | 10 +- 8 files changed, 408 insertions(+), 3 deletions(-) create mode 100644 modules/capabilities/wardrowbe/README.md create mode 100644 modules/capabilities/wardrowbe/default.nix diff --git a/README.md b/README.md index 6b3e1c5..d215d94 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,10 @@ Both workstations import the provisional `work` profile. See company credentials and always-available read-only mounts. Only company-root and workbench environments are migrated; product/personal projects remain user-owned. +Orion's wardrobe service has its own +[setup and operations guide](modules/capabilities/wardrowbe/README.md). +Provision its SOPS secrets and two DNS records before building or activating Orion. + ## Maintenance ```console diff --git a/docs/current-work.md b/docs/current-work.md index dd4b854..16c5db4 100644 --- a/docs/current-work.md +++ b/docs/current-work.md @@ -18,6 +18,25 @@ Keep this record current by replacing superseded state. Pass-specific procedures stay here, not in AGENTS. The user must agree before the overall pass is complete. The unedited `~/sync/notes/setup-todo.md` remains an idea inbox, not authorization. +## Wardrowbe on Orion: implemented, awaiting provisioning and activation + +- Orion selects the Wardrowbe capability at `wardrobe.jelles.net`, with Tinyauth + password access at `wardrobe-auth.jelles.net`. Trusted household members select + their profile through Wardrowbe's development login after the gateway; profile + identity is not independently verified. +- Podman containers use immutable image digests, a dedicated network and persistent + named volumes. Database/queue health gates migrations and API startup; frontend + and workers wait for the API. Caddy protects all application routes; published + container ports are localhost-only. Internal AI is disabled everywhere. No + Codex bridge, Ollama or paid provider is configured. +- Four new system SOPS keys and both DNS records remain user-provisioned. Missing + SOPS keys must be supplied before Orion builds or activates. See + [Wardrowbe setup](../modules/capabilities/wardrowbe/README.md) for provisioning, + activation, operations, upgrade limits and manual acceptance. +- Focused source/configuration review only. No tests, host evaluation/build, + `just validate`, activation or remote changes. No existing secrets were decrypted + or read for discovery. + ## Implemented, reviewed and validated; awaiting user checkpoint - Flat capability owners; service category flattened. AI, theme, Handy and diff --git a/docs/design.md b/docs/design.md index f3cec68..e1f4557 100644 --- a/docs/design.md +++ b/docs/design.md @@ -23,6 +23,10 @@ LAN SSH on Polaris, and laptop power/firmware management on Zenith, are delibera Orion owns shared services. Tailnet administration supplements existing access; public Orion SSH closes only after private access is verified. Web services stay public. +Wardrowbe uses a password gateway for trusted household access, followed by profile +selection inside the app; verified individual identity is deferred. Its internal AI +is disabled. Paid API usage, a Codex bridge and local model integration are deferred. + Nix owns reproducible installation, integration and selected settings. Applications own credentials, sessions, content and deliberately interactive preferences. No plaintext secrets enter Git, Nix evaluation or store outputs. Company credentials diff --git a/modules/capabilities/wardrowbe/README.md b/modules/capabilities/wardrowbe/README.md new file mode 100644 index 0000000..c70193c --- /dev/null +++ b/modules/capabilities/wardrowbe/README.md @@ -0,0 +1,131 @@ +# Wardrowbe on Orion + +[Wardrowbe](https://github.com/Anyesh/wardrowbe) is served through the existing +Caddy instance. The capability owns container images, routing, runtime secret +templates and startup ordering. Image references and settings live in +`default.nix`; endpoints live in the shared service facts. + +Internal AI is disabled. Upload clothes and enter tags manually; automatic tagging +and AI outfit generation are unavailable. Local rotation and background removal +remain available. No AI provider key or notification provider is configured. + +Tinyauth provides a cookie-based password gateway. After authenticating there, +Wardrowbe's development login accepts an email/name to select a profile. Use a +consistent email for each person. This is for a trusted household: gateway users +can select one another's profiles. It is not verified individual identity. + +## Provision before building or activating Orion + +Add these top-level keys with `sops edit modules/secrets/secrets.yaml`, using the +existing system-secret recipients. The keys are deliberately not provisioned by +the configuration change; Orion's SOPS build checks require them to exist. + +| Key | Value | +| --- | --- | +| `wardrowbe-postgres-password` | A new 64-character hexadecimal password | +| `wardrowbe-secret-key` | A separate new 64-character hexadecimal signing secret | +| `wardrowbe-nextauth-secret` | A separate new 64-character hexadecimal session secret | +| `wardrowbe-auth-users` | A single-line `kiri:bcrypt-hash` string | + +Generate each of the first three separately with `openssl rand -hex 32`. If +OpenSSL is unavailable, use `nix run --quiet nixpkgs#openssl -- rand -hex 32`. +Keep values inside the encrypted document; do not paste them into Nix or chat. +Hexadecimal database passwords work directly in the application's connection URL. + +On a workstation with Podman, create the gateway user interactively: + +```console +podman run --rm -it ghcr.io/tinyauthapp/tinyauth:v5.2.0@sha256:220aa235c76393a73fece831d7ba967b8698d3907fd8e3c4c952f699c7b142a5 user create --interactive +``` + +Choose username `kiri` and your password, and answer **No** to Docker formatting. +Put the emitted `username:hash` string +in `wardrowbe-auth-users` as a quoted YAML scalar. Additional gateway accounts can +be added as a comma-separated list on the same line. If Podman is unavailable, +prefix its arguments with `nix run --quiet nixpkgs#podman --` instead of `podman`. +This command only creates a hash; gateway sessions persist separately on Orion. + +Create DNS records for `wardrobe.jelles.net` and `wardrobe-auth.jelles.net` pointing +at Orion. Publish an AAAA record only if Orion has working public IPv6. Existing +Caddy firewall access supplies HTTP/HTTPS and automatic certificates. + +The private container network reserves `10.89.30.0/24`, with gateway +`10.89.30.1`. It must not overlap Orion's routes. If changing it, update both the +network declaration and Tinyauth's trusted-proxy address before first activation. + +Add new source files to Git before using a Git-backed flake. From this repository, +activate explicitly: + +```console +nixos-rebuild switch --quiet --target-host orion --sudo --flake .#orion +``` + +The initial activation downloads container images, including the image-processing +model. Allow time and disk space for those downloads. Containers start on boot; +PostgreSQL and Redis become healthy before the backend runs `alembic upgrade head`. +The frontend and workers wait for the backend's health check. A failed migration +prevents the API from starting. Each container has a 15-minute startup deadline. + +## Operation + +Run these commands on Orion. Container names and unit names correspond directly +to the declarations in `default.nix`: + +```console +sudo systemctl status podman-wardrowbe-backend.service +sudo journalctl -u podman-wardrowbe-backend.service -n 80 --no-pager +sudo podman ps --filter name=wardrowbe +sudo podman volume ls --filter name=wardrowbe +``` + +Persistent named volumes contain PostgreSQL, Redis's append-only queue, uploaded +images and Tinyauth's SQLite sessions. They are root-owned Podman state under +`/var/lib/containers/storage/volumes`, outside Nix. Container recreation and system +rollback preserve these volumes; do not remove them or prune volumes. The network +is also retained when services stop. + +Only Caddy serves public traffic. Frontend, backend and gateway host ports bind to +localhost; database and queue ports remain inside the container network. Every +Wardrowbe route, including API and signed image URLs, passes through the gateway. +The gateway's own login endpoint is public. Gateway cookies are secure, HTTP-only +and scoped to `jelles.net` to support redirects between the two subdomains. + +SOPS renders root-only mode-0400 environment files at activation. Changes to their +contents restart the consuming containers. Rotating session secrets signs users out. +Changing `POSTGRES_PASSWORD` alone does **not** change an existing database role's +password: coordinate the database password change with the encrypted value. + +## Upgrade and recovery + +Review upstream release notes and migrations, then update the image tags and +manifest digests together. Upgrade backend and frontend to the same release; +workers use the backend image. Image updates happen through explicit configuration +activation, with no automatic image updater. Review resource requirements before +enabling AI later. + +If startup fails, inspect the failing container unit's journal. After correcting +the cause, start the affected units and any dependents whose startup was blocked. +To restart the complete application in dependency order: + +```console +sudo systemctl restart podman-wardrowbe-postgres.service podman-wardrowbe-redis.service podman-wardrowbe-backend.service podman-wardrowbe-worker.service podman-wardrowbe-image-worker.service podman-wardrowbe-frontend.service podman-wardrowbe-auth.service +``` + +Reverting a NixOS generation restores configuration and image references, not +database schema changes. Do not assume an older application can use a migrated +database; follow upstream recovery guidance. No backup system is added here. + +## Manual acceptance after activation + +- Open the wardrobe in a private browser window. It should require the gateway + password; unauthenticated API and image requests should also be blocked. +- Authenticate, select a Wardrowbe profile, complete onboarding and upload a photo. + Add tags manually and confirm the photo renders; try a rotation. +- With the gateway session active, open `/api/v1/capabilities` on the wardrobe + origin. Both internal AI capabilities should be `false`. +- Restart the application and confirm the profile, photo and tags remain present. +- Check that both public hostnames have valid HTTPS certificates and that the + existing Orion web services still work. + +No tests, builds, activation or browser acceptance checks were performed while +preparing this capability. diff --git a/modules/capabilities/wardrowbe/default.nix b/modules/capabilities/wardrowbe/default.nix new file mode 100644 index 0000000..e088376 --- /dev/null +++ b/modules/capabilities/wardrowbe/default.nix @@ -0,0 +1,234 @@ +{ config, ... }: +let + nixosModules = config.flake.modules.nixos; +in +{ + flake.modules.nixos.wardrowbe = + { config, lib, ... }: + let + service = config.facts.services.wardrowbe; + auth = config.facts.services.wardrowbe-auth; + podman = lib.getExe config.virtualisation.podman.package; + # Multi-architecture manifest digests; updating a tag alone cannot change an image. + images = { + backend = "ghcr.io/anyesh/wardrowbe:backend-1.10.3@sha256:75fd6f85fb7b91ccbb227b52d616bab11b5130c4e22858579f45736419fb592d"; + frontend = "ghcr.io/anyesh/wardrowbe:frontend-1.10.3@sha256:730cf52bd8a03b94473b129e9a988b3dae9818e9377fd88917029156ee7a2e17"; + auth = "ghcr.io/tinyauthapp/tinyauth:v5.2.0@sha256:220aa235c76393a73fece831d7ba967b8698d3907fd8e3c4c952f699c7b142a5"; + postgres = "docker.io/library/postgres:15-alpine@sha256:f7d23353e1b15400d22ebe31189f4d314b87a4c129cc400c8c2d8d4ca127bf81"; + redis = "docker.io/library/redis:7-alpine@sha256:858f009f9709ce576febc734aa78b8f6d624b82571f9ddb6bda4377c833b3499"; + }; + healthOptions = [ + "--health-interval=10s" + "--health-timeout=5s" + "--health-start-period=30s" + "--health-retries=6" + "--health-on-failure=kill" + ]; + backendContainer = { + image = images.backend; + environment = { + # Profile selection is available only behind the password gateway. + DEBUG = "true"; + AI_INTERNAL_ENABLED = "false"; + AI_VISION_ENABLED = "false"; + AI_TEXT_ENABLED = "false"; + APP_URL = service.origin; + CORS_ORIGINS = builtins.toJSON [ service.origin ]; + REDIS_URL = "redis://wardrowbe-redis:6379/0"; + STORAGE_PATH = "/data/uploads"; + LOG_LEVEL = "INFO"; + }; + environmentFiles = [ config.sops.templates."wardrowbe-backend.env".path ]; + volumes = [ "wardrowbe-uploads:/data/uploads" ]; + }; + backendUnits = [ + "podman-wardrowbe-backend.service" + "podman-wardrowbe-worker.service" + "podman-wardrowbe-image-worker.service" + ]; + containers = { + wardrowbe-postgres = { + image = images.postgres; + environment = { + POSTGRES_USER = "wardrobe"; + POSTGRES_DB = "wardrobe"; + }; + environmentFiles = [ config.sops.templates."wardrowbe-postgres.env".path ]; + volumes = [ "wardrowbe-postgres:/var/lib/postgresql/data" ]; + podman.sdnotify = "healthy"; + extraOptions = healthOptions ++ [ + "--health-cmd=pg_isready -h 127.0.0.1 -U wardrobe -d wardrobe" + ]; + }; + wardrowbe-redis = { + image = images.redis; + cmd = [ + "redis-server" + "--appendonly" + "yes" + ]; + volumes = [ "wardrowbe-redis:/data" ]; + podman.sdnotify = "healthy"; + extraOptions = healthOptions ++ [ "--health-cmd=redis-cli ping" ]; + }; + wardrowbe-backend = backendContainer // { + dependsOn = [ + "wardrowbe-postgres" + "wardrowbe-redis" + ]; + ports = [ "127.0.0.1:8002:8000" ]; + cmd = [ + "sh" + "-c" + "alembic upgrade head && exec uvicorn app.main:app --host 0.0.0.0 --port 8000" + ]; + podman.sdnotify = "healthy"; + extraOptions = healthOptions ++ [ + "--health-cmd=curl --fail --silent http://127.0.0.1:8000/api/v1/health" + ]; + }; + wardrowbe-worker = backendContainer // { + dependsOn = [ "wardrowbe-backend" ]; + cmd = [ + "arq" + "app.workers.worker.WorkerSettings" + ]; + }; + wardrowbe-image-worker = backendContainer // { + dependsOn = [ "wardrowbe-backend" ]; + cmd = [ + "arq" + "app.workers.image_worker.ImageWorkerSettings" + ]; + }; + wardrowbe-frontend = { + image = images.frontend; + dependsOn = [ "wardrowbe-backend" ]; + environment = { + BACKEND_URL = "http://wardrowbe-backend:8000"; + NEXTAUTH_URL = service.origin; + DEV_MODE = "true"; + }; + environmentFiles = [ config.sops.templates."wardrowbe-frontend.env".path ]; + ports = [ "127.0.0.1:3002:3000" ]; + podman.sdnotify = "healthy"; + extraOptions = healthOptions ++ [ + "--health-cmd=node -e \"fetch('http://127.0.0.1:3000').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))\"" + ]; + }; + wardrowbe-auth = { + image = images.auth; + environment = { + TINYAUTH_APPURL = auth.origin; + TINYAUTH_AUTH_SECURECOOKIE = "true"; + TINYAUTH_AUTH_TRUSTEDPROXIES = "10.89.30.1"; + TINYAUTH_LABELPROVIDER = "none"; + TINYAUTH_ANALYTICS_ENABLED = "false"; + TINYAUTH_DATABASE_PATH = "/data/tinyauth.db"; + TINYAUTH_UI_TITLE = "Wardrobe login"; + }; + environmentFiles = [ config.sops.templates."wardrowbe-auth.env".path ]; + volumes = [ "wardrowbe-auth:/data" ]; + ports = [ "127.0.0.1:3003:3000" ]; + podman.sdnotify = "healthy"; + extraOptions = healthOptions ++ [ "--health-cmd=tinyauth healthcheck" ]; + }; + }; + in + { + imports = [ + nixosModules.caddy + nixosModules.sops + ]; + + sops.secrets = { + wardrowbe-postgres-password = { }; + wardrowbe-secret-key = { }; + wardrowbe-nextauth-secret = { }; + wardrowbe-auth-users = { }; + }; + sops.templates = { + "wardrowbe-postgres.env" = { + content = '' + POSTGRES_PASSWORD=${config.sops.placeholder.wardrowbe-postgres-password} + ''; + restartUnits = [ "podman-wardrowbe-postgres.service" ]; + }; + "wardrowbe-backend.env" = { + content = '' + DATABASE_URL=postgresql+asyncpg://wardrobe:${config.sops.placeholder.wardrowbe-postgres-password}@wardrowbe-postgres:5432/wardrobe + SECRET_KEY=${config.sops.placeholder.wardrowbe-secret-key} + ''; + restartUnits = backendUnits; + }; + "wardrowbe-frontend.env" = { + content = '' + NEXTAUTH_SECRET=${config.sops.placeholder.wardrowbe-nextauth-secret} + ''; + restartUnits = [ "podman-wardrowbe-frontend.service" ]; + }; + "wardrowbe-auth.env" = { + content = '' + TINYAUTH_AUTH_USERS=${config.sops.placeholder.wardrowbe-auth-users} + ''; + restartUnits = [ "podman-wardrowbe-auth.service" ]; + }; + }; + + virtualisation.oci-containers = { + backend = "podman"; + containers = lib.mapAttrs ( + _: container: + { + networks = [ "wardrowbe" ]; + } + // container + ) containers; + }; + + systemd.services = { + wardrowbe-network = { + description = "Wardrowbe container network"; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + script = '' + ${podman} network exists wardrowbe || \ + ${podman} network create --subnet 10.89.30.0/24 --gateway 10.89.30.1 wardrowbe + ''; + }; + } + // lib.mapAttrs' ( + name: _: + lib.nameValuePair "podman-${name}" { + requires = [ "wardrowbe-network.service" ]; + after = [ "wardrowbe-network.service" ]; + serviceConfig.TimeoutStartSec = lib.mkForce 900; + } + ) containers; + + services.caddy.virtualHosts = { + ${auth.domain}.extraConfig = "reverse_proxy 127.0.0.1:3003"; + ${service.domain}.extraConfig = '' + request_body { + max_size 50MB + } + route { + forward_auth 127.0.0.1:3003 { + uri /api/auth/caddy + } + handle /api/auth/* { + reverse_proxy 127.0.0.1:3002 + } + handle /api/* { + reverse_proxy 127.0.0.1:8002 + } + handle { + reverse_proxy 127.0.0.1:3002 + } + } + ''; + }; + }; +} diff --git a/modules/facts.nix b/modules/facts.nix index 7238216..38ece2b 100644 --- a/modules/facts.nix +++ b/modules/facts.nix @@ -235,6 +235,14 @@ in scheme = "https"; domain = "vault.jelles.net"; }; + wardrowbe = { + scheme = "https"; + domain = "wardrobe.jelles.net"; + }; + wardrowbe-auth = { + scheme = "https"; + domain = "wardrobe-auth.jelles.net"; + }; }; externalDevices.pixel-10.syncthingId = "MTJHEHA-UMZDQZ7-BTMRRLQ-Y7BFPUJ-ZTY6LZX-PDXV3IS-XVJCU7B-EPETBQZ"; diff --git a/modules/hosts/orion/default.nix b/modules/hosts/orion/default.nix index 6f5a504..22511b6 100644 --- a/modules/hosts/orion/default.nix +++ b/modules/hosts/orion/default.nix @@ -24,6 +24,7 @@ in nixosModules.actual nixosModules.gitea nixosModules.transcription + nixosModules.wardrowbe ./_hardware.nix ./_disk.nix ]; diff --git a/modules/secrets/secrets.yaml b/modules/secrets/secrets.yaml index 9fdc50a..4a7d577 100644 --- a/modules/secrets/secrets.yaml +++ b/modules/secrets/secrets.yaml @@ -5,6 +5,10 @@ orion-ip: ENC[AES256_GCM,data:S6fpCWnD8dvchvrHlEo=,iv:72+oRxHUEJ7imJ+sWjGbG+TUrS hashed-password: ENC[AES256_GCM,data:mhYvALF/VrPMFFYEIGiML1MoRxdOI/J9akZPD+OF5drDTR4J5LBn3EejlbzP8CTCRsyoIF6tigMBxF8ZLz9W7hOVwT70Hr/yXfLWJ2lfBsf7l/SPZ+6NglKppCeQQktm4QKIHTVj7CkbRw==,iv:0RxXkb+vz/QK9dvecCZpWCWCsoPhi2F3OCt6CwUDqX4=,tag:j7QO8xUfWUnSpk4mMQUYmA==,type:str] gemini-api-key-neovim: ENC[AES256_GCM,data:B8FeFt45FsU3aagyLDKXiwmx0mRrsw4C8RQ3EWXwZ+YfWLMvwJad,iv:1HqBD6vc07Ke/PMYXfHqFrWDGw/UMjiiBjLRN33/xHI=,tag:czcrYGbJFi41rYtIPM4qTQ==,type:str] booklore-db-pass: ENC[AES256_GCM,data:dlPGXQ24itEaBRJSJ9WOogWCdF3atFQ2ZtlLGyGq8Tin5OmSZI6lZUzSE+femBW5SBTIlKQvzHEPCs9MT5tyMIqetzGLm+mMN3FDW7si684Cuv9z9Uq5gjAZWh14KQMWYPI=,iv:oLnqu2EDFBVcBpswVRXXeF617YolPxOUx9CscHRRn/8=,tag:Si6gF1EXhcHalk11D3Exlw==,type:str] +wardrowbe-auth-users: ENC[AES256_GCM,data:0padDqNkWA9p6GXaX3bpyH78tEeOndlX6/Gm0OzsfecnPkJmBJUQZr/328+51Q4eWPRflg4NRm04nejrqohKGcs=,iv:VJQD/ko4B18PbJh0gWdfQJ3xlGeHW4oBlho4e3Bkhk8=,tag:iE7gfUi1khmwIJA/3L8zvA==,type:str] +wardrowbe-postgres-password: ENC[AES256_GCM,data:3t+py1nmtzbdFFMbBtQXQKttRj1MQrz+wcfjQGPYahOaQTIDu1Pzk3KJf+LANCTftpvrwO6N92K0b5q0+1MEzw==,iv:o9Hiew2vx6OinLBOKgPyx0K+WVTAvPopcEJoLtYCZTY=,tag:xi0wueITInYXwvVaLusqyw==,type:str] +wardrowbe-secret-key: ENC[AES256_GCM,data:c9++BrtGsAFHML3gUG/E319jg9kxixH8RfZqs9pymxjYEFJsclaiWmknqeel4luIAuD5ufvDRzBLqxaBSE6rhQ==,iv:YntoeASLdE+lbU+15eIaR4SqWU3PzaWH8MJFlW1FwaM=,tag:aZhDTPc3qbNTZ8N9lvCJgA==,type:str] +wardrowbe-nextauth-secret: ENC[AES256_GCM,data:3UZm0L+0q7C6OVYfCyIghzK80dPKOOz9BLfFCHctD2oMckPl0yfL5S1NqsXULycx+ctwWPV7SQj7/AiD6cyw1w==,iv:SocQDS2GbQ6ZWifmwb7AJC7SNkAMjJWetlTM9TfUC3Q=,tag:WuXajPY4e4zF5T6zC7okTQ==,type:str] sops: age: - enc: | @@ -25,7 +29,7 @@ sops: sWu/+CcQDpUu4tAeBSCUZ/BV2nTvfb94DZGK2dWUFniJaZit5tRZlg== -----END AGE ENCRYPTED FILE----- recipient: age1upsxyevxdv3xax8jhnffclryuve70k63cjrca69ffds4h2m3nvuql59ufw - lastmodified: "2026-08-08T14:56:00Z" - mac: ENC[AES256_GCM,data:pXcvH73xNMSkOGytdhJ4Qu1pH8d3qS/FkPfGvbkzQoNLKRlLiArJExPYIkEtFdr7mDhYer91wPtkJu2HyyxCVPkPp8EFaeVdGuFufLQruQjP2KRfR841hohVW4KN53nh1AJT22CW2LTqgS0BnTBrd6wUQWvBHdek99eh8vwgurI=,iv:d8I+vKIgfirDvkHXVSNWJrKUO5vACkbk3VSN8TXSE+Q=,tag:itzAvtD+aBFmDilxZBepLw==,type:str] + lastmodified: "2026-10-10T13:58:26Z" + mac: ENC[AES256_GCM,data:S21qc5jLIoRrR28yiNmQHAU9soV74haGAbYXxsut0PqeRAMzUj4RSdBiR7Mja84r+w148Gx4GqTrPLhVxfj+78lyWcV3MWYbf9rr4tbxUYbhlJPRwVPdeP/JEc4rhPzX7QnUcrlym2zOhumJ/RoUHWW2yUEC/7DmzCqMXH1Kx5U=,iv:p7lwWl76cvELLhMlOdnxFdYdoIt/tVwxL/ihR/T3d7Y=,tag:DWsJYfaIZ2yZqXPktl9V1g==,type:str] unencrypted_suffix: _unencrypted - version: 3.12.2 + version: 3.13.3