Skip to content

feat(cryptify): merge cryptify into the workspace - #277

Merged
rubenhensen merged 407 commits into
mainfrom
spike/cryptify-in-workspace
Jul 31, 2026
Merged

feat(cryptify): merge cryptify into the workspace#277
rubenhensen merged 407 commits into
mainfrom
spike/cryptify-in-workspace

Conversation

@rubenhensen

Copy link
Copy Markdown
Contributor

Closes #255. The decision comment on that issue has the full spike writeup and the measurements; this is the change it recommends.

cryptify becomes a workspace member, compiled against the in-tree pg-core. All 402 of its commits come across as a history-preserving subtree merge.

Why

Today postguard builds pg-core against bincode-next 3.0.0-rc.14 and cryptify builds pg-core against bincode-next 3.1.1, because each repo resolves its own lockfile. Same source, two versions of the serializer that writes the container header, in two services that exchange containers, with nothing comparing them. CLAUDE.md's rule (pin the exact rc, re-audit on any bump) is not reaching cryptify's copy.

That is benign right now, and provably so: pg-compat's lockfile is also on 3.1.1, so the green wire-compat check already demonstrates rc.14-sealed bytes opening under 3.1.1. But it falls out of another gate's dependency resolution by accident, and the next bincode bump is unguarded. One workspace is one lockfile, and the #127 memory-vs-stream class becomes a PR-time test failure rather than a cross-repo surprise.

The price

libsqlite3-sys sets links = "sqlite3" and only one package per resolve graph may claim it. pg-pkg reaches it through sqlx, cryptify through rusqlite. Optional deps are in the graph regardless of features, so pg-pkg pulls sqlx-sqlite in despite enabling only postgres.

sqlx-sqlite 0.9.0  wants libsqlite3-sys >=0.30.1, <0.38.0
rusqlite    0.40.1 wants libsqlite3-sys ^0.38.1

Empty intersection. The newest pair that intersects is sqlx 0.9 + rusqlite 0.39, so this PR carries both. pg-pkg needed no source changes for sqlx 0.9.

The standing cost, and the part worth a deliberate yes: every future rusqlite bump now waits on sqlx's libsqlite3-sys ceiling. cryptify is already one release ahead of what sqlx permits.

What else had to change, and why

  • cryptify/build.rs read Cargo.lock beside its manifest to stamp PG_CORE_VERSION into the X-PostGuard header. A member has no lockfile there, so it now walks up from CARGO_MANIFEST_DIR.
  • api-diff.yml covers both specs via a matrix, not two jobs. pg-pkg/tests/api_gate.rs and cryptify's mod api_gate_tests each require exactly one fail-on: and one include-checks: line in that file, so neither can claim a setting the committed job does not use. Two oasdiff steps would give two of each and fail both suites.
  • build.yml's matrices key on the crate directory instead of a pg- prefix, which renames the Test/Format/Clippy workspace (…) contexts. Only the two Wire compat contexts are required on main, so nothing breaks, but a ruleset added later needs the new names.
  • Both Dockerfiles. cryptify's builds from the repo root now. postguard's needed COPY cryptify (cargo-chef loads every member's manifest even for a build that never compiles it) and its Rust pin moved 1.91.1 → 1.96.1, because sqlx 0.9 declares rust-version = 1.94.0. Both of those passed the whole test matrix on host stable and failed only in Docker.
  • cryptify's own .github/, Cargo.lock and release-plz.toml are gone, replaced by an entry in the root release-plz.toml.

Verification

Run locally on this branch, mirroring build.yml's per-crate invocations:

lane result
pg-core (test,rust,stream) pass, 18s
pg-pkg (all-features) pass, 29s
pg-cli (all-features) pass, 17s
pg-ffi (all-features) pass, 10s
cryptify (all-targets) 157 tests pass, 27s
cryptify clippy -D warnings pass
cargo fmt --all --check pass
wire-compat (seal with HEAD, open with published pg-core) pass

Both images build: docker build . and docker build -f cryptify/Dockerfile ..

Deliberately not in this PR

Image publishing still comes from the cryptify repo. delivery.yml's Docker jobs push to ghcr.io/${{ github.repository }}, so publishing cryptify's image from here means either renaming it away from ghcr.io/encryption4all/cryptify or changing that package's GHCR access. Either affects whatever pulls it, so it is an ops decision rather than a mechanical port.

That makes this a coherent intermediate state, not a half-finished one: postguard becomes source of truth for the code and gets the one-lockfile guarantee immediately, while the cryptify repo keeps building and pushing images exactly as it does today. Decommissioning it is a follow-up, and it needs privacybydesign/postguard-ops in the loop.

Also outstanding: release-plz will treat cryptify as an initial release. A subtree import brings commits, not tags, and the old tags were bare v0.1.27 against this workspace's cryptify-v{version} pattern. Without a hand-placed cryptify-v0.1.27 tag on the import commit, the first changelog entry covers all 402 imported commits. Worth doing before this lands on main.

Reviewing this

The subtree merge makes the diff large. git diff main...HEAD -- . ':!cryptify' is the 8-file change to the existing tree; everything under cryptify/ is imported as-is apart from Cargo.toml, build.rs, Dockerfile and the one include_str! path in src/main.rs.

rubenhensen and others added 30 commits February 12, 2026 16:17
Remove frontend, add dev API UI, and clean up project
Add linux/arm64 Docker image builds
Remove frontend, improve backend reliability
Replace single QEMU multi-platform job with parallel native runner jobs
that merge into a multi-arch manifest. Add cargo-chef to Dockerfile for
dependency layer caching. Add release/edge Cargo profiles (lto=true for
published releases, lto=false for all other builds).
anchore/scan-action@v3 was pinning grype v0.74.4 (Nov 2023). v7.3.2
brings grype v0.104+ with an up-to-date vulnerability database.

github/codeql-action/upload-sarif@v3 was showing a deprecation warning
and will be removed in December 2026; upgrade to v4.

Both actions are now pinned to their commit SHA per security best practice.
Bumps [keccak](https://github.com/RustCrypto/sponges) from 0.1.5 to 0.1.6.
- [Commits](RustCrypto/sponges@keccak-v0.1.5...keccak-v0.1.6)

---
updated-dependencies:
- dependency-name: keccak
  dependency-version: 0.1.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Bumps [bytes](https://github.com/tokio-rs/bytes) from 1.10.1 to 1.11.1.
- [Release notes](https://github.com/tokio-rs/bytes/releases)
- [Changelog](https://github.com/tokio-rs/bytes/blob/master/CHANGELOG.md)
- [Commits](tokio-rs/bytes@v1.10.1...v1.11.1)

---
updated-dependencies:
- dependency-name: bytes
  dependency-version: 1.11.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Bumps [time](https://github.com/time-rs/time) from 0.3.44 to 0.3.47.
- [Release notes](https://github.com/time-rs/time/releases)
- [Changelog](https://github.com/time-rs/time/blob/main/CHANGELOG.md)
- [Commits](time-rs/time@v0.3.44...v0.3.47)

---
updated-dependencies:
- dependency-name: time
  dependency-version: 0.3.47
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
…keccak-0.1.6

chore(deps): bump keccak from 0.1.5 to 0.1.6 in /cryptify
…bytes-1.11.1

chore(deps): bump bytes from 1.10.1 to 1.11.1 in /cryptify
…time-0.3.47

chore(deps): bump time from 0.3.44 to 0.3.47 in /cryptify
Many email clients strip out SVGs for security and anti-tracking
reasons, causing the PostGuard logo to not render for a large portion
of recipients. Replace the inline SVG logo with a hosted PNG image
from postguard.eu.

Closes encryption4all/postguard-website#29
…site

fix: replace SVGs with accessible alternatives in email template
Bumps [rustls-webpki](https://github.com/rustls/webpki) from 0.103.8 to 0.103.10.
- [Release notes](https://github.com/rustls/webpki/releases)
- [Commits](rustls/webpki@v/0.103.8...v/0.103.10)

---
updated-dependencies:
- dependency-name: rustls-webpki
  dependency-version: 0.103.10
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
…rustls-webpki-0.103.10

chore(deps): bump rustls-webpki from 0.103.8 to 0.103.10 in /cryptify
…als-separate-fields

feat: accept smtp_username and smtp_password as separate config fields
rubenhensen and others added 26 commits June 4, 2026 15:45
* feat(email): replace unicode checkmark with inline PNG image

The signer-verified circle previously rendered a unicode `&#10003;`
glyph which is styled inconsistently across mail clients. Embed a
PNG of the PostGuard checkmark via `cid:pg-check` to match the
shape used in the download page UI.

* feat(email): remove circle around signer-verified checkmark
Parse the X-POSTGUARD-CLIENT-VERSION header (host,host_version,app,app_version,
reused from pg-pkg) on uploads. Log the full raw value at init and finalize so
exact client versions are greppable, and expose a cardinality-safe
cryptify_uploads_by_app_total{app} counter (app field only; full version stays
in logs). Add X-POSTGUARD-CLIENT-VERSION to the CORS allowlist so browser
pg-js can send it.
Replace the production PKG URL https://postguard-main.cs.ru.nl/pkg with
https://pkg.postguard.eu/ in conf/config.toml and the commented example
in conf/config.dev.toml.

Since the new URL carries a trailing slash, harden the startup parameter
fetch in rocket() to trim a trailing slash before appending
/v2/sign/parameters (mirroring the existing trim in
PkgClient::validate_api_key), avoiding a // in the request path.

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Refreshes transitive deps to latest compatible versions including
aws-lc-rs, bincode-next, rustls, tower-http, wasm-bindgen and others.

Closes #167

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>
…t:3000) and DELETE method (#179)

* fix(cors): allow Office add-in origins + DELETE method

Browser callers from the Office add-in were blocked by CORS when reaching
/fileupload/* and /filedownload/*: the preflight returned no
Access-Control-Allow-Origin because the production allowed_origins regex
only matched the postguard.eu/nl website.

- conf/config.toml: extend allowed_origins to also match
  https://addin.postguard.eu (Outlook prod) and https://localhost:3000
  (Office add-in dev), keeping the existing postguard.(eu|nl) origins.
- build_rocket CORS: add DELETE to allowed_methods so the preflight
  advertises GET, POST, PUT, DELETE. Content-Type and Authorization are
  already in the allowed-headers list.
- Add integration tests over the real build_rocket CORS fairing asserting
  the preflight succeeds (echoes Allow-Origin, advertises all four methods
  and the required headers) for both add-in origins, and is rejected for an
  unlisted origin.

Refs #154

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(test): correct misleading PROD_ALLOWED_ORIGINS comment

The comment claimed a config typo would fail the suite, but the CORS
tests use this hand-maintained copy and never read conf/config.toml.
Reword to state it is a copy that must be kept in sync.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Resolve dependency CVEs and refresh outdated crates.

Security fixes:
- lettre 0.11.19 -> 0.11.22 (RUSTSEC-2026-0141: inverted-boolean bug
  disabling TLS hostname verification on the boring-tls backend)
- rand 0.10.0 -> 0.10.1 (RUSTSEC-2026-0097: aliased mutable reference /
  UB when a custom log logger calls rand::rng() during reseeding)

minor/patch bumps:
- tokio 1.48.0 -> 1.52.3
- uuid 1.18.1 -> 1.23.4
- reqwest 0.13.0 -> 0.13.4
- serde_json 1.0.145 -> 1.0.150
- chrono 0.4.42 -> 0.4.45
- log 0.4.28 -> 0.4.33
- url 2.5.7 -> 2.5.8
- tokio-util 0.7.17 -> 0.7.18

major bump:
- minreq 2.14.1 -> 3.0.0: renamed the https-native feature to
  https-native-tls (3.0 feature rename); call site in rocket() uses only
  get/with_timeout/send/json which are unchanged.

cargo audit reports no vulnerabilities. cargo fmt/clippy/test all pass
(127 tests).

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
)

The `/usage` route used the `ApiKey` guard, whose `FromRequest` always
succeeds (yielding the anonymous default tier when no key is present).
The handler's default-tier branch then looked up usage by the
caller-supplied `email` query parameter with no ownership check, so any
unauthenticated caller could probe usage for an arbitrary address.

Introduce a `ValidatedApiKey` request guard that fails the request
(401 on NoCredentials/Rejected, 503 on PkgUnreachable) so the
"authenticated" intent is enforced by the type system. Switch `/usage`
to that guard and key the lookup on the validated tenant; the `email`
query param is now optional and only echoed back.

Also make the `cryptify_token` comparisons constant-time via
`subtle::ConstantTimeEq` (new `cryptify_tokens_match` helper), mirroring
`recovery_tokens_match`, in `check_cryptify_token`, `upload_chunk`, and
`classify_chunk_request`.

Adds a regression test asserting unauthenticated `/usage` returns 401,
and updates the OpenAPI description.

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Add an endpoint that returns the email template pg-pkg has linked to the
caller's API key (issue #54, requirement 10 of postguard#81). The key is
validated through the existing `ApiKey` request guard, so the same
`Authorization: Bearer PG-…` flow the upload endpoints use applies here.

- Extend the pg-pkg `/v2/api-key/validate` response and `ApiKey` guard to
  carry an optional `email_template`.
- `GET /email-template` returns 200 + template for a valid key, 401 for a
  missing/invalid key, 404 when the key is valid but has no template, and
  503 when pg-pkg is unreachable during validation.
- Branch mapping extracted into a pure `resolve_email_template` helper for
  unit testing; full route also covered end-to-end through the real guard
  against a mock pg-pkg.
- Document the endpoint and a bearer security scheme in api-description.yaml.

Closes #54

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…185)

The InternalServerError(Some(..)) and ServiceUnavailable(Some(..)) call
sites in src/main.rs returned internal diagnostic strings ("could not open
file", "couldn't read postguard file", "pg-pkg was unreachable while
validating the API key", etc.) directly to HTTP clients, leaking
implementation detail (GHSA-r95f-qf3j-xccw, low severity).

Each site now returns the generic constant GENERIC_INTERNAL_ERROR_MSG
("an internal error occurred") and writes the detailed diagnostic to a
server-side log::error!() call, so operators keep observability without
exposing internals. Where the error closures previously discarded the
underlying error (|_|), they now capture and log it, improving
diagnosability over the prior behaviour.

Adds integration test finalize_internal_error_body_is_generic asserting a
500 body is the generic message and contains none of the old leaky detail.

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The config schema already accepts `usage_db = "<path>"`, but
`CryptifyConfig` never parsed it and the rolling-quota state lived only
in the in-memory `Store.shared.state.usage` map, so any pod restart or
redeploy wiped everyone's rolling quota.

- Parse `usage_db: Option<String>` in `RawCryptifyConfig` / `CryptifyConfig`
  with a `usage_db()` accessor.
- Back the usage map with SQLite (`rusqlite`, bundled) at that path. The
  DB is the source of truth; the in-memory map is a cache. On startup
  existing usage is loaded into the cache; every accounted upload is
  written through and stale rows outside the rolling window are pruned.
- `usage_db` unset → in-memory only, preserving prior behaviour. A
  configured-but-unopenable DB panics at startup (loud config error).
- Tests: persistence across a simulated restart, continued accumulation
  after restart, rolling-window eviction persisted to the DB, lazy
  in-memory eviction after reload, and config parsing.

Closes #134

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…email (#187)

* fix(email): make recipient download link a prominent selectable code block

The notification email's download-link block ({{link_str}}) rendered at
13px — smaller than the 16px primary button above it — and used a low
weight/contrast style that was hard to read and select. July 2026 user
test (postguard-website#296) flagged it as small and hard to copy.

Restyle the {{link_str}} URL as a full-width monospace code block:
- font-size 16px (no smaller than the primary button)
- monospace family, higher contrast (dark #030E17 text)
- more padding, subtle background + border so it reads as a code block
- display:block + word-break for easy full selection

No inline copy buttons (unreliable across email clients). Plain-text
template already renders the label + URL on their own lines and stays
consistent. Adds a render assertion guarding the >=16px monospace style.

Closes #186

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(email): pin download-link assertion to restyled <a>

The prior font-size:16px substring check was satisfied by the primary
download button regardless, so it failed to guard the link block. Assert
a contiguous substring unique to the restyled <a> (display:block +
monospace + font-size:16px) so a font-size regression genuinely fails.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Add an optional `metrics_token` config (also settable via ROCKET_METRICS_TOKEN).
When set, /metrics requires `Authorization: Bearer <token>` (constant-time
compared via subtle); when unset it stays open and logs a startup warning, so
rolling this out doesn't break existing scrapers. The auth lives in the app,
not the ingress, so it travels with cryptify to external hosts that can't be
firewalled. /health stays public.

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>
The website tags its uploads with X-Cryptify-Source for per-channel
metrics (postguard-website#228), but the header was never added to the
CORS allow-list. Browsers include it in the preflight's
Access-Control-Request-Headers, rocket_cors rejects the preflight with
a 403 carrying no Access-Control-Allow-Origin, and uploads from the
website fail before they start. Staging hits this directly; production
only works because its nginx answers preflights itself.

Add the header to the allow-list, and extract build_cors() so the
preflight smoke tests exercise the production CORS config instead of a
test-local copy — the duplicated config is why the existing smoke test
couldn't catch this regression.
* docs: add agent & contributor notes (migrated from dobby memory)

* docs: correct purge timer notes in CLAUDE.md

The Purge timer block described a hard 15-minute eviction from creation
at src/store.rs:80. It is actually a resettable 1-hour idle timeout:
Store::create (src/store.rs:292) seeds the deadline from idle_ttl
(DEFAULT_UPLOAD_SESSION_IDLE_TIMEOUT_SECS = 60 * 60) and Store::touch
re-inserts it on each chunk PUT / status check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix: retry the startup verifying-key fetch instead of panicking

Cryptify fetched the IBS verifying key from pg-pkg once at startup and
panicked if the PKG was unreachable or the response unparsable, creating a
hard restart-order dependency (cryptify -> pg-pkg -> irma server): a brief PKG
unavailability window during a rolling deploy took cryptify down with it.

The fetch now retries transient failures with exponential backoff (500ms..10s)
for a 120s budget, mirroring the existing /api-key/validate retry pattern, and
runs the blocking minreq call via spawn_blocking. If the budget is exhausted
the process still exits with a clear error so a misconfigured pkg_url does not
fail silently.

Fixes #235

* fix: log non-2xx PKG responses distinctly from parse failures in the startup retry
…#193)

Part 2 of #236 (the cryptify leg). Finalize required
exactly pbdf.sidn-pbdf.email.email in the sender's signing identity; test
environments cannot issue pbdf credentials, blocking real end-to-end flows.
The `email_attribute` config key (default: the production value, so deployed
configs are unaffected) now names the attribute type carrying the sender's
email. Companion changes: pg-pkg (#244) and pg-js.
These three dependencies were declared in Cargo.toml but never used
anywhere in the crate. Removing them prunes qrcode's image subtree
(image, moxcms, pxfm, byteorder-lite) and strum's heck from Cargo.lock.

Closes #191

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* chore: remove unused irma dependency

The `irma` crate was declared in Cargo.toml but never imported anywhere in
the crate. Attributes reach cryptify already signed inside the
PostGuard-sealed file and are read back through pg-core's Unsealer, so
there is no IRMA client here to migrate.

`irma` stays in Cargo.lock as a transitive dependency of pg-core.

Closes #194

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: say irma is still compiled in via pg-core, not just in the lockfile

`cargo tree -i irma` shows irma 0.2.1 on the normal dependency edge under
pg-core, so it is built into the binary, not lockfile residue. It also brings
reqwest 0.11.27 alongside cryptify's own reqwest 0.13.4. The old wording could
lead the postguard-e2e knownDrift follow-up to record the pair as resolved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* docs: true up api-description.yaml with the mounted routes

The spec had drifted from the service in ways a client reading it would hit:

- `/staging/preview/{uuid}` was mounted but undocumented.
- `PUT /fileupload/{uuid}` and `POST /fileupload/finalize/{uuid}` documented a
  409 for a `cryptifytoken` mismatch; both return 400, and no route in the
  service returns 409.
- Finalize validates a `cryptifytoken` header, which the spec did not list, so
  a client following the spec got a 400 from the extractor.
- 400 bodies are plain-text messages, not `{"message": …}` JSON.
- `/metrics` requires `Authorization: Bearer <metrics_token>` when the
  deployment configures one; the spec said firewall-only and listed no 401.
- `/filedownload/{uuid}` answers `Range` requests with 206 or 416 and never
  returns the documented 400, and it sets no `Content-Type` at all.
- Missing responses: 400/422/500 on init, 500/503 on chunk PUT, 500 on
  finalize, 503 on `/usage`.
- `PayloadTooLarge` gained `resets_at` on the rolling-window 413.

`api_routes()` is now the single mount list, and `mod api_description_tests`
compares it against the spec so a route can no longer be added, removed, or
renamed without the spec following.

Refs #247

* docs: correct four spec statements and document the exclusive Content-Range end

Follow-up on the review of this PR. All five were verified against the
handlers, not inferred from the spec:

- `PUT /fileupload/{uuid}`: the `Content-Range` end byte is exclusive
  (`upload_chunk` uses `end - start`), which no version of the spec has ever
  said. An external client following RFC 7233 sends one byte too many and gets
  a 400. Documented on the parameter, since the spec is the contract pg-js and
  pg-dotnet are written against.
- `GET /filedownload/{uuid}`: `Accept-Ranges: bytes` is set on the response
  builder after both 404 early returns, so it is not "always" advertised.
  Reworded, and added the header to the 416, which does carry it.
- `GET /filedownload/{uuid}`: added the 500 from a failed `file.seek` on the
  range path.
- `POST /fileupload/init`: the 400 also covers a syntactically invalid JSON
  body, which Rocket answers with its default error page rather than the
  plain-text message. Same caveat the chunk and finalize 400s already carry.
- `StagingPreview.confirmation`: null whenever `state.sender` is unset, i.e.
  when previewing before finalize — the normal staging flow, and the case the
  old wording excluded.

cargo fmt/clippy/test all pass (154 tests); the spec still validates as
OpenAPI 3.0.3 with all $refs resolving.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit b2a0cda35e4646797ab2e3e3bc952685236647fb)
#203)

* ci: pin the oasdiff gate's settings with a mutation test

`fail-on: ERR` with no `include-checks`, the setting the gate landed with in
#201, fails open on ten of the changes the spec's contract forbids. Measured
against oasdiff v1.26.1 by mutating the real spec one way per rule: a `401`
that becomes a `403` and a dropped response enum value rate ERR but are
opt-in, and a removed or renamed optional response property, a removed request
parameter, a removed request property and the constraint-narrowing `*-set`
family all rate WARN. Cryptify's routes are unversioned, so each of those
reaches every pinned client the moment it deploys.

Adds `mod api_gate_tests` in `src/main.rs`, which runs the engine with the
flags the action's entrypoint builds and asserts stop-or-pass per mutation. It
fails on the shipped settings and names every gap; it passes on `fail-on: WARN`
plus the two opt-in checks. The engine is not on any runner, so it skips in CI
while `every_api_gate_mutation_still_applies` still runs there.

The workflow itself is in a PR comment: the App has no `workflows: write`.

Refs #202. Part of #247 (workstream C).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ci: assert the workflow runs the settings the gate test pins

Review on #203: `FAIL_ON` and `INCLUDE_CHECKS` described settings nothing
tied to the committed workflow, which still says `fail-on: ERR` with no
`include-checks`, and `cargo test` was green anyway. So the branch shipped a
passing test certifying a configuration the gate does not use, which is the
fail-open the module exists to catch.

`the_workflow_uses_the_settings_this_module_pins` reads
`.github/workflows/api-diff.yml` and asserts its two step inputs are the two
constants. It needs no `oasdiff`, so it runs on every runner. It is red until
a maintainer applies the workflow patch from the PR comment, which makes the
merge order enforced rather than documented.

Also from the review: CLAUDE.md now says the settings are pending rather than
present-tense fact and names where the patch lives; the tier counts are the
`-f json` ones (WARN adds 30 on top of ERR's 212, not 31 on 213 — the table
output has a header row and a trailing blank); the versioned-route mutation
goes through `once` like every other anchor, so a renamed `getUsage` fails
loudly instead of no-op'ing; and a root `.gitattributes` marks the spec `-text`
so the multi-line anchors survive a Windows checkout.

Refs #202.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test: assert the api gate still runs, not only what it runs with

`the_workflow_uses_the_settings_this_module_pins` pinned the gate's two
inputs but not whether the job is ever triggered, which is the one
fail-open it could not see: settings that read correctly on a gate that
never fires. The workflow's own header comment names the foreseeable edit
("There is deliberately no `on: paths:` filter"), and a later `paths:`
would skip the gate on every PR that does not touch the spec with the
test still green.

Asserts a `pull_request` trigger, no `paths:`/`paths-ignore:` filter, and
no `if:` on the job. The trigger check is line-based over the `on:` block
rather than a byte-exact match, so a rewrite between the mapping, list
and scalar forms of `on:` does not go red for nothing.

* ci: tune the oasdiff gate to WARN with the two opt-in ERR checks

Dobby's YAML from the #203 comment, applied verbatim (the App cannot push workflows). This is the half its guard test was failing on: the_workflow_uses_the_settings_this_module_pins reads this file and asserts fail-on and include-checks match the constants the test module pins, so the settings and the mutation test that certifies them cannot land apart.

At fail-on=ERR with no include-checks the gate passed ten of the changes this spec's contract forbids.

---------

Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ruben Hensen <ruben.hensen@protonmail.com>
git-subtree-dir: cryptify
git-subtree-mainline: e642786
git-subtree-split: 4df67fa
Second half of the #255 spike. The commit before this
one is the history-preserving subtree import; this one makes it build, test and
release as a workspace member.

Resolver: pg-pkg's sqlx and cryptify's rusqlite both reach libsqlite3-sys, which
sets `links = "sqlite3"`, and only one package per resolve graph may claim a
given `links` value. Optional dependencies sit in the graph whether or not their
feature is on, so pg-pkg pulls sqlx-sqlite in despite enabling only `postgres`.
sqlx-sqlite 0.9.0 wants libsqlite3-sys >=0.30.1,<0.38 and rusqlite 0.40.1 wants
^0.38.1: empty intersection, workspace does not resolve. sqlx 0.9 + rusqlite
0.39 is the newest pair that does.

- root Cargo.toml: cryptify joins `members`
- cryptify: pg-core becomes a path dependency; own Cargo.lock, release-plz.toml,
  .github/ and duplicate profile blocks dropped
- build.rs: walk up for Cargo.lock — a member has none beside its manifest, and
  the panic would have been at build time, not resolve time
- api-diff.yml: one matrix over both specs, not two jobs. Both api-gate suites
  require exactly one `fail-on:`/`include-checks:` line in the file
- build.yml: matrices key on the crate directory so `cryptify` fits; this
  renames the `Test/Format/Clippy workspace` contexts
- Dockerfile: build context moves to the repo root
Root .gitignore's `*.pem` matches the four fixtures #241 committed under
pg-pkg/testdata/jwt_rotation/, leaving them tracked *and* ignored. release-plz
refuses to run against that, so delivery.yml's `Release-plz PR` job has failed
on every push to main since #241 landed on 2026-07-16 (12 runs). `Release-plz
release` is unaffected, which is why nothing looked broken.

Belongs in its own PR, not the cryptify spike; carried here only so the spike
could get far enough to answer the release-plz question on #255.
Neither failure shows up in the test matrix; both are Docker-only.

`cargo chef prepare` loads every workspace member's manifest, including ones
the build never compiles, so the Dockerfile's member-by-member COPY list has to
gain cryptify or the image fails with "failed to load manifest for workspace
member /app/cryptify".

With that fixed it fails again on MSRV: sqlx 0.9 declares rust-version 1.94.0
and this image pinned rust 1.91.1. sqlx 0.9 is what resolves the libsqlite3-sys
`links` conflict, so the pin moves rather than the dependency. 1.96.1-slim-trixie
is what cryptify's own Dockerfile already used, so both images now agree.

Verified by building both: pg-pkg-spike:edge (192MB), cryptify-spike:edge (170MB).
Brings in the 0.6.2/0.3.7/0.6.0/0.1.3 release (#187) and the .gitignore fix
(#274). Two conflicts:

- .gitignore: took main's wording, which is what #274 actually shipped. The
  spike carried an earlier, terser version of the same fix.
- cryptify's pg-core requirement moved 0.6.1 -> 0.6.2 by hand. release-plz
  maintains these reqs on unpublished members too (pg-pkg carries one), but it
  could not touch cryptify's during #187 because cryptify was not yet a member.

Merged rather than rebased on purpose: rebasing replays all 296 imported
commits onto main and discards the subtree merge.
@dobby-coder
dobby-coder Bot requested a review from leonbotros July 30, 2026 20:15
@rubenhensen
rubenhensen merged commit ba380a1 into main Jul 31, 2026
33 of 34 checks passed
@rubenhensen
rubenhensen deleted the spike/cryptify-in-workspace branch July 31, 2026 07:07
@github-actions github-actions Bot mentioned this pull request Jul 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

decide & spike: merge cryptify into the postguard workspace

1 participant