Development runtime for local napplet authoring and static pointer-loaded napplet testing.
The runtime is designed to be used from a napplet package script:
{
"scripts": {
"dev": "kehto paja --target-url http://127.0.0.1:5173 -- pnpm vite --host 127.0.0.1"
}
}
The target URL is explicit on purpose. Kehto can spawn any framework command and
wait for that URL, but it does not guess which URL the framework chose. Loading
that URL through Paja as injected srcdoc lets Kehto install mandatory
window.napplet.shell plus enabled optional domains before app code runs. The
Kehto host-owned shell prelude completes shell.ready / shell.init and caches
capability queries,
while a <base> tag keeps the
app's own assets and HMR pointed at the target dev server without Vite, Svelte,
React, or any other framework lock-in.
The package provides the typed option model, CLI parser, runtime server, host
page, and host config surface. Local target-url mode keeps one target iframe
with a reload loop and a development console wired through a real
ShellBridge, @kehto/runtime, and service adapters for the current web NAP
surface: relay/outbox, storage, identity, keys, config, resource, theme, notify,
media, upload, intent, count, link, common, lists, serial, BLE, WebRTC, DM, FS,
CVM, and inc. Relay/outbox defaults to live public relays
and uses NIP-65 relay-list bootstrap plus kind 3 contact-list reads for
identity flows; --relay-mode memory is the explicit deterministic fixture
mode and does not advertise relay, outbox, count, or DM. shell is the
mandatory, non-toggleable handshake domain; the deprecated legacy package path
remains an upstream compatibility alias to inc.
The target iframe is sandboxed without allow-same-origin, so the napplet
document has an opaque origin and requests its own assets with Origin: null.
<script type="module"> is always fetched in CORS mode, so a dev server that
does not allow that origin blocks the napplet's entry module and the frame
renders blank. Vite's default server.cors allowlist covers only localhost,
127.0.0.1, and [::1] origins, so it rejects null:
// vite.config.js
export default {
server: { cors: { origin: '*' } },
};
Any dev server works as long as it answers Origin: null with
Access-Control-Allow-Origin: * or null. Paja probes the target on startup
and logs a paja.target.cors.error entry in the message log, plus a console
warning, when the target would block the sandboxed frame.
The console shows supported interfaces with per-domain injection toggles,
runtime ACL controls, signer controls, and a filterable message log with visible
error details. It starts expanded and collapses to the left with the chevron
button in the top bar; the same button brings the whole panel back. Collapsing
is presentation-only — the target iframe keeps its identity and the loaded
napplet keeps running — and Paja remembers the choice per browser origin. In
runtime-pointer mode, ACL controls always display and mutate
the active tab's resolver-verified d-tag and aggregate hash; a grant or revoke
rerenders that same identity immediately. Paja auto-connects a browser NIP-07 signer when window.nostr is
available, can connect to a bunker/NIP-46 URI, and only uses the generated local
development signer when the Dev signer button is selected. Sign, publish, DM
send, filesystem picker, Blossom upload, and external-link requests use one serialized in-page
confirmation dialog. Deny has initial focus and Escape denies. A napplet-scoped
sign request defaults to one-time approval, with explicit options to remember
that event kind or trust every kind from the napplet identity and target. The trust
choice carries a visible warning. Remembered signing authority is keyed by the
active signer pubkey, host-owned napplet d-tag and aggregate hash, and the Paja
target boundary. Runtime pointers are isolated by verified artifact hash;
direct development targets are isolated by exact target URL, and their trust
survives code reloads at that URL. Changing the signer, identity, artifact, or
target asks again. Missing source identity or a nonnumeric kind remains
one-shot, denials are never remembered, and the signer controls can revoke
every remembered approval. If durable deletion fails, Paja keeps the approval
listed and logs the failure instead of claiming revocation. A full Paja host
reload creates a new ephemeral Dev signer and therefore asks again; a stable
NIP-07 or NIP-46 account can reuse its saved choice. Publish and other operation
confirmations continue to prompt independently. This is Paja runtime policy under draft
NAP-RELAY PR #2 at 0be8abce18beb46ca37bd4ddd042f58d30b4eedc, not
a Kehto kernel default. Upload consent identifies the requesting napplet, file, MIME type,
size, selected server, and durable public effect before bytes leave the browser.
A denial or a live publish with no accepting relay returns a canonical failure
and is not added to Paja's in-memory relay view. Paja's scoped-relay hook
likewise waits for the backend result and returns false after denial or
transport failure.
WebRTC is advertised only when the host has the browser WebRTC API, a live
relay boundary, and a connected signer with NIP-44 support. Paja owns the
RTCPeerConnection and data channels, uses signed kind-25050 Nostr events for
encrypted offer/answer signaling, and asks for explicit session consent with a
network-metadata warning. Napplets receive only NAP session/events and JSON data
channel payloads—never SDP, ICE state, relay sockets, or peer-connection objects.
This implementation tracks pinned
NAP-WEBRTC 5fae95dd2c8e59bd06c654e0845656add077dcda
and the kind/tag conventions in
NIP-100 PR #363 at ead1cd6.
DM is advertised only with live relays and Paja's selected Dev signer, whose
runtime-owned secret key can create and unwrap real NIP-17 gift wraps. Sends
receive one explicit plaintext/recipient confirmation, publish only verified
kind-1059 envelopes through the authorized relay path, and reload encrypted
history from relays rather than treating a memory fixture as persistence.
Napplets receive normalized NAP-DM messages, never secret keys, seals, rumors,
or relay sockets. This implementation follows draft
NAP-DM a0a48588.
FS is advertised only after Paja successfully opens the browser's real
origin-private filesystem. Each verified napplet identity gets a durable OPFS
/workspace; browser file/directory pickers add session-only opaque virtual
mounts after host approval. The backend implements metadata, directory lists,
bounded range reads, canonical padded-base64 writes, replace/append/patch,
revisions and preconditions, recursive mkdir/remove, atomic handle moves when
the browser supports them, and advisory watches over actual storage. Host
paths and handles never cross the NIP-5D boundary. This implementation follows
draft NAP-FS b640cf33.
Other domains are equally capability-bound. Relay, outbox, and count require
live relays; count uses NIP-45 COUNT without downloading events. Storage
requires writable localStorage; the memory setting is an unadvertised fixture.
Keys requires a document listener, media requires the browser Media Session
API, notifications require Paja's host renderer, links require browser
navigation, and intent requires the installed-catalog/runtime-tab host
controller. DM also requires the Dev signer and live relays; FS requires a
successful OPFS probe, while picker calls additionally require the corresponding
browser API and a host-owned approval click. Missing host boundaries remove
those domains from shell.init.
Live relay URLs are validated before advertisement, fixture events and local
publish echoes never enter live reads, and relay event sidecars disclose only
sources that the relay pool actually observed.
A signed-in napplet reads Paja identity and social data only through existing
identity.getPublicKey, identity.getFollows, and ordinary kind-0
outbox.query messages. Paja exposes no social namespace, direct networking,
or signer/key capability for this behavior.
Paja privately validates the active account's replacement kind-3 contact list, then warms followed kind-0 profile records through its established outbox router. The resulting snapshot is active-account-scoped and memory-only. It is distinct from generic simulation cache mode; it is not napplet-owned storage and has no durable-cache controls. Captured-key request correlation keeps a follows request bound to the account that started it, while generation-safe background writes prevent stale-account data from becoming the active snapshot.
A normal query can include matching cached RelayEventResult values, but Paja
retains the base router's query-wide incomplete and error fields. A cache hit
does not make a degraded query complete. Profile winner selection, pagination,
follow mutation, durable-cache management, and per-author completeness are
outside this behavior.
NAP-IDENTITY at 6461e4b37c29dc09a20dff35d9515889c4433874
is byte-identical to the recorded napplet/naps master document for this phase.
Pinned NAP-OUTBOX at 4589a8f9a16d8aa29b3740e2b3b0cdca11e0976e
together with installed @napplet/nap@0.32.0 types is the PoC contract because
current master has no NAP-OUTBOX path. Paja therefore makes no current-master
OUTBOX conformance claim. Blossom upload behavior targets pinned
NAP-UPLOAD at a7cc17463cbf5d9cb87884b31071bc4fc826034c.
The static Paja Runtime build is served at /web/paja/ in the GitHub Pages
artifact. It uses the same browser host and service adapters, but loads verified
napplet HTML from pasted naddr or nevent pointers with hmr: none. Each
loaded pointer becomes a closeable header tab; loading an already-running
napplet opens a choice to load another instance, switch to the existing tab, or
cancel. Each tab includes a share control that copies a /web/paja/?naddr=...
or /web/paja/?nevent=... link for that pointer, and the browser remembers open
runtime tabs in local storage so returning to /web/paja/ restores the previous
pointer set. An explicit pointer in the URL still takes precedence over restored
tabs.
The static artifact defaults to Paja's standard live relays and memory uploads. To point it at specific live relays or Blossom upload servers, pass comma-separated host lists when generating the artifact:
PAJA_RELAY_URLS="wss://relay.example,wss://relay.example.net" \
PAJA_UPLOAD_SERVERS="https://blossom.example" \
node scripts/build-paja-pages.mjs
When PAJA_UPLOAD_SERVERS is set, the runtime enables the Blossom upload rail
and also makes those servers explicit NAP-RESOURCE fallbacks. It is not needed
for read-side discovery: resources learned through OUTBOX use their event hints
and hinted-author/publisher server lists first, then the active shell user's
published list, the current window's verified pointer-manifest servers, and
finally configured runtime fallbacks.
Paja keeps resolver-verified pointer and manifest facts in an installed catalog, separate from the live tab/controller map. A verified install inserts or replaces the catalog record; an explicit artifact removal removes it. Closing, reloading, or replacing a frame never makes an installed handler unavailable, so a cold target can still be selected and started later.
Intent selection considers only exact compatible contracts from that catalog. Paja can use a compatible user default, ask its host chooser when more than one candidate is available, or reject an ambiguity. An explicit handler d-tag is accepted only when it is an installed compatible handler and the invoking sender has been explicitly authorized for it. It is not a request to deliver to an arbitrary running frame.
When Paja receives an invocation, it selects and opens or reuses a verified
target. The controller waits for the target generation's
registered MessageEvent.source to establish its real shell.ready session;
it checks that generation is still current, sends one target-only inc.event
with the selected queryless convention, and returns the final handled target
identity. A superseded target/source, failed open/readiness, or terminal send is
handled by the controller's replacement/retry/terminal policy and produces a
canonical failed IntentResult.
@napplet/shim@0.30.0 supplies no generic shell API. Kehto deliberately keeps
its host-owned mandatory window.napplet.shell prelude: it installs the live
receiver before the one bare shell.ready, caches the first shell.init, and
provides local ready(), supports(), read-only services, and one-shot
onReady(). This is the documented upstream-package-drift exception, not a
shim capability.
Before Paja assigns a verified runtime-pointer document to srcdoc, it inserts
Kehto's local Class-1 CSP before the host-owned namespace prelude. The policy
denies all defaults; permits inline script/style, WebAssembly compilation through
the narrow 'wasm-unsafe-eval' source, data:/blob: images, and data: fonts;
keeps JavaScript string evaluation blocked; grants connect-src only to the resolved relay and Blossom
origins; explicitly denies worker, child, frame, media, object, manifest,
prefetch, base, and form capabilities; and ends with frame-ancestors 'self'.
The NIP-5D verified-srcdoc and opaque-sandbox rules do not mandate this CSP;
it is Kehto policy. Local target-URL authoring mode is intentionally outside
this verified-artifact policy path.
Environment simulation can be supplied through CLI flags or a JSON config file:
kehto paja \
--target-url http://127.0.0.1:5173 \
--identity-mode fixed \
--identity-pubkey 4444444444444444444444444444444444444444444444444444444444444444 \
--relay-mode disabled \
--capability relay:off \
--capability outbox:off \
--theme light \
--config-value 'density="compact"'
{
"targetUrl": "http://127.0.0.1:5173",
"simulation": {
"identity": {
"mode": "fixed",
"pubkey": "4444444444444444444444444444444444444444444444444444444444444444"
},
"relay": { "mode": "disabled" },
"capabilities": { "domains": { "relay": false, "outbox": false } },
"theme": { "mode": "light" },
"config": { "values": { "density": "compact" } }
}
}
Configured config.values are only a seed for identities with no saved
settings. A napplet must register a valid NAP-CONFIG schema before any values
are delivered. Paja then validates/defaults the seed, persists commits under
the host-resolved (dTag, aggregateHash), and exposes a shell-owned settings
dialog; napplets remain read-only. If durable browser storage or that host UI
is unavailable, Paja does not advertise config.
Paja keeps memory as the default unadvertised upload fixture. It does not
register window.napplet.upload, return success, or store bytes. Opt into real
Blossom storage with a shell-owned server and an active Dev, NIP-07, or NIP-46
signer:
kehto paja \
--target-url http://127.0.0.1:5173 \
--upload-mode blossom \
--upload-server https://blossom.example \
-- pnpm vite --host 127.0.0.1
Paja prompts with the napplet identity, file details, selected server, and a
public/durable warning before it signs or sends bytes. Production servers must
use HTTPS; plain HTTP is accepted only for loopback development hosts. The
server must allow Paja's browser origin, PUT and OPTIONS, plus the
Authorization and Content-Type CORS headers.
Explicit servers win. With no explicit server, Paja may use an independently
warmed snapshot of the active signer's newest BUD-03 kind 10063 server
tags. upload.info and upload.upload never initiate that discovery. Pointer
loader Blossom hints are artifact sources, not upload policy. The current path
uses the first server only, returns its direct HTTP(S) URL, and does not mirror
or construct BUD-10 URLs.
Completion requires the server descriptor to confirm the exact local SHA-256
and byte size as a non-negative safe integer. Missing or mismatched proof is a
failed result even after an HTTP success. The configured identity, provider,
signer, discovery author, and signed authorization pubkeys must agree; a fixed
pubkey without signEvent is read-only. This implements the draft
NAP-UPLOAD at a7cc174.
Paja's developer-runtime policy accepts arbitrary http: and https: resource
URLs so a normal remote image does not look broken merely because its origin was
not pre-granted. Paja resolves those URLs with browser fetch, omits credentials
and referrer data, caps responses at 10 MiB, and classifies MIME from returned
bytes. Its byte classifier recognizes checksum-valid Game Boy ROM headers as
application/vnd.nintendo.gb-rom; it never trusts a server-supplied media type.
Browser network and CORS rules still apply: an unreadable response is the
canonical network-error, while any CORS-readable response is returned as NAP
bytes.
data: remains locally decoded. blossom: is a separate, content-addressed
boundary and is advertised because each request may provide server locations
without a host default. Paja accepts only public-looking HTTPS origin hints,
discards invalid/private literals, and deduplicates them. For a canonical URL
previously returned to the same napplet window by outbox.getEvent,
outbox.query, or outbox.subscribe, Paja retains bounded event context
without prefetching bytes. Resolution tries request and
event-local server hints first, then lazily queries hinted authors' and the
event publisher's newest BUD-03 kind 10063 lists through the verified
NIP-65-aware OUTBOX router, then queries the active shell user's BUD-03 list
through that same router, then uses the current window's verified
pointer-manifest servers, followed by upload-runtime fallbacks. The user-list
lookup works independently of upload mode; an upload runtime may reuse the same
servers when present. ROM-specific event and publisher locations retain
priority over the user/runtime fallbacks. The combined list is capped at eight
candidates. The only accepted identifier is blossom:sha256:<hex>;
Paja refuses redirects, verifies the requested SHA-256, and permits plain-HTTP
transport only for configured loopback development defaults. Browser-only Paja
cannot pin DNS results, so production runtimes must add the draft's DNS-time
private-address checks. The current
NAP-RESOURCE draft at fa6bcc6
assigns fetching and policy to the runtime and defines no wire-level Blossom
server-hint field. Retaining verified manifest servers per window is therefore
Paja host policy, informed by the current
NIP-5D draft at 24711d9,
which defines manifest server tags. The existing request-server compatibility
path comes from the earlier NAP-RESOURCE draft at 9511232, with the merged package implementation
napplet/web#206@19e0029b released as
@napplet/core/@napplet/nap 0.32.0, @napplet/shim 0.30.0, and
@napplet/sdk 0.28.0. Publisher discovery follows
Blossom BUD-03 at b5bd280.
Full package docs: docs/packages/paja.md.
Getting started: docs/how-tos/paja-getting-started.md.
Local authoring how-to: docs/how-tos/paja-local-authoring.md.
Generated API module: docs/api/modules/_kehto_paja.html (run pnpm docs:api).