Appearance
@kehto/paja
Single-window Paja local authoring workshop for napplet development.
Alpha status: Kehto is an early runtime toolkit for a draft NIP-5D protocol. The Paja API is new in v1.22 planning work and is not yet a stability guarantee for final NAP contracts.
Install
bash
pnpm add @kehto/pajaInstall @kehto/cli instead when you only need the kehto paja command in an app package's development scripts.
Manifest Facts
| Field | Value |
|---|---|
| Source | packages/paja/package.json, packages/paja/src/index.ts |
| Version | 0.24.4 |
| Runtime entry | ./dist/index.js |
| CLI runner entry | ./dist/cli.js |
| Types entry | ./dist/index.d.ts |
| Dependencies | @kehto/acl, @kehto/firewall, @kehto/nip, @kehto/runtime, @kehto/services, @kehto/shell, @kehto/wm |
| Side effects | false |
Peer Dependencies
| Package | Range |
|---|---|
@napplet/core | >=0.32.0 <0.33.0 |
@napplet/nap | >=0.32.0 <0.33.0 |
nostr-tools | >=2.23.3 <=2.x |
Primary APIs
| Area | Exports |
|---|---|
| Options | normalizePajaOptions, PajaOptions, PajaRawOptions, PajaCommand, PajaOptionsError |
| Simulation | normalizePajaSimulation, summarizePajaSimulation, PajaSimulation, PajaSimulationRawOptions, PAJA_SIMULATION_DOMAINS |
| Config files | loadPajaConfigFile, mergePajaRawOptions, resolvePajaRawOptions |
| Host config | createPajaHostConfig, createPajaRuntimeHostConfig, PajaHostConfig, PajaPointerRuntimeConfig, formatPajaUrl |
| Host page | renderPajaHtml, bundled /__kehto/browser-host.js runtime bootstrap |
| Runtime pointers | decodePajaPointer, resolvePajaPointer, injectPajaRuntimeCsp, PAJA_NAPPLET_MANIFEST_KIND, PAJA_NAPPLET_MANIFEST_KINDS |
| Local files | createPajaLocalTarget, isPajaLocalTarget, isPajaLocalHtmlFile, readNappletIdMeta, findRelativeAssetReferences, PAJA_LOCAL_SINGLE_FILE_HINT, PajaLocalTarget, PajaLocalFileInput, PajaRuntimeTarget |
| Parity metadata | PAJA_UPSTREAM_WEB_DOMAINS, PAJA_ADVERTISED_DOMAINS, PAJA_HANDSHAKE_DOMAINS, PAJA_COMPATIBILITY_ALIASES, PAJA_REQUIRED_SERVICES, getMissingAdvertisedDomains, getMissingServices |
| Readiness | waitForTargetUrl, ReadinessError, WaitForTargetUrlOptions, ReadinessFetch |
| Server | startPajaServer, PajaServer, PajaServerOptions |
| Target CORS | probeTargetCors, classifyTargetCors, PAJA_TARGET_CORS_HINT, PajaTargetCorsDiagnostic, PajaTargetCorsStatus, PajaTargetCorsFetch |
| Defaults | DEFAULT_PAJA_HOST, DEFAULT_PAJA_PORT, DEFAULT_READY_TIMEOUT_MS, DEFAULT_PAJA_RUNTIME_WAIT_MS |
CLI
bash
kehto paja --target-url http://127.0.0.1:5173 -- pnpm vite --host 127.0.0.1The target URL is explicit. Managed-command mode may start any framework dev command, but readiness waits for the provided URL instead of guessing the port or framework.
Simulation flags use the same schema as config files. Common flags:
bash
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 \
--storage-mode memory \
--upload-mode blossom \
--upload-server https://blossom.example \
--theme light \
--config-value 'density="compact"'--storage-mode memory is an explicit unadvertised fixture setting. Production storage capability requires writable localStorage so NAP-STORAGE values survive reloads.
The config-file form is the same raw option object:
json
{
"targetUrl": "http://127.0.0.1:5173",
"simulation": {
"identity": {
"mode": "fixed",
"pubkey": "4444444444444444444444444444444444444444444444444444444444444444"
},
"relay": { "mode": "disabled" },
"capabilities": { "domains": { "relay": false, "outbox": false } },
"storage": { "mode": "memory" },
"upload": {
"mode": "blossom",
"servers": ["https://blossom.example"],
"discoverServers": true,
"maxBytes": 104857600,
"mimeTypes": ["image/png", "application/pdf"]
},
"theme": { "mode": "light" },
"config": { "values": { "density": "compact" } }
}
}CLI flags override config-file values at the same nested paths.
For package-manager script examples covering pnpm, npm, and yarn, see Use Paja for local napplet authoring.
Browser Host
In local target-url mode, the served host page is a single-window development runtime: a control console beside one sandboxed target iframe, plus compact top and bottom bars. The iframe is created without a static src; the browser bootstrap sets sandbox="allow-scripts", registers the iframe origin with @kehto/shell, fetches the explicit target URL through the local Paja server, and renders it as injected srcdoc. Paja prepends mandatory window.napplet.shell plus the runtime-owned optional domain namespace before authored scripts run and adds a <base> tag so target assets and HMR still resolve against the app dev server. A real ShellBridge completes shell.ready / shell.init; @kehto/runtime then handles ACL, firewall, storage, INC, relay/outbox, and service dispatch. Reload uses a generation-specific internal window id so the same iframe can receive a fresh shell.init without restarting the CLI or the app dev server.
Because the frame is sandboxed without allow-same-origin, the napplet document has an opaque origin and requests its own assets with Origin: null. Module scripts are always fetched in CORS mode, so the app dev server must answer that origin with Access-Control-Allow-Origin: * or null, or the entry module is blocked and the frame renders blank. Vite's default server.cors allowlist matches only localhost, 127.0.0.1, and [::1] origins, so Vite projects need server: { cors: { origin: '*' } }.
Paja does not leave that failure silent. GET /__kehto/target-cors.json probes the configured target with an explicit Origin: null request — a header a browser cannot forge, which is why the probe runs on the Paja server — and classifies the response as allowed, blocked, or unreachable. The browser host requests it once per target-url boot and, for anything other than allowed, appends a paja.target.cors.error entry to the message log and warns on the console with the remedy. classifyTargetCors and probeTargetCors are exported for reuse.
The console is a collapsible left column. It starts expanded, and one chevron button in the top bar collapses it to the left and restores it; the toggle stays visible in both states, including after a reload with no runtime tabs open, so a collapsed console is always one press away. The toolbar never requires hovering over a hidden edge to reveal it. The choice is remembered per browser origin. Collapsing is CSS-only presentation: the target iframe is never navigated or recreated, so the running napplet keeps its generation, message log, and shell state while the stage reclaims the full width.
All sections are flush independent native accordions, expanded by default: Pointer where present, Interfaces, ACL, Signer, Resource servers, Messages, and Events last. Enter/Space toggle focused headers; visible focus and chevrons show their state. Hidden bodies retain controls, drafts and the running app. Origin-local kehto:paja:sidebar-sections:v1 stores validated collapsed booleans, independently of whole-drawer visibility and resource settings. Invalid preferences default expanded; unavailable storage leaves toggles usable for the session.
The console includes:
- Interfaces — every supported Paja domain has an injection toggle. Toggling a domain updates the live capability override and reloads the target so the next
shell.initreflects the changed support surface. - ACL — every runtime capability can be granted or revoked for the target napplet identity. In runtime-pointer mode, both the displayed state and the mutation use the active tab's resolver-verified d-tag and aggregate hash. The controls write through
bridge.runtime.aclState, rerender immediately, and the next matching request is allowed or denied by the real runtime gate. - Signer — Paja auto-connects a browser NIP-07 signer when
window.nostris available, can connect to a bunker/NIP-46 URI, and only uses the generated development signer when the Dev signer button is selected. Sign, publish, Blossom upload, and external-link requests use one serialized in-page confirmation dialog. Deny has initial focus and Escape denies. Napplet-scoped signing defaults to one-time approval. The dialog can instead remember the exact event kind or trust every kind from that napplet identity and target; the trust option has a prominent warning. Grants are keyed by active signer pubkey, host-owned napplet d-tag and aggregate hash, and the Paja target boundary. Runtime pointers use their verified artifact hash; direct targets use the exact target URL, with an explicit warning that trust survives code reloads at that URL. Another signer, identity, artifact, or target asks again. Unknown identity/kind remains one-shot, denials are never remembered, and Forget remembered approvals revokes all saved signer choices. Publish and other operation confirmations remain independent prompts. If browser storage refuses deletion, Paja retains the listed approval and logs the failed revocation. A full host reload creates a new ephemeral Dev signer and asks again; stable NIP-07/NIP-46 accounts can reuse their saved choices. This is Paja policy permitted by draft NAP-RELAY PR #2 at0be8abce18beb46ca37bd4ddd042f58d30b4eedc, not a Kehto runtime default. Upload consent shows the requesting napplet, file, MIME type, size, 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 does not enter Paja's in-memory relay view. Its scoped-relay hook likewise waits for the backend result and returnsfalseafter denial or transport failure. - Resource servers — extra Blossom lookup origins with a newline textarea and Save button, immediately before the Messages section (see below).
- Messages — inbound and outbound envelopes are logged with a text filter, including Paja system events such as interface changes, ACL changes, signer connection changes, signing/publish confirmations, and visible details for
.errorenvelopes. - Events — relay receipts grouped by the routing box that chose the relays, then by relay, then by event kind, with the session receipt total in the section header. Boxes render in Paja's routing precedence, never in count order, so they do not reshuffle while the session runs:
- outbox relays (NIP-65) — relays resolved from the requested authors' NIP-65 write lists. When a session only ever used hints or the configured set, the box renders as
no author-routed readsinstead of a bare zero, because author-routed relays are discovered per read and cannot be listed from configuration. - relay hints — an outbox read the napplet pointed at relays itself with NAP-OUTBOX
relayshints, which the shell validates and uses alongside the rest of the plan. - fallback relays — the host's configured fallback/bootstrap relay set. Reads can start while NIP-65 discovery is pending, even if author relays resolve later; authorless queries also read this set alongside valid hints. Authorless live subscriptions use fallback only when no valid hints remain. This box does not imply that author discovery failed. Configured relays are listed from boot, so one that has served nothing yet still appears, marked
unuseduntil Paja reads from it andsilentonce a read returns no events. This is the only box that can render without traffic. - scoped relay — a read the napplet pinned to one relay with an explicit NAP-RELAY
relay.subscriberelayfield (a scoped relay, not a hint), which bypasses host tier selection. - host pool — Paja's own reads: the relay service without a hint, social cache, contact lists, relay lists, count, CVM, Blossom lookups, upload discovery, WebRTC, and DM traffic. Counting happens in the relay backend before delivery, so every read path is covered, while simulated memory-mode fixtures and Paja's own publishes are not. Each relay row carries a bar scaled against the busiest relay of the session, its receipt count, and its
kind - countrows. A box is a routing decision rather than a relay set, so the same relay appears in two boxes when two reads reached it for different reasons — a relay that is both an app hint and a configured fallback keeps its two receipt counts apart instead of showing one merged number. Every receipt belongs to exactly one box, so box counts add up to the header total. Counts are receipts, not distinct events: repeated deliveries and the same event arriving from several relays each count, and the header badge's tooltip reports how many unique events those receipts came from. Receipts are observed on each live relay's verified, filter-matching event callback before delivery deduplication; a silent relay is never credited from another relay's response or a previous read. Equivalent relay URL spellings share one row. Napplet delivery and query results remain deduplicated. Counts accumulate for the host session and the section header'sresetbutton clears them without a reload.
- outbox relays (NIP-65) — relays resolved from the requested authors' NIP-65 write lists. When a session only ever used hints or the configured set, the box renders as
The GitHub Pages artifact also includes a static Paja Runtime at /web/paja/. That route uses createPajaRuntimeHostConfig, keeps hmr: none, and loads verified napplets into ShellBridge-backed iframe tabs from pasted naddr or nevent pointers. naddr pointers resolve the latest matching NIP-5D named manifest (35129) by author and d tag, or a root manifest (15129) by author with an empty identifier and no #d filter; nevent pointers resolve a specific NIP-5D snapshot, root, or named manifest event id (5129, 15129, or 35129). In both cases Paja verifies the signed manifest and artifact hash (or legacy aggregate and blobs), then prepares a rendered copy with the same runtime-owned window.napplet.<domain> namespace before assigning iframe srcdoc. prepareNappletSrcdoc inserts validated CSP first, followed by the mandatory namespace. Its conservative defaults permit inline script/style, WebAssembly compilation while blocking JavaScript string evaluation, data:/blob: images, and data: fonts; other resource classes are denied. Direct connections require exact resolved relay/Blossom origins or explicit host grants. createPajaRuntimeHostConfig({ csp: { connectOrigins, directives } }) accepts host policy changes; shell validation rejects broad JavaScript evaluation, wildcard connections, and policies that disable the bootstrap. The CSP contract distinguishes enforced recommendations from configurable defaults. Configure frame-ancestors through the host HTTP response. NIP-5D requires the verified srcdoc and opaque allow-scripts sandbox, but not this CSP baseline. Local target-URL mode retains its separate development policy. Loading an already-running napplet opens an in-page choice to load another instance, switch to the existing tab, or cancel. Each verified tab's Share control opens a dialog with its selectable /web/paja/?naddr=... or /web/paja/?nevent=... app link. Copy app link shows a confirmation on success; blocked or unavailable clipboard access selects the link for manual copying. Apps with advertised intents also offer Create intent link. Share keeps focus and pointer input in the host overlay while the apps remain loaded. 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 browser page title follows the active napplet tab name, including when switching or closing tabs. With no active napplet, the title is @kehto/paja.
Pointer relay hints are preferred, not exhaustive. Paja queries embedded naddr / nevent relay hints first in the ordered candidate list, then any pointer-specific relay overrides, then the effective configured live simulation relays. URLs are normalized and deduplicated without changing first occurrence. When relay simulation is disabled, configured relay URLs are not added as fallbacks. Connection, fanout, and EOSE share one pointer-resolution deadline; the UI distinguishes deadline or connection failure from the clean case where all queried relays reached EOSE without a matching manifest. Wider relay search does not weaken loading: manifest signature, artifact/legacy-aggregate verification, Blossom hash, and srcdoc verification still fail closed.
Local index.html files
In runtime-pointer mode, Paja can also open a napplet straight from disk. Use Open file… next to Load, or drop an index.html anywhere on the Paja page (not onto a running napplet frame, which receives its own drag events). The file opens in a new runtime tab named after the file.
- Identity comes from the bytes. Paja hashes the exact file bytes (
sha256) and derivesaggregateHashas the NIP-5A aggregate over the single/index.htmlpath entry. That is the same derivation the resolver checks for a published single-file napplet, so editing the file gives it a new identity. ThedTagcomes from the NIP-5D publishing metadata<meta name="napplet-id" content="…">. Without it, Paja useslocal-<file-stem>. - Same loading path as verified pointers. Paja registers the identity before the frame runs. It injects the Class-1 CSP (with
connect-src 'none', since a local file has no relay or Blossom hints) and then the runtime-ownedwindow.nappletprelude, including enabled optional domains. The bytes go in throughsrcdocunder the sameallow-scriptssandbox. - Development only and unverified. A local file has no signed manifest. It never enters the installed napplet catalog, never becomes an intent delivery target, has no share link, and is not restored after a page reload.
- Self-contained single-file HTML only. A
srcdocdocument has no base URL, and the CSP denies network loads, so relative<script src>, stylesheets, and images do not load. Paja lists relative references it finds in the status line and the message log. Build with an inlining bundler (for examplevite-plugin-singlefile). Opening a folder or zip of assets is not supported.
Installed catalog and intent lifecycle
Paja stores immutable resolver-verified manifest facts separately from live tabs. Closing a frame leaves its catalog record available for cold starts; explicit artifact removal deletes it. Restored pointers are reverified before entering the catalog. Entries preserve exact conventions and ordered parameter names under publisher/kind-safe opaque IDs, including nameless root and snapshot artifacts. The target's resolved intent domain must be available; INC is not required.
Catalog installs, replacements, removals, and user default-handler updates emit intent.changed to eligible open napplets (napplet.intent.onChanged(...)). Replacements notify both removed and added archetypes, once per slug, using the current filtered catalog. A no handler rejection remains terminal: callers must retry explicitly; installing a handler does not replay the failed invocation. This follows napplet/naps NAP-INTENT on master (last changed at e5308ac), whose runtime behavior recommends notifications on catalog/default changes.
Selection uses exact contracts and user-owned default, chooser, recommendation, and explicit-authorization policy. Explicit handler IDs require sender-aware authorization and cannot address an arbitrary running frame.
The controller retains work before returning acceptance. It then opens or reuses a verified target, waits for its registered source and real shell.ready, checks that the generation is current, and sends one target-only intent.deliver. Replacement, readiness failure, and terminal send failure remain host-observable controller outcomes; they do not produce a second source result. Accepted work survives source teardown and is never replayed by tab restoration.
Paja activates delivered handler tabs because its stage shows one tab at a time. behavior.focus is advisory; reuse: false creates a new surface. Existing caller tabs remain open. Installed contracts and domains appear in pointer diagnostics.
Kehto provides the optional window.napplet.shell interface for environment information. Other domains route through trusted iframe creation identity independently of readiness. supports(domain) queries namespace presence locally. See the living NAP-SHELL.
NAP and Service Parity
Paja can advertise the following web NAP domains when their real host backend is live:
relay, outbox, identity, storage, inc, theme, keys, link, common, lists, serial, ble, webrtc, media, notify, config, resource, cvm, upload, intent, count, dm, and fs.
shell and gamepad are optional advertised domains. The retained PAJA_HANDSHAKE_DOMAINS compatibility export is empty. GAMEPAD uses one host reader and focus-scoped state delivery; native controller access is denied in each frame. See Controller input.
dm is conditional on live relays plus Paja's selected Dev signer. The runtime-owned key creates and verifies actual NIP-17 gift wraps, history is loaded from relays, and one host confirmation covers the cleartext send before validated kind-1059 envelopes are published. It follows draft NAP-DM a0a48588.
fs is conditional on a successful OPFS probe. It provides an identity-scoped durable /workspace, browser-mediated session picker grants, strict virtual paths, bounded range I/O, canonical base64, revisions, replace/append/patch writes, directory mutation, supported atomic moves, and advisory watches without exposing browser handles or host paths. It follows draft NAP-FS bd1b9e91.
The Interfaces panel reports workspace and native open/save/directory picker support separately. Firefox can expose OPFS without the native picker APIs; workspace operations then work, while picker operations return unsupported. The message log identifies the missing API. ACL controls such as fs:read and fs:write grant permission but cannot add browser support. See the browser picker compatibility reference.
Default service wiring uses live relay/outbox behavior, localStorage state persistence through the runtime, browser or configured identity, schema-validated identity-scoped config and host-owned theme, notification, media, upload, intent, resource, common-profile, bookmark-list, serial, BLE, WebRTC, NIP-17 DM, OPFS, and CVM adapters. keys.forward dispatches an unbound forwarded keystroke in the host context. link.open accepts only HTTP(S), asks for consent, and opens with noopener,noreferrer. Relay/outbox uses NIP-65 relay lists (kind:10002) with fallback relays, and the identity service reads contact lists (kind:3) so social-graph napplets can be tested against real account state. --relay-mode memory retains an internal fixture/event store for tests but does not register or advertise relay, outbox, or count. Live relay URLs are validated as credential-free ws:/wss: URLs; fixture events and local publish echoes never enter live reads, relay-tier edits drive the corresponding transports, and event sidecars contain only relay URLs that the pool actually observed.
WebRTC is conditional rather than simulated: the domain is advertised only with a browser RTCPeerConnection implementation, live relay access, and a connected NIP-44 signer. Paja owns peer connections and data channels, obtains explicit session consent, signs kind-25050 Nostr signaling, encrypts offer/answer SDP, and keeps application payloads exclusively on the data channel. Signer changes tear down sessions and refresh the shell environment; napplets never receive SDP, ICE internals, relay sockets, or browser networking objects. The signaling boundary follows pinned NAP-WEBRTC 5fae95dd2c8e59bd06c654e0845656add077dcda and NIP-100 PR #363 head ead1cd6.
Standard identity and private social cache
A signed-in napplet uses the existing identity.getPublicKey, identity.getFollows, and ordinary kind-0 outbox.query messages. Paja adds no custom social service, direct networking path, or signer/key capability. It privately validates the active account's replacement kind-3 contact list and warms followed kind-0 profile records through the established outbox router.
That snapshot is active-account-scoped and memory-only, separate from generic simulation cache mode. It is not napplet-owned storage and has no durable-cache controls. Captured-key request correlation keeps a follows response associated with the account that started it, and generation-safe background writes prevent stale account data from becoming active.
A normal query can include matching cached RelayEventResult values. The base router remains authoritative for query-wide incomplete and error, so cached values never make a degraded query complete. Profile winner selection, pagination, follow mutation, durable cache management, and per-author completeness remain outside this behavior.
NAP-IDENTITY 6461e4b37c29dc09a20dff35d9515889c4433874 is byte-identical to the phase's recorded napplet/naps master document. Pinned NAP-OUTBOX 4589a8f9a16d8aa29b3740e2b3b0cdca11e0976e and installed @napplet/nap@0.32.0 types govern this PoC because current master has no NAP-OUTBOX path. This is not a current-master OUTBOX conformance claim. Blossom behavior targets pinned NAP-UPLOAD a7cc17463cbf5d9cb87884b31071bc4fc826034c.
NAP-UPLOAD
Upload mode defaults to memory, an explicit unadvertised fixture that stores nothing. It does not register an upload service, expose an upload hook, or return kehto-dev:// success values. blossom mode is opt-in through simulation.upload.mode or --upload-mode blossom. Repeat --upload-server <url> for an ordered explicit list; CLI server values replace the config-file list. Paja uses only the first effective server in this release and returns its direct descriptor URL. Mirroring, failover, and BUD-10 result construction are not implemented.
The shell chooses the server. Explicit normalized servers take priority. If the list is empty and discovery is enabled, signer connection or change warms a cache from the active pubkey's newest BUD-03 kind 10063 event, preserving its ordered server tags. upload.info and upload.upload read that cache only; they do not query relays, reconnect a signer, use a public default, or consult runtime-pointer Blossom hints.
HTTPS is accepted everywhere. HTTP is restricted to localhost, the 127.0.0.0/8 range, and [::1]. Credentials, non-loopback HTTP, empty URLs, queries, and fragments are rejected. upload.info.returns reports https or the permitted loopback http form from the same selected server. A Blossom server used from the browser must support CORS preflight and allow PUT, Authorization, and Content-Type from the Paja origin.
Before hashing, signing, or storage egress, Paja enforces the Blossom rail, maxBytes, and mimeTypes, then prompts with the requesting napplet, filename, size, MIME type, server, and a public/durable warning. Authorization requires a writable Dev, NIP-07, or NIP-46 signer. Configured identity, provider identity, awaited signer pubkey, BUD-03 author, and returned kind-24242 event pubkey must match. A fixed pubkey without signEvent cannot upload.
complete is reported only when the Blossom descriptor provides a usable HTTP(S) URL, the exact local SHA-256, and an exact non-negative safe-integer size. Missing, malformed, or mismatched proof fails closed. Successful results include direct URL, MIME type, hash, size, and NIP-94 url, optional m, x, and size tags. This behavior targets the draft NAP-UPLOAD at a7cc174.
The count domain sends NIP-45 COUNT with the complete OR-filter set to the first configured relay that accepts the request. It reports that actual relay, returns its exact count with approximate: false, and never downloads matching event payloads. Broad empty filters are refused as too expensive; disabled or memory relay mode also disables count advertisement.
NAP-RESOURCE
Paja implements the draft NAP-RESOURCE at 9511232f69313aa7953d110e35d32cc28d506f66 with real data:, http:, https:, and content-addressed blossom: backends. resource.info reports all four schemes because each Blossom request can carry accepted server hints without a host default. This disclosure is advisory, not an authorization grant. All paths expose the enforced 10 MiB response, 100-URL bulk, and eight-server per-resource caps. The host ignores declared or upstream media types, classifies a narrow safe image/audio/video/font/text set plus checksum-valid Game Boy ROM headers, and rejects SVG, HTML, XML and script prefixes, including NUL-bearing or invalid-UTF-8 suffixes. HTTP(S) and data: still require recognized types. Canonical Blossom alone may deliver unknown opaque invalid-UTF-8 or NUL-bearing binary as application/octet-stream, after capped reads and matching SHA-256. Known formats retain sniffed MIME; Game Boy ROM results use application/vnd.nintendo.gb-rom regardless of upstream Content-Type. Cancellation remains window-scoped and drops late terminal envelopes. Byte-identifiable UTF-16 markup is checked by decoding the complete capped buffer; identifiable UTF-32 document signatures are conservatively rejected. UTF-16 nonmarkup can remain opaque. This encoding guard is bounded by the response size cap, not a general markup parser.
Paja deliberately accepts arbitrary HTTP(S) origins because it is a developer runtime. It uses browser fetch with credentials omitted and no referrer. The browser still decides which response bytes JavaScript may read: a network or CORS rejection becomes network-error, while a CORS-readable response still passes byte caps and MIME policy. Plain HTTP may also be rejected by the browser's mixed-content rules when Paja itself is served securely. This resource choice is independent of Paja's signer confirmation boundary.
Paja accepts blossom:<hash>.gbc?xs=cdn.example&as=<pubkey>&sz=32768 and the blossom:sha256:<hash>.gbc?xs=cdn.example&sz=32768 compatibility alias, each requiring exactly 64 hexadecimal characters. The optional single ASCII-alphanumeric extension is preserved in GET /<hash>.<ext> but never establishes MIME; query parameters are never forwarded to the blob server. Internal reference keys use blossom:<hash> while resource cache keys retain the caller's URL. Actual alias reads/server-resolution requests emit KEHTO_COMPAT_BLOSSOM_SHA256 once per host module lifetime; canonical requests stay quiet. The warning gives replacement guidance without logging resource data. Both forms use the same SHA-256 verification, byte caps, local MIME safety, and server policy. Paja accepts public-looking HTTPS request hints, discards invalid/private literals, and deduplicates equivalent origins. Repeated URI xs (scheme-less domains use HTTPS) and as (64-hex author keys) work without prior events. outbox.getEvent, outbox.query, and outbox.subscribe results privately index Blossom references, explicit event server/structured-source hints, BUD-10 URI xs/as hints, and the verified event publisher for that source window. A later resource.bytes for the URL tries explicit request servers, URI xs, and event-local servers first, then lazily reads URI as, event-hinted authors' and the event publisher's newest BUD-03 kind 10063 list through the base OUTBOX router, then tries the active shell user's BUD-03 list through the same router, then the current window's verified pointer-manifest servers, upload-runtime defaults, and finally the sidebar's extra resource servers. The user-list lookup does not depend on Blossom upload mode. Paja does not prefetch event resources and works while upload mode remains memory. Event state is bounded, memory-only, and cleared with the napplet window. Server-list lookups are cached for five minutes and concurrent lookups share one pending query; incomplete misses remain retryable. Event matching uses the hash only, never transferring an observed extension or size to a request. Request hints are not retained in window state; byte caches remain keyed by the original URL and runtime-bound napplet identity.
URI length is capped at 8192 characters, retained unique xs and the complete unique author lookup list at eight each, and combined server candidates at eight. Fragments, unsafe extensions/path injection, malformed escapes/authors, and invalid or repeated sz fail invalid-request before discovery. sz must be one positive decimal safe integer; declared sizes above 10 MiB fail too-large before I/O. A numeric Content-Length must match before reading (mismatched bodies are cancelled), and actual bytes must always match sz, even without a usable header. Host-configured HTTP is restricted to minutes; incomplete misses remain retryable.
Resource servers is a host-owned newline textarea and Save button in both target modes, not a NAP-CONFIG field or CLI/config option. Bare domains (including optional ports) use HTTPS: cdn.example becomes https://cdn.example. Save trims blanks, canonicalizes public HTTPS origins and deduplicates in order; an invalid line rejects the entire save atomically. Credentials, non-root paths, query/fragment delimiters, HTTP and local/private literals are rejected. Save blank to remove only extras.
The list is stored under kehto:paja:resource-servers in origin-scoped localStorage, independently of uploads and per-napplet configuration. Stored JSON is revalidated before use. Failed persistence still applies valid changes session-only, with explicit feedback that a previously saved list may return after reload. Successful saves affect subsequent Blossom requests across all running tabs without reloading frames or resetting event context. Extras are the last candidates in the existing eight-server budget, so a full earlier list can prevent them from being tried. They do not select upload destinations, change initial pointer/artifact resolution, redirect direct HTTP(S) reads or expand napplet CSP/network grants.
This setting was checked against pinned NAP-RESOURCE 9511232f69313aa7953d110e35d32cc28d506f66 and current PR #80 head fa6bcc6935aa19e7b70ab2a2c721dafca77c78e1 below. It is runtime-owned lookup policy, not a wire migration: the newer draft removes request servers and uses bulk urls instead of requests. Existing packaged wire compatibility remains unchanged; this does not claim full DNS-time network conformance for a browser-only runtime.
The combined list is capped at eight. Host-configured HTTP is restricted to loopback development; request, event, and publisher hints never permit it. Redirects are refused and Paja verifies the returned bytes against the requested SHA-256 before delivery. A hash or size mismatch is decode-failed unless another candidate succeeds; all-definitive misses are not-found; any inconclusive transport failure without success is network-error. Browser-only Paja cannot pin DNS resolution, so production runtimes still must perform the draft's DNS-time private-address checks. The publisher lookup follows BUD-03 b5bd2801d1763aa635fc8fea7a76597e0eb18990. BUD-10 at the same ref requires lowercase hashes and an extension (default .bin) when emitting URIs. Paja deliberately retains extensionless reads, mixed-case hashes and the sha256: alias as local compatibility, not full draft conformance. Its stronger public-HTTPS hint policy does not attempt BUD-10's suggested HTTP retry. Upload descriptors and URI generation remain unchanged. The current NAP-RESOURCE draft fa6bcc6935aa19e7b70ab2a2c721dafca77c78e1 was rechecked at PR #80's exact head for the opaque Blossom policy. This change preserves byte-derived MIME, hash verification, complete Blobs, ordered independent bulk results and no raw SVG delivery. It does not claim full draft compliance: existing developer HTTP policy, DNS enforcement, SVG rejection instead of rasterization, and server-hint wire drift remain out of scope. The draft has no wire-level Blossom server-hint field. Paja's window-scoped pointer fallback is host policy informed by NIP-5D's manifest server tags at draft head 24711d9c47bbdd07908bf1d52bf677d9cbc530f0.
Hashtree, Nostr, and other unimplemented schemes remain unadvertised and fail with unsupported-scheme. HTTP(S) dispatch and Blossom dispatch are separate: an ordinary web URL is fetched directly, while blossom: can only resolve an exact digest through accepted request, event, publisher, verified pointer, or configured servers.
Environment Simulation
The normalized simulation object controls:
- Capability domain advertisement through the production
shell.initpath. - ACL mode and firewall mode metadata for development policy profiles.
- Anonymous or fixed identity mode.
- Live, memory fixture, or disabled relay/outbox behavior.
- Local, memory, or disabled storage mode advertisement.
- Memory or disabled artifact/cache metadata.
- Unadvertised memory fixture, real Blossom, or disabled upload mode; shell-owned servers, BUD-03 discovery, maximum bytes, and MIME policy.
- Media, notification, intent, and CVM availability.
- Initial config values used only after schema registration when an identity has no saved shell settings. Paja validates/defaults and persists later host UI commits under
(dTag, aggregateHash); it does not expose a napplet write path. - Theme mode and values returned by
theme.get.
The top bar includes a theme selector and reload button; the bottom bar summarizes active simulation state, HMR strategy, runtime address, and lifecycle status. Theme changes apply immediately to the Paja theme service and survive the next iframe reload.
Scope Boundaries
- Owns the local development host process, option normalization, managed command startup, target readiness polling, host HTML rendering, browser bootstrap, and runtime config JSON and target HTML endpoints.
- Fetches the app-provided target URL into injected
srcdoc; target assets and HMR still resolve against the app dev server through the injected<base>. - Does not guess framework dev-server ports, mutate app build tooling, or add framework-specific adapters.
- Does not replace the full playground. Paja is the package-scoped authoring runtime for one target app; the playground remains the multi-napplet demo and topology surface.
API Reference
- Generated module:
docs/api/modules/_kehto_paja.html - Getting started: Paja getting started
- Local authoring how-to: Use Paja for local napplet authoring
NIP-5D event compatibility
Current manifests use a direct artifact x hash, plain-text content, role-matched z/i intent declarations, and required R / optional O domains. Kehto also accepts legacy aggregate events through an isolated compatibility adapter. Existing aggregateHash host/cache/ACL fields carry the verified artifact hash for current events; legacy identities keep their original aggregate. Both paths verify signatures and bytes before runtime injection and srcdoc execution. For the schema and removal boundary, see event migration.
Paja loads napplets that declare unknown or unavailable domains. Optional O integrations load quietly so the napplet can gracefully degrade. For unavailable required R domains (or legacy requires declarations), Paja logs a warning per frame navigation that full functionality may be unavailable, then continues. Declarations never grant capabilities: unsupported or disabled domain objects remain absent from window.napplet. Check namespace presence before using an experimental integration. Paja does not implement an experimental protocol merely because a manifest names it.
NAP-INTENT links and lifecycle
This follows NAP-INTENT PR #106 at fc121fc264615482143eda86125863d2e1f741a2 and NIP-5D PR #2303 at 020cb8b33a9e4c6b8ca4b2f9d0ed0a67843b68f7.
For a verified app with advertised intents, choose Share → Create intent link. Select its convention, include the text parameters you want, or switch to JSON. Parameters start omitted; selecting Include with an empty field sends an explicit empty string. Names stay in advertised order and extra named fields are allowed. No parameter type or requiredness is inferred. An empty advertised list still permits a payload. Opening or editing the builder never invokes an intent. Use Copy link to share it, or Test intent to open the shared content.
The outer URL carries a complete percent-encoded convention URI in intent. For example, ?intent=napplet%3Aprofile%2Fopen%3Fpubkey%3Dabc%252B123 contains napplet:profile/open?pubkey=abc%2B123 and delivers text abc+123. Outer and inner layers decode once each; literal + stays plus. Optional outer payload contains JSON, including null, primitives, arrays, or objects, and cannot accompany an inner query. URLs are limited to 16 KiB UTF-8. Duplicate keys, malformed encoding, and conflicting target instructions are rejected. Ordinary pointer-only app links retain their existing behavior.
New links recommend the app using a named-35129 #naddr when available; snapshot or unnamed apps use an exact outer pointer. The builder also offers recipient-default routing. Explicit pointers must verify and advertise the exact contract; failure never silently falls back. Recommendation relay hints remain untrusted discovery inputs. Invalid, incompatible, or disallowed recommendations do not override an applicable saved default.
Opening a shared link opens its verified app and delivers the content directly, following Paja's ordinary pointer navigation and capability policy. No URI or payload review is shown. If an applicable saved default conflicts with the link's app, Paja names both apps and asks which to use. A one-time choice leaves the default unchanged; Always use this app explicitly saves the chosen app. Without a usable hint or default, ordinary installed-handler selection applies. Installed app pointers are saved separately from open tabs and reverified when loaded; cached pointers never authorize execution or supply trusted metadata.
Progress and failures use a small notice. Successful delivery dismisses it. Closing the notice or cancelling an app choice before dispatch sends nothing. After acceptance, the incoming intent is removed from the current URL so reload restores the app without delivering the shared content again. Accepted work is retained until the verified target is ready.
Paja invokes through a separate signed launcher napplet with a verified artifact and authenticated iframe endpoint. Its source supplies the opaque sender; the URL cannot supply one. The hidden launcher is removed after acceptance and is never persisted or restored. Verification precedes srcdoc, and host bootstrap injection stays outside signed bytes. Native external-origin sender semantics remain a documented upstream spec gap; no host sender identity is invented.
NIP-5D runtime pointers
Paja MUST load all three NIP-5D manifest kinds: root 15129, named 35129, and snapshot 5129. Use an naddr with an empty identifier for the latest root, an naddr with the literal d identifier for the latest named napplet, or a nevent to pin a signed event of any of the three kinds. Snapshots require nevent because they are immutable events without replaceable coordinates. All paths verify the signed manifest and artifact hash before injecting srcdoc. Distinct snapshots retain separate tabs and catalog identities even when their artifact bytes match.
Controller diagnostics
Paja shows a numbered controller icon directly beside the console arrow for each controller the browser detects. Each slot has its own color and input animation; hover for the controller model and activity state. Buttons and stick movement light up only that controller (stick movement below 0.12 is ignored). Quick taps retain the highlight briefly so they are easy to spot. Reduced-motion settings keep the activity highlight without animation. Disconnecting removes the icon. Browsers may require a button press before exposing a newly connected controller. These host diagnostics work without a napplet gamepad subscription and do not change focus-scoped input delivery.