Skip to content

Signed-in visitors: a public app's players sign in with a key (app-repos work item 4) - #624

Open
Evanfeenstra wants to merge 4 commits into
mainfrom
feature/visitors
Open

Evanfeenstra wants to merge 4 commits into
mainfrom
feature/visitors

Conversation

@Evanfeenstra

@Evanfeenstra Evanfeenstra commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

Work item 4 of plans/app-repos.md (§5): a visitor of a public app may sign in by signing strut's challenge with a key, and is then that key's actor on this server. Parts A and B both land here: the seam, key and ticket, then LNURL-auth and Nostr.

What a signed-in visitor gets (§5.1)

On POST /public/mcp, over the anonymous visitor's rules:

  • its own app/state, kept across visits, and app/report-error;
  • channels: POST /apps/:name/channel-ticket names the visitor, for a public app at the version the public view serves;
  • graph writes on the app's declared data.graph types, each KEPT as a step:<type> run with the visitor as actor, and graph/find on those types;
  • run/launch of a granted workflow whose closure spends nothing (runnableCan), as the visitor, and the run reads of its own runs of it. A spending workflow launches only if it is runnable, through the public run door.

Refused, as for any app page: job/start, authoring, llm / agent / exec. A visitor is not a member: past what is open to anyone, the gate passes GET /auth/session and its app's channel ticket, and every other route is 403 (a sweep, cookie and bearer).

The trap, fixed first

auth.ts decided "is this session the key's equal" as role !== "member". fullSession is now an allowlist: the key login (no role) or admin. A visitor's token copied into the operator's cookie opens nothing (tested).

The seam

  • GET /visitors/challenge → { challenge, exp, methods, visitorKey }. The challenge is strut-visitor.<claims>.<hmac>, claims { host, visitorKey, nonce, exp }, 60 s, good once, bound to the request's host. It is spent only after its signature holds.
  • POST /visitors/session { challenge, method, … } → the visitor's cookie and { actor, token, exp }; DELETE signs out; GET /visitors says the methods and who is signed in.
  • Verifiers (src/visitors.ts): ecdsa-p256 → p256:<compressed key>; ecdsa-secp256k1 and bip340 → secp:<x-only key>, so one secp256k1 key is one person whichever way it signed. webauthn is named, not built.
  • Methods:
    • key: a non-extractable WebCrypto P-256 key in this site's IndexedDB;
    • ticket: POST /auth/ticket { actor, role: "visitor" }, traded at POST /auth/session, which now returns the token for a visitor;
    • lnurl: LUD-04 by QR. The wallet calls back GET /visitors/lnurl, and the page takes the session for its k1 with its challenge;
    • nostr: NIP-07 signs a NIP-98 event over the body's hash. Root key only: not offered under visitorKey: domain.
  • Limits (Ruling 5): writes and launches per actor per UTC day, serve.yaml's limits over strut's defaults, and a fixed per-address budget on top.
  • serve.yaml gains signIn, visitorKey, limits, written as the public view's visitors.

The kit and the host

  • useVisitor() → { available, methods, actor, signIn(), signOut() }, over @strut/kit/visitor (experimental["strut/visitor"] in ui/initialize, strut/visitor/sign-in and /sign-out), the wallet's pattern. On a change the page's useAppState and queries are read again as the new viewer's.
  • The host page (web/src/visitor-signin.ts, SignInSheet.tsx) draws the sheet over the frame and holds the session. It is one tap when this browser's key is all there is. It keeps the token in this site's localStorage and sends it when the cookie is dropped (Ruling 4). The app never sees a key.

Choices beyond the plan

  • A visitor's session lives in a cookie of its own (strut_visitor), so signing in on a box never replaces the operator's console session on the same origin. It lasts 30 days.
  • On POST /public/mcp, Authorization already carries a priced launch's credit token (L402 …). So the host page sends the visitor's token there as x-strut-visitor, which the server accepts beside Authorization: Bearer.
  • Reads stay the anonymous visitor's ("not a private read"), widened only by the visitor's own data: its state, its runs, and nodes of the app's declared types.
  • A visitor's direct launch takes input and key alone: no job, conversation, files or params. A visitor cannot cancel a run.
  • A visitor's edit or delete reaches any row of a declared type, as for any app page. The App page tells builders to grant graph/create-node alone for a leaderboard.
  • ticket needs no entry in signIn: minting one already takes the key.
  • Limits: 1 000 writes and 100 launches a visitor a day by default. The per-address budget is ten times that, fixed, whatever limits says. Counted in memory, so a restart starts the day over.
  • /visitors/* share the /public per-address request budget (1200/min): the routes are open, and an LNURL login keeps state (in memory, at most 10 000 pending, 5 minutes each).
  • Signing in drops what the page set before and the server refused. An app sets it again once signed in; the App page shows the pattern.
  • A non-served strut names its methods in the Publish dialog's Public view tab: a Sign-in section (the methods, a key per domain, each visitor's daily writes and launches) that writes the record's visitors. A served box's serve.yaml writes it instead.
  • A visitor page offers channels only where the public view offers sign-in. Until the visitor signs in, a join waits and retries.

Known limits

  • LNURL-auth's usual phishing risk: a QR code shown on an attacker's page and scanned by a victim signs the attacker's page in as the victim. The wallet shows the domain, as with any LNURL-auth.
  • x-forwarded-for names the address, so the per-address budget is a budget, not a boundary (as for /public today).

Tests

  • src/visitors.test.ts (19). The seam: verifiers, root vs per-domain child keys, NIP-98, LNURL logins, limits, the rules. A public app signed in: two keys are two actors with their own app/state; the bearer token and x-strut-visitor work as the cookie does; a replayed challenge or one for another host is refused, and a bad signature does not burn the challenge. One root key is one actor on two hosts, and with visitorKey: domain each host's derived child (through LNURL-auth end to end) is another, and Nostr is not offered. A non-spending launch reads back to its visitor alone; a spending one outside runnable is refused; per-actor limits hold; no Jamie, no other app. On Ladybug: rows kept with their actor, read by every visitor, undeclared types refused.
  • createStrut.test.ts: a visitor's session sweep (403 on every route outside its scope, as cookie, bearer and x-strut-visitor); the 401, peer and member sweeps updated for the open /visitors/* routes.
  • kit/src/visitor/visitor.test.ts, web/src/page.test.ts, src/packages/serve.test.ts, web/src/public-view.test.ts (the Sign-in section's helpers; the defaults and methods held equal to the server's).
  • npm test: 4349 tests, 0 fail, 54 skipped. Web tsc and vite build clean.

Checked in a browser

A served strut (STRUT_SERVE=file: a fixture package whose app uses useVisitor and useAppState, signIn: [key, lnurl]) in the machine's Chrome via Playwright. The built-in pane was hidden, and the app frame is an opaque srcdoc it cannot read into. Results:

  • Anonymous, +1 stays local. Sign in shows the sheet; "Continue on this device" signs in as p256:….
  • Two writes, then a reload: still that actor, count: 2. With the cookies cleared, the token alone carries on.
  • A second profile is a different actor; the LNURL QR draws; sign-out returns to anonymous.

The Public view tab's Sign-in section, on a scratch strut: ticking the browser key and Nostr, 500 writes a day, and a key per domain, then Save. GET /visitors then offered key alone with visitorKey: domain, Nostr dropped by the server; a fresh browser showed the saved state.

Not touched: #622's files (item 3). plans/app-repos.md's status block will need a trivial merge with #622's.

…h a key

plans/app-repos.md work item 4 (§5). A public app's visitor may sign in and
is then that key's actor on this server: its own app/state, channels, rows
of the app's declared graph types, and launches of granted workflows that
spend nothing.

- auth.ts: "is this session the key's equal" is an allowlist (no role, or
  admin), so a new role opens nothing. Role and scope `visitor`; the
  visitor's session in its own cookie (strut_visitor) and as a bearer token
  (or x-strut-visitor); the challenge, a minute, good once, bound to the host.
- src/visitors.ts: the verifier seam (ecdsa-p256, ecdsa-secp256k1, bip340;
  webauthn named), the methods (key, ticket, lnurl, nostr), LNURL-auth's
  pending logins, NIP-98, per-actor daily limits with a per-address budget
  on top, and §5.1's rules over the anonymous visitor's.
- /visitors, /visitors/challenge, /visitors/session, /visitors/lnurl; the
  gate's visitor scope (GET /auth/session and its app's channel ticket);
  /public/mcp as the signed-in visitor.
- serve.yaml's signIn, visitorKey and limits, written as the public view's
  `visitors`.
- The kit's useVisitor() over @strut/kit/visitor; the host page's sign-in
  sheet and providers (a WebCrypto key in IndexedDB, Nostr, LNURL by QR).
…ach visitor's daily limits

The record's `visitors` (plans/app-repos.md §5) from the Publish dialog,
for a strut that is not serving (a served box's serve.yaml writes it). The
helpers keep the record as the server does: no method, no `visitors`, so
a Save leaves the draft unchanged; the defaults and the method list are
held equal to the server's by public-view.test.ts.
# Conflicts:
#	AGENTS.md
#	plans/app-repos.md
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