How to tell whether a host actually implements Webpath. Every case below is a black-box check over plain HTTP — no internal access, no SDK. Point it at any host that claims Webpath support and run it.
🧪 Runnable starter kit available; full suite coming soon. A drop-in script (
conformance.sh) runs the L1 Core cases against any host — it joins a throwaway space, checks each rule, and tears the space down on exit:WEBPATH_HOST=https://knower.cc ./conformance.shThe complete L2/L3 suite (append, reserved-path projections, QR, search, sharing) is in progress. Every case below also carries a
curlyou can run by hand.
knower.cc is the reference host (it implements the spec as its
native API); these cases pass against it.
Each case has an ID, the rule it asserts (the normative line from the spec), a probe (a request), and the expected result. Keywords MUST / MUST NOT / SHOULD are normative — a host failing a MUST is non-conformant.
# Set up once. Use a throwaway space per run.
export HOST=https://knower.cc # the platform host under test
export NS=$(curl -s -X POST $HOST/join -d "{\"name\":\"wpconf-$(date +%s)\"}")
export SPACE=$(echo "$NS" | sed -E 's/.*"space":"([^"]+)".*/\1/') # https://wpconf-….knower.cc/
export TOKEN=$(echo "$NS" | sed -E 's/.*"token":"([^"]+)".*/\1/') # pth_…
Levels: L1 Core (the data plane + identity surface — required to claim conformance) · L2 Full (reserved-path projections, search, QR, sharing) · L3 Optional (versioning, subscriptions, custom domains — out of scope here).
Email IS the account — no passwords. Sign-in never enumerates accounts.
| ID | Rule | Probe | Expect |
|---|---|---|---|
| WP-A1 | OTP request MUST always answer 204, account or not. | POST /api/v1/auth/otp {"email":"x@y.z"} | 204, no body, whether or not x@y.z has an account |
| WP-A2 | Verify MUST exchange a correct code for a session and create the account on first verify. | POST /api/v1/auth/verify {"email","code"} | 200, sets a session cookie, created:true first time |
| WP-A3 | A wrong/expired/locked code MUST fail closed. | verify with a bad code | 401 otp-invalid, detail names attempts remaining |
| WP-A4 | OTP MUST be rate-limited. | 4th request inside 15 min for one email | 429 with a Retry-After header |
| WP-A5 | The session cookie MUST be host-scoped. | inspect Set-Cookie | __Host--prefixed, Secure, Path=/, no Domain — not sent to sibling spaces |
A space IS a hostname. Joining mints exactly one label.
| ID | Rule | Probe | Expect |
|---|---|---|---|
| WP-S1 | /join MUST mint <name>.<host> and return a usable key. | POST /join {"name":"foo"} | 201 with space, token (pth_), expires_at, llms_txt, paste |
| WP-S2 | A taken name MUST be refused with suggestions. | join the same name twice | 409 space-name-taken, ≥1 suggestion |
| WP-S3 | An invalid/reserved name MUST be refused, not silently mangled. | join "a.b", "www", "api" | 422 (space-name-invalid / reserved); a dot is rejected (one label only) |
| WP-S4 | A new space MUST start empty (nothing is seeded). | GET $SPACE?list | 200, zero entries |
Keys are scoped to a space, a path prefix, and a set of ops. Shown once.
| ID | Rule | Probe | Expect |
|---|---|---|---|
| WP-K1 | Minting MUST return the secret exactly once. | POST /api/v1/tokens {scope} | 201 with pth_…, jti; subsequent GET /api/v1/tokens shows label+jti, never the secret |
| WP-K2 | Revocation MUST take effect quickly and globally. | DELETE /api/v1/tokens/<jti> | key stops working (≤60 s); a write with it → 401 token-revoked |
| WP-K3 | A key MUST NOT work outside its scope. | use a /notes/-scoped key to write /other.md or another host | 403 out-of-scope, never a cross-scope write |
| WP-K4 | A key MUST NOT reach the control plane. | call POST /api/v1/tokens with a pth_ key | 403 (admin op) |
| WP-K5 | revoke-all MUST invalidate every prior key at once (owner only). | POST /api/v1/spaces/<ns>/revoke-all | 200, key_generation increments; old keys → 401, fresh key works; non-owner → 403 |
The URL is the storage key. Verbs do exactly what HTTP says.
| ID | Rule | Probe | Expect |
|---|---|---|---|
| WP-D1 | PUT then GET MUST round-trip the bytes verbatim. | PUT /notes/t.md then GET /notes/t.md | bytes identical; 201 (new) / 200 (replace); ETag on both |
| WP-D2 | Content-Type MUST be inferred from the extension. | GET the file above | Content-Type: text/markdown (not whatever the client declared) |
| WP-D3 | A declared Content-Type MUST NOT override the extension under a restricted policy. | PUT notes.md with header Content-Type: text/html | stored/served as text/markdown (no smuggled HTML) |
| WP-D4 | Directory GET (trailing slash) MUST list. | GET /notes/?list | 200 JSON, includes t.md |
| WP-D5 | DELETE MUST remove the file; the next GET MUST 404. | DELETE /notes/t.md then GET | 204, then 404 as application/problem+json |
| WP-D6 | Whole-space/prefix DELETE MUST require confirmation. | DELETE $SPACE without ?confirm | 400 confirm-required; with ?confirm=<host> → proceeds |
A clean URL is the rendered view; an explicit extension is verbatim source.
| ID | Rule | Probe | Expect |
|---|---|---|---|
| WP-R1 | A clean URL MUST resolve <path>.md (rendered) then <path>.html (served). | GET /page (with page.md present) | 200 text/html, markdown rendered, Content-Location: /page.md |
| WP-R2 | An extension URL MUST return raw source, never rendered. | GET /page.md · GET /page.html | .md → text/markdown; .html → text/plain (browser does not execute it) |
| WP-R3 | Rendered markdown MUST be sanitized. | render a .md containing <script>/onerror= | scripts/handlers stripped from HTML; source bytes via .md stay verbatim |
| WP-R4 | A clean URL with no backing file MUST 404. | GET /nothing | 404 problem+json |
private (default) · unlisted · public. A private file MUST NOT leak its existence.
| ID | Rule | Probe | Expect |
|---|---|---|---|
| WP-V1 | New files MUST default to private. | PUT /m.html (no ?visibility) | ?meta shows visibility:"private" |
| WP-V2 | A private file MUST be invisible to anonymous readers — same 404 as a missing file. | GET /m incognito (no key) | 404 byte-identical to a truly-missing path (no existence leak) |
| WP-V3 | A valid key/owner session MUST read the private file. | GET /m.html?token=$TOKEN | 200 |
| WP-V4 | Visibility MUST be settable on write and flippable later. | PUT …?visibility=public; PATCH ?meta {"visibility":"public"} | public immediately; anonymous clean URL then renders |
| WP-V5 | Private resolution MUST NOT leak via the clean-URL fallback. | private page.md, GET /page incognito | denied — never renders a private source |
Line-safe accumulation without a read-modify-write round-trip.
| ID | Rule | Probe | Expect |
|---|---|---|---|
| WP-P1 | Append to a missing file MUST create it. | PUT /log.jsonl?append (no file) | 201 |
| WP-P2 | Line-oriented appends MUST insert a join newline when the prior tail lacks one. | append {"b":2} to {"a":1} (no trailing \n) | two distinct lines, never glued |
| WP-P3 | Concurrent appends MUST NOT lose data. | two appends in parallel | both records present (CAS) |
| WP-P4 | Whole-file/binary formats MUST refuse append. | ?append on .json / .html / .png | 409 not-appendable, hint = GET→modify→PUT |
| WP-P5 | A malformed line body MUST write nothing. | ?append a broken JSON line to .jsonl | 400 invalid-append-body, file untouched |
| WP-P6 | ?append and ?prepend together MUST be rejected. | both flags on one request | 400 |
ETags everywhere; preconditions prevent lost updates.
| ID | Rule | Probe | Expect |
|---|---|---|---|
| WP-C1 | Every response MUST carry an ETag. | any GET/PUT | ETag header present |
| WP-C2 | A stale If-Match write MUST be rejected. | PUT with an old ETag | 412, body links the current version |
| WP-C3 | A fresh If-Match write MUST succeed. | re-read, retry with the new ETag | 200 |
| WP-C4 | Conditional GET MUST support If-None-Match. | GET with the current ETag | 304 Not Modified |
| WP-C5 | Range reads SHOULD be supported. | GET with Range: bytes=0-3 | 206 with the partial body |
Config, links, audit, stats, the skill file are all files at reserved paths, synthesized on read so they can't drift.
_-prefixed basenames are never user-writable.
| ID | Rule | Probe | Expect |
|---|---|---|---|
| WP-X1 | Every space MUST serve llms.txt, ≤1,500 bytes, no auth, naming its real host. | GET /llms.txt | 200 text/plain, < 1500 bytes, contains the space hostname |
| WP-X2 | llms.txt MUST be read-only (it's generated). | PUT /llms.txt | 405 llms-generated with a hint |
| WP-X3 | _config.json MUST mirror config (read) and accept only editable fields (PATCH). | GET /_config.json; PATCH {"uploads":["md"]}; PATCH {"key_generation":9} | read OK; edit OK; server-owned field → 422 field-not-writable; PUT → 405 |
| WP-X4 | _links.json MUST be the owner-only links projection and refuse writes. | GET /_links.json (owner) ; PUT | 200 index; write → 405; anonymous → 404 |
| WP-X5 | .well-known/jwks.json MUST publish the verification keys. | GET /.well-known/jwks.json | 200, a JWK set, public, no secrets |
| WP-X6 | An audit/stats feed, if present, MUST be owner-only. | GET /_audit, /_stats anonymously | 404/401 — never expose actors or private paths |
Every non-2xx is
application/problem+jsonwith an agent-relayabledetail.
| ID | Rule | Probe | Expect |
|---|---|---|---|
| WP-E1 | Every error MUST be application/problem+json. | any failing request | Content-Type: application/problem+json, body has type/title/status/detail |
| WP-E2 | detail MUST be a full sentence a non-technical user can act on. | revoke a key, then PUT …?token=<dead> | 401, detail names the space and how to get a new key |
| WP-E3 | type MUST be a stable URI. | inspect any error | type is an absolute URL under a stable errors namespace |
| WP-E4 | A dead key on a public read MUST be ignored (still 200); on a write it MUST 401. | GET public?token=<dead> ; PUT …?token=<dead> | 200 ; 401 token-revoked |
A key carried in the URL must never persist or echo back.
| ID | Rule | Probe | Expect |
|---|---|---|---|
| WP-T1 | Any ?token= response MUST be private, no-store. | GET …?token=$TOKEN -i | Cache-Control: private, no-store |
| WP-T2 | The token MUST NOT appear in any response body, Location, Content-Location, or links. | grep the full response for the token | absent everywhere |
| WP-T3 | A header token and a query token that disagree MUST be rejected. | Authorization: Bearer A + ?token=B | 400 |
| ID | Rule | Probe | Expect |
|---|---|---|---|
| WP-Q1 | ?qr MUST render the URL as a QR on any path, with no storage read or auth. | GET /anything?qr (text/html vs default) | HTML shell + inline SVG for browsers; Unicode QR + URL for terminals; ?qr=svg/?qr=txt explicit; bad value → 400 |
| WP-Q2 | ?qr MUST NOT encode the token into the QR. | GET /p?qr&token=$TOKEN | encoded URL is token-free; response private, no-store |
| WP-F1 | ?search SHOULD rank results across the space (scope by prefix). | GET /?search=<term> | 200, ranked matches over path/title/description |
| Gate | Bar |
|---|---|
| Conformant (L1 Core) | All WP-A*, S*, K*, D*, R*, V*, C*, E*, T* green |
| Full (L2) | Plus WP-P*, X*, Q*, F* |
| Privacy floor | WP-V2, WP-V5, WP-E2, WP-T2 are non-negotiable — a failure here is a security defect, not a missing feature |
If a host passes L1, an agent that knows only the API Reference can drive it unaided — which is the whole point.
→ API Reference · Build an App · Examples · Webpath home