@strav/captcha 0.4.31 → 1.0.0-alpha.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,9 @@
1
+ # @strav/captcha
2
+
3
+ Stateless CAPTCHA primitives for Strav 1.0 — honeypot, proof-of-work,
4
+ distorted-text SVG. HMAC-signed tokens, pluggable challenge registry,
5
+ HTTP middleware + validation rule + refresh routes under
6
+ `@strav/captcha/http`, view helper under `@strav/captcha/view`.
7
+
8
+ See [docs/captcha/](../../docs/captcha/) for the full guide and API
9
+ reference.
package/package.json CHANGED
@@ -1,31 +1,40 @@
1
1
  {
2
2
  "name": "@strav/captcha",
3
- "version": "0.4.31",
3
+ "version": "1.0.0-alpha.42",
4
+ "description": "Strav CAPTCHA primitive — stateless HMAC-signed tokens + pluggable challenge registry. Ships honeypot, hashcash-style proof of work, and inline-SVG distorted-text challenges in the root, with HTTP middleware + validation rule + refresh routes under @strav/captcha/http and a view helper under @strav/captcha/view.",
4
5
  "type": "module",
5
- "description": "Built-in CAPTCHA for the Strav framework — honeypot, proof-of-work, and SVG text challenges with no external dependencies",
6
- "license": "MIT",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
7
8
  "exports": {
8
9
  ".": "./src/index.ts",
9
- "./*": "./src/*.ts"
10
- },
11
- "strav": {
12
- "islands": {
13
- "namespace": "captcha",
14
- "dir": "./src/islands"
15
- }
10
+ "./http": "./src/http/index.ts",
11
+ "./view": "./src/view/index.ts"
16
12
  },
17
13
  "files": [
18
- "src/",
19
- "package.json",
20
- "tsconfig.json"
14
+ "src",
15
+ "README.md"
21
16
  ],
17
+ "engines": {
18
+ "bun": ">=1.3.14"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "dependencies": {
24
+ "@strav/kernel": "1.0.0-alpha.42"
25
+ },
22
26
  "peerDependencies": {
23
- "@strav/kernel": "0.4.31",
24
- "@strav/http": "0.4.31",
25
- "@strav/view": "0.4.31"
27
+ "@strav/cache": "1.0.0-alpha.42",
28
+ "@strav/http": "1.0.0-alpha.42",
29
+ "@types/bun": ">=1.3.14"
30
+ },
31
+ "peerDependenciesMeta": {
32
+ "@strav/cache": {
33
+ "optional": true
34
+ },
35
+ "@strav/http": {
36
+ "optional": true
37
+ }
26
38
  },
27
- "scripts": {
28
- "test": "bun test tests/",
29
- "typecheck": "tsc --noEmit"
30
- }
39
+ "devDependencies": null
31
40
  }
@@ -0,0 +1,14 @@
1
+ import { StravError, type StravErrorOptions } from '@strav/kernel'
2
+
3
+ /**
4
+ * Misconfiguration of `@strav/captcha` — missing secret, unknown
5
+ * challenge type registered, etc.
6
+ *
7
+ * Verification *failures* are NOT exceptions — they return
8
+ * `{ ok: false, reason }` so callers can surface targeted form errors.
9
+ */
10
+ export class CaptchaError extends StravError {
11
+ constructor(message: string, options: StravErrorOptions = {}) {
12
+ super(message, { code: 'captcha-error', status: 500 }, options)
13
+ }
14
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * `CaptchaManager` — the DI-resolved unit that holds the signing
3
+ * secret + challenge registry and exposes `issue`/`verify`. The HTTP
4
+ * middleware and validation rule (in `./http/`) and the view helper
5
+ * (in `./view/`) resolve it from the container.
6
+ *
7
+ * Replay prevention is a separate concern from verification: a cache is
8
+ * optional on the manager so apps that only use the validation rule
9
+ * (idempotent endpoints, single-shot tokens) don't need to wire one.
10
+ * The middleware DOES require it — see `./http/captcha_middleware.ts`.
11
+ */
12
+
13
+ import { honeypotChallenge } from './challenges/honeypot_challenge.ts'
14
+ import { powChallenge } from './challenges/pow_challenge.ts'
15
+ import { svgChallenge } from './challenges/svg_challenge.ts'
16
+ import { CaptchaError } from './captcha_error.ts'
17
+ import { ChallengeRegistry } from './registry.ts'
18
+ import { hashAnswer, randomHex, signToken, verifyToken } from './token.ts'
19
+ import {
20
+ DEFAULTS,
21
+ type CaptchaConfig,
22
+ type CaptchaTokenPayload,
23
+ type ChallengeName,
24
+ type ChallengeType,
25
+ type IssueOptions,
26
+ type IssuedChallenge,
27
+ type VerifyResult,
28
+ } from './types.ts'
29
+
30
+ export interface CaptchaManagerOptions {
31
+ config: CaptchaConfig
32
+ }
33
+
34
+ export class CaptchaManager {
35
+ readonly registry = new ChallengeRegistry()
36
+ readonly config: Required<Omit<CaptchaConfig, 'secret'>> & { secret: string | Uint8Array }
37
+
38
+ constructor(opts: CaptchaManagerOptions) {
39
+ if (!opts.config?.secret) {
40
+ throw new CaptchaError(
41
+ 'CaptchaManager: `config.secret` is required (HMAC signing key, ≥ 32 bytes of entropy).',
42
+ { code: 'captcha.missing-secret' },
43
+ )
44
+ }
45
+ this.config = {
46
+ secret: opts.config.secret,
47
+ honeypotField: opts.config.honeypotField ?? DEFAULTS.honeypotField,
48
+ tokenField: opts.config.tokenField ?? DEFAULTS.tokenField,
49
+ responseField: opts.config.responseField ?? DEFAULTS.responseField,
50
+ difficulty: opts.config.difficulty ?? DEFAULTS.difficulty,
51
+ ttlMinutes: opts.config.ttlMinutes ?? DEFAULTS.ttlMinutes,
52
+ }
53
+ this.registry.register(honeypotChallenge)
54
+ this.registry.register(powChallenge)
55
+ this.registry.register(svgChallenge)
56
+ }
57
+
58
+ /** Register a custom challenge type. */
59
+ register(type: ChallengeType): void {
60
+ this.registry.register(type)
61
+ }
62
+
63
+ /** All registered type names. */
64
+ listTypes(): ChallengeName[] {
65
+ return this.registry.list()
66
+ }
67
+
68
+ /**
69
+ * Issue a fresh challenge. Synchronous — sign + the built-in issuers
70
+ * all run without I/O. Caller embeds `token` in a hidden field and
71
+ * uses `props` (and `html` for SVG) to render the challenge.
72
+ */
73
+ issue(type: ChallengeName, opts: IssueOptions = {}): IssuedChallenge {
74
+ const challenge = this.registry.get(type)
75
+ const ttlMinutes = opts.ttlMinutes ?? this.config.ttlMinutes
76
+
77
+ // 16 bytes (32 hex chars) — salts double as the PoW challenge value,
78
+ // so give them enough entropy that a precomputed PoW table is
79
+ // impractical.
80
+ const salt = randomHex(16)
81
+
82
+ const issued = challenge.issue({
83
+ ...opts,
84
+ difficulty: opts.difficulty ?? this.config.difficulty,
85
+ salt,
86
+ })
87
+
88
+ const tokenData: Omit<CaptchaTokenPayload, 'v' | 'iat' | 'jti'> = {
89
+ t: type,
90
+ s: salt,
91
+ exp: ttlMinutes,
92
+ ...(issued.answer !== undefined ? { ah: hashAnswer(issued.answer, salt) } : {}),
93
+ ...(issued.extra ?? {}),
94
+ }
95
+
96
+ const { token } = signToken(this.config.secret, tokenData)
97
+
98
+ return {
99
+ token,
100
+ props: issued.props,
101
+ ...(issued.html !== undefined ? { html: issued.html } : {}),
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Decode and verify a token + the user's response. Pure: no cache
107
+ * writes, no I/O. Replay prevention happens upstream in the middleware
108
+ * (after we know verification passed) so failed attempts don't burn
109
+ * the replay slot.
110
+ */
111
+ verify(
112
+ token: string | undefined | null,
113
+ response: unknown,
114
+ body: Record<string, unknown> = {},
115
+ ): VerifyResult {
116
+ if (!token || typeof token !== 'string') {
117
+ return { ok: false, reason: 'token_missing' }
118
+ }
119
+
120
+ const decoded = verifyToken(this.config.secret, token)
121
+ if (!decoded.ok) return decoded
122
+
123
+ if (!this.registry.has(decoded.payload.t)) {
124
+ return { ok: false, reason: 'unknown_type' }
125
+ }
126
+ const challenge = this.registry.get(decoded.payload.t)
127
+ const result = challenge.verify(decoded.payload, response, body)
128
+ if (!result.ok) return result
129
+
130
+ return { ok: true, payload: decoded.payload }
131
+ }
132
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * `CaptchaProvider` — registers `CaptchaManager` under its own token,
3
+ * reading config from `config.captcha`. Apps that want to pre-register
4
+ * custom challenge types can pass a `define` hook.
5
+ *
6
+ * new CaptchaProvider({
7
+ * define(captcha) {
8
+ * captcha.register(myCustomChallenge)
9
+ * },
10
+ * })
11
+ */
12
+
13
+ import { type Application, ConfigRepository, ServiceProvider } from '@strav/kernel'
14
+ import { CaptchaError } from './captcha_error.ts'
15
+ import { CaptchaManager } from './captcha_manager.ts'
16
+ import type { CaptchaConfig } from './types.ts'
17
+
18
+ export interface CaptchaProviderOptions {
19
+ /** Hook invoked at boot, after the manager is created. */
20
+ define?: (captcha: CaptchaManager) => void | Promise<void>
21
+ }
22
+
23
+ export class CaptchaProvider extends ServiceProvider {
24
+ override readonly name = 'captcha'
25
+ override readonly dependencies = ['config']
26
+
27
+ constructor(private readonly options: CaptchaProviderOptions = {}) {
28
+ super()
29
+ }
30
+
31
+ override register(app: Application): void {
32
+ app.singleton(CaptchaManager, (c) => {
33
+ const cfg = c.resolve(ConfigRepository).get('captcha') as CaptchaConfig | undefined
34
+ if (!cfg) {
35
+ throw new CaptchaError(
36
+ 'CaptchaProvider: missing `config.captcha`. Add a `captcha` config slice with at least `{ secret: env.required("APP_KEY") }`.',
37
+ { code: 'captcha.missing-config' },
38
+ )
39
+ }
40
+ return new CaptchaManager({ config: cfg })
41
+ })
42
+ }
43
+
44
+ override async boot(app: Application): Promise<void> {
45
+ if (this.options.define) {
46
+ await this.options.define(app.resolve(CaptchaManager))
47
+ }
48
+ }
49
+ }
@@ -1,14 +1,14 @@
1
1
  import type { ChallengeType } from '../types.ts'
2
2
 
3
3
  /**
4
- * Invisible-field honeypot. The middleware injects a hidden input that
5
- * humans never see; bots that auto-fill every field on the page will
6
- * trip it. Free, zero UX, layer it under everything else.
4
+ * Invisible-field honeypot. The view helper injects a hidden input that
5
+ * humans never see; bots that auto-fill every field on the page trip it.
6
+ * Free, zero UX, layer it under everything else.
7
7
  *
8
8
  * The honeypot field name is checked at the middleware layer — this
9
- * type just establishes the token shape so the protection still
10
- * surfaces a `pow_insufficient`/`token_*` error path consistent with
11
- * other types when the form is submitted without a token.
9
+ * type just establishes the token shape so the protection still surfaces
10
+ * a `pow_insufficient` / `token_*` error path consistent with other
11
+ * types when the form is submitted without a token.
12
12
  */
13
13
  export const honeypotChallenge: ChallengeType = {
14
14
  name: 'honeypot',
@@ -1,6 +1,5 @@
1
- import { encrypt } from '@strav/kernel'
2
- import type { ChallengeType } from '../types.ts'
3
- import { DEFAULTS } from '../types.ts'
1
+ import { createHash } from 'node:crypto'
2
+ import { DEFAULTS, type ChallengeType } from '../types.ts'
4
3
 
5
4
  /**
6
5
  * Hashcash-style proof of work. The client must find a nonce such that
@@ -27,13 +26,12 @@ export const powChallenge: ChallengeType = {
27
26
  if (typeof response !== 'string' || response.length === 0) {
28
27
  return { ok: false, reason: 'pow_insufficient' }
29
28
  }
30
- const difficulty = payload.d ?? DEFAULTS.difficulty
31
-
32
29
  // Cap nonce length so a malicious client can't ship megabyte payloads
33
30
  // through our hash function.
34
31
  if (response.length > 64) return { ok: false, reason: 'pow_insufficient' }
35
32
 
36
- const digest = encrypt.sha256(payload.s + ':' + response)
33
+ const difficulty = payload.d ?? DEFAULTS.difficulty
34
+ const digest = createHash('sha256').update(`${payload.s}:${response}`).digest('hex')
37
35
  if (countLeadingZeroBits(digest) < difficulty) {
38
36
  return { ok: false, reason: 'pow_insufficient' }
39
37
  }
@@ -48,7 +46,9 @@ export const powChallenge: ChallengeType = {
48
46
  export function countLeadingZeroBits(hex: string): number {
49
47
  let bits = 0
50
48
  for (let i = 0; i < hex.length; i++) {
51
- const nibble = parseInt(hex[i]!, 16)
49
+ const ch = hex[i]!
50
+ const nibble = Number.parseInt(ch, 16)
51
+ if (Number.isNaN(nibble)) break
52
52
  if (nibble === 0) {
53
53
  bits += 4
54
54
  continue
@@ -1,6 +1,6 @@
1
- import { encrypt } from '@strav/kernel'
2
- import type { ChallengeType } from '../types.ts'
1
+ import { randomBytes } from 'node:crypto'
3
2
  import { hashAnswer, safeEqual } from '../token.ts'
3
+ import type { ChallengeType } from '../types.ts'
4
4
 
5
5
  const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' // no 0/O/I/1
6
6
  const LENGTH = 6
@@ -44,7 +44,7 @@ export const svgChallenge: ChallengeType = {
44
44
 
45
45
  /** Pick `n` characters from the confusables-pruned alphabet. */
46
46
  function randomCode(n: number): string {
47
- const bytes = encrypt.randomBytes(n)
47
+ const bytes = randomBytes(n)
48
48
  let out = ''
49
49
  for (let i = 0; i < n; i++) {
50
50
  out += ALPHABET[bytes[i]! % ALPHABET.length]
@@ -53,14 +53,11 @@ function randomCode(n: number): string {
53
53
  }
54
54
 
55
55
  /**
56
- * Build the SVG body for a code. We seed RNG from the code so re-rendering
57
- * the same answer (e.g. for caching) yields the same image but since
58
- * the salt + jti rotate per challenge issuance, the image still differs
59
- * across requests.
56
+ * Build the SVG body for a code. The jitter uses `Math.random()` it's
57
+ * fine because security comes from the answer hash, not from making the
58
+ * SVG unpredictable.
60
59
  */
61
60
  export function renderSvg(code: string): string {
62
- // Bun's Math.random is fine for visual jitter — security comes from
63
- // the answer hash, not from making the SVG unpredictable.
64
61
  const glyphs: string[] = []
65
62
  const stepX = (WIDTH - 30) / code.length
66
63
  const baselineY = HEIGHT / 2 + FONT_SIZE / 3
@@ -76,7 +73,7 @@ export function renderSvg(code: string): string {
76
73
  `<text x="${x.toFixed(1)}" y="${y.toFixed(1)}" font-size="${FONT_SIZE}" ` +
77
74
  `font-family="Helvetica, Arial, sans-serif" font-weight="700" fill="${fill}" ` +
78
75
  `transform="rotate(${rot.toFixed(1)} ${x.toFixed(1)} ${y.toFixed(1)}) skewX(${skew.toFixed(1)})">` +
79
- `${ch}</text>`
76
+ `${ch}</text>`,
80
77
  )
81
78
  }
82
79
 
@@ -89,7 +86,7 @@ export function renderSvg(code: string): string {
89
86
  const y2 = Math.random() * HEIGHT
90
87
  const stroke = `hsl(${Math.floor(Math.random() * 360)}, 50%, 40%)`
91
88
  lines.push(
92
- `<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" stroke="${stroke}" stroke-width="1.5" />`
89
+ `<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" stroke="${stroke}" stroke-width="1.5" />`,
93
90
  )
94
91
  }
95
92
 
@@ -0,0 +1,99 @@
1
+ /**
2
+ * `captcha()` — form/JSON guard middleware. Place after `csrf()` and
3
+ * (optionally) after a rate limiter so cheap rejections never reach the
4
+ * CAPTCHA verifier.
5
+ *
6
+ * router.post('/register', [
7
+ * csrf(),
8
+ * captcha({ types: ['honeypot', 'pow'] }),
9
+ * ], handler)
10
+ *
11
+ * The middleware resolves a `CaptchaManager` and `Cache` from the
12
+ * request-scoped container. Apps that opt out of replay prevention
13
+ * (idempotent endpoints) should use the validation rule instead.
14
+ */
15
+
16
+ import { Cache } from '@strav/cache'
17
+ import type { HttpContext, MiddlewareFn } from '@strav/http'
18
+ import { CaptchaManager } from '../captcha_manager.ts'
19
+ import { consumeReplay } from '../replay.ts'
20
+ import type { ChallengeName, FailureReason } from '../types.ts'
21
+
22
+ export interface CaptchaMiddlewareOptions {
23
+ /** Challenge types accepted by this guard. @default ['honeypot', 'pow'] */
24
+ types?: ChallengeName[]
25
+ /** Override the field names from the manager config. */
26
+ honeypotField?: string
27
+ tokenField?: string
28
+ responseField?: string
29
+ /** Skip the guard for matching requests (e.g. internal health checks). */
30
+ skip?: (ctx: HttpContext) => boolean | Promise<boolean>
31
+ /** Custom failure response. Falls back to JSON 422. */
32
+ onFailure?: (ctx: HttpContext, reason: FailureReason) => Response | Promise<Response>
33
+ }
34
+
35
+ export function captcha(options: CaptchaMiddlewareOptions = {}): MiddlewareFn {
36
+ const types = (options.types ?? ['honeypot', 'pow']) as ChallengeName[]
37
+ const useHoneypot = types.includes('honeypot')
38
+ const challengeTypes = types.filter((t) => t !== 'honeypot')
39
+
40
+ return async (ctx, next) => {
41
+ if (await options.skip?.(ctx)) return next()
42
+
43
+ // Only guard state-changing methods. GET/HEAD shouldn't carry forms.
44
+ const method = ctx.request.method.toUpperCase()
45
+ if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') return next()
46
+
47
+ const manager = ctx.container.resolve(CaptchaManager)
48
+ const honeypotField = options.honeypotField ?? manager.config.honeypotField
49
+ const tokenField = options.tokenField ?? manager.config.tokenField
50
+ const responseField = options.responseField ?? manager.config.responseField
51
+
52
+ const body = await readBody(ctx)
53
+
54
+ if (useHoneypot) {
55
+ const trap = body[honeypotField]
56
+ if (typeof trap === 'string' && trap.trim() !== '') {
57
+ return failure(ctx, 'honeypot_tripped', options)
58
+ }
59
+ }
60
+
61
+ if (challengeTypes.length === 0) return next()
62
+
63
+ const token = typeof body[tokenField] === 'string' ? (body[tokenField] as string) : ''
64
+ if (!token) return failure(ctx, 'token_missing', options)
65
+
66
+ const result = manager.verify(token, body[responseField], body)
67
+ if (!result.ok) return failure(ctx, result.reason, options)
68
+
69
+ if (!challengeTypes.includes(result.payload.t)) {
70
+ // Token is valid but for a type this guard wasn't asked to accept —
71
+ // happens when a challenge was issued for a different form.
72
+ return failure(ctx, 'token_invalid', options)
73
+ }
74
+
75
+ const cache = ctx.container.resolve(Cache)
76
+ const fresh = await consumeReplay(cache, result.payload)
77
+ if (!fresh) return failure(ctx, 'replayed', options)
78
+
79
+ return next()
80
+ }
81
+ }
82
+
83
+ async function readBody(ctx: HttpContext): Promise<Record<string, unknown>> {
84
+ try {
85
+ const body = await ctx.request.input()
86
+ return body && typeof body === 'object' ? (body as Record<string, unknown>) : {}
87
+ } catch {
88
+ return {}
89
+ }
90
+ }
91
+
92
+ async function failure(
93
+ ctx: HttpContext,
94
+ reason: FailureReason,
95
+ options: CaptchaMiddlewareOptions,
96
+ ): Promise<Response> {
97
+ if (options.onFailure) return options.onFailure(ctx, reason)
98
+ return ctx.response.json({ errors: { _captcha: [reason] } }, { status: 422 })
99
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Validation rule — `rule.custom('captcha')` integration for `FormRequest`.
3
+ *
4
+ * Register once at boot:
5
+ *
6
+ * import { registerCaptchaRule } from '@strav/captcha/http'
7
+ * registerCaptchaRule()
8
+ *
9
+ * Then reference in a `FormRequest`:
10
+ *
11
+ * rules() {
12
+ * return {
13
+ * _captcha: rule.custom('captcha'),
14
+ * email: rule.string().email(),
15
+ * }
16
+ * }
17
+ *
18
+ * NOTE: the rule does NOT perform replay prevention (`FormRequest`
19
+ * validation runs before route handlers and may be re-run on retry).
20
+ * For replay-safe checks use the `captcha()` middleware. The rule fits
21
+ * idempotent endpoints where double-submit is harmless.
22
+ */
23
+
24
+ import { registerRule } from '@strav/http'
25
+ import { CaptchaManager } from '../captcha_manager.ts'
26
+
27
+ /**
28
+ * Register the `captcha` rule on the http rule registry. Idempotent —
29
+ * safe to call from boot in both production and tests.
30
+ */
31
+ export function registerCaptchaRule(): void {
32
+ registerRule<unknown>('captcha', async (value, ctx) => {
33
+ const manager = ctx.container.resolve(CaptchaManager)
34
+ const token = typeof value === 'string' ? value : ''
35
+ const body = (await safeInput(ctx)) ?? {}
36
+ const responseField = manager.config.responseField
37
+ const result = manager.verify(token, body[responseField], body)
38
+ return result.ok ? true : `captcha.${result.reason}`
39
+ })
40
+ }
41
+
42
+ async function safeInput(ctx: Parameters<Parameters<typeof registerRule>[1]>[1]): Promise<Record<string, unknown> | undefined> {
43
+ try {
44
+ const body = await ctx.request.input()
45
+ return body && typeof body === 'object' ? (body as Record<string, unknown>) : undefined
46
+ } catch {
47
+ return undefined
48
+ }
49
+ }
@@ -0,0 +1,5 @@
1
+ // `@strav/captcha/http` — middleware, validation rule, refresh routes.
2
+
3
+ export { captcha, type CaptchaMiddlewareOptions } from './captcha_middleware.ts'
4
+ export { registerCaptchaRule } from './captcha_rule.ts'
5
+ export { mountCaptchaRoutes, type MountCaptchaRoutesOptions } from './refresh_routes.ts'
@@ -0,0 +1,59 @@
1
+ /**
2
+ * `mountCaptchaRoutes(router)` — refresh endpoint at `<prefix>/:type` so
3
+ * client-side widgets can request a new challenge without a full page
4
+ * reload. Stateless, no session required.
5
+ *
6
+ * mountCaptchaRoutes(router)
7
+ * // → GET /__captcha/svg, GET /__captcha/pow
8
+ *
9
+ * The route handler resolves `CaptchaManager` from the per-request
10
+ * container. Layer your own rate-limit middleware on the group prefix —
11
+ * this package doesn't ship one (a leaky-bucket rate limiter is the
12
+ * caller's concern).
13
+ */
14
+
15
+ import type { Router } from '@strav/http'
16
+ import { CaptchaManager } from '../captcha_manager.ts'
17
+ import type { ChallengeName } from '../types.ts'
18
+
19
+ export interface MountCaptchaRoutesOptions {
20
+ /** URL prefix. @default '/__captcha' */
21
+ prefix?: string
22
+ }
23
+
24
+ export function mountCaptchaRoutes(router: Router, options: MountCaptchaRoutesOptions = {}): void {
25
+ const prefix = options.prefix ?? '/__captcha'
26
+
27
+ router.group({ prefix }, (r) => {
28
+ r.get('/:type', (ctx) => {
29
+ const manager = ctx.container.resolve(CaptchaManager)
30
+ const type = ctx.request.params.type as ChallengeName | undefined
31
+ if (!type || !manager.registry.has(type)) {
32
+ return ctx.response.json({ error: 'Unknown challenge type' }, { status: 404 })
33
+ }
34
+
35
+ const issued = manager.issue(type)
36
+
37
+ // SVG type returns the image directly — easy `<img src=…>` embed.
38
+ // Other types return JSON so the client can hydrate its own widget.
39
+ if (type === 'svg' && issued.html) {
40
+ return new Response(issued.html, {
41
+ status: 200,
42
+ headers: {
43
+ 'Content-Type': 'image/svg+xml',
44
+ 'Cache-Control': 'no-store',
45
+ // Pass the token via header so the client can stash it without
46
+ // re-parsing the SVG body.
47
+ 'X-Captcha-Token': issued.token,
48
+ },
49
+ })
50
+ }
51
+
52
+ return ctx.response.json({
53
+ token: issued.token,
54
+ props: issued.props,
55
+ html: issued.html,
56
+ })
57
+ })
58
+ })
59
+ }
package/src/index.ts CHANGED
@@ -1,44 +1,27 @@
1
- // Middleware
2
- export { captcha } from './middleware.ts'
3
-
4
- // Issue / verify primitives
5
- export { issueChallenge, verifyChallenge } from './challenges.ts'
6
- export { honeypotChallenge, powChallenge, svgChallenge } from './challenges.ts'
7
-
8
- // Registry for custom challenge types
9
- export { registerChallengeType, getChallengeType, listChallengeTypes } from './registry.ts'
10
-
11
- // Validation rule
12
- export { captchaRule } from './validation_rule.ts'
13
-
14
- // Refresh route
15
- export { mountCaptchaRoutes } from './route.ts'
16
-
17
- // View helper / @captcha directive support
1
+ // Public API of @strav/captcha.
2
+ //
3
+ // Root barrel exports the manager + provider + types + token + replay
4
+ // + challenge primitives. Integrations ship under subpaths:
5
+ // - `@strav/captcha/http` (middleware, validation rule, refresh routes)
6
+ // - `@strav/captcha/view` (form-fragment helper for view.globals)
7
+
8
+ export { honeypotChallenge } from './challenges/honeypot_challenge.ts'
9
+ export { countLeadingZeroBits, powChallenge } from './challenges/pow_challenge.ts'
10
+ export { renderSvg, svgChallenge } from './challenges/svg_challenge.ts'
11
+ export { CaptchaError } from './captcha_error.ts'
12
+ export { CaptchaManager, type CaptchaManagerOptions } from './captcha_manager.ts'
13
+ export { CaptchaProvider, type CaptchaProviderOptions } from './captcha_provider.ts'
14
+ export { ChallengeRegistry } from './registry.ts'
15
+ export { consumeReplay } from './replay.ts'
16
+ export { hashAnswer, randomHex, safeEqual, signToken, verifyToken } from './token.ts'
18
17
  export {
19
- installCaptchaHelpers,
20
- configureCaptchaHelper,
21
- captchaHelper,
22
- } from './view_helper.ts'
23
-
24
- // Token utilities (exposed so consumers can build custom flows)
25
- export { sealToken, unsealToken, hashAnswer, safeEqual, consumeReplay } from './token.ts'
26
-
27
- // SVG renderer (exposed for users who want to render outside the directive)
28
- export { renderSvg } from './challenges/svg.ts'
29
-
30
- // PoW utilities (count leading zero bits — useful for testing)
31
- export { countLeadingZeroBits } from './challenges/pow.ts'
32
-
33
- // Types
34
- export { DEFAULTS } from './types.ts'
35
- export type {
36
- ChallengeName,
37
- ChallengeType,
38
- CaptchaTokenPayload,
39
- CaptchaOptions,
40
- IssueOptions,
41
- IssuedChallenge,
42
- VerifyResult,
43
- FailureReason,
18
+ type CaptchaConfig,
19
+ type CaptchaTokenPayload,
20
+ type ChallengeName,
21
+ type ChallengeType,
22
+ DEFAULTS,
23
+ type FailureReason,
24
+ type IssueOptions,
25
+ type IssuedChallenge,
26
+ type VerifyResult,
44
27
  } from './types.ts'