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:

  1. 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.
  2. Tokens are path-bound, not global. Replay across paths is rejected. Documented; tested with property token-not-replayable-across-paths.
  3. 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.
  4. No secret key. Token is auditability, not security: a static site has no server secret to bind. Treat tokens as bound-receipts.
  5. Length cap (max-code-length = 256). JS module had no cap; we close that gap so the validator can't be DoS'd.
  6. Failure result NEVER carries :code. Browser can render :code safely; raw input is dropped. See failed-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 count call, not after FNV-1a over 10 MB of bytes.
  • apply-code is closed-shape: the only writer of :code is 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 :code is the registry's string. Adversarial HTML can never reach :code.
  • Tokens are derived from path|code combined, 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-32 hash 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. :discount is a number so tiered codes fit without an API break, but no registry entry carries a discount other than 1.0 today.