Skip to content

feat(detector): serve detector only from per-session provider bundles; always-PoW fallback - #2744

Merged
HughParry merged 27 commits into
mainfrom
feat/detector-bundle-pool-encryption
Aug 3, 2026
Merged

feat(detector): serve detector only from per-session provider bundles; always-PoW fallback#2744
HughParry merged 27 commits into
mainfrom
feat/detector-bundle-pool-encryption

Conversation

@HughParry

@HughParry HughParry commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

No description provided.

@HughParry
HughParry force-pushed the feat/detector-bundle-pool-encryption branch 3 times, most recently from 459dece to b716672 Compare June 22, 2026 14:42
…lback

Adds the provider-side building blocks for the precomputed, per-session
detector bundle pool (bumblebee-style):

- DetectorBundlePool: load precomputed {id}.js/{id}.json pairs into memory,
  uniform-random per-session pick, hot-swap replace() for admin push.
- Redis short-TTL session->bundle mapping (cache:detector:{id}, 60s).
- Frictionless fallback to a real PoW challenge when the pool is initialised
  but empty. Dark until the pool is initialised at boot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@HughParry
HughParry force-pushed the feat/detector-bundle-pool-encryption branch 2 times, most recently from 525e857 to 85483e7 Compare June 23, 2026 10:30
…resolution

Wires the detector bundle pool end-to-end (Part 2):

- Boot: initialise the pool from PROSOPO_DETECTOR_POOL_DIR ONLY when the dir
  exists (absent => legacy path; present-but-empty => PoW fallback).
- Client assign endpoint (POST .../client/detector/assign): picks a random
  bundle, stores the short-TTL session->bundle binding, returns the obfuscated
  detector inline; returns useProviderBundle:false when no pool. Excluded from
  the Prosopo-User header requirement (runs before the account exists).
- Admin replace-pool endpoint (hot-swap the in-memory pool).
- decryptPayload resolves the assigned bundle (single deterministic decrypt with
  its own keypair + inner cipher config) and falls back to the legacy key pool.
  innerConfig threaded through getBotScore + regenerated decode bundles.
- Client (customDetectBot): resolves provider, asks for a bundle, loads it via
  blob import or falls back to the bundled detector; threads detectorSessionId.
- types: ApiParams.detectorSessionId, assign/replace paths + bodies.

All dark unless a pool dir is provisioned, so existing detection is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@HughParry
HughParry force-pushed the feat/detector-bundle-pool-encryption branch from 85483e7 to 41bd528 Compare June 23, 2026 11:14
@HughParry
HughParry marked this pull request as draft June 23, 2026 11:41
…; always-PoW fallback

The detector now lives ONLY in the provider-served pool bundles — no bundled/
inlined detector and no legacy detector-key pool. Each session's bundle encrypts
everything it produces (score, SIMD readings, behavioural data) with its own RSA
keypair + inner ChaCha20-Poly1305 cipher; the provider decrypts each payload with
that exact bundle.

- Pool is always initialised at boot (missing/empty dir ⇒ empty pool), so the
  three states collapse to two: bundles present ⇒ per-session serving; no bundles
  ⇒ always PoW (covers no-pool, empty-pool, and client `detectorUnavailable`).
- `detectorSessionId → bundleId` Redis binding resolved at the frictionless hop;
  the bundleId is promoted onto the durable session record so later hops (SIMD
  attach, PoW/puzzle/image solution submit) decrypt with the same bundle.
- Client: removed the inlined `@prosopo/detector` runtime import (type-only now);
  when no provider bundle can be obtained/run it signals `detectorUnavailable`
  and the provider serves PoW.
- All server decrypt paths resolve the session bundle and pass its inner cipher;
  legacy key-pool brute force + env fallback removed. Decrypt failure fails closed.
- Session record + schema gain `bundleId`; frictionless request gains
  `detectorUnavailable`. Tests updated for the bundle-only model.

Note: temporary [POOL-DEBUG] logging retained for local validation; strip before merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@HughParry HughParry changed the title feat(provider): detector bundle pool foundations + empty-pool PoW fallback feat(detector): serve detector only from per-session provider bundles; always-PoW fallback Jun 24, 2026
@HughParry
HughParry marked this pull request as ready for review June 24, 2026 12:09
HughParry and others added 16 commits June 24, 2026 13:53
… detector dep

Fixes two CI failures from the detector-only-on-providers change:

- e2e: the no-detector PoW fallback built session params from the client's
  (empty) token, and sendCaptcha rejects a falsy token ("Session parameters
  must be set before sending a pow captcha") → 400 → widget stuck. The bypass
  session legitimately has no detection token, so synthesise a unique one.
- lint:refs: `@prosopo/detector` is now a type-only import in
  procaptcha-frictionless, so it must be a tsconfig reference, not a runtime
  dependency. Removed it from package.json dependencies and the tsconfig
  references (type still resolves via the workspace); lockfile updated.

Adds a regression test asserting the no-detector fallback never passes an empty
token to sendPowCaptcha.

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

The previous change removed @prosopo/detector from procaptcha-frictionless's deps
and tsconfig references (it's now type-only) — but `typeof import("@prosopo/detector")`
still needs the package's .d.ts, so `tsc --build` failed in CI ("Cannot find module
'@prosopo/detector'") because detector is no longer built as a project reference.

Declare the detector's default-export signature locally from shared @prosopo/types
primitives instead. This satisfies both the ref-linter (no detector dep/ref) and
`tsc --build` (no detector build needed), with no behavioural change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolved conflicts (additive on both sides unless noted):
- database/redisCache.ts: keep both the detector-bundle cache methods
  (cacheDetectorBundle/getDetectorBundle) and main's session-escalation
  cache methods (cache/get/invalidateCachedSessionEscalation).
- types/provider/api.ts (GetFrictionlessCaptchaChallengeRequestBody):
  adopt main's boundedString(INPUT_LIMITS.*) hardening AND keep the
  feature's detectorSessionId/detectorUnavailable fields (bounded for
  consistency; token stays bounded-but-empty-allowed for the
  detectorUnavailable case).
- provider/api/captcha.ts: combine main's express type imports
  (NextFunction/Request/Response for asyncHandler) with the feature's
  assignDetectorBundle import.
- getFrictionlessCaptchaChallenge/handler.ts: take the feature's prebuilt
  shortCircuitInput (a superset of main's inline literal — same fields
  plus requestId + detectorUnavailable).
- procaptcha-frictionless/package.json: take main's @prosopo/api 3.5.6 /
  @prosopo/common 3.1.41; do NOT re-add @prosopo/detector (feature made
  DetectorType local/type-only).
- unit test imports: keep main's AccessPolicyType import + feature's
  fuller vitest import.
- package-lock.json: regenerated (--package-lock-only) from main's lockfile
  against the merged package.json set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…h, 3-arg detector

- remove all [POOL-DEBUG] rollout logging (provider + client, 6 files)
- delete orphaned getBehavioralData brute-force decrypt loop + its test (replaced by resolve-by-bundleId single-key decrypt)
- detector signature 5-arg -> 3-arg: drop vestigial env + randomProviderSelectorFn params (DetectorType + customDetectBot call site)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Conflicts resolved by keeping both sides' additive optional params (feature: detectorSessionId/detectorUnavailable/bundleId; main: currentUrl):
- getFrictionlessCaptcha(): detectorSessionId, detectorUnavailable, currentUrl
- createSession(): bundleId, currentUrl (incl. realigning the 2nd/non-conflicted call site)
- types api-schema, handler destructure, submitPoW escalation: keep both
- procaptcha-frictionless package.json: main's version bumps, dropped @prosopo/detector (detector is provider-served, not bundled)
- package-lock.json regenerated

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Version-bump-only conflicts: take main's bumped @prosopo/* versions; keep @prosopo/detector omitted (provider-served). package-lock regenerated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Previous regen on macOS dropped the linux @rollup/rollup-* binaries, breaking CI's vite/rollup builds. Reconciled in place from main's lockfile (all platforms preserved), detector dep still removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Commits a throwaway-key detector-pool bundle (packages/provider/fixtures/detector-pool) and sets PROSOPO_DETECTOR_POOL_DIR for the integration test + frictionless cypress provider startups. Makes frictionless run real scored detection instead of the empty-pool PoW fallback, fixing ipValidation + frictionless.cy.ts. Keys are throwaway/test-only.

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>
…pool-encryption

# Conflicts:
#	package-lock.json
#	packages/procaptcha-frictionless/package.json
#	packages/procaptcha-frictionless/src/customDetectBot.ts
#	packages/provider/src/api/captcha/getFrictionlessCaptchaChallenge/handler.ts
#	packages/provider/src/api/captcha/getFrictionlessCaptchaChallenge/shortCircuit.ts
#	packages/provider/src/api/startProviderApi.ts
Since the detector moved to per-session provider bundles, every decrypt path
resolves a bundle's own keypair. Nothing reads the Mongo detector-key pool
anymore, so pushing/rotating keys through the admin API was already a no-op —
live-looking code with no effect. Remove the whole surface:

- admin endpoints UpdateDetectorKey / RemoveDetectorKey (+ their unit tests)
- ProviderApi.updateDetectorKey / .removeDetectorKey
- ClientTaskManager.updateDetectorKey / .removeDetectorKey
- IProviderDatabase.storeDetectorKey / .getDetectorKeys / .removeDetectorKey
- the `detector` collection registration, DetectorRecordSchema, DetectorSchema
  and the DetectorKey type
- UpdateDetectorKeyBody / RemoveDetectorKeyBodySpec / UpdateDetectorKeyResponse
- rate-limit entries for both admin paths

Also corrects the assignDetectorBundle doc comment, which still described a
fallback to "the bundled detector and the legacy key-pool decryption path" —
both removed earlier in this branch.

The `detector` collection is left on disk; no migration drops it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdWox4Yt4P2sNqESzSY2q9
…-decrypt path

Pool push:
- dedicated 128 MB body limit on ReplaceDetectorPool (~86 MB for 100 bundles);
  all other routes keep the 1 MB backstop. Not raised further because
  express.json buffers into one string and V8 caps strings at 512 MiB (~620
  bundles) — past that needs a chunked push, not a bigger number.
- a pushed pool is written to the pool dir (staged + renamed so a crash can't
  leave a partial set) and reloaded at boot, instead of vanishing with the
  process on any restart. The response reports `persisted` so an in-memory-only
  push can be alarmed on.
- provider1/provider2 each mount their own host volume for the pool. Never bake
  it into the image: prosopo/provider is public on Docker Hub and every bundle
  ships its own RSA private key. Separate dirs because the two containers run
  different image versions.

Release stamping:
- bundles record the release they were built from; providers skip bundles from
  another release (PROSOPO_DETECTOR_POOL_RELEASE, unset = no check). The widget
  no longer carries a detector and runs whatever we serve, so this was the only
  thing that could catch a pool built off the wrong tag.

Failed decryption:
- now a first-class decision (DECRYPTION_FAILED, 3 image rounds) evaluated
  before every other check. decryptPayload substitutes userAgent/userId
  undefined, baseBotScore 1 and timestamp 0 on failure, so these sessions were
  being decided by those placeholders: the UA check compares a real hash to
  undefined and can never match, so they surfaced as USER_AGENT_MISMATCH sized
  at 6 rounds, and on any sitekey with autoBanScoreThreshold set the score of 1
  hard-blocked them with a 401 first. A payload we cannot read is absence of
  evidence, not evidence of a bot.
- timestampDecayFunction drops its now-unreachable decryptionFailed arm.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdWox4Yt4P2sNqESzSY2q9
provider.Caddyfile had no `encode` directive and Caddy does not compress by
default, so every proxied response went out uncompressed. That was cheap when
the largest bodies were small JSON replies; it stops being cheap now that
/detector/assign returns ~830 KB of obfuscated JS inline, once per frictionless
session.

Measured on the pool fixture: gzip 2.6x (827 -> 319 KiB), zstd 3.1x (-> 271
KiB) — obfuscated output compresses well because the obfuscator's string-array
encoding is highly repetitive. At 15k sessions/hour that takes one provider
from ~9.15 TB/month of egress to ~3 TB.

Placed above `reverse_proxy` inside the `route` block, where directive order is
literal, so it wraps the proxied response. Verified with `caddy adapt` against
prosopo/caddy:2.5.7-dns-3455: the encode handler lands immediately before
reverse_proxy with prefer ["zstd","gzip"].

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdWox4Yt4P2sNqESzSY2q9
…pool-encryption

# Conflicts:
#	packages/procaptcha-frictionless/src/customDetectBot.ts
HughParry and others added 8 commits July 23, 2026 10:58
The stage-into-a-sibling-and-rename approach was destructive against the
provider's bind-mounted pool directory. `rmSync(dir, {recursive:true})`
successfully deleted the mount's CONTENTS, then `renameSync` failed with EBUSY
on the mount point itself — so a push wiped the on-disk pool and reported
SUCCESS, with only the `persisted:false` flag hinting anything was wrong. Unit
tests on a tmpdir never hit it because a tmpdir isn't a mount point.

Verified on staging: a 20-bundle push now returns persisted:true, writes 20
js/json pairs (0600) to both provider volumes on all three pronodes, and
reloads count:20 after a container restart.

Write each bundle to a per-file temp name and rename it into place within the
same directory (still atomic, reader never sees a partial bundle), then prune
bundles no longer present. Never touch the directory itself. Two tests pin the
invariant that made this fail: the pool dir's inode is unchanged across a
persist, and no .tmp files are left behind.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdWox4Yt4P2sNqESzSY2q9
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdWox4Yt4P2sNqESzSY2q9
Brings the Vite 8 / Vitest 4 upgrade (#2850) and follow-ups into the
detector-pool branch.

Conflicts:
- procaptcha-frictionless/package.json: took main's newer workspace versions
  but kept this branch's removal of @prosopo/detector — the pool loads bundles
  dynamically and declares the detector signature locally in detectorLoader.ts.
- customDetectBotSimd.test.ts: kept main's vitest 4 fix (ProviderApi mock must
  be a function expression, not an arrow, because customDetectBot calls it with
  `new`) and re-added this branch's assignDetectorBundle mock.
- package-lock.json: regenerated.
The widget threw on load and never defined window.procaptcha, so every e2e
run timed out waiting for it.

Cause is a Rolldown chunk cycle between the shared browser chunk and
utilCryptoChunk. It only appears on this branch: the detector is no longer
bundled into the widget, which reshapes the module graph enough for the two
chunks to reference each other. Whichever side evaluated first then read the
other's module-scope bindings before they were assigned — first
`init_dist` (the nodePolyfills global shim, declared with `var`, so hoisted
undefined), and behind it `isHex` reading `.test` of an undefined regex.

Putting util-crypto in the shared browser chunk gives both a single
evaluation order. It also preserves the reason that chunk exists: one module
instance, one componentsCache.

Verified by loading the built bundle in headless chromium:
  main  26 chunks, 0 cycles, PASS
  pool  28 chunks, 1 cycle,  FAIL ("init_dist is not a function")
  fixed 25 chunks, 0 cycles, PASS

manualChunks was tried first to isolate the polyfill shims into their own
chunk; Rolldown ignores the returned name for modules it has already
co-located, so that had no effect and was dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GagGdoZ8Lyj876K5EsDwRb
Conflicts were the procaptcha-frictionless dependency block in package.json
and its mirror in package-lock.json: main's release bumps versus this branch
having dropped @prosopo/detector as a runtime dependency.

Took main's version bumps and kept the detector dependency removed —
procaptcha-frictionless imports DetectorType type-only now, so lint:refs
requires it be a tsconfig reference rather than a package.json dependency.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RJsacV3Y8wrzn8xexXPyeP
Fallout from merging v3.6.77 — main added tests against an API this branch
changed, which git merged cleanly but tsc rejected:

- "updates the detector key" / "removes a detector key" cover
  ProviderApi.updateDetectorKey/.removeDetectorKey and the matching
  AdminApiPaths entries. This branch removed that whole surface in
  4865a7a, since per-session bundles carry their own keypair and the
  Mongo detector-key pool is gone. Dropped both tests.

- "includes the optional context when supplied" passes currentUrl and
  iframeUrl positionally. This branch inserted detectorSessionId and
  detectorUnavailable ahead of them, so both arguments were landing on the
  wrong parameters — iframeUrl onto a boolean, which is what tsc caught.
  Pass undefined for the two new parameters so the URLs land where the
  test's assertions expect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RJsacV3Y8wrzn8xexXPyeP
routingFrictionless has failed since the merge that brought it in from
main (it was authored in #2876, where no detector pool exists). The spec
asserts the frictionless-phase captchaType directly, but this branch's
runNoDetectorPowFallback answers on an empty pool at handler.ts:439 —
before setRoutingContext at handler.ts:614 — so sendCaptcha sees no
routing context and returns the PoW baseline without ever invoking
route().

CI confirms it: the provider logs `empty_detector_pool_pow_fallback`
three times, then the image and puzzle cases fail with "expected 'pow'
to equal 'image'/'puzzle'". The pow case only passed because PoW is
already the baseline.

Point the step at the committed fixture pool, as the frictionless step
already does, so the fallback is correctly declined and the request
reaches the routing machine. escalation/postPowPuzzle don't need this —
they assert the post-PoW escalation.captchaType and are content to start
from PoW.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XJ7KiTDKiu3mxQ4iuLFK2y
@HughParry
HughParry merged commit 787017b into main Aug 3, 2026
10 checks passed
@HughParry
HughParry deleted the feat/detector-bundle-pool-encryption branch August 3, 2026 10:31
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