From 9c89ff5db116cb6d7aee197a92d6db9b7ca9000d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=A4fer?= Date: Thu, 1 Oct 2026 13:52:34 +0200 Subject: [PATCH] feat(resume): commit masked client names so builds need no sops key CI and the Netlify build needed SOPS_AGE_KEY only to decrypt the client names and mask them right away. Netlify exposes build-scoped variables to the whole build, including its automatic `pnpm install`, so the key could not be kept away from dependency code there. `pnpm mask-clients` now decrypts locally and writes the masked names as plain fields next to the encrypted ones. Normal builds read those, and only RESUME_MODE=unredacted still calls sops. It has to be run after editing encrypted fields; a build fails if an entry has encrypted fields but no masked ones, which catches a forgotten run for a new client, but not a renamed one. Masked and unredacted .generated output is byte-identical to before. Verified with `pnpm check:quick` and a Netlify-like build in a node:26 container without any key. Closes #141 --- .github/workflows/ci.yml | 11 +-- AGENTS.md | 10 +-- README.md | 14 +-- netlify.toml | 8 +- package.json | 1 + resume.i18n.json | 72 +++++++++++---- scripts/generate-resume-source.ts | 17 ++-- scripts/mask-clients.ts | 23 +++++ scripts/netlify-build.sh | 20 +---- src/lib/utils/resolve-sops-fields.test.ts | 94 +++++++++++++++++++ src/lib/utils/resolve-sops-fields.ts | 105 +++++++++++++++++----- 11 files changed, 283 insertions(+), 92 deletions(-) create mode 100644 scripts/mask-clients.ts create mode 100644 src/lib/utils/resolve-sops-fields.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38876ea..9b47834 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,20 +27,14 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - # SOPS_AGE_KEY is attached only to steps that actually decrypt - # resume.i18n.json, not at job level, so checkout/mise/install never see it. - name: Quick verification run: pnpm check:quick - env: - SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }} # Builds and serves the full site (including PDFs) to run these tests - # this is also the ./build the Lighthouse step below reuses, so it's # never rebuilt a second time. - name: E2E tests run: pnpm test:e2e - env: - SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }} # This job runs inside the Playwright container, so Chrome isn't on the # path Lighthouse's launcher checks. Resolve it via Playwright's own API @@ -49,9 +43,8 @@ jobs: id: chrome run: echo "path=$(node -e "console.log(require('@playwright/test').chromium.executablePath())")" >> "$GITHUB_OUTPUT" - # No buildCommand/env here: lighthouserc.json's staticDistDir already - # points at ./build from the E2E step above, so this neither rebuilds - # nor needs the decryption key. + # No buildCommand here: lighthouserc.json's staticDistDir already + # points at ./build from the E2E step above, so this doesn't rebuild. - name: Run Lighthouse CI uses: treosh/lighthouse-ci-action@v12 with: diff --git a/AGENTS.md b/AGENTS.md index 4b0973e..c73eb51 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,14 +74,14 @@ The authored resume source is: There are no generated `resume.de.json`/`resume.en.json` files committed to the repo — they're gitignored and regenerated fresh by `pnpm build:json-resume` (`scripts/generate-resume-source.ts`): -1. Decrypt `resume.i18n.json` (`sops -d`) and mask redacted-client fields (see "Redacted Clients" below), writing the result to `.generated/resume-source.json` (locale-agnostic, never committed). +1. Read `resume.i18n.json` and resolve redacted-client fields to their committed masked values (see "Redacted Clients" below), writing the result to `.generated/resume-source.json` (locale-agnostic, never committed). 2. `deriveResume` (`src/lib/utils/derive-resume.ts`, a pure function) localizes that into a single locale's JSON Resume data, sorts dated sections, and computes skills. Written to `.generated/resume.de.json` / `.generated/resume.en.json`. -This avoids keeping derived copies in sync, and avoids stale generated output (e.g. duration text computed from "now") ever getting committed. `build:json-resume` is the only place `sops`/decryption is ever invoked — everything downstream (`src/lib/data/resume.ts`, `scripts/build-typst-data.ts`, tests) just reads the generated per-locale files as plain data, so none of it needs to be Node-only or server-only. +This avoids keeping derived copies in sync, and avoids stale generated output (e.g. duration text computed from "now") ever getting committed. `build:json-resume` (in `RESUME_MODE=unredacted` only) and `pnpm mask-clients` are the only places `sops`/decryption is ever invoked — everything downstream (`src/lib/data/resume.ts`, `scripts/build-typst-data.ts`, tests) just reads the generated per-locale files as plain data, so none of it needs to be Node-only or server-only. The pipeline is: -- `resume.i18n.json` -> (`pnpm build:json-resume`, decrypt + mask, one Node script) -> `.generated/resume-source.json` +- `resume.i18n.json` -> (`pnpm build:json-resume`, resolve masked fields, one Node script) -> `.generated/resume-source.json` - `.generated/resume-source.json` -> (`deriveResume`, pure, once per locale) -> `.generated/resume.de.json` / `.generated/resume.en.json` - `.generated/resume..json` -> web rendering (`src/lib/data/resume.ts` imports both generated files directly) - `.generated/resume..json` -> Typst-ready data (`scripts/build-typst-data.ts` imports the same generated files directly) @@ -105,7 +105,7 @@ Fields prefixed `sopsEncrypted` (`sopsEncryptedEntity`, `sopsEncryptedUrl`, `sop This is a scoped mitigation against low-effort CV/data farming (bots and scrapers reading the current tree or the deployed site), not a guarantee that these identities are unreachable in this public repo altogether. Earlier commits still contain the plaintext values in `resume.i18n.json` blobs from before they were encrypted, and that's an accepted tradeoff, not an oversight — a recruiter who goes digging through Git history is out of scope for this protection. **Do not rewrite Git history to purge old blobs** as a way to "fully" fix this, whether in response to review feedback or otherwise, without being explicitly asked; that's a judgment call for Robert, not something to do preemptively. If plaintext history turns out to be trivially discoverable by an AI agent in a way that matters, that's a reason to revisit later, not a bug to fix now. -Edit with `sops resume.i18n.json` or view with `sops -d resume.i18n.json`; `biome.json` excludes this file so it never fights `sops`'s formatting. `.sops.yaml` lists two age recipients: a personal key for local interactive edits, and a CI-only key (its private half lives in the `SOPS_AGE_KEY` GitHub Actions secret, attached only to the individual CI steps that actually invoke `sops` — never at job level, to keep it out of reach of unrelated steps like checkout or dependency install; the same key is a Netlify build environment variable, which `scripts/netlify-build.sh` removes from the environment before installing tools and passes only to `pnpm build`) so `pnpm build`/`pnpm check:quick` can decrypt in CI without ever exposing the personal key. +Edit with `sops resume.i18n.json` or view with `sops -d resume.i18n.json`; `biome.json` excludes this file so it never fights `sops`'s formatting. After changing an encrypted field, run `pnpm mask-clients`: it writes the masked value as a plain field next to each encrypted one (e.g. `entity: "ta***bH"` next to `sopsEncryptedEntity`) and flags the entry `redacted: true`. Builds read only these committed masked values, so CI and Netlify need no sops key. A build fails if an entry has encrypted fields but no masked ones, which catches a forgotten run for a new client; a changed name of an existing client is not detected, so don't skip the command. ## PDF And Print @@ -329,7 +329,7 @@ Every `build:*` script does exactly one job and can be run standalone; `pnpm bui ```bash pnpm build:paraglide # compile Paraglide UI messages -pnpm build:json-resume # decrypt + mask resume.i18n.json -> .generated/resume.{de,en}.json (gitignored, requires a sops key) +pnpm build:json-resume # resolve masked fields of resume.i18n.json -> .generated/resume.{de,en}.json (gitignored) pnpm build:typst-data # -> typst/content/{de,en}.typ.json (implies build:json-resume) pnpm build:pdf # -> static/{de,en}/*.pdf (implies build:json-resume; internally also runs build-typst-data.ts) pnpm build:vite # vite build -> build/ diff --git a/README.md b/README.md index 95375b8..0af526f 100644 --- a/README.md +++ b/README.md @@ -142,16 +142,10 @@ Important deployment behavior: ## PDF Build And Deploy - Netlify builds the site, including the PDFs, from its own repository checkout. -- `Typst`, `sops` and `age` are not part of Netlify's build image, so `scripts/netlify-build.sh` installs them with mise first. It does that only on Netlify's build servers (`NETLIFY=true`); local builds, e.g. with `netlify deploy`, need the tools on `PATH` already. `mise.toml` sets them to `latest`, so every build uses their newest release. -- Netlify exposes `SOPS_AGE_KEY` to the whole build, including its automatic `pnpm install`. `scripts/netlify-build.sh` keeps the key away from mise and hands it only to `pnpm build`. +- `Typst` is not part of Netlify's build image, so `scripts/netlify-build.sh` installs it with mise first. It does that only on Netlify's build servers (`NETLIFY=true`); local builds, e.g. with `netlify deploy`, need Typst on `PATH` already. `mise.toml` sets it to `latest`, so every build uses its newest release. +- Builds need no secrets: they use the masked client names committed in `resume.i18n.json` (see "Redacted Clients" in `AGENTS.md`). - Vendored `ttf` or `otf` fonts under `typst/fonts/` are required for the PDF build. See [`typst/README.md`](/home/robert/Development/schaefer-development/roschaefer.de/typst/README.md). -Required Netlify environment variable (mark it secret, scope it to builds): - -- `SOPS_AGE_KEY` (a dedicated age private key for decrypting `resume.i18n.json` - see "Redacted Clients" in `AGENTS.md`) - -GitHub Actions only runs checks. It needs `SOPS_AGE_KEY` as a repository secret too, but no Netlify credentials. - ## SEO / Social Metadata The site includes: @@ -171,5 +165,5 @@ The current social preview image is: - Print output is optimized primarily for Firefox. - The web version and print version share the same content source. - Generated Paraglide files in `src/lib/paraglide` are never committed (the directory has its own nested `.gitignore`) and are recompiled on every `dev`/`build`/`check`/`test` run. -- The decrypted, masked resume source (`.generated/resume-source.json`, `.generated/resume.de.json`, `.generated/resume.en.json`) is likewise gitignored and always regenerated fresh - see `AGENTS.md` for why. -- CI needs a `SOPS_AGE_KEY` repository secret (a dedicated age private key, separate from any personal key) to decrypt `resume.i18n.json` during `pnpm build`/`pnpm check:quick`. +- The masked resume source (`.generated/resume-source.json`, `.generated/resume.de.json`, `.generated/resume.en.json`) is likewise gitignored and always regenerated fresh - see `AGENTS.md` for why. +- After editing the encrypted client fields with `sops resume.i18n.json`, run `pnpm mask-clients` to update the committed masked names. diff --git a/netlify.toml b/netlify.toml index 4483783..ff8b10f 100644 --- a/netlify.toml +++ b/netlify.toml @@ -1,14 +1,14 @@ -# Typst, sops and age are not part of Netlify's build image, so the build -# installs them with mise before running the site build. +# Typst is not part of Netlify's build image, so the build installs it with +# mise before running the site build. [build] command = "scripts/netlify-build.sh" publish = "build" # Netlify installs Node and pnpm itself and runs `pnpm install` before the -# build command, so mise only has to provide the remaining tools. +# build command. sops and age are only needed locally (`pnpm mask-clients`). [build.environment] NODE_VERSION = "26" -MISE_DISABLE_TOOLS = "node,pnpm" +MISE_DISABLE_TOOLS = "node,pnpm,sops,age" [[redirects]] from = "/linkedin" diff --git a/package.json b/package.json index 60d0468..22c15e5 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "scripts": { "build:paraglide": "paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide --emit-ts-declarations", "build:json-resume": "node scripts/generate-resume-source.ts", + "mask-clients": "node scripts/mask-clients.ts", "build:typst-data": "pnpm build:json-resume && node scripts/build-typst-data.ts", "build:pdf": "pnpm build:json-resume && node scripts/build-pdfs.ts", "build:vite": "vite build", diff --git a/resume.i18n.json b/resume.i18n.json index 3ece344..11771be 100644 --- a/resume.i18n.json +++ b/resume.i18n.json @@ -344,8 +344,10 @@ "en": "This SvelteKit application for AI-assisted interviews and editorial publishing replaces brittle infrastructure spread across Airtable, Voiceflow, and additional custom services with a single serverless application. Using the built-in workflows of ElevenLabs AI Agents significantly reduces maintenance overhead and complexity while improving the phone agent's response time by several seconds, which is crucial for a good user experience." }, "type": "experience", + "entity": "ta***bH", "sopsEncryptedEntity": "ENC[AES256_GCM,data:SNhET44YI9q9sSQcpNmjYEE=,iv:p+GggAMwCKDpubrs9Y7liBwHS2Jo0rlMvq/RvgeRgCA=,tag:XZ15Wu7xHzXPGumyE3Nf4g==,type:str]", - "sopsEncryptedUrl": "ENC[AES256_GCM,data:ypykDECVJtKwJ9iumPXaXg0XAfCW9Cho/VTjYZOv//CWKDP3i4CBHmFysYHebQ==,iv:3lMYJN/E3Fuma/JhyluNTq5c0lub3p3jlqEi0nGGLIc=,tag:Lhs6sn5dw/WhyLvE20RF/w==,type:str]" + "sopsEncryptedUrl": "ENC[AES256_GCM,data:ypykDECVJtKwJ9iumPXaXg0XAfCW9Cho/VTjYZOv//CWKDP3i4CBHmFysYHebQ==,iv:3lMYJN/E3Fuma/JhyluNTq5c0lub3p3jlqEi0nGGLIc=,tag:Lhs6sn5dw/WhyLvE20RF/w==,type:str]", + "redacted": true }, { "id": "remote-telescope-platform", @@ -379,7 +381,9 @@ "en": "With this platform for remotely controlling telescopes through the astrophotography software NINA, deep-sky imaging gets accessible without owning expensive hardware or the expertise to operate it. Users select an astronomical target and book observation time; the application sends imaging sequences to NINA via Tailscale, which executes them overnight and delivers the results. The core challenge is turning a Windows-only, inherently interactive application into a fully autonomous pipeline." }, "type": "experience", - "sopsEncryptedEntity": "ENC[AES256_GCM,data:1OjXKegxjXmE+jK7I1or2/SigDYf,iv:x7MCTpka06ed/DxgnmAGEzcLD1QdPNnGJ7CPd5wX68Y=,tag:HXMQiLZpkImz7fMjJSq8HA==,type:str]" + "entity": "Bo***bH", + "sopsEncryptedEntity": "ENC[AES256_GCM,data:1OjXKegxjXmE+jK7I1or2/SigDYf,iv:x7MCTpka06ed/DxgnmAGEzcLD1QdPNnGJ7CPd5wX68Y=,tag:HXMQiLZpkImz7fMjJSq8HA==,type:str]", + "redacted": true }, { "id": "entrepreneur-social-platform", @@ -416,8 +420,10 @@ "en": "For this free and open-source social platform for entrepreneurs, the delivery and infrastructure architecture was fundamentally reworked after deployments had previously been performed manually via a remote desktop on the target server, regularly causing downtime. Moving the project to a self-managed Kubernetes setup on Hetzner with Terraform gave every developer production-like test environments for their own branches while keeping operating costs extremely low at under 20 euros per month." }, "type": "experience", + "entity": "***", "sopsEncryptedEntity": "ENC[AES256_GCM,data:RWsSPQ==,iv:FY0IQBeXQFaOHahgB4/w5DHKeg9f6v4ScNVpWGfva0E=,tag:l0h5g2P8WN385KpvBXqkDg==,type:str]", - "sopsEncryptedUrl": "ENC[AES256_GCM,data:9QwRC/6a//4m5wnuvmwTwWZ4uaRK8yHk,iv:C4f0ITLBXi80PlDpOH6P6ilTLCtVmfzYKX+gpobxGBU=,tag:Dcm0R0411BR7wwf5345iHw==,type:str]" + "sopsEncryptedUrl": "ENC[AES256_GCM,data:9QwRC/6a//4m5wnuvmwTwWZ4uaRK8yHk,iv:C4f0ITLBXi80PlDpOH6P6ilTLCtVmfzYKX+gpobxGBU=,tag:Dcm0R0411BR7wwf5345iHw==,type:str]", + "redacted": true }, { "id": "audience-engagement-platform", @@ -451,8 +457,10 @@ "en": "For this platform for audience engagement and crowd-based reporting in journalism, the Ruby on Rails application integrates messengers such as WhatsApp, Telegram, Signal, Threema, and email, allowing editorial teams to reach people through channels they already use. Its core differentiator is a direct 1:1 conversation between journalists and their audience, turning a newsroom's own community into a valuable asset for reporting and research." }, "type": "experience", + "entity": "ta***bH", "sopsEncryptedEntity": "ENC[AES256_GCM,data:hsX6keEmzrEOprq56/g/7v4=,iv:l+T9KN0scUXvHpDbPCnavnOb8WI1TfrTXlIw+a8iAVg=,tag:rAUDS8UFwV9bYcGdzMqXNQ==,type:str]", - "sopsEncryptedUrl": "ENC[AES256_GCM,data:1QMb0ZkLcr1SKVfqgQXC/7m9rf2kYiLmf4M8Q7rlbEfrY1db4388iac+sHhW2g==,iv:zAuuGpeIoqgPxtLRQ+Ar7CcutBzap01n/Viwlvbe83s=,tag:fBrqqRHBoLOxlSSgmS6nmg==,type:str]" + "sopsEncryptedUrl": "ENC[AES256_GCM,data:1QMb0ZkLcr1SKVfqgQXC/7m9rf2kYiLmf4M8Q7rlbEfrY1db4388iac+sHhW2g==,iv:zAuuGpeIoqgPxtLRQ+Ar7CcutBzap01n/Viwlvbe83s=,tag:fBrqqRHBoLOxlSSgmS6nmg==,type:str]", + "redacted": true }, { "id": "automotive-price-comparison", @@ -487,8 +495,10 @@ "en": "For this software for the automotive aftermarket that helps repair shops compare prices across wholesalers, an Electron desktop application was migrated to a cloud-based platform on Google Cloud, Auth0, and Hasura. This significantly improved software quality and increased the number of connected wholesalers from 3 to 240." }, "type": "experience", + "entity": "au***bH", "sopsEncryptedEntity": "ENC[AES256_GCM,data:fFt0czt7OBqrKDU=,iv:FaqCWWNlK7VAQxXCcCEANX2R/Zf4ApS5N6iCgGnED5E=,tag:yZyU92PBf/dNsft2WKcXlw==,type:str]", - "sopsEncryptedUrl": "ENC[AES256_GCM,data:WVrq5ARLlazLoR/SqlRz1cF+MWi60Q==,iv:FtKlv1XyOodRZnH4osFPcPzZ1IKiElWZcagPEYzCCL0=,tag:AwZlYpUFJbixZ3i1rJJzqg==,type:str]" + "sopsEncryptedUrl": "ENC[AES256_GCM,data:WVrq5ARLlazLoR/SqlRz1cF+MWi60Q==,iv:FtKlv1XyOodRZnH4osFPcPzZ1IKiElWZcagPEYzCCL0=,tag:AwZlYpUFJbixZ3i1rJJzqg==,type:str]", + "redacted": true }, { "id": "retail-website-relaunch", @@ -518,8 +528,10 @@ "en": "Rebuilt a retail client's website with SvelteKit and Hygraph and deployed it statically on Netlify. Server-side logic runs in serverless functions, and content is loaded from the headless CMS via GraphQL. The relaunch improved performance, SEO, and maintainability." }, "type": "experience", + "entity": "Jo***er", "sopsEncryptedEntity": "ENC[AES256_GCM,data:csm0wl6LCfeD2TMj+pYxKuw=,iv:X2F4zXQZXxcahWK17L32ebc4rIx+6wPcIQOICOEIfqg=,tag:a8nlnYGcyEp1qpjBz8HMXg==,type:str]", - "sopsEncryptedUrl": "ENC[AES256_GCM,data:ihSnEzUfd/k4dKdGiuCOqSg2WNG6m/27ZGOgsZNcIw==,iv:I5WIU8qahF4fPM3UJIhDQtgjZJ1/dE+t+yis6IrFppE=,tag:foluaaTCYY/JBjbpszAFCw==,type:str]" + "sopsEncryptedUrl": "ENC[AES256_GCM,data:ihSnEzUfd/k4dKdGiuCOqSg2WNG6m/27ZGOgsZNcIw==,iv:I5WIU8qahF4fPM3UJIhDQtgjZJ1/dE+t+yis6IrFppE=,tag:foluaaTCYY/JBjbpszAFCw==,type:str]", + "redacted": true }, { "id": "course-booking-system", @@ -548,8 +560,10 @@ "en": "Built a custom booking system for a course provider's sewing classes and integrated it into their website. Courses for adults, teens, and children can be managed online, including registration and waiting lists." }, "type": "experience", + "entity": "Jo***er", "sopsEncryptedEntity": "ENC[AES256_GCM,data:OOxaKclTmqJR2wbjHy7myro=,iv:+q9bhFHfhsEGVztGb9Cjq0KE0QtBGrBIIEtqhtj1jtU=,tag:m7gNzMVKuurppEAAiDwvbQ==,type:str]", - "sopsEncryptedUrl": "ENC[AES256_GCM,data:SqGBgKHK3PO5hbbmcHTBmgBjqEjZyvXV3A==,iv:uOzR6xXVnSoLx+Pk0MehKj9v4THaawoNHxQZpcxWYXk=,tag:30KR0KuTBfrmq7EsPpnggg==,type:str]" + "sopsEncryptedUrl": "ENC[AES256_GCM,data:SqGBgKHK3PO5hbbmcHTBmgBjqEjZyvXV3A==,iv:uOzR6xXVnSoLx+Pk0MehKj9v4THaawoNHxQZpcxWYXk=,tag:30KR0KuTBfrmq7EsPpnggg==,type:str]", + "redacted": true }, { "id": "continuing-education-booking-system", @@ -578,8 +592,10 @@ "en": "Built a custom booking system for a continuing education provider. The solution includes email confirmations, waiting lists, and an implementation based on SvelteKit with Hygraph as the headless CMS." }, "type": "experience", + "entity": "Jo***er", "sopsEncryptedEntity": "ENC[AES256_GCM,data:d+vVNOX+vOfAE3xyEeHiVss=,iv:o0Zeg//XVyZishVc2Rhg5fRdfuNSr+t3/gS1chOlwY4=,tag:dYwypdxhY2Mg6f6oWWSjvw==,type:str]", - "sopsEncryptedUrl": "ENC[AES256_GCM,data:8A9jLNLyQxpDC2yb53nNKMD66Vdginc=,iv:wTx3RBrQG6jfrnGcm+kj86B/xglhYs930kMNjcVyeGU=,tag:NXwLZ1GiK+6Piy9jh4NFvA==,type:str]" + "sopsEncryptedUrl": "ENC[AES256_GCM,data:8A9jLNLyQxpDC2yb53nNKMD66Vdginc=,iv:wTx3RBrQG6jfrnGcm+kj86B/xglhYs930kMNjcVyeGU=,tag:NXwLZ1GiK+6Piy9jh4NFvA==,type:str]", + "redacted": true }, { "id": "holiday-apartment-booking-site", @@ -608,8 +624,10 @@ "en": "Built a booking website with calendar integration for a holiday apartment on Norderney. Day-by-day availability is maintained in Google Calendar and synchronized to the site via Google APIs." }, "type": "experience", + "entity": "Jo***er", "sopsEncryptedEntity": "ENC[AES256_GCM,data:56/AikTQVGtOqBFCfebeG38=,iv:8uwI4VfbDyhrJLapHKdm8+x/HjsXodQSXv2qqMzcgbw=,tag:/VidS6LHJvBOaxI9S97ksw==,type:str]", - "sopsEncryptedUrl": "ENC[AES256_GCM,data:2N/nX7hi9MlbY/Y5Jdq1IBQcSQxRdbE7D4N1hjs=,iv:moi4cY2Os8/15MAzi9M1r17DIgDpO9ASXY/PL9rohtY=,tag:TRHCgOQsCFz6RWJOPLg+dw==,type:str]" + "sopsEncryptedUrl": "ENC[AES256_GCM,data:2N/nX7hi9MlbY/Y5Jdq1IBQcSQxRdbE7D4N1hjs=,iv:moi4cY2Os8/15MAzi9M1r17DIgDpO9ASXY/PL9rohtY=,tag:TRHCgOQsCFz6RWJOPLg+dw==,type:str]", + "redacted": true }, { "id": "systems-development-and-framework", @@ -674,8 +692,10 @@ "en": "Technical Lead work on product architecture, GraphQL APIs, production use of Neo4J, and Kubernetes-based operations for a community-built open-source social network that offers communities a privacy-friendly alternative to large platforms. The project grew to around 80 code contributors on GitHub; newcomers were onboarded through GitHub, Discord, and online pair programming, mentored directly, and most contributions were reviewed. Neo4J ran in production as the primary database to model the network's social graph directly." }, "type": "experience", + "entity": "Hu***bH", "sopsEncryptedEntity": "ENC[AES256_GCM,data:Nl4Ua1hbJXKVgqstCPL7+fObJfruwg==,iv:2zJAEKIMpF6QcYziEQ2/M5D5cPMywAwEFF20QAVT/3g=,tag:ZoPqOE3Y+2n1tL10rbjGSQ==,type:str]", - "sopsEncryptedUrl": "ENC[AES256_GCM,data:Nm4Pux8pFbuepI2dnA4N6kx3/Kvf/4Yqmn1ZewDEPtRnC22+cqxsZhJu2TyDACZ85ZhJrI1tnaU=,iv:3ZFTkCMAQ647dBDEO3e4CKKG5Pnh016+WGtRniTyWdU=,tag:0Bh6j+4a7UqEkgxuOCLiFg==,type:str]" + "sopsEncryptedUrl": "ENC[AES256_GCM,data:Nm4Pux8pFbuepI2dnA4N6kx3/Kvf/4Yqmn1ZewDEPtRnC22+cqxsZhJu2TyDACZ85ZhJrI1tnaU=,iv:3ZFTkCMAQ647dBDEO3e4CKKG5Pnh016+WGtRniTyWdU=,tag:0Bh6j+4a7UqEkgxuOCLiFg==,type:str]", + "redacted": true }, { "id": "foss-social-network-infra-migration", @@ -709,8 +729,10 @@ "en": "Migrated assets from a Kubernetes volume to S3-compatible object storage to reduce deployment complexity and cost. Rebuilt the infrastructure with Terraform and sops, and moved from a managed Kubernetes cluster on Digital Ocean to a self-hosted Kubernetes cluster on Hetzner, cutting monthly costs by 80%." }, "type": "experience", + "entity": "bu***V.", "sopsEncryptedEntity": "ENC[AES256_GCM,data:pqghmjTrA/o5bUyavo3eQA==,iv:rQ9Zoyr1shqrKEgCyInv6jzVflJponHoHIwVHS6+RZ4=,tag:UHuGaH+5ZbXm/Ge2P4nmQA==,type:str]", - "sopsEncryptedUrl": "ENC[AES256_GCM,data:cUhlgJIne+kjoxbtjHmtNtzSNabMb6iARdQitNvI9LpbqpZl/O9TSB5AoIbwwNdo6NOQ6+kZZtY=,iv:rM1IFjzaU0fdH6qdKBEZex/OdVKY8ipRJ9Ky9veNIkk=,tag:I1dYt50Y00ZqP7D1HlclXA==,type:str]" + "sopsEncryptedUrl": "ENC[AES256_GCM,data:cUhlgJIne+kjoxbtjHmtNtzSNabMb6iARdQitNvI9LpbqpZl/O9TSB5AoIbwwNdo6NOQ6+kZZtY=,iv:rM1IFjzaU0fdH6qdKBEZex/OdVKY8ipRJ9Ky9veNIkk=,tag:I1dYt50Y00ZqP7D1HlclXA==,type:str]", + "redacted": true }, { "id": "find-open-source-contributors-vuejs-berlin", @@ -768,8 +790,10 @@ "en": "Building on an earlier journalistic sensor-data project, the platform was reused and extended for a new format. Reports were generated automatically from sensor data collected from three beehives. The project was nominated for the Grimme Online Award and won the German Reporter Award." }, "type": "experience", + "entity": "ta***bH", "sopsEncryptedEntity": "ENC[AES256_GCM,data:qYQ7xnEqut0KeEl0l7rrdb4=,iv:y7/nTzAtB87visCUQR3w0iczssbVXkulmRedbl0NLas=,tag:fyiOCFQzBH1cDT0iaDcazg==,type:str]", - "sopsEncryptedUrl": "ENC[AES256_GCM,data:5TPCQC1YtjHswaVfUJ6VbherFRnU83/z5sCRb8f7fm/2HS+mWqDbEQCkWFvTPGP174mYRJ0zqMPJdVghzGU3K18T,iv:5uhIrxYhA00rJWXuvqQ4LeBEqN4M3UXve5zjws2V9Eo=,tag:d7rFeR+hq5t2DlqYVDiWPw==,type:str]" + "sopsEncryptedUrl": "ENC[AES256_GCM,data:5TPCQC1YtjHswaVfUJ6VbherFRnU83/z5sCRb8f7fm/2HS+mWqDbEQCkWFvTPGP174mYRJ0zqMPJdVghzGU3K18T,iv:5uhIrxYhA00rJWXuvqQ4LeBEqN4M3UXve5zjws2V9Eo=,tag:d7rFeR+hq5t2DlqYVDiWPw==,type:str]", + "redacted": true }, { "id": "rundfunk-mitbestimmen-project", @@ -897,8 +921,10 @@ "en": "Custom CMS development for the company website with a focus on maintainability and search engine optimization." }, "type": "experience", + "entity": "So***AG", "sopsEncryptedEntity": "ENC[AES256_GCM,data:FdvT5n65RCXGzCkZXw==,iv:VUh3/Ap52Dtw8SCET1zZYJtWphpRdAsoMOKesswbPC8=,tag:w64nsGSzkncp+Qxi8aeXSg==,type:str]", - "sopsEncryptedUrl": "ENC[AES256_GCM,data:V/m+Xno2xmokkKG/krnjbKVahdM/YCk=,iv:zPoPoiX3YsVrXYJwGw56Acqu411Nwzc6IGRDQGM7FtQ=,tag:Prh1oLJrSba1S2enltYg9A==,type:str]" + "sopsEncryptedUrl": "ENC[AES256_GCM,data:V/m+Xno2xmokkKG/krnjbKVahdM/YCk=,iv:zPoPoiX3YsVrXYJwGw56Acqu411Nwzc6IGRDQGM7FtQ=,tag:Prh1oLJrSba1S2enltYg9A==,type:str]", + "redacted": true }, { "id": "informatikradar-1", @@ -940,7 +966,9 @@ }, "location": "Lunenburg, Lower Saxony, Germany", "type": "experience", - "sopsEncryptedEntity": "ENC[AES256_GCM,data:tDPMg1mjr/hJ4MwLk5wfQwQ=,iv:JVJT/rbl9uHnj3f5mXkMY2wetCo3opm9o+AEJMtElwk=,tag:bWEos6mqzgPZROpTI2Rz8w==,type:str]" + "entity": "ta***bH", + "sopsEncryptedEntity": "ENC[AES256_GCM,data:tDPMg1mjr/hJ4MwLk5wfQwQ=,iv:JVJT/rbl9uHnj3f5mXkMY2wetCo3opm9o+AEJMtElwk=,tag:bWEos6mqzgPZROpTI2Rz8w==,type:str]", + "redacted": true }, { "id": "story-trolley-ember-meets-iot", @@ -1019,6 +1047,7 @@ "de": "Für WDR und Spiegel Online entstand ein journalistisches Format, das Berichte automatisiert aus Sensordaten von drei Milchkühen erzeugte. Das Projekt erhielt große mediale Aufmerksamkeit mit mehr als 63 Berichten und Features.", "en": "For WDR and Spiegel Online, a journalistic format generated reports automatically from sensor data collected from three dairy cows. The project received broad media attention with more than 63 reports and features." }, + "entity": "ta***bH", "sopsEncryptedEntity": "ENC[AES256_GCM,data:0/Gh7BX5va//9yhLjyLmbNY=,iv:U+6I34nYRvMTzLQ55nHBpiNnjuj6hPo54+gbkN2JkGA=,tag:Zq6ecU7CFTr4UZ069SxNzw==,type:str]", "links": [ { @@ -1027,7 +1056,8 @@ "kind": "press" } ], - "type": "experience" + "type": "experience", + "redacted": true }, { "id": "coding-in-the-woods", @@ -1090,8 +1120,10 @@ "en": "Rapid IoT prototype of a smart shopping cart." }, "type": "experience", + "entity": "ta***bH", "sopsEncryptedEntity": "ENC[AES256_GCM,data:u40+rv5ntGoZYzKWR6o3FaA=,iv:YokTM8pqL2oFpMlZCwAra/7fRyDS5MQjsHWVi97TwUg=,tag:MGtKRVvrhFQbEoFRaYcDSA==,type:str]", - "sopsEncryptedUrl": "ENC[AES256_GCM,data:ZXnbGvWtsGCISXy6YHGhEUGFtuXsSoH68lT5LPdWnYuleRSiEaURHDMOJiLmv8os,iv:VMgSJw/Z/HC6jUdMgi5FEvsTMPtMneK7Ks8lZk8pK70=,tag:qqCbohU7YAhyv/f3WGhpxg==,type:str]" + "sopsEncryptedUrl": "ENC[AES256_GCM,data:ZXnbGvWtsGCISXy6YHGhEUGFtuXsSoH68lT5LPdWnYuleRSiEaURHDMOJiLmv8os,iv:VMgSJw/Z/HC6jUdMgi5FEvsTMPtMneK7Ks8lZk8pK70=,tag:qqCbohU7YAhyv/f3WGhpxg==,type:str]", + "redacted": true }, { "id": "rundfunk-mitbestimmen-nerd-nite-17", @@ -1293,8 +1325,10 @@ }, "location": "Bern, Switzerland", "type": "experience", + "entity": "OX***AG", "sopsEncryptedEntity": "ENC[AES256_GCM,data:pQtkQjWGDw==,iv:ixA1FMd/4+Oq8ERXL0LAAONu8jT8/2aRFVdelNu4H10=,tag:SAV/EXX0NurmEX7iBHVwMA==,type:str]", - "sopsEncryptedUrl": "ENC[AES256_GCM,data:MNha6GUwmyOQSw3D1SRYzkvgzMeHpA==,iv:3hnzro/H9+B1+yNq460s1638tv09kq7CRocOQwcjNWQ=,tag:e3ZggOOXP3oFUhxXuD1fow==,type:str]" + "sopsEncryptedUrl": "ENC[AES256_GCM,data:MNha6GUwmyOQSw3D1SRYzkvgzMeHpA==,iv:3hnzro/H9+B1+yNq460s1638tv09kq7CRocOQwcjNWQ=,tag:e3ZggOOXP3oFUhxXuD1fow==,type:str]", + "redacted": true }, { "id": "supply-chain-risk-research", @@ -1313,8 +1347,10 @@ }, "location": "Belfast, United Kingdom", "type": "experience", + "entity": "***", "sopsEncryptedEntity": "ENC[AES256_GCM,data:PSDr,iv:ZWT4M+3BQkSZNYuuCvYU5RNrMsOmLYLqMA1Nb4Zt34s=,tag:Frw8Jc7SyFAo4EC7gImgBg==,type:str]", - "sopsEncryptedUrl": "ENC[AES256_GCM,data:BWbB2pW2yLSYTb3bE81UagOaBpg=,iv:g1hDMrFCQ0X4BFXE+63mTDZVdoCZPlQlOa+mxaFgFo8=,tag:O3nwrFhfpdgWe2eMYrVncQ==,type:str]" + "sopsEncryptedUrl": "ENC[AES256_GCM,data:BWbB2pW2yLSYTb3bE81UagOaBpg=,iv:g1hDMrFCQ0X4BFXE+63mTDZVdoCZPlQlOa+mxaFgFo8=,tag:O3nwrFhfpdgWe2eMYrVncQ==,type:str]", + "redacted": true }, { "id": "enterprise-programming-models-in-memory-databases", diff --git a/scripts/generate-resume-source.ts b/scripts/generate-resume-source.ts index 9b34b5d..f4b342f 100644 --- a/scripts/generate-resume-source.ts +++ b/scripts/generate-resume-source.ts @@ -12,16 +12,19 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const rootDir = path.resolve(__dirname, ".."); -// "masked" (default) is what every normal build/dev/test run uses - real -// client identities never leave this script. "unredacted" restores real -// values for local, personal use (e.g. printing your own real CV); it must -// never be the mode CI runs in. +// "masked" (default) is what every normal build/dev/test run uses - it reads +// the masked client names committed by `pnpm mask-clients` and needs no sops +// key. "unredacted" decrypts the real values for local, personal use (e.g. +// printing your own real CV); it must never be the mode CI runs in. const mode = process.env.RESUME_MODE === "unredacted" ? "unredacted" : "masked"; -const decrypted = JSON.parse( - execFileSync("sops", ["-d", path.join(rootDir, "resume.i18n.json")], { encoding: "utf8" }), +const resumePath = path.join(rootDir, "resume.i18n.json"); +const source = JSON.parse( + mode === "unredacted" + ? execFileSync("sops", ["-d", resumePath], { encoding: "utf8" }) + : await fs.readFile(resumePath, "utf8"), ); -const { sops: _sops, ...sourceWithoutSops } = decrypted; +const { sops: _sops, ...sourceWithoutSops } = source; const resolved = resolveSopsEncryptedFields(sourceWithoutSops, mode); const outDir = path.join(rootDir, ".generated"); diff --git a/scripts/mask-clients.ts b/scripts/mask-clients.ts new file mode 100644 index 0000000..d9aa784 --- /dev/null +++ b/scripts/mask-clients.ts @@ -0,0 +1,23 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { addMaskedSopsFields } from "../src/lib/utils/resolve-sops-fields.ts"; + +// Writes the masked client names next to their encrypted fields in +// resume.i18n.json, so builds can read them without a sops key. Run this +// locally after editing the encrypted fields with `sops resume.i18n.json`. +// `mac_only_encrypted: true` in .sops.yaml keeps the file decryptable after +// these plain-field edits. + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const resumePath = path.resolve(__dirname, "..", "resume.i18n.json"); + +const encrypted = JSON.parse(await fs.readFile(resumePath, "utf8")); +const decrypted = JSON.parse(execFileSync("sops", ["-d", resumePath], { encoding: "utf8" })); + +await fs.writeFile( + resumePath, + `${JSON.stringify(addMaskedSopsFields(encrypted, decrypted), null, "\t")}\n`, +); diff --git a/scripts/netlify-build.sh b/scripts/netlify-build.sh index 03b1085..3784147 100755 --- a/scripts/netlify-build.sh +++ b/scripts/netlify-build.sh @@ -1,30 +1,16 @@ #!/usr/bin/env bash -# Netlify exposes SOPS_AGE_KEY to the entire build. Take it out of the -# environment first, so mise and the tools it downloads never see it, and -# hand it back only to the site build that decrypts resume.i18n.json. -# Local builds usually have no SOPS_AGE_KEY; sops then uses its key file. set -euo pipefail -sops_age_key="${SOPS_AGE_KEY-}" -unset SOPS_AGE_KEY - # Only Netlify's own build servers set NETLIFY=true (the Netlify CLI sets -# NETLIFY_LOCAL=true instead). Their build image lacks these tools, while -# local builds are expected to have them already. +# NETLIFY_LOCAL=true instead). Their build image lacks Typst, while local +# builds are expected to have it already. if [[ "${NETLIFY-}" == true ]]; then curl -fsSL https://mise.run | sh mise="$HOME/.local/bin/mise" "$mise" trust "$mise" install - - # Put the mise tools on PATH directly instead of using `mise exec`, which - # would run the mise binary with the key in its environment. PATH="$("$mise" bin-paths | paste -sd:):$PATH" export PATH fi -if [[ -n "$sops_age_key" ]]; then - SOPS_AGE_KEY="$sops_age_key" pnpm build -else - pnpm build -fi +pnpm build diff --git a/src/lib/utils/resolve-sops-fields.test.ts b/src/lib/utils/resolve-sops-fields.test.ts new file mode 100644 index 0000000..69af03d --- /dev/null +++ b/src/lib/utils/resolve-sops-fields.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { addMaskedSopsFields, resolveSopsEncryptedFields } from "./resolve-sops-fields.ts"; + +// Synthetic fixtures. Never use real client names here - this file is public. +const decrypted = { + projects: [ + { + id: "client-project", + name: "Client project", + sopsEncryptedEntity: "Acme Consulting GmbH", + sopsEncryptedUrl: "https://acme.example", + }, + { id: "public-project", entity: "Public Org" }, + ], +}; + +const encrypted = { + projects: [ + { + id: "client-project", + name: "Client project", + sopsEncryptedEntity: "ENC[entity]", + sopsEncryptedUrl: "ENC[url]", + }, + { id: "public-project", entity: "Public Org" }, + ], + sops: { mac: "ENC[mac]" }, +}; + +const committed = { + projects: [ + { + id: "client-project", + name: "Client project", + entity: "Ac***bH", + sopsEncryptedEntity: "ENC[entity]", + sopsEncryptedUrl: "ENC[url]", + redacted: true, + }, + { id: "public-project", entity: "Public Org" }, + ], + sops: { mac: "ENC[mac]" }, +}; + +describe("addMaskedSopsFields", () => { + it("adds the masked name next to its encrypted field and flags the entry as redacted", () => { + expect(addMaskedSopsFields(encrypted, decrypted)).toEqual(committed); + }); + + it("adds no plain field for URLs, since a masked URL points nowhere", () => { + const [project] = (addMaskedSopsFields(encrypted, decrypted) as typeof committed).projects; + expect(project).not.toHaveProperty("url"); + }); + + it("replaces masked fields from an earlier run, so it can run repeatedly", () => { + const renamed = structuredClone(decrypted); + renamed.projects[0].sopsEncryptedEntity = "Globex AG"; + + const [project] = (addMaskedSopsFields(committed, renamed) as typeof committed).projects; + expect(project.entity).toBe("Gl***AG"); + expect(addMaskedSopsFields(committed, decrypted)).toEqual(committed); + }); +}); + +describe("resolveSopsEncryptedFields", () => { + it("uses the committed masked fields without decrypting in masked mode", () => { + expect(resolveSopsEncryptedFields(committed.projects, "masked")).toEqual([ + { id: "client-project", name: "Client project", entity: "Ac***bH", redacted: true }, + { id: "public-project", entity: "Public Org" }, + ]); + }); + + it("restores the real values in unredacted mode", () => { + const decryptedCommitted = addMaskedSopsFields(decrypted, decrypted); + + expect(resolveSopsEncryptedFields(decryptedCommitted, "unredacted")).toEqual({ + projects: [ + { + id: "client-project", + name: "Client project", + entity: "Acme Consulting GmbH", + url: "https://acme.example", + }, + { id: "public-project", entity: "Public Org" }, + ], + }); + }); + + it("fails in masked mode when encrypted fields were never masked, e.g. a new client", () => { + expect(() => resolveSopsEncryptedFields(encrypted.projects, "masked")).toThrow( + /"client-project".*pnpm mask-clients/, + ); + }); +}); diff --git a/src/lib/utils/resolve-sops-fields.ts b/src/lib/utils/resolve-sops-fields.ts index b4e71c2..20940b8 100644 --- a/src/lib/utils/resolve-sops-fields.ts +++ b/src/lib/utils/resolve-sops-fields.ts @@ -31,15 +31,74 @@ const maskedValueFor = (value: unknown): unknown => { return undefined; }; +const sopsEncryptedFieldName = (key: string): string | undefined => { + const match = sopsEncryptedFieldPattern.exec(key); + return match ? toFieldName(match[1]) : undefined; +}; + +// The plain fields that a sopsEncrypted* field stands in for, e.g. "entity" +// for "sopsEncryptedEntity". Their committed values are masked. +const shadowedFieldNames = (value: Record): Set => + new Set( + Object.keys(value) + .map(sopsEncryptedFieldName) + .filter((name) => name !== undefined), + ); + +/** + * Adds the masked form of every sopsEncrypted*-prefixed field to an encrypted + * resume.i18n.json tree, taking the real values from its decrypted + * counterpart: e.g. sopsEncryptedEntity gets a plain `entity` ("ta***bH") + * right before it, and the containing object is flagged `redacted: true`. + * Values without a masked form (URLs, links) get no plain field. Plain fields + * from an earlier run are replaced, so this is safe to run repeatedly. + */ +export const addMaskedSopsFields = (encrypted: unknown, decrypted: unknown): unknown => { + if (Array.isArray(encrypted)) { + const decryptedEntries = Array.isArray(decrypted) ? decrypted : []; + return encrypted.map((entry, index) => addMaskedSopsFields(entry, decryptedEntries[index])); + } + + if (!isPlainObject(encrypted)) { + return encrypted; + } + + const decryptedObject = isPlainObject(decrypted) ? decrypted : {}; + const shadowed = shadowedFieldNames(encrypted); + const isRedacted = shadowed.size > 0; + + const entries = Object.entries(encrypted) + .filter(([key]) => !isRedacted || (key !== "redacted" && !shadowed.has(key))) + .flatMap(([key, entry]) => { + const fieldName = sopsEncryptedFieldName(key); + if (fieldName === undefined) { + return [[key, addMaskedSopsFields(entry, decryptedObject[key])]] as const; + } + + const masked = maskedValueFor(decryptedObject[key]); + return masked === undefined + ? ([[key, entry]] as const) + : ([ + [fieldName, masked], + [key, entry], + ] as const); + }); + + return { + ...Object.fromEntries(entries), + ...(isRedacted ? { redacted: true } : {}), + }; +}; + /** * Resolves every sopsEncrypted*-prefixed field (e.g. sopsEncryptedEntity -> - * entity) in an already-decrypted resume.i18n.json object. In "masked" mode - * (the default, used by every normal build), sensitive values are replaced - * with a partial reveal or dropped, and the containing object is flagged - * `redacted: true`. In "unredacted" mode, the real decrypted values are - * restored verbatim - only ever for local, gitignored use. The field name - * being encrypted is what marks it as sensitive, not any hardcoded knowledge - * of "entity" specifically. + * entity) in a resume.i18n.json tree. In "masked" mode (the default, used by + * every normal build), the input does not need to be decrypted: the encrypted + * fields are dropped, leaving the masked plain fields and `redacted: true` + * that `addMaskedSopsFields` committed next to them. In "unredacted" mode, the + * input must be decrypted, and the real values replace the masked ones - only + * ever for local, gitignored use. The field name being encrypted is what marks + * it as sensitive, not any hardcoded knowledge of "entity" specifically. */ export const resolveSopsEncryptedFields = (value: unknown, mode: SopsFieldMode): unknown => { if (Array.isArray(value)) { @@ -50,22 +109,24 @@ export const resolveSopsEncryptedFields = (value: unknown, mode: SopsFieldMode): return value; } - const hasSopsEncryptedFields = Object.keys(value).some((key) => - sopsEncryptedFieldPattern.test(key), - ); + const shadowed = shadowedFieldNames(value); + if (shadowed.size > 0 && mode === "masked" && value.redacted !== true) { + throw new Error( + `"${String(value.id ?? value.name)}" has encrypted fields but no masked ones. Run \`pnpm mask-clients\`.`, + ); + } - const resolvedEntries = Object.entries(value).flatMap(([key, entry]) => { - const match = sopsEncryptedFieldPattern.exec(key); - if (!match) { - return [[key, resolveSopsEncryptedFields(entry, mode)]] as const; - } + const resolvedEntries = Object.entries(value) + .filter(([key]) => mode === "masked" || (key !== "redacted" && !shadowed.has(key))) + .flatMap(([key, entry]) => { + const fieldName = sopsEncryptedFieldName(key); + if (fieldName === undefined) { + return [[key, resolveSopsEncryptedFields(entry, mode)]] as const; + } - const resolvedValue = mode === "unredacted" ? entry : maskedValueFor(entry); - return resolvedValue === undefined ? [] : ([[toFieldName(match[1]), resolvedValue]] as const); - }); + return mode === "unredacted" ? ([[fieldName, entry]] as const) : []; + }); - return { - ...Object.fromEntries(resolvedEntries), - ...(mode === "masked" && hasSopsEncryptedFields ? { redacted: true } : {}), - }; + const { redacted, ...rest } = Object.fromEntries(resolvedEntries); + return redacted === undefined ? rest : { ...rest, redacted }; };