Skip to content

Pro backend integration tests - PFS edition#98

Open
jagerman wants to merge 144 commits into
session-foundation:devfrom
jagerman:pro-backend-integration-tests-pfs
Open

Pro backend integration tests - PFS edition#98
jagerman wants to merge 144 commits into
session-foundation:devfrom
jagerman:pro-backend-integration-tests-pfs

Conversation

@jagerman

Copy link
Copy Markdown
Member

This extends PR #96 with integration tests that build the pro-backend and run a local copy to perform pro-backend <-> libsession endpoint testing.

(Not to merge before the other pro backend 0.2.0-related PRs are merged)

jagerman added 30 commits July 7, 2026 17:10
session-deps is a new repo that consolidates how we handle loading and
doing static bundle builds of various common external dependencies
across Session projects.
Carrying a libsodium fork is too much of a nuissance as updating it to
the latest version is non-trivial.

This drops the libsodium-internal fork in favour of using tweenacl's
implementation *just* for the X25519 -> Ed25519 pubkey conversion, and
using a stock libsodium for everything else.

This also bumps the libsodium requirement up to 1.0.21: that version
will be required for SHAKE support in future commits on this branch.
- Blake2b hashing:
  - Add nicer hash::blake2b functions for simpler has computations where
    you can just pass a bunch of spannables and get the hash over them,
    rather than needing to do a bunch of manual C API update calls.
  - Add a ""_b2b_pers for a compile-time validated blake2b
    personalisation string
  - Drop make_blake2b32_hasher().
    - TODO: convert these to make full use of hash::blake2b(...), as
      above.
- Change cleared_array to take a Char type instead of forcing unsigned
  char.
- Add cleared_uchars (and cleared_bytes) for the old force-unsigned char
  typedef.
- Make `to_span` work for any input convertible to a span
- Tighten up various functions taking fixed length values (like session
  ids, pubkeys) to take compile-time-fixed spans instead of dynamic
  extent spans.  This allows these generic functions to not have to
  worry about length checking.
- Add a generic random::fill(s) function that fills some spannable type
  s with random bytes.
This switches to the PR branches for session-router and libquic to use
session-deps.  (This was required under ninja builds, in particular, to
get the deduplication handling for gmp and nettle via new session-deps
code to deal with that).
- Refactor manual libsodium blake2b hash calls to use simpler
  hash::blake2b functions instead.
- Unify usage of array_uc32/33/64 and uc32/33/64: now we have just
  uc32/33/64 and cleared_uc32/33/64 (i.e. the "array_" prefix is gone).
- Add a unsigned char array literal, which is quite useful for static
  hash keys.
- Including raw integer bytes would break the hashes on a non-little
  endian arches; this adds a helper than ensures we are always hashing
  the little-endian value.
- Make the remaining manual hash use blake2b_pers.  This didn't get
  autoconverted before because the `if` around part of the hash, but
  that if is actually completely unnecessary: if the value is empty, a
  blake2b hash update does nothing, and so it can just be always
  included (and when empty, it is still the right thing).
oxenc has buggy "constexpr" overloads that just break if invoked, and
they get invoked here with a uint8_t value + unsigned char array.  The
issue is fixed in oxenc dev, but switching to a byte here works around
it for current and older oxenc versions.
The recent commit to unify the types wasn't applied to the test suite.
hash::blake2b (and related) now take integers directly, writing the
integer bytes (with byte swapping applied, if necessary), which further
simplies the hashing API.
Make way for account keys to be in here as well.
If two devices rotate account keys at approximately the same time, both
might end up with inconsistent "active" keys.  This commit adds
deterministic tie breaking (always prefering the later, with fallback to
seed ordering).
Adds a "needs push" concept, along with more tracking fields to let us
distinguish between various possible states.
`session::AdjustedClock` now carries an adjustable static offset and
when `now()` is called it returns standard system clock timepoints with
the adjustment applied.

The networking code had a similar adjustment already, although it was
per-network-object: this change replaces that, and makes it now global
across all uses of the clock anywhere, instead of per-Network instance.
jagerman and others added 19 commits July 19, 2026 15:13
Drop the privileged tonistiigi/binfmt helper container in favour of a
one-time host install of qemu-user-static + binfmt-support (registers the
binfmt_misc handlers with the F flag, which is what makes them work inside
containers). Guard on the handler being present. Also bump the base image
bookworm -> sid so the audit runs against a modern toolchain representative
of what the code targets.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The *_unix_ts members/params are sys_seconds / sys_ms time points, so the
name baked the representation into the identifier. Rename them to *_at (as
the backend did) to stop embedding the type. Pure rename: JSON wire keys and
protobuf accessors are untouched, so there is no wire change. Also drop the
stale expiry_unix_ts_ms grace-period doc formula (the wire is integer seconds
now; no *_ms field survives).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Requests now carry {endpoint, content_type, data} instead of {endpoint, body}.
Clients relay `data` verbatim under `content_type` and must NOT assume or parse
a format -- libsession owns the wire encoding and may change it. Renames the C
mirror to match: session_pro_backend_to_json -> session_pro_backend_request
(json field -> data, plus endpoint + content_type), *_request_to_json ->
*_request_build, to_json_free -> request_free. The content type is single-sourced
("application/json") next to the endpoint strings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The C request API reimplemented the wire pipeline: a per-request _build_sigs
that signed, an input struct the caller hand-filled with pubkeys + precomputed
sigs, and a _request_build that re-serialized the body from them. Replace all
of it with loose-arg builders that take the same inputs as the C++ *_request()
functions (private keys + params) and just call them -- one wire implementation,
living in C++.

session_pro_backend_request now owns the built C++ ProRequest in an opaque
void* and points endpoint/content_type/data into it (zero copy); request_free
deletes it. Deletes the _build_sigs/_build_sig functions, the per-request input
structs, the add_pro_payment_user_transaction struct, and the
master_rotating_signatures/signature return structs. Tests and the dev-server
integration block are rewritten to the single-call API.

Also tightens the internal *_hash/*_body helpers to fixed-extent spans
(span<...,32> for pubkeys, span<...,64> for sigs) so sizes are enforced at
compile time rather than silently truncated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Desktop consumes the backend's X25519 pubkey as a constant, so expose it
alongside PUBKEY: session::pro_backend::PUBKEY_X25519 (C++) and the C export
SESSION_PRO_BACKEND_PUBKEY_X25519. A unit test asserts the hardcoded bytes
equal the runtime crypto_sign_ed25519_pk_to_curve25519(PUBKEY) conversion so
they cannot drift.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ded tags

Iterated the occasional big-endian audit into a working state (validated on
real s390x): use the host's qemu-user-static + binfmt-support (no privileged
tonistiigi/binfmt container); debian:sid for a modern toolchain; a clean
build dir each run; -DENABLE_NETWORKING_SROUTER=OFF plus libngtcp2/gnutls/
libevent from apt to satisfy the mandatory oxen::quic without building it from
source; and fail loudly if any filter tag matches no tests (guards against a
tag rename silently shrinking the audit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Response::status was a uint32_t whose domain changed by endpoint (0/1/2 for
most, plus 100/101 for add-payment) -- and for every response except add-payment
it just duplicated !errors.empty(). Remove it: Response is now { errors } with a
success() helper (C: response_header drops status; success == errors_count == 0),
and add-payment collapses to the same -- the backend already sends the human
reason (including already-redeemed / unknown-payment) in errors, so display is
unaffected. Deletes the AddProPaymentResponseStatus C++ enum, the C
SESSION_PRO_BACKEND_ADD_PRO_PAYMENT_RESPONSE_STATUS enum, and its magic-int
values. Parsers still read the wire "status" int internally to decide success;
they just no longer surface it. (A future machine-readable discriminator, if
needed, should be an opaque string slug on the wire like the Q13 statuses.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…r (Delta session-foundation#12)

Reworks the response envelope to spec §5 / Delta session-foundation#12. The wire `status` becomes
a string ok/fail/error parsed into a CLOSED ResponseStatus enum -- an unrecognized
value is a protocol error, never passed through (backend confirmed status is
exhaustive, Q14). The `errors` array becomes a single `error` string
(diagnostic/fallback, first-reason-only). A new open/additive `error_code` slug
(opaque; unknown values pass through) carries the machine-readable outcome.

Response is renamed ResponseBase (it's only ever a base) and is now
{ ResponseStatus status; optional<string> error_code; optional<string> error; }
with an explicit operator bool(). The C response_header mirrors it (status enum +
error_code/error string8, {NULL,0} == none). get-details account status key
renamed status -> user_status (Q15). Deletes the obsolete integer status enum.
A libsession-side parse failure yields a protocol error (error_code
"invalid_response").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… drop BLAKE2b

Implements pro-wire-protocol.md Delta session-foundation#13 / §1.1: all five Ed25519
signatures (the offline proof §2 + the four signed requests §3.1–3.4)
now sign the message bytes directly instead of a BLAKE2b-256 digest.

- Add session::pro::signed_message() (src/pro_message.hpp): builds the
  signed message per §1.1 — 16-byte domain prefix, fixed-width raw
  keys/tags (self-delimiting), integers as canonical decimal ASCII
  (std::to_chars), strings verbatim, a single NUL between adjacent
  variable-length fields.
- Replace ProProof::hash() with ProProof::signed_message() and delete the
  C session_protocol_pro_proof_hash() (the 32-byte digest no longer
  exists in the protocol).
- Drop the now-dead hash.hpp includes; BLAKE2b is gone from the Pro
  signing path.
- Retype/rename the 16-byte domain-prefix constants (session_protocol.hpp)
  from _b2b_pers spans to std::string_view, *_PERS -> *_DOMAIN; drop the C
  session_protocol_pro_proof_hash declaration.
- Update tests to sign via signed_message() and to compare the proof's
  signature (not a hash) across the encode/decode round-trip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Client-facing enumeration of the user-visible purchasable payment
platforms (provider-code slugs: google_play, app_store) so clients build
their "purchase via …" store list at runtime instead of hardcoding two
stores. Hidden mechanisms (rangeproof) are handled but never listed — they
surface only for users who already hold them.

C++ `visible_platforms()` returns std::span<const std::string_view>; C
`session_pro_backend_visible_platforms(size_t*)` returns a static
pointer-array derived from it. Both built from the existing
SESSION_PRO_BACKEND_PAYMENT_PROVIDER_CODE_* constants (single source).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tion#14)

Parse the closed `plan` grammar (pro-wire-protocol.md §1 / Delta session-foundation#14) in
libsession rather than handing clients the raw wire code to reinterpret.
get-details items now carry a parsed ProPlanPeriod{count, unit} (unit ∈
second/day/week/month/year/lifetime) instead of a raw string.

- Add parse_plan_period() + ProPlanPeriod/ProPlanUnit (C++), mirrored by
  SESSION_PRO_BACKEND_PLAN_UNIT + plan_count/plan_unit on the C item.
- The unit is preserved, never canonicalized ("12m" != "1y" as values).
  "lifetime" -> {0, lifetime}; invariant count == 0 iff unit == lifetime.
- Closed grammar (a backend<->libsession contract, like the §5 status
  enum): an unrecognized code is a protocol error, not passed through raw.
- Reserves a testing-only `s` (seconds) unit; single-unit only ("1y6m"
  invalid). Response-parse only -- no wire or signing change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Use the idiomatic `typedef struct X { ... } X;` instead of the split
`typedef struct X X; struct X { ... };`. None of these structs are self-
or mutually-referential, so the forward typedef bought nothing but extra
lines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The provider-code slugs were defined in the C header (static const char[])
and referenced from C++ — backwards for a library whose C++ side is the
primary implementation. Move them to C++ constexpr std::string_view
(session::pro_backend::PAYMENT_PROVIDER_*, the single source) and have the
C `SESSION_PRO_BACKEND_PAYMENT_PROVIDER_CODE_*` symbols reference them as
`extern const char* const`, matching how URL / PUBKEY / the endpoints
already work.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rework the C get-details/proof/revocations/refund response structs so each
one owns the parsed C++ response object (via an opaque `void* internal_`)
and its fields point into it, instead of deep-copying into fixed buffers
backed by a hand-rolled bump arena. This mirrors how the request side
already owns its C++ ProRequest.

- Response string fields (`error_code`/`error`, item `status`/
  `payment_provider`/`payment_id`, details `status`) become `const char*`
  views (NUL-terminated; NULL when absent); the per-item revocation `tag`
  becomes a `const unsigned char*` view. All the `char[N]`+`_count` pairs
  are gone -- including the 128-byte `payment_id` buffer that silently
  truncated long Google composites.
- `header.internal_arena_buf_` -> `void* internal_`; each `*_response_free`
  is a one-liner over a `c_free_response<Owned>` template constrained to
  `std::derived_from<ResponseBase>` (the two item-bearing holders derive
  from their C++ response type). File-local `to_c()` converters (taking a
  non-const ref, so a temporary can't bind) replace the manual per-field
  fills.
- Delete `arena_t` / `arena_alloc` / `arena_alloc_to_string8` (types.h,
  types.cpp) -- now used nowhere.
- Reorder pro_backend.h: request struct + `request_free` above the response
  group (no longer wedged between the header and the responses); each
  `*_free` decl sits immediately after its struct.

C-API-only change (response side); the C++ API is unchanged. iOS/nodejs
glue must read the new `const char*`/pointer fields instead of sized
buffers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cleans up C-isms the original Pro code sprayed through the header/impl:

- Collapse the split `typedef struct X X; struct X {...};` into the
  idiomatic `typedef struct X {...} X;`.
- Move each `*_free` declaration to sit immediately after the struct it
  frees (it's effectively that struct's destructor).
- Move the protocol limit/padding constants to C++ as the source of truth
  (`session::` inline constexpr) with the C `SESSION_PROTOCOL_*` symbols
  referencing them as `extern const int`; drop the misleading `PRO_` from
  the two non-Pro ones (STANDARD_CHARACTER_LIMIT,
  STANDARD_PINNED_CONVERSATION_LIMIT).
- Replace the reinvented `array_uc32_from_ptr_result` with
  `std::optional` (maybe_uc32_from_ptr).
- Replace the `sizeof((T*)0)->member` null-deref idiom with
  `sizeof(T::member)`.
- Rename the meaningless `utf`/`utf_size` params to `text`/`text_size`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…der constants)

- Rename ProviderUrls -> ProviderURLs (initialism casing).
- All five URL fields are now std::optional<std::string_view>: a provider
  may populate none of them (e.g. a one-shot crypto provider), so clients
  test the optional instead of an empty-string sentinel. The C struct's
  matching fields may now individually be NULL.
- Replace the per-call constructed value + std::optional<ProviderURLs>
  return with fixed per-provider `constexpr ProviderURLs` constants and a
  `const ProviderURLs*` return (nullptr = no URLs at all). URLs are one-line
  `""sv` literals (guaranteed null-terminated) so clang-format won't reflow
  them mid-string.
- Use designated initialisers for the provider constants.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Now that every ProviderURLs field is optional, the by-value C return could no
longer distinguish an unknown provider code from a recognised provider that
simply has no URLs -- both produced an all-NULL struct. Add a `bool found`
member (true iff the C++ provider_urls() returned non-null) so C clients get
the same distinction the C++ `const ProviderURLs*` gives, without a returned
pointer they'd have to reason about freeing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`string8` (`{char* data; size_t size}`) was a structural duplicate of the
existing `span_u8` (`{unsigned char* data; size_t size}`) -- same ptr+len byte
span, right down to a duplicate pair of alloc/copy_or_throw helpers. Once its
uses are mapped, none actually need a string-with-length:

- SESSION_PROTOCOL_STRINGS (24 fields) and session_protocol_pro_features_for_msg
  `error` are static, null-terminated strings -> `const char*`. The `error`
  string_view only ever views static literals ("..." + simdutf::error_to_string,
  which returns string_view over literals), so there is nothing to free and no
  dangling. SESSION_PROTOCOL_STRINGS drops the `string8_literal` macro for plain
  string literals.
- session_pro_backend_request `data` is an opaque (possibly binary) payload that
  genuinely needs ptr+len -> `span_u8`, the type already used for sig/msg.

Then delete the dead string8_alloc_or_throw/string8_copy_or_throw helpers (no
callers; duplicates of span_u8_*), and the `string8` struct + `string8_literal`
macro. Test helper string8_equals -> span_u8_equals, now a std::string_view
compare instead of a hand-rolled memcmp.

C-API change (clients): session_protocol_strings fields and
session_protocol_pro_features_for_msg.error are now `const char*`;
session_pro_backend_request.data is now `span_u8`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jagerman
jagerman force-pushed the pro-backend-integration-tests-pfs branch from e19c529 to cadeb33 Compare July 22, 2026 21:26
…details (Delta session-foundation#15)

The old combined get-details endpoint conflated a cheap entitlement check with a
heavy payment ledger; no client used the ledger beyond the latest item. Split it
into two endpoints (both a signed-request flip, new domains):

- get_pro_status (domain ProGetProStatus_; signed `master_pkey ‖ ts`) -- the hot
  "am I Pro?" path. Response: user_status, auto_renewing, expiry_ts,
  refund_requested_ts, grace_period_duration, error_report, and a single
  `latest_payment` (or none). No list, no pagination. C++ ProStatusResponse /
  parse_pro_status; C session_pro_backend_get_pro_status_response with a
  bool has_latest_payment + embedded latest_payment.
- get_payment_details (domain ProGetPayDetails; signed
  `master_pkey ‖ ts ‖ \0 ‖ limit ‖ \0 ‖ before`) -- paginated history. Request
  {limit, before}; response {payments_total, items, next_cursor}, no user_status.
  Keyset pagination: `before`/`next_cursor` is an opaque cursor (std::optional /
  const char* NULL at end-of-data), echoed verbatim, never parsed.

Payment-item parsing is factored into a shared parse_payment_item helper used by
both `latest_payment` and `items`. No "pro details" terminology survives
(identifiers, endpoints, domains, the error-report enum -> GET_PRO_STATUS_*).

C-API change (clients): get_pro_details_{request_build,response,response_parse,
response_free} and SESSION_PRO_BACKEND_GET_PRO_DETAILS_ENDPOINT are replaced by
get_pro_status_* and get_payment_details_* equivalents.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jagerman
jagerman force-pushed the pro-backend-integration-tests-pfs branch from cadeb33 to 5cbbce0 Compare July 22, 2026 22:10
jagerman and others added 5 commits July 23, 2026 14:30
The doc preamble linked a pinned old commit of server.py on the Doy-lee
session-pro-backend fork; the backend has since merged upstream, making the
fork+commit reference doubly stale. Cite the authoritative pro-wire-protocol.md
spec instead, matching the stable branch's wording. Comment-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The backend removed the "Delta N" change-history annotations from
pro-wire-protocol.md (the spec now reflects only the current state), so those
citations no longer resolve. Drop the "Delta session-foundation#14" references from the `plan`
billing-period doc comments, keeping the still-valid §1 spec reference (the plan
grammar lives in §1). Comment-only; all other §-references verified against the
current spec.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stand up an ephemeral session-pro-backend (throwaway postgres + flask with
provider egress stubbed) and drive all Pro endpoints through libsession's C++
API over real HTTP (direct + zero-hop onion v4), asserting every request
round-trips, the backend accepts each signed digest, responses parse, and issued
proofs verify against the backend's live signing key. This catches wire-contract
/ lockstep drift the self-consistent unit tests structurally cannot.

- tests/pro_backend/run-dev-backend.sh: hermetic launcher (initdb/pg_ctl +
  ephemeral signing key + flask, polls /status, runs testAll, tears down).
- tests/pro_backend/seed_payment.py: injects witnessed-unredeemed payments and
  revocations straight into the DB via the backend's own helpers.
- test_pro_backend.cpp: replace the legacy C-API [dev_server] block with a
  transport-abstracted [pro_live] suite: /status round-trip, full lifecycle flow
  (both providers, both transports), get_pro_revocations. The lifecycle flow
  covers the Delta session-foundation#15 endpoint split — get_pro_status + paginated
  get_payment_details, including an opaque next_cursor round-trip.
- .drone.jsonnet: "Debian sid (Pro backend live)" pipeline running [pro_live].

Tracks Delta session-foundation#12 envelope, session-foundation#13 sign-the-message-directly, session-foundation#14 parsed plan,
session-foundation#15 get_pro_status/get_payment_details split, and the generations-table
revocation reshape.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The live [pro_live] suite catches libsession<->backend signing drift, but not a
fault where BOTH sides share the same wrong construction -- they would still
agree. Add frozen, spec-anchored known-answer vectors that pin libsession's
signed-message construction (wire spec 1.1 / 2 / 3, Delta session-foundation#13) with no running
backend, and that freeze the wire format against silent future drift.

- tests/pro_backend/gen_kat.py: generates the vectors from the backend's
  independent implementation for fixed deterministic inputs.
- test_pro_backend.cpp [pro_kat]: for each signed request, asserts the request's
  Ed25519 signature covers exactly the expected (hand-verified) message bytes;
  for the proof, asserts ProProof::signed_message() equals the expected bytes and
  that a frozen backend signature verifies (and a tampered key does not). Covers
  the Delta session-foundation#15 split endpoints: get_pro_status, and get_payment_details with
  both an empty `before` (newest page, trailing NUL) and a non-empty opaque
  cursor. Ed25519 determinism makes each signature its own known answer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two housekeeping changes now that the backend merged to session-foundation dev:

- The wire spec dropped its "Delta N" change-history annotations, so those
  citations in the harness comments/vectors are dangling. Reworded to cite the
  current spec sections instead (plan -> §1, signed-message construction ->
  §1.1/§2/§3, the read-request split -> §3.4) or dropped the parenthetical.
  Verified remaining §-references resolve against the current section map.
- .drone.jsonnet: point the CI backend clone at session-foundation dev (was the
  now-merged jagerman phase2-foundation). An integration/drift harness should
  track the counterpart's mainline; a pinned ref is what went stale before.

No behavior change to the tests themselves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jagerman
jagerman force-pushed the pro-backend-integration-tests-pfs branch from 5cbbce0 to 51cbdcd Compare July 23, 2026 17:58
jagerman and others added 2 commits July 23, 2026 20:05
The backend's main.py imports psycopg_pool (psycopg 3's connection pool,
requirements.txt: psycopg-pool~=3.2) — a separate Debian package from
python3-psycopg. Without it flask fails to import `main` and the ephemeral
backend never starts, so the [pro_live] step's launcher reports "flask exited
during startup: ModuleNotFoundError: No module named 'psycopg_pool'".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Pro-backend-live pipeline runs in a minimal Debian container whose default
locale is C/POSIX -> ASCII. The backend's migration SQL carries non-ASCII bytes
(an em-dash in a comment); psycopg encodes each query with the connection's
client encoding, which follows the DB/locale, so bootstrap_db died with
`UnicodeEncodeError: 'ascii' codec can't encode character '—'` and flask
never started. run-dev-backend.sh now exports LC_ALL/LANG=C.UTF-8 (always
present in glibc) and initdb's the cluster with --encoding=UTF8, so the cluster
and every client (flask, seed helper, psql) speak UTF-8 as in production.
Reproduced locally under LC_ALL=C and confirmed fixed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

1 participant