Content gate: spec
Table of Contents
1. Purpose
The content gate decides whether to render a wall for a path given a
registry of gated content and a pure user-state value. Promo codes
unlock a path by producing a path-bound token; the core never reads or
writes localStorage. Namespace: wal-sh.adtech.content-gate.core
(host-neutral .cljc).
ORACLE: src/wal_sh/adtech/content_gate/core.cljc (pure),
browser.cljs (storage adapter + overlay).
2. Requirements
Data shapes:
- gate
{:path String :title String :tier Keyword :price Number :promo-codes [String...]}; codes are stored UPPERCASE.- user-state
{:tokens {path token}}; a pure value the adapter persists. The core never reads or writes localStorage.- token
"v1:<8hex>:<8hex>:<8hex>"; FNV-1a-32 of (path, code, combined). Path-bound by construction.
Decisions worth flagging:
- Codes are case-INsensitive. Spec said "case-sensitive in JS"; the
actual JS does
.toUpperCase()on both sides. We preserve the real behavior, not the spec text. PLANS.org could be updated. - Tokens are path-bound, not global. Replay across paths is
rejected. Documented; tested with property
token-not-replayable-across-paths. - Discount is a number, not a flag. Currently always 1.0 for valid codes; the number shape leaves room for tiered codes without an API break.
- No secret key. Token is auditability, not security: a static site has no server secret to bind. Treat tokens as bound-receipts.
- Length cap (
max-code-length= 256). JS module had no cap; we close that gap so the validator can't be DoS'd. - Failure result NEVER carries
:code. Browser can render:codesafely; raw input is dropped. Seefailed-apply-never-leaks-input.
3. Contract signature
Pure (core.cljc):
| Function | Signature | Pure | Notes |
|---|---|---|---|
gated-content |
[gate...] |
yes (const) | default registry |
find-gate |
[registry path] → gate or nil |
yes | suffix+substring match |
normalize-code |
[s] → String or nil |
yes | trim, upper-case, length-guard |
apply-code |
[gate raw-code] → result |
yes | never throws |
unlock-token |
[path code] → token |
yes | deterministic; cross-host equal |
verify-token |
[token path code] → bool |
yes | path+code bound |
unlocked? |
[gate user-state path] → bool |
yes | iterates :promo-codes |
gate? |
[registry user-state path] → bool |
yes | the decision: render wall? |
record-unlock |
[user-state path token] → user-state' |
yes | pure assoc |
empty-user-state |
[] → user-state |
yes | constructor |
Result map (apply-code):
| Key | When | Type |
|---|---|---|
:valid? |
always | bool |
:discount |
always | number ∈ [0.0, 1.0] |
:reason |
on failure | #{:no-gate :empty-code :too-long :unknown-code} |
:code |
only on success | String (normalized, from registry; never raw input) |
:token |
only on success | String token |
4. Failure-mode catalog (negative spec)
Each row is a hostile / malformed input the core MUST reject without
throwing. Verified by core_prop_test.cljc properties (named) and
core_test.cljc examples.
| # | Input | Expected behavior | Verified by |
|---|---|---|---|
| 1 | nil code |
{:valid? false :reason :empty-code} |
apply-code-empty-rejected |
| 2 | empty string "" |
same as nil | apply-code-empty-rejected |
| 3 | whitespace-only " " / "\t\n" |
trimmed → empty → rejected | apply-code-empty-rejected, nil-or-blank-code-always-rejected (50 cases) |
| 4 | non-string (e.g. 42) |
:empty-code (no throw) |
apply-code-empty-rejected |
| 5 | HTML/JS injection "<script>alert(1)</script>" |
:unknown-code; result has NO :code key |
apply-code-html-injection-not-echoed, failed-apply-never-leaks-input (100 cases) |
| 6 | oversized (> max-code-length = 256) |
:too-long (length-guarded BEFORE hashing) |
apply-code-oversized-rejected, oversized-code-rejected (25 cases × random pad) |
| 7 | unknown code ("NOPE") |
:unknown-code |
apply-code-unknown-rejected, junk-codes-never-validate (100 cases) |
| 8 | nil gate (path not in registry) |
:no-gate |
apply-code-no-gate-rejected |
| 9 | discount > 1.0 in code table | impossible by construction: apply-code writes its own :discount ∈ [0.0,1.0]; the registry's :promo-codes are strings, not discount tables. The closed-shape API closes this failure mode |
discount-never-negative-never-over-one (100 cases) |
| 10 | discount < 0 in code table | same; see #9 | discount-never-negative-never-over-one |
| 11 | tampered token (any 1-char flip in the hex) | verify-token returns false; falls back to gated |
tampered-token-never-verifies (100 cases) |
| 12 | replay across paths (token from path A pasted at B) | verify-token false; unlocked? false |
unlock?-token-from-different-path, token-not-replayable-across-paths (50 cases) |
| 13 | wrong token version ("v2:...") |
verify-token false; cheap rotation hook |
verify-token-roundtrip (includes "garbage" and "v1:aa:bb:cc") |
| 14 | non-gated path with a planted bogus token in user-state | gate? false; tokens on non-gated paths are inert |
non-gated-path-never-locked (50 cases) |
| 15 | corrupted localStorage blob (browser) | adapter swallows, resets to {}; user re-enters code |
read-tokens try/catch in browser.cljs |
Design notes that close negative cases by construction:
- Length-check happens BEFORE hashing so a 10 MB code is rejected
on a
countcall, not after FNV-1a over 10 MB of bytes. apply-codeis closed-shape: the only writer of:codeis the successful branch, and it writes the normalized form drawn from the registry, never user input. Even if a generator finds a string that happens to upper-case to a registered code, the value placed in:codeis the registry's string. Adversarial HTML can never reach:code.- Tokens are derived from
path|codecombined, not just per-field. Pasting<path-hash>:<code-hash>from another path won't verify because the combined hash won't match.
5. Cross-references
- metered-access/spec.org: the sibling paywall module; the meter counts views, the gate checks tokens. Both keep storage in the browser adapter.
- ab-engine/spec.org: source of the
fnv1a-32hash the unlock token is built from. - ORACLE:
src/wal_sh/adtech/content_gate/core.cljc(pure)src/wal_sh/adtech/content_gate/browser.cljs(storage + overlay)test/wal_sh/adtech/content_gate/core_prop_test.cljc(properties)
6. Open questions
- PLANS.org still says codes are case-sensitive; the implementation and the JS source both upper-case. The spec text should follow the observed behavior (decision 1 above).
- Tiered discounts.
:discountis a number so tiered codes fit without an API break, but no registry entry carries a discount other than 1.0 today.