Skip to content

docs: NAP specification, tutorials and comparisons - #12

Merged
tcheeric merged 20 commits into
masterfrom
develop
Sep 1, 2026
Merged

tcheeric merged 20 commits into
masterfrom
develop

Conversation

@tcheeric

Copy link
Copy Markdown
Owner

Documentation only. nap publishes no Maven artifact, so there is no version to bump.

What is here

  • Tutorial index, README entry point and guide cross-links, so the tutorials are
    reachable rather than only present.
  • Comparisons: NAP vs OAuth 2.0, NAP vs WebAuthn.
  • A voucher-bound authorization draft under extensions.
  • Tutorial 09 material: the derived audience resolver on getExternalBaseUrl, a
    Fastify appendix, and a "before you ship" section.
  • scripts/doccheck.py, the shared documentation checker.

Verification

doccheck.py run locally. GitHub Actions runners are not being assigned org-wide
at the moment.

tcheeric and others added 20 commits August 22, 2026 14:33
…mple

Opens the tutorial series specified in specs/002-tutorial-series.

- docs/tutorials/00-nostr-for-nap.md — keys, the three signers, what NIP-98
  signs, and the one thing a signature does not prove.
- Fixes four documentation defects: the stale version in README, the two
  places claiming stepUp() always throws (§6.1 was right, and session.ts
  mints the token), the two claiming NAP ships no KeyStore implementation,
  and the three packages with no README.
- examples/merchant-app joins the workspace, with one HTTP seam covering
  init, completion, payload-mismatch rejection, guarded routes, permission
  denial, step-up, refresh rotation, and cookie logout.
- docs/tutorials/01-a-server-you-can-curl.md walks the whole exchange by
  hand. Every output in it is copied from a real run.

Three library findings recorded in CONTEXT.md rather than fixed: the guards
expose no principal, NapExpressGuardOptions.clock does not reach the expiry
check, and @types/express needs the same dedupe nostr-tools does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the merchant app's frontend and the tutorial built on it.

- Vite dev server proxying /auth, /api, /permissions so the browser sees one
  origin. Both the SameSite=Lax cookie and the NIP-98 audience depend on that,
  so the server must run with NAP_BASE_URL pointed at the frontend's origin —
  verified by reading auth_url back out of a live /auth/init.
- useNapBootstrap: createNapSession with the callbacks spread whole, resume()
  on mount behind a loading phase, destroy() on unmount.
- SignerPicker renders useNip07()'s 'detecting' tri-state and all four
  Nip07Error codes distinctly; the switch is exhaustive with no default.
- Test now asserts logout clears the cookie under the name it was written with.
- Example pins React 19 to match nap-react's @types/react major (CONTEXT.md
  finding 9: nap-react declares no react peer dependency at all).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Adds a `support` role with no permissions and one `requireRole`-guarded
  staff route: the case a permission check cannot express, which is the test
  for whether a role guard is the right tool.
- Frontend gains a voucher list and create form gated on the session hook's
  permissions, with the 403 handled anyway — the hidden button is reachable
  from a second tab, a stale snapshot, or curl.
- Three tests: viewer read/write split, the role guard both ways, and a live
  revocation biting mid-session because the guards re-read the ACL. The last
  one is what `aclResolver` in the guard options buys, asserted rather than
  claimed.
- Tutorial output is all from live runs, including the boot failure from a
  deliberately typo'd permission key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- `docker-compose.yml` (Postgres 16 on 5433) plus `db/schema.sql`, the DDL the
  repo does not ship. Derived from the store's queries; verified by running it.
- `src/stores.ts` selects in-memory or Postgres off DATABASE_URL, and carries
  the challenge sweeper. All three stores swap together as a constructor call.
- Found and worked around: `pg` returns BIGINT as a string, and
  `PostgresSessionStore` casts it to `number` without converting — so
  /auth/session ships `"expires_at":"1787407576"` on Postgres and a number in
  memory. Comparisons coerce and appear fine; arithmetic concatenates, and the
  session body is the cross-implementation contract. Pool-scoped INT8 parser in
  the example, filed as CONTEXT.md finding 10.
- Tutorial has the reader lose a session to a restart before fixing it, and
  states plainly that markExpired() marks but never deletes and sessions are
  never swept — demonstrated with real row counts.

CI unchanged: no Docker, in-memory stores, typecheck covers the wiring. The
tutorial says so rather than implying the path is tested.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tutorial 05 walks the 900-second wall, `refreshTtlSeconds`, rotation, and
reuse detection from a captured live run of the example. The client side is
the point: neither `nap-client-web` nor `nap-react` contains the string
"refresh", so `refreshLoop.ts` is the sixty lines an integrator has to write,
including the two things that are easy to get wrong — `credentials: 'include'`
so the rotated cookie lands, and never retrying a refresh whose outcome is
unknown, which the server reads as theft and answers by revoking the session.

The example gains `NAP_REFRESH_TTL` / `NAP_SESSION_TTL` knobs so the reader can
watch a session expire in a minute rather than a quarter of an hour, a cookie
`transformBody`, and a test covering a rotation plus the replay that kills the
session.

Guide corrections: §11.3's "no client refreshes for you" bullet omitted that in
cookie mode the token does not reach the browser at all by default, and §5.2
framed `transformBody` as a render optimisation when it is mandatory for any
`nap-client-web` consumer — the bare `{status:'ok'}` body makes `login()` throw
a `TypeError`. Filed as CONTEXT.md finding 11; no library code changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tutorial 06 enforces the `stepUp: true` flag that ticket 03 declared on
`stripe:manage`, from the curl transcript through a React component that
elevates on the 403 rather than deciding for itself when to prompt.

Three findings the guide did not carry, all confirmed against a running
server. A step-up mints a *second* session rather than upgrading the first, so
a bearer client that keeps its original access token holds an elevation it can
never spend — the guard checks the step-up token against the session the
request's own credential names. Omitting `registry` from the guard options
disables enforcement silently, which is the one piece of step-up wiring that
fails quietly rather than loudly. And guard denials reach no `AuditLogger` at
all: the whole sequence emitted two records, both `NAP_COMPLETE_SUCCESS`.

The consent framing is stated three times over because the feature invites the
mistake: step-up is evidence of present key control, and a NIP-46 bunker with
pre-granted permissions satisfies it without asking anybody. The "try it" ends
by pressing the button twice and getting one prompt.

Guide §6.1's account of `stepUp()` is confirmed empirically — no disagreement
with ticket 02's fix. The forward-link filenames for 06/07/08 had drifted into
two conventions; normalised on the short form the shipped files already use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The example gains a `RemoteSigner` component offering both pairing directions
from one field — a pasted `bunker://` URL, or a `nostrconnect://` URI the page
emits for the user to scan — with optional encrypted persistence so a reload
does not redo the two-device dance. It is offered in both branches of the
signer picker, including the no-extension one, where it is the difference
between a dead end and a login.

`describe()` in the picker now falls through to a NIP-46 taxonomy rather than
collapsing six codes into "something went wrong": `login()` and `stepUp()`
surface whichever signer is behind the session, and only `DECLINED` is a choice
somebody made.

The tutorial's §3 is the reason this is a tutorial and not a paragraph. The
client pubkey is public — in the URI and in the `#p` filter every relay sees —
and NIP-44 conversation keys are ECDH, so anyone can send something that
decrypts. Only the returned secret proves the sender read the URI, and an
`{error}` arriving mid-pairing is unauthenticated: treating it as a decline
hands every relay operator a kill switch on every pairing in flight.

Also honest about what it does not have. The pairing sequences are described
rather than pasted, because a live bunker on a live relay is not reproducible
from a repository, and the tutorial says so up front rather than presenting
invented output as a transcript.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes the series' signer arc with in-page key custody, wired the way the
RFC requires rather than the way apps reach for by default.

Example app:
- `storage.ts` — one `createWebCryptoKeyStore` and one
  `createSignerPreferenceStore`, shared by the screens and the session.
- `KeyLogin.tsx` — enrol an nsec under a passphrase, or unlock a stored
  one. Never writes plaintext key material (RFC §1181).
- `LockScreen.tsx` — a total switch over `lockRecovery()`. The three arms
  need three affordances and two cannot satisfy the other's.
- `autoLock` + `keyStore` wired together, which is the combination that
  avoids the bricked session `createNapSession` throws to prevent.
- Signer preference across reloads, honouring the promise tutorial 02
  made — with `resume({ verifyIdentity: true })` on every restore path,
  because the cookie outlives the page and the signer does not.
- `SignerChoice` carries signer, kind and verifyIdentity together so they
  cannot drift apart.

Tutorial covers: why this is last, encryption at rest, the wiring throw,
the three-way lock recovery, why nothing unlocks on the user's behalf,
cross-tab lock/logout, and the ceiling — a hostile script on the origin
defeats all of it, because the app's own code must be able to decrypt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes Phase 4 of the integration guide's build order against the example
app, and says plainly what that does and does not mean.

Example app: the rate limiter, both outstanding-challenge caps, and the
router body limit are now spelled out rather than inherited, because the
numbers are the thing you are supposed to size. Adds an opt-in `trustProxy`
— it decides `req.ip`, which the per-IP cap counts on, and `req.protocol`,
which a request-derived audience resolver reads. Adds the DELETE job
tutorial 04 said you would have to write, on its own hourly cadence with
separate horizons for challenges and sessions.

Two integration tests where the hardening is observable over HTTP: the 413
for an oversize /auth/init body, and a 429 with a Retry-After from a
tightened limiter.

The tutorial covers what the caps do differently (arrival vs. concurrency —
both answer 429, and only the audit log's `cap` field tells them apart),
revocation timing, cookie attributes and the proxy, the multi-domain
audience allowlist and why it has no default, nostr-tools deduping, clock
skew, and a closing table of what the example app still is not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…alBaseUrl

createRequestDerivedBaseUrlResolver returns a (req) => string, which is
getExternalBaseUrl's shape, not AudienceResolver's. audienceResolver is the
separate full-URL option for a rewriting gateway; the tutorial now says so
rather than conflating them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One page instead of a second example application, per ADR 0001: the
difference between the two adapters is about twenty lines, and a parallel
Fastify example would double the surface that has to stay correct.

Opens with the whole substitution as a table, then works through mounting,
the raw body, the body limit, guards, the permissions plugin, cookie mode,
and audience/proxy/rate-limiter wiring — each keyed to the tutorial section
it substitutes into. Names the option differences explicitly rather than
leaving them to be discovered: bodyLimitBytes takes bytes and not '1kb',
permissionsFastifyPlugin is a factory where createPermissionsRouter is not,
guards attach as preHandler, and the cookie attribute type differs.

Two things worth the reader's attention in the other direction. Express's
global-express.json() trap does not exist here, because the plugin's parser
is scoped to its encapsulation context — but wrapping the registration in
fastify-plugin makes NAP's 1 kB raw-body parser the whole app's global JSON
parser. And Fastify's own body-limit default is 1 MB, not Express's 100 kB,
so the adapter's 1 kB default is protecting you from ten times as much.

The §1–§7 wiring was compiled against the workspace under npm run typecheck.
Everything in nap-server — the rate limiter, both challenge caps, the ACL
resolver, step-up — needs no translation at all, which is the point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Collects the WebAuthn precedent NAP already follows — currently scattered
across the RFC, the integration guide, the best-practices file and a doc
comment in nap-server/src/audience.ts — into one page, and states the three
places NAP is permanently weaker: no clone detection, no multi-credential
recovery, no signed UP/UV bit.

Standalone by construction. Nothing links to it yet, so dropping it is
deleting one file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Links to RFC Appendix D as the comparison proper rather than forking it.
Adds what Appendix D lacks: the "you have OAuth today" angle, adapting
guide §10's five phases to an OAuth incumbent — including the account
linking decision (IdP `sub` vs hex pubkey) that a NIP-98 incumbent never
has to make, scopes-in-the-token vs the login-time ACL snapshot, and what
an authorization server was doing for you that NAP does not.

Leads with which question each protocol answers, and says plainly that
they are not substitutes where delegation is the requirement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- docs/tutorials/README.md lists all ten tutorials with what each one gets
  you, routes the Nostr-literate reader to guide §1–§3, and links the two
  comparison pages as one line each so dropping them is a one-line delete.
- README gains a "Start here" section pointing at the series, and states
  that examples/merchant-app consumes the packages from the workspace.
- Guide §0.4's phase table gains a Tutorial column, plus a note on the three
  things the phases do not order (refresh, step-up, non-NIP-07 signers).
  Ten sections gain a "Walk it:" pointer into the tutorial that walks them.
- Guide version claim corrected 0.3.0 -> 0.10.1.

Every relative link in docs/, README.md and the package READMEs verified to
resolve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Spec for using an Imani-issued Cashu voucher as the source of a NAP
session's roles and permissions, replacing the ACL store row. Changes
authorization only; NIP-98 authentication is untouched.

Core design: the voucher is P2PK-locked to a key, and the completion's
NIP-98 event must be signed by that same key. Mint URL is mandatory
(keyset is not a locator, liveness is mint-local, trust is per-mint) and
the mint allowlist follows the audience-allowlist rules.

Draft for review. Six open questions, the load-bearing one being how
VOUCHER and P2PK secret kinds compose against an unmodified mint.
scripts/doccheck.py verifies that every relative link resolves, every
backtick-quoted type name matches a real Java or TypeScript type, and every
environment variable bound in application config appears in the configuration
docs. It exits non-zero so it can gate a commit.

Where present, .doccheck-allow declares names that are deliberately not types in
this repository: third-party and platform types, and vocabulary from design
proposals that are not yet built.
Three fixes found while extending the checker across the rest of the stack:

- Index .ts and .tsx declarations and exported React components. Several repos
  ship a TypeScript client whose types are legitimately named in docs; without
  this the checker reported them all as unknown.
- Skip plans/, specs/, superpowers/, and archive/ directories. Those documents
  quote link lines destined for other files, whose relative paths are correct
  only from the target, so checking them reports the quoting rather than a
  broken link.
- Extend the built-in skip list with JDK, Spring, browser, and node types
  commonly named in prose.
The link checker special-cased http, https, and mailto. A nostr: URI in a README
was therefore resolved as a relative path and reported broken. Any scheme before
the first slash now counts as external.

Verified against a fixture: scheme URIs skip, a genuinely broken relative link
is still reported and still exits non-zero.
A superseded checkout keeps its documentation as written; repairing its links
would imply the tree is maintained. A repository can now declare
'doccheck: skip-links' in .doccheck-allow to record that decision.

Opt-in only, verified against a fixture: without the marker a broken link is
still reported and still exits non-zero.
The class check indexes sibling repositories because docs legitimately cite types
in them. It guarded against a single-repo checkout with a MIN_TYPES=50 threshold,
which is unsound: a repository with its own sources easily exceeds 50 while still
lacking every sibling, so in CI the check ran against an incomplete index and
reported cross-repo references as unknown. Simulating a CI clone confirmed it:
cashu-mint alone produced 35 false unknowns.

Detect the case directly instead, by asking whether any sibling repository is
present, and handle the partial workspace between the two extremes:

  single repo      class check skipped; links and config still checked
  partial          unresolved names reported but not failed, since a name may
                   live in a repo that is not checked out
  full workspace   class check enforced

Verified in all three modes, and against a fixture with a genuinely broken link
and a fake class name, which still fails and still exits non-zero.
@tcheeric
tcheeric merged commit 73d5b8a into master Sep 1, 2026
4 checks passed
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