Commits
tenant_ctx() hardcoded config_for empty, so the Stripe/Printful providers never received
their keys — real payments couldn't work by any path (the LGTM_STRIPE_*/LGTM_PRINTFUL_* env
vars were loaded into Config but read nowhere). Build config_for from those env vars (the
single-tenant reference-impl source); multi-tenant (Phase 2) will layer the integration_config
table on top. Makes real Stripe work end-to-end + Printful fulfillment-submit work. Deploy
docs: go-live cutover uses the env vars directly, exact Stripe webhook endpoint + events, and
notes Printful webhooks remain unsupported (no signing secret).
Remove conversational residue from public-facing docs: draft-history
asides in SPEC.md ("earlier assumption is dropped", "was Phase 1.5",
resolved items listed as open questions), discovery narrative in the
RELEASING/skill tag-trigger notes, a dangling code fence in the release
skill, and absolute authoring-machine paths in the Phase 1 plan.
Delete the docs/superpowers working artifacts (Phase 1 plan,
publishable-impl plan + design spec) — both are implemented and shipped
(v0.1.0-v0.2.2); tags preserve them. Gitignore docs/superpowers/ so
future plans stay uncommitted.
Publishes the store's ES256 receipt-signing key so verifiers resolve issuer DID → key
(SPEC 9), in a DID-method-agnostic way so any shop can plug in their own DID:
- GET /api/signing-key (any method): returns { did, verificationMethod } — the shop
publishes it under their DID however their method requires.
- GET /.well-known/did.json (did:web:<host> only): the full DID document, hostable at the
host root (the SSR storefront already proxies it). 404 for did:plc etc.
did:plc stores (the atproto default) take the /api/signing-key verificationMethod and add
it via a PLC operation (or a future attestationKey record). Pure, unit-tested builders;
deploy docs document the connect-your-DID flow per method + the still-stubbed admin OAuth.
Adds a one-shot `--role seed` to the server binary (migrate → seed demo store → exit) and
extracts the seeder into a shared `server::seed` module the bin + role + tests all call.
Dockerfile splits ENTRYPOINT ["server"] + CMD ["--role","all"], so seeding is just
`docker run <image> --role seed` — retiring the `--entrypoint /usr/local/bin/seed-demo`
gotcha. Drops the seed-demo binary from the image (kept as a cargo-run convenience bin).
Deploy docs + prod compose updated; the integration test now exercises the real seeder.
Confirmed the Spindle does not create a pipeline on tag pushes (isolated re-push of v0.2.0
produced no run), so revert to branch-triggered publish keyed off the VERSION file (the
proven mechanism that shipped v0.1.0), with the manifest-inspect idempotency guard. release.sh
now bumps VERSION; the git tag stays as the git-cliff changelog boundary only. Restores
VERSION=0.2.0 so this push publishes v0.2.0.
Lets a storefront (e.g. www SSR) render the catalog and get variant_ids for checkout
without direct DB access. Returns active products under the product collection (excludes
the storefront self record) each with their non-hidden variants (id, sku, price, kind,
availability, currency). Two queries, no N+1.
seed-demo must override the ENTRYPOINT (server --role all) or it starts a stray server;
document DOCKER_DEFAULT_PLATFORM=linux/amd64 for Apple Silicon/Colima.
Orders are store-authored (SPEC 8), so author_did is the store DID; load_order sourced
buyer_did from it, making receipts record the store as buyer. Source it from the space skey.
Worker polls for the key rather than giving up if it's absent at boot (seed-demo after
boot) — fixes orders stalling at Paid until a manual restart.
healthz returns JSON {status, version, commit} from LGTM_BUILD_VERSION / LGTM_BUILD_COMMIT
(Docker build-args in the runtime stage; defaults dev/unknown) so a deployed instance is
identifiable.
Conventional-commits changelog via git-cliff (cliff.toml, Keep-a-Changelog). release.sh
regenerates CHANGELOG ([Unreleased] -> version), commits, tags. RELEASING.md + the release
skill document the tag-based flow.
Publish fires on a v* tag push; image tag + the version baked into /healthz derive from
TANGLED_REF_NAME. Idempotent (skips if the tag's image already exists). Removes the VERSION
file; manual publish-image.sh derives the version from the git tag.
ats:// 6-component space_uri + space_ref_uri; orderSpace space declaration
(type:space) whose collections are the four permissioned leaves; ensure_space
uses the orderSpace space type (record collections unchanged); buyer OAuth scope
is space:...orderSpace; space_did authority = store_did (simplespace, no minted
per-space DID). PG-canonical runtime behavior unchanged.
ats:// 6-component addressing; space type is a type:"space" declaration (orderSpace);
authority = store_did (simplespace anchors on the store's own DID — no per-space minted
DID); getDelegationToken/getSpaceCredential/listRepoOps/listRepos sync; deniable LtHash
commit (blake3 XOF + sig-over-ctx/mac); resolves the controlled-DID open question.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Vendor all 6 apalis-sql 0.6.4 postgres migrations into migrations/0006_apalis.sql
so our app migrator (single lineage, versions 1-6) owns the apalis schema setup.
Remove the worker::setup_storage() call from boot (no more apalis migrator collision).
Add DevFulfillmentProvider to dev.rs and register it in fake-payments mode so the
full worker pipeline (fulfillment -> receipt mint -> sign -> Fulfilled) completes
without a real Printful API key.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Load store_signing_key at startup via load_store_signer() helper (shared by
attest service + worker monitor); degrades to stub/no-op with a warning when
no key row exists yet instead of hard-failing on first boot.
- Fix 1: worker_fut now calls worker::production::build_production_deps(pool,
store_did, collection, registry, signer) + worker::build_monitor(state).run().await
so --role all/worker actually drains the apalis.jobs queue.
- Fix 2: RealAttestService holds Arc<Signer>; on attest() it loads receipt_hash
+ product_uri, calls claims::attest(signer, hash, facts), writes the
purchase_claim row, and returns at://{store_did}/shop.lgtm.commerce.purchaseAttestation/{order_id}.
StubAttestService covers the no-key fallback.
- Fix 3: products.rs test helper now collects bool flags out of the MutexGuard
into owned values before drop, eliminating the await_holding_lock lint.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
e2e crate (e2e/src/lib.rs):
- stripe_test_signature helper (t=..,v1=HMAC-SHA256) + 3 unit tests (known-
answer vector, determinism, secret variation)
e2e/tests/e2e.rs:
- #[ignore] full-stack HTTP test (runs with docker compose up + --ignored);
drives checkout → signed webhook → poll worker → attest → backer wall
crates/server/tests/e2e.rs (RUNS against embedded PG):
- test_phase1_full_loop_inline: full 7-step Phase 1 loop in-process via
#[sqlx::test]; FakeStripe (HMAC-verifying) + FakePhysical + real ES256
receipt signing + real route handlers
Steps that run live (no #[ignore]):
1 seed tenant+product+variant
2 POST /api/checkout -> checkout_url + order_id + pending_payment
3 POST /webhooks/stripe (HMAC-signed) -> order=Paid + job enqueued
4 process_paid_order inline (real adapters) -> Fulfilled + sig verified
5 POST /api/claims/attest -> attestation_ref at-uri
6 insert verified purchase_claim (simulates Jetstream indexer)
7 GET /api/backers -> exactly one verified backer
Supporting changes:
- public.rs: checkout response now includes order_id (required by e2e)
- webhooks.rs: PaymentCaptured handler now updates order status to Paid
before enqueuing (fixes the pending_payment -> Paid transition gap)
- README.md: "Running the pilot (Phase 1)" + "Deferred to Phase 1.5+"
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- crates/server/src/boot.rs: BootStep + Role + boot_plan (migrations always
first); 5 unit tests pin the ordering (migrate < serve for all roles)
- crates/server/src/main.rs: add mod boot; use boot::Role throughout;
existing parse_role + migrate-before-serve order preserved
- Dockerfile: cargo-chef multi-stage builder -> distroless/cc-debian12 runtime;
ENTRYPOINT = server --role all (advisory-locked migrations then serve+worker)
- .dockerignore: exclude target/, .env, .git/, docs/; keep migrations/, lexicons/,
crates/, Cargo.* (Rust-only, no node_modules)
- docker-compose.yml: add app service (build ., depends_on postgres healthy,
env_file .env, port 8080); docker build is a documented manual/CI check
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- PUT /admin/settings/storefront: write storefront public record (key 'self') via
PublicWriter; upsert PG mirror row (on conflict (tenant_id, at_uri))
- PUT /admin/settings/integrations/:id: validate submitted JSON against integration's
config_schema (required fields non-empty), seal with lgtm_core::crypto::seal,
upsert integration_config.config_enc
- validate_config(): rejects missing-required and empty-required fields with named error
- Tests: storefront PUT writes record + mirror; integration PUT rejects missing-required
and does NOT store; on success config_enc is ciphertext and open() recovers plaintext
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- GET /admin/orders: session-guarded Maud order table
- GET /admin/orders/:id: order detail with fulfillment + receipt revocation status
- POST /admin/orders/:id/refund: payment provider refund → OrderStatus::Refunded
transition → receipt revoked_at set → purchase_claim.revoked = true via receipt_hash
- OrderStatusShim encodes the same Paid|Fulfilling|Fulfilled→Refunded legal transition
from core without materializing a full Order object
- Test: RefundSpy asserts provider.refund() called; DB assertions verify all 4 cascade steps
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- GET /admin/products: session-guarded Maud table from PG product mirror
- POST /admin/products: write product + variant public records via PublicWriter,
upsert PG mirror rows, return HTMX partial (new table row)
- PATCH /admin/products/:id: re-put public record at same rkey, update mirror title
- Tests: create writes both public records AND PG rows; patch updates mirror title
- html_partial() helper in layout.rs for HTMX partials outside full layout
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Add admin module with rust-embed Assets serving /admin/static/*
- Implement is_authorized_admin (DID equality gate) + SESSION_DID_KEY
- Implement require_admin session guard (redirect unauthenticated → start)
- Implement auth_routes: POST /admin/auth/atproto/start (PKCE) + GET callback
(token exchange is M4-deferred stub; CSRF validation is live)
- Maud layout() returns Html<String> via axum::response::Html (maud 0.26 has no axum feature)
- Add PublicWriter trait + PublicWriteOut to AppState; ProductionPublicWriter in main.rs
- Update all AppState initializers (test_support, route tests) with public_writer field
- Session redirect test: assert is_redirection() (axum Redirect::to → 303, not 302)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
POST /api/claims/attest: joins order -> space, checks space.skey ==
x-buyer-did header (SPEC §8 ownership); non-owner -> 403. On success
delegates to AttestService seam and returns {attestation_ref}.
GET /api/backers: returns verified=true AND revoked=false claims only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GET /api/receipts/:id: buyer_did header must match receipt.buyer_did;
mismatch or missing -> 404 (no existence leak, SPEC §17).
GET /api/entitlements/:id/download: DownloadVerifier seam checks sig+exp,
then revoked_at from receipt_record must be NULL; any fail -> 403.
test_support.rs: shared state_for_test builder + no_op_downloads/fixed_attestor.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
POST /webhooks/:integration_id resolves integration from Registry,
verify_webhook -> on Some(event) insert webhook_event ON CONFLICT DO NOTHING.
Only rows_affected==1 triggers enqueue(Job::ProcessPaidOrder).
Bad sig -> 400; duplicate event id -> 200 no-op (no re-enqueue).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
/api/storefront reads the storefront mirror row from the product table.
/api/checkout: CartRequest -> WebSurface::build_draft -> place_order ->
persist order_record + order_item -> return {checkout_url}.
Empty/invalid cart returns 400.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
AppState wires db, registry, store (PgPermissionedStore), config,
SurfaceSet, JobSink (enqueue seam), DownloadVerifier, AttestService.
/healthz returns 200; /readyz pings DB + every payment provider health.
main.rs parses --role server|worker|all with ApalisJobSink for production.
wiring.rs isolates concrete integrations (Stripe/Printful/Files) from handlers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
reconcile_with diffs each fulfillment row's local status against the provider's status
(via the ProviderStatus port) and returns the divergences for the admin (SPEC §18);
unreachable providers are skipped, not false-flagged. The public reconcile() uses a
no-op adapter until M11 wires the Registry. dead_letters() queries apalis.jobs for
Failed jobs with their last_error + attempt count. Tested with fakes / seeded rows:
one divergent + one agreeing order yields exactly one mismatch; a seeded Failed apalis
job is surfaced. The dead-letter test uses bare #[sqlx::test] (apalis owns its tables;
mixing the two migrators in one DB clashes on versioning).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reads per-order opt-in from payment_ref JSON (claim_opt_in); no opt-in -> no-op (the
mutual opt-in is the privacy guarantee, no dox). When opted in, loads the receipt_hash,
produces a shop attestation over it via the ClaimAttester port, and seeds a pending
purchase_claim row (verified=false; the Jetstream indexer + claims::verify_claim flip it
later). Tested with fakes: opted-in creates an attestation + pending claim; not-opted-in
writes nothing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Loads order, Paid->Fulfilling, dispatches each item by variant.kind family via the
FulfillmentPort (external_id = order.id for provider idempotency), mints + persists a
signed receipt into the (store,buyer) space via ReceiptMinter, then Fulfilling->Fulfilled.
Re-running a Fulfilled order is a no-op; a failing submit leaves it Fulfilling (recoverable),
never Fulfilled, with no receipt. Handler tested directly with fakes (FulfillmentPort/
ReceiptMinter/ClaimAttester) over seeded Postgres rows: happy path, idempotent re-run
(no dup receipt/fulfillment), and failure-path recoverability.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Job enum (serde tag="kind"), PostgresStorage with "worker::Job" namespace,
enqueue() pushes a job row, build_monitor() registers one worker dispatching
ProcessPaidOrder/AttestClaim/Reconcile. Tests: serde round-trip + live enqueue
against apalis.jobs (apalis storage setup works under #[sqlx::test], no #[ignore]).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- signed_download_url_at: /api/entitlements/:id/download?exp=..&sig=.. with sig=HMAC-SHA256(master_key, "id|exp")
- verify_download: rejects expired (now>exp), tampered sig, or revoked entitlement
- All 5 Files unit tests pass; no network access, no #[ignore]d tests
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Pure-function helpers: parse_shipping_rates, classify_submit (idempotency), map_event, verify_signature
- classify_submit detects "already exists" duplicate error and returns original external_id, no dup
- verify_webhook maps package_shipped -> FulfillmentShipped; rejects bad HMAC-SHA256 sig
- All 7 Printful unit tests pass; live submit test is #[ignore]d
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Adds hmac + hex to workspace deps
- Adds NullStore + Registry::empty() to lgtm-core for test fixtures
- Derives Debug on NormalizedEvent (needed for test assertions)
- Implements StripePayment: HMAC-SHA256 webhook sig verification, PaymentCaptured/PaymentRefunded event mapping, create_subscription->Unsupported
- All 7 Stripe unit tests pass; live checkout test is #[ignore]d
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
tenant_ctx() hardcoded config_for empty, so the Stripe/Printful providers never received
their keys — real payments couldn't work by any path (the LGTM_STRIPE_*/LGTM_PRINTFUL_* env
vars were loaded into Config but read nowhere). Build config_for from those env vars (the
single-tenant reference-impl source); multi-tenant (Phase 2) will layer the integration_config
table on top. Makes real Stripe work end-to-end + Printful fulfillment-submit work. Deploy
docs: go-live cutover uses the env vars directly, exact Stripe webhook endpoint + events, and
notes Printful webhooks remain unsupported (no signing secret).
Remove conversational residue from public-facing docs: draft-history
asides in SPEC.md ("earlier assumption is dropped", "was Phase 1.5",
resolved items listed as open questions), discovery narrative in the
RELEASING/skill tag-trigger notes, a dangling code fence in the release
skill, and absolute authoring-machine paths in the Phase 1 plan.
Delete the docs/superpowers working artifacts (Phase 1 plan,
publishable-impl plan + design spec) — both are implemented and shipped
(v0.1.0-v0.2.2); tags preserve them. Gitignore docs/superpowers/ so
future plans stay uncommitted.
Publishes the store's ES256 receipt-signing key so verifiers resolve issuer DID → key
(SPEC 9), in a DID-method-agnostic way so any shop can plug in their own DID:
- GET /api/signing-key (any method): returns { did, verificationMethod } — the shop
publishes it under their DID however their method requires.
- GET /.well-known/did.json (did:web:<host> only): the full DID document, hostable at the
host root (the SSR storefront already proxies it). 404 for did:plc etc.
did:plc stores (the atproto default) take the /api/signing-key verificationMethod and add
it via a PLC operation (or a future attestationKey record). Pure, unit-tested builders;
deploy docs document the connect-your-DID flow per method + the still-stubbed admin OAuth.
Adds a one-shot `--role seed` to the server binary (migrate → seed demo store → exit) and
extracts the seeder into a shared `server::seed` module the bin + role + tests all call.
Dockerfile splits ENTRYPOINT ["server"] + CMD ["--role","all"], so seeding is just
`docker run <image> --role seed` — retiring the `--entrypoint /usr/local/bin/seed-demo`
gotcha. Drops the seed-demo binary from the image (kept as a cargo-run convenience bin).
Deploy docs + prod compose updated; the integration test now exercises the real seeder.
Confirmed the Spindle does not create a pipeline on tag pushes (isolated re-push of v0.2.0
produced no run), so revert to branch-triggered publish keyed off the VERSION file (the
proven mechanism that shipped v0.1.0), with the manifest-inspect idempotency guard. release.sh
now bumps VERSION; the git tag stays as the git-cliff changelog boundary only. Restores
VERSION=0.2.0 so this push publishes v0.2.0.
Lets a storefront (e.g. www SSR) render the catalog and get variant_ids for checkout
without direct DB access. Returns active products under the product collection (excludes
the storefront self record) each with their non-hidden variants (id, sku, price, kind,
availability, currency). Two queries, no N+1.
ats:// 6-component space_uri + space_ref_uri; orderSpace space declaration
(type:space) whose collections are the four permissioned leaves; ensure_space
uses the orderSpace space type (record collections unchanged); buyer OAuth scope
is space:...orderSpace; space_did authority = store_did (simplespace, no minted
per-space DID). PG-canonical runtime behavior unchanged.
ats:// 6-component addressing; space type is a type:"space" declaration (orderSpace);
authority = store_did (simplespace anchors on the store's own DID — no per-space minted
DID); getDelegationToken/getSpaceCredential/listRepoOps/listRepos sync; deniable LtHash
commit (blake3 XOF + sig-over-ctx/mac); resolves the controlled-DID open question.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Vendor all 6 apalis-sql 0.6.4 postgres migrations into migrations/0006_apalis.sql
so our app migrator (single lineage, versions 1-6) owns the apalis schema setup.
Remove the worker::setup_storage() call from boot (no more apalis migrator collision).
Add DevFulfillmentProvider to dev.rs and register it in fake-payments mode so the
full worker pipeline (fulfillment -> receipt mint -> sign -> Fulfilled) completes
without a real Printful API key.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Load store_signing_key at startup via load_store_signer() helper (shared by
attest service + worker monitor); degrades to stub/no-op with a warning when
no key row exists yet instead of hard-failing on first boot.
- Fix 1: worker_fut now calls worker::production::build_production_deps(pool,
store_did, collection, registry, signer) + worker::build_monitor(state).run().await
so --role all/worker actually drains the apalis.jobs queue.
- Fix 2: RealAttestService holds Arc<Signer>; on attest() it loads receipt_hash
+ product_uri, calls claims::attest(signer, hash, facts), writes the
purchase_claim row, and returns at://{store_did}/shop.lgtm.commerce.purchaseAttestation/{order_id}.
StubAttestService covers the no-key fallback.
- Fix 3: products.rs test helper now collects bool flags out of the MutexGuard
into owned values before drop, eliminating the await_holding_lock lint.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
e2e crate (e2e/src/lib.rs):
- stripe_test_signature helper (t=..,v1=HMAC-SHA256) + 3 unit tests (known-
answer vector, determinism, secret variation)
e2e/tests/e2e.rs:
- #[ignore] full-stack HTTP test (runs with docker compose up + --ignored);
drives checkout → signed webhook → poll worker → attest → backer wall
crates/server/tests/e2e.rs (RUNS against embedded PG):
- test_phase1_full_loop_inline: full 7-step Phase 1 loop in-process via
#[sqlx::test]; FakeStripe (HMAC-verifying) + FakePhysical + real ES256
receipt signing + real route handlers
Steps that run live (no #[ignore]):
1 seed tenant+product+variant
2 POST /api/checkout -> checkout_url + order_id + pending_payment
3 POST /webhooks/stripe (HMAC-signed) -> order=Paid + job enqueued
4 process_paid_order inline (real adapters) -> Fulfilled + sig verified
5 POST /api/claims/attest -> attestation_ref at-uri
6 insert verified purchase_claim (simulates Jetstream indexer)
7 GET /api/backers -> exactly one verified backer
Supporting changes:
- public.rs: checkout response now includes order_id (required by e2e)
- webhooks.rs: PaymentCaptured handler now updates order status to Paid
before enqueuing (fixes the pending_payment -> Paid transition gap)
- README.md: "Running the pilot (Phase 1)" + "Deferred to Phase 1.5+"
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- crates/server/src/boot.rs: BootStep + Role + boot_plan (migrations always
first); 5 unit tests pin the ordering (migrate < serve for all roles)
- crates/server/src/main.rs: add mod boot; use boot::Role throughout;
existing parse_role + migrate-before-serve order preserved
- Dockerfile: cargo-chef multi-stage builder -> distroless/cc-debian12 runtime;
ENTRYPOINT = server --role all (advisory-locked migrations then serve+worker)
- .dockerignore: exclude target/, .env, .git/, docs/; keep migrations/, lexicons/,
crates/, Cargo.* (Rust-only, no node_modules)
- docker-compose.yml: add app service (build ., depends_on postgres healthy,
env_file .env, port 8080); docker build is a documented manual/CI check
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- PUT /admin/settings/storefront: write storefront public record (key 'self') via
PublicWriter; upsert PG mirror row (on conflict (tenant_id, at_uri))
- PUT /admin/settings/integrations/:id: validate submitted JSON against integration's
config_schema (required fields non-empty), seal with lgtm_core::crypto::seal,
upsert integration_config.config_enc
- validate_config(): rejects missing-required and empty-required fields with named error
- Tests: storefront PUT writes record + mirror; integration PUT rejects missing-required
and does NOT store; on success config_enc is ciphertext and open() recovers plaintext
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- GET /admin/orders: session-guarded Maud order table
- GET /admin/orders/:id: order detail with fulfillment + receipt revocation status
- POST /admin/orders/:id/refund: payment provider refund → OrderStatus::Refunded
transition → receipt revoked_at set → purchase_claim.revoked = true via receipt_hash
- OrderStatusShim encodes the same Paid|Fulfilling|Fulfilled→Refunded legal transition
from core without materializing a full Order object
- Test: RefundSpy asserts provider.refund() called; DB assertions verify all 4 cascade steps
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- GET /admin/products: session-guarded Maud table from PG product mirror
- POST /admin/products: write product + variant public records via PublicWriter,
upsert PG mirror rows, return HTMX partial (new table row)
- PATCH /admin/products/:id: re-put public record at same rkey, update mirror title
- Tests: create writes both public records AND PG rows; patch updates mirror title
- html_partial() helper in layout.rs for HTMX partials outside full layout
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Add admin module with rust-embed Assets serving /admin/static/*
- Implement is_authorized_admin (DID equality gate) + SESSION_DID_KEY
- Implement require_admin session guard (redirect unauthenticated → start)
- Implement auth_routes: POST /admin/auth/atproto/start (PKCE) + GET callback
(token exchange is M4-deferred stub; CSRF validation is live)
- Maud layout() returns Html<String> via axum::response::Html (maud 0.26 has no axum feature)
- Add PublicWriter trait + PublicWriteOut to AppState; ProductionPublicWriter in main.rs
- Update all AppState initializers (test_support, route tests) with public_writer field
- Session redirect test: assert is_redirection() (axum Redirect::to → 303, not 302)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
POST /api/claims/attest: joins order -> space, checks space.skey ==
x-buyer-did header (SPEC §8 ownership); non-owner -> 403. On success
delegates to AttestService seam and returns {attestation_ref}.
GET /api/backers: returns verified=true AND revoked=false claims only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GET /api/receipts/:id: buyer_did header must match receipt.buyer_did;
mismatch or missing -> 404 (no existence leak, SPEC §17).
GET /api/entitlements/:id/download: DownloadVerifier seam checks sig+exp,
then revoked_at from receipt_record must be NULL; any fail -> 403.
test_support.rs: shared state_for_test builder + no_op_downloads/fixed_attestor.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
POST /webhooks/:integration_id resolves integration from Registry,
verify_webhook -> on Some(event) insert webhook_event ON CONFLICT DO NOTHING.
Only rows_affected==1 triggers enqueue(Job::ProcessPaidOrder).
Bad sig -> 400; duplicate event id -> 200 no-op (no re-enqueue).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
/api/storefront reads the storefront mirror row from the product table.
/api/checkout: CartRequest -> WebSurface::build_draft -> place_order ->
persist order_record + order_item -> return {checkout_url}.
Empty/invalid cart returns 400.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
AppState wires db, registry, store (PgPermissionedStore), config,
SurfaceSet, JobSink (enqueue seam), DownloadVerifier, AttestService.
/healthz returns 200; /readyz pings DB + every payment provider health.
main.rs parses --role server|worker|all with ApalisJobSink for production.
wiring.rs isolates concrete integrations (Stripe/Printful/Files) from handlers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
reconcile_with diffs each fulfillment row's local status against the provider's status
(via the ProviderStatus port) and returns the divergences for the admin (SPEC §18);
unreachable providers are skipped, not false-flagged. The public reconcile() uses a
no-op adapter until M11 wires the Registry. dead_letters() queries apalis.jobs for
Failed jobs with their last_error + attempt count. Tested with fakes / seeded rows:
one divergent + one agreeing order yields exactly one mismatch; a seeded Failed apalis
job is surfaced. The dead-letter test uses bare #[sqlx::test] (apalis owns its tables;
mixing the two migrators in one DB clashes on versioning).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reads per-order opt-in from payment_ref JSON (claim_opt_in); no opt-in -> no-op (the
mutual opt-in is the privacy guarantee, no dox). When opted in, loads the receipt_hash,
produces a shop attestation over it via the ClaimAttester port, and seeds a pending
purchase_claim row (verified=false; the Jetstream indexer + claims::verify_claim flip it
later). Tested with fakes: opted-in creates an attestation + pending claim; not-opted-in
writes nothing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Loads order, Paid->Fulfilling, dispatches each item by variant.kind family via the
FulfillmentPort (external_id = order.id for provider idempotency), mints + persists a
signed receipt into the (store,buyer) space via ReceiptMinter, then Fulfilling->Fulfilled.
Re-running a Fulfilled order is a no-op; a failing submit leaves it Fulfilling (recoverable),
never Fulfilled, with no receipt. Handler tested directly with fakes (FulfillmentPort/
ReceiptMinter/ClaimAttester) over seeded Postgres rows: happy path, idempotent re-run
(no dup receipt/fulfillment), and failure-path recoverability.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Job enum (serde tag="kind"), PostgresStorage with "worker::Job" namespace,
enqueue() pushes a job row, build_monitor() registers one worker dispatching
ProcessPaidOrder/AttestClaim/Reconcile. Tests: serde round-trip + live enqueue
against apalis.jobs (apalis storage setup works under #[sqlx::test], no #[ignore]).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- signed_download_url_at: /api/entitlements/:id/download?exp=..&sig=.. with sig=HMAC-SHA256(master_key, "id|exp")
- verify_download: rejects expired (now>exp), tampered sig, or revoked entitlement
- All 5 Files unit tests pass; no network access, no #[ignore]d tests
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Pure-function helpers: parse_shipping_rates, classify_submit (idempotency), map_event, verify_signature
- classify_submit detects "already exists" duplicate error and returns original external_id, no dup
- verify_webhook maps package_shipped -> FulfillmentShipped; rejects bad HMAC-SHA256 sig
- All 7 Printful unit tests pass; live submit test is #[ignore]d
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Adds hmac + hex to workspace deps
- Adds NullStore + Registry::empty() to lgtm-core for test fixtures
- Derives Debug on NormalizedEvent (needed for test assertions)
- Implements StripePayment: HMAC-SHA256 webhook sig verification, PaymentCaptured/PaymentRefunded event mapping, create_subscription->Unsupported
- All 7 Stripe unit tests pass; live checkout test is #[ignore]d
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>