Skip to content

v3.7.1 — Admin credentials portal (+ critical Beam auth fix)

Choose a tag to compare

@thunpisit thunpisit released this 31 Jul 01:36
· 47 commits to main since this release
b1af87e

Manage BeamCheckout and Resend credentials from /admin/settings/secrets instead of wrangler secret put + Cloudflare account access. Encrypted at rest with AES-GCM-256.

⚠️ If you are on v3.7.0, upgrade. That release shipped a Beam adapter that could not authenticate — see Critical fix below. v3.7.0 is marked superseded.


⚙️ Setup: what goes where

This is the section forks need. Two lists.

✅ Manage at /admin/settings/secrets

Set these from the admin UI. super_admin only.

Key Required? What it does
BEAM_MERCHANT_ID Yes, for Beam HTTP Basic username. Beam authenticates as base64(merchantId:apiKey) — a separate credential from the API key. Shown in full (a public identifier, not a secret) so you can verify it against Lighthouse.
BEAM_API_KEY Yes, for Beam Basic password. Creates charges, issues refunds.
BEAM_WEBHOOK_SECRET Yes, for Beam Base64 HMAC key verifying X-Beam-Signature. Wrong value = customers charged, orders stuck pending.
RESEND_API_KEY Optional Order receipts + abandoned-cart email. Unset = email silently disabled, checkout still works.

All three Beam values are required together — the provider refuses to construct with any missing, so misconfiguration fails at boot rather than at checkout.

Secret fields are write-only: after saving, only a masked ••••••••4a2f preview is shown. Plaintext is never sent back to the browser.

🔒 Still required in the Cloudflare Dashboard

Key Why it can't move
BETTER_AUTH_SECRET Read in authHook on every request, before a session exists — storing it behind a session-gated page is circular. It signs session cookies, so read access = forge a login as any user. And it is the key-derivation root encrypting the secrets table, so storing it beside the ciphertext defeats the encryption.
CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID Deploy-time credentials that create the Worker — they cannot live inside it.

Also Cloudflare-only (bindings and plain config)

Bindings in wrangler.toml: DB (D1), MEDIA_BUCKET (R2), CONTENT_CACHE (KV).

Vars: BETTER_AUTH_URL, PUBLIC_SITE_URL, CMS_SITE_URL, DEFAULT_LOCALE, SUPPORTED_LOCALES, CRON_SECRET, RESEND_FROM, BEAM_BASE_URL (optional — set to https://playground.api.beamcheckout.com for sandbox).

ℹ️ RESEND_FROM stays env-only, so a site can have the API key set in the UI and still not send. Tracked in #116.

🔁 Precedence: env always wins

A key set as a Cloudflare env var takes precedence; the UI shows "Set in Cloudflare" with the field disabled. Deliberate:

  • a leaked key can be rotated with wrangler secret put without needing a working admin panel — which may be exactly what's compromised
  • existing deployments keep working untouched; nothing to migrate
  • staging can override production values from a shared database

To manage a key from the UI, remove the env var first.


🚨 Critical fix: Beam authentication

The adapter assumed Beam was "roughly Stripe-shaped". It is not, and four defects meant it could never authenticate against the real API:

# We did Beam requires Impact
1 Authorization: Bearer <apiKey> HTTP Basic base64(merchantId:apiKey) Every charge and refund rejected
2 No merchant ID at all Merchant ID is the Basic username Cannot authenticate
3 /v1 in base URL → /v1/charges /api/v1/charges on the bare host 404
4 Hex digest, lowercased signature base64, case-sensitive, key decoded from base64 first Real webhooks rejected — customers charged, orders stuck pending

Defect 4 is the worst: the customer pays, Beam confirms, we reject the signature, and the order sits pending forever while the money is gone.

Added 13 contract tests pinning the wire format against Beam's docs — nothing previously asserted headers, paths, or digest encoding, which is why all four were invisible. Mutation-verified: restoring bearer auth fails 1 test; restoring the hex digest fails 4.

Verify against a real sandbox webhook before trusting production traffic. These tests pin the format against documentation, not a live endpoint, and Beam's webhook doc page 404'd on direct fetch during research (the scheme was corroborated across two independent sources).


🚀 Upgrading a fork

git fetch upstream && git merge upstream/main
npx wrangler d1 migrations apply <your-db> --remote   # applies 0022_managed_secrets

The migration is required. Without it the page renders and explains itself, but saving fails.

Expect conflicts in wrangler.toml (keep yours — upstream ships placeholders), and possibly .github/workflows/deploy.yml and README.md if customised. Engine files under src/ should take upstream.

⚠️ Rotating BETTER_AUTH_SECRET makes every stored secret undecryptable. Intentional — it fails closed rather than handing a wrong key to a payment provider. The UI reports "Cannot decrypt" and prompts re-entry. A re-key command is tracked in #116.


What else is in v3.7

First tagged release; package.json moves 0.1.0 → 3.7.1 to match the milestone scheme the README already used (shipped through v3.5; v3.6 = registry + spec layer).

  • Registry + spec layer (#91 #94 #96 #100) — user-definable content types; typed spec/attribute model with value intervals, qualifiers and polarity; edge attributes and external relation targets
  • Phase 5 (#105) — idempotent demo seed scripts, integration tests applying the real migration files
  • CI fixes (#102 #103 #106) — nav registry no longer depends on module init order (this took the demo down once), vitest baseline, Paraglide compile in the deploy job, D1 step gated on token scope

Other fixes

  • Client-bundle leak (caught pre-merge). Resolving credentials in the shop plugin's onInit put the secrets service on the browser import graph. SvelteKit's guard blocked the build. Moved to beam-config.server.ts; verified no crypto reaches the client bundle. Also fixed latent staleness — a provider built at boot would keep using a key you'd since rotated.
  • D1 upsert switched to raw ON CONFLICT DO UPDATE; drizzle's builder isn't uniform across versions on D1's sqlite dialect.
  • macOS duplicate-file guard in .gitignore. A duplicated migration silently re-runs CREATE TABLE, blocks the chain, and leaves schema tests passing against stale schema.

Tests

103 total, up from 46 at the start of this cycle. Guards for masking, unmanaged keys, and the Beam wire format are all mutation-verified.

Full changelog: f1aef87...v3.7.1