Appearance
@kernlize/core
Opinionated shell composition over kehto and applesauce. Browser-agnostic: the DOM is reached only through the KernlHost hooks and the FrameLike shape, so the whole composition runs in vitest against fakes.
| Field | Value |
|---|---|
| npm | @kernlize/core |
| JSR | @kernl/core |
| Version | 0.8.0 |
| Entry | ./dist/index.js, types ./dist/index.d.ts; @kernlize/core/wm (@kernl/core/wm on JSR) is ./dist/wm/index.js, the layouts and the window manager without the network stack; @kernlize/core/crypto (@kernl/core/crypto on JSR) is ./dist/crypto/index.js, the Web Crypto fallback |
| Dependencies | @internet-privacy/marmot-ts ^0.9.0; @kehto/acl ^0.20.3; @kehto/firewall ^0.7.0; @kehto/nip ^0.8.0; @kehto/runtime ^0.28.0; @kehto/services ^0.30.1; @kehto/shell ^0.32.0; @kehto/wm ^0.0.2; @kernlize/bridges workspace:^; @kernlize/guard workspace:^; @kernlize/messaging workspace:^; @noble/ciphers ^2.4.0; @noble/curves ^2.4.0; @noble/hashes ^2.4.0; applesauce-accounts ^7.0.2; applesauce-common ^7.0.0; applesauce-core ^7.0.3; applesauce-loaders ^7.0.2; applesauce-relay ^7.0.0; applesauce-signers ^7.0.1; nostr-double-ratchet ^0.0.183; rxjs ^7.8.2 |
| Version | 0.5.0 | | Entry | ./dist/index.js, types ./dist/index.d.ts; @kernlize/core/wm (@kernl/core/wm on JSR) is ./dist/wm/index.js, the layouts and the window manager without the network stack; @kernlize/core/crypto (@kernl/core/crypto on JSR) is ./dist/crypto/index.js, the Web Crypto fallback; @kernlize/core/cache (@kernl/core/cache on JSR) is ./dist/cache/index.js, the event cache, which starts ./dist/sqlite-worker.js | | Dependencies | @internet-privacy/marmot-ts ^0.9.0; @kehto/acl ^0.20.3; @kehto/firewall ^0.7.0; @kehto/nip ^0.8.0; @kehto/runtime ^0.28.0; @kehto/services ^0.30.1; @kehto/shell ^0.32.0; @kehto/wm ^0.0.2; @kernlize/bridges workspace:^; @kernlize/guard workspace:^; @kernlize/messaging workspace:^; @noble/ciphers ^2.4.0; @noble/curves ^2.4.0; @noble/hashes ^2.4.0; @sqlite.org/sqlite-wasm 3.54.1-build1; applesauce-accounts ^7.0.2; applesauce-common ^7.0.0; applesauce-core ^7.0.3; applesauce-loaders ^7.0.2; applesauce-relay ^7.0.0; applesauce-signers ^7.0.1; applesauce-sqlite ^7.0.0; nostr-double-ratchet ^0.0.183; rxjs ^7.8.2 | | Peer dependencies | @napplet/core >=0.38.0 <0.39.0; @napplet/nap >=0.37.0 <0.38.0; @napplet/shim >=0.30.0; nostr-tools >=2.23.3 <=2.x | | Side effects | none |
Install
This package is @kernlize/core on npm and @kernl/core on JSR, the same code at the same version. Once released:
sh
npm install @kernlize/core
# or
deno add jsr:@kernl/coreNeither registry has a release yet, so both commands fail. During the alpha the package is built from the kernl source repository, which is private, and consumed as a workspace dependency under its npm name. The package index has the steps.
json
{ "dependencies": { "@kernlize/core": "workspace:^" } }Purpose
This is where the five opinions in SOUL.md become code. createKernl builds one EventStore, one RelayPool and one AccountManager, adapts them to kehto's relay, auth and dm contracts, registers kehto's reference services, builds the ShellAdapter and hands it to createShellBridge, puts the guard pipeline in front of the bridge for the window-level hello and behind it, as an observer, for port traffic, wraps the account signer so destructive signatures ask the user first, and exposes the applet lifecycle (decodePointer → resolve → plan → mount), the grants surface, consent summaries and layouts to the shell app. The layering is in Architecture.
ts
import { createKernl, type KernlHost } from '@kernlize/core';
declare const host: KernlHost;
declare const iframe: HTMLIFrameElement;
const kernl = createKernl({ host });
window.addEventListener('message', (event) => kernl.handleWindowMessage(event));
const summary = await kernl.resolve(kernl.decodePointer('nostr:naddr1…'));
kernl.grants.allowDomain(summary.identity, 'relay');
const plan = await kernl.plan(summary);
if (plan.missingRequired.length === 0) kernl.mount(plan, iframe);Composition
createKernl(options) runs once per page and performs these steps in order (kernl.ts). Anything the host passes in KernlOptions is used as is; anything missing is created with kernl's defaults.
- Event layer.
options.eventStore ?? new EventStore(),options.pool ?? new RelayPool(),options.accounts ?? createKernlAccounts(host.storage). A pool the shell created itself is closed bydestroy(); a supplied pool is left open. - Relay tiers.
createRelayTiers(host.storage, options.relays ?? {}). Persisted choices atkernl:relays:v1win overoptions.relays, which only overridesDEFAULT_RELAY_TIERSon a fresh device. - Registries.
options.messaging ?? createDefaultMessaging(...), withcreatePrivateMessagesBackendandcreateLegacyMessagesBackendas theprivateandlegacybackends unlessoptions.messagingBackendsoverrides them, and availability tracking the active account (Messaging backends), andoptions.bridges ?? createDefaultBridges({ handoff: host.openLink, torrent: options.torrent }), so downloads, sharing and mail hand off through the host's confirm-before-leaving opener and aKernlOptions.torrentbackend, when given, downloads magnet links inside kernl instead (D-017). The default bridge registry has noauthorizehook, so in the composed shell a resource request is checked only by the HTTP bridge's SSRF policy and the applet'sresourcegrant. - Guard pipeline.
createGuardPipelinewithcreateGuardConfig(options.guard?.profile ?? 'strict'), the host'snowwhen given,onEventfeedingkernl.guard.events$,getVisibilitydelegated tohost.getVisibility,onQuarantinemarking the handle revoked with reasonquarantineand callingbridge.revokeNapplet(windowId, 'host'), andonReleasecallingbridge.releaseDeferred(windowId)when a window becomes visible. - Services.
createKernlServicesregistersrelay,identity,theme,notify,link,resource,dmandoutboxover the pool, the tiers, the event store, the active signer, the consent gate, the host, the bridge registry and the messaging registry. Theoutboxservice is wrapped so thatoutbox.publishpasses the consent gate first, because kehto's outbox service signs without naming the window.options.themeis used only whenisThemeaccepts it.resolveIdentitymaps a live window to its verified identity so the resource service names the requesting applet to the bridges. - ShellAdapter.
createShellAdapterbuilds kehto's adapter from the relay-pool hooks, the relay-config hooks over the tiers,createAuthHooks(accounts, consent), the service registry,capabilities.resolveEnvironmentfromcreateEnvironmentResolver(grants),onNappletRevoked(removes the frame through the handle, on every reason includingdeferred-overflow),filterNappletMessage(answers kehto withpipeline.observe:dispatch,deferordrop),getFocusContext({ focused }from the pipeline's visibility),onFirewallEventandfirewallPersistencefromcreateRuntimeFirewallHooks(runtime refusals become guard events without the message; the config is kept atkernl:firewall:v1), andaclDefaultPolicy: 'restrictive'(D-020). Choices kehto left open are fixed here: applets cannot create windows (createWindowreturns null), hotkeys are not forwarded, there is no worker relay, update behaviour isbanner, signatures are verified by applesauce'sverifyEvent,link.isAvailableis true andgetNip66Suggestionsis null. - Bridge.
createShellBridge(adapter), unchanged (D-001). - Grants.
createGrants({ acl: bridge.runtime.aclState, storage: host.storage, availableDomains: () => buildShellCapabilities(adapter).domains }). The ACL is restrictive, so an applet holds only what the grants layer writes, see Grants.pipeline.profile$is then followed: on boot and on every profile changeapplyRuntimeFirewallProfilesets the runtime's unfocused multiplier and startup burst, andapplyRuntimeFirewallRulesreinstalls the rates of every open window. - Consent. Before steps 5 and 6,
lookupReplaceable(eventStore, { loader: createReplaceableLoader(eventStore, pool, tiers), relays: () => [...outbox, ...discovery] })builds the before lookup andcreateSigningConsent({ host, lookupBefore, resolveApplet: identityOf, pubkey })builds the gate, and both of the routes an applet's write can take pass through it:relay.publishis signed throughShellAdapter.auth.getSigner(windowId), whosesignEventcalls the gate first, andoutbox.publishis gated in the outbox service wrapper before kehto signs it. For a kindisDestructiveKindaccepts (every replaceable and addressable kind, 5 and 62), the gate describes the draft against the current version, from the event store or else from the outbox and discovery relays withinCURRENT_VERSION_TIMEOUT_MS, callshost.presentConsentand waits; anything but yes rejects withdenied:consentand nothing is signed. Routine kinds are not prompted; the shell's own signing (no window) is not gated; answers are not remembered. kehto's runtime has no destructive-signing consent of its own. Separately,bridge.registerConsentHandler(createConsentHandler(host, lookupBefore, identityOf))answers kehto's ownConsentRequest, which the runtime raises only for a firewallpromptpolicy and without an event, so the handler refuses it without asking. - Session.
accounts.active$drivessession$(toSession(account)or null) and callsbridge.publishIdentityChanged(pubkey)with the active pubkey or''on sign-out, so bound applets hear every identity change through kehto's live port. - Lifecycle.
createAppletLifecyclewithoptions.artifactCache ?? openDefaultArtifactCacheandhost.fetchBlob ?? defaultFetchBlob. - Events.
connectEventCache(eventStore, options.eventCache)opens the event cache in the background; the replaceable loader, the profiles and the catalogue ask it first (D-030).kernl.mailboxesfollows the active account's kind 10002 and sets theoutboxtier from it;kernl.directorywaits forstart()(D-029).
destroy() closes every open applet, unsubscribes the session subscription, disposes the dm service, destroys the bridge and closes the pool if the shell owns it.
The KernlHost contract
The host is the only way the core reaches the user or the page. Applets never reach these hooks.
| Member | Called when |
|---|---|
storage: StorageLike | Every persisted value: accounts (kernl:accounts:v1, kernl:active-account:v1), relay tiers (kernl:relays:v1), grants (kernl:grants:v1), messaging preferences (kernl:messaging:v1), installed applets (kernl:installed:v1) and handler choices (kernl:intent-defaults:v1, kernl:intent-sync:v1). localStorage satisfies it; tests use a Map. |
presentConsent(prompt) | An applet window asked the shell to sign, through relay.publish or outbox.publish, an event whose kind is destructive. prompt.summary is a ConsentSummary whose event is the unsigned draft (empty id and sig); prompt.resolve(true) signs, prompt.resolve(false) (also on dismiss) makes the signer throw denied:consent. The core never times a prompt out. |
notify(notice) | An applet calls notify.send, or the guard has something to say. notice.tone is info or warning; windowId names the applet when there is one. |
openLink(url) | An applet's link.open passed the link policy (LINK_PROTOCOLS: https: and mailto: only). |
getVisibility(windowId) | Current visibility for a window, when the core needs it synchronously: the guard asks the first time it meets a window that handle.setVisibility has not reported on yet, and keeps that answer until the next setVisibility. |
onRevoked(windowId, reason) | The core removed a frame because the bridge revoked its binding, or the guard quarantined it (reason is quarantine). |
chooseHandler?(prompt) | Optional. An applet asked for a role and an action and the shell needs the user to pick who does it. prompt is a HandlerPrompt: { archetype, action, convention, source, options, recommended? }, with options the installed applets that can, preferred first, and empty when none can. Resolve a HandlerChoice { id, remember }, possibly for an applet installed while the chooser was open with kernl.install, or undefined on dismiss. Without it, a request with several handlers and no default is refused. |
openApplet?(address) | Optional. Open the applet at an address under its existing grants and resolve its window id, or undefined when it did not open. Called to start an intent's handler. |
focusApplet?(windowId) | Optional. Bring a running handler's window to the front. |
chooseReply?(prompt) | Optional. A one-to-one message is about to leave through a provider other than the one its recipient last wrote through, and the reply rule is ask. prompt is a ReplyPrompt from @kernlize/messaging: { peer, theirs, mine }. Resolve a ReplyAnswer { use: 'theirs' | 'mine', remember: 'once' | 'conversation' | 'always' }, or undefined on dismiss, which stops the send with denied:cancelled. Without it the provider the user's order picked is used. See Messaging. |
fetchBlob?(sha256, servers) | Optional. Replaces defaultFetchBlob for artifact bytes; the bytes are hashed by kehto either way. |
now?() | Optional clock for the guard. |
The Kernl surface
| Member | What it is |
|---|---|
accounts, eventStore, pool, bridge | The applesauce AccountManager, EventStore and RelayPool, and kehto's ShellBridge. The web shell hides all but accounts from its UI. |
guard: GuardFacade | config, profile$, setProfile(profile), events$, mitigations(), release(windowId). setProfile rebuilds kernl's guard layers shell-wide; held window-level messages are dropped and counters restart. Open windows keep the runtime firewall rates they were mounted with. It is not persisted. |
grants: Grants | The user-facing grants surface, see Grants. |
relays: RelayTiers | get(), add(tier, url), remove(tier, url), set(tier, urls), changes$. add and set throw invalid:relay-url for anything that is not ws:// or wss://; set with an unchanged list emits nothing. |
files: AccountFileServers | Where the account's uploads go; the upload domain is offered and every upload is asked about (D-033). |
mailboxes: AccountMailboxes | The signed-in account's published inbox and outbox (kind 10002, D-029). While a list is found, the outbox tier follows its outboxes, so every publish goes where the account says it writes. save signs with the active account and refuses denied:read-only for a read-only or signed-out session. |
directory: RelayDirectory | Monitored servers (kind 30166) from DEFAULT_MONITOR_RELAYS, idle until start(); search completes an address and suggest proposes servers. Settings starts it while open. |
messaging, bridges | The registries the services read. |
catalog: AppletCatalog | stream(options?) emits the store's entries from the apps and super tiers as they arrive; load(options?) resolves with the final list, see The store and intents. |
profiles: Profiles | card$(pubkey) and card(pubkey): how the shell names a person. The session label follows the account's published name. |
intents: Intents | installed$, installed(), uninstall(catalogId) and defaults (a HandlerDefaults: get, all, set(archetype, id | undefined), synced, load, sync, setSync, changes$). |
install(summary) | Install a verified applet without opening it; throws invalid:unresolved for a summary this shell did not verify. |
session$, session | Session | null: { accountId, pubkey, label, readOnly }. The label is the account's metadata.name, then account.name, then the abbreviated pubkey. |
login(account) | Adds the account to the manager if it is not there, makes it active, returns its Session. KernlAccountManager refuses plaintext-key account types here. |
logout() | Clears the active account. |
decodePointer(input) | See resolve → plan → mount. |
resolve, plan, mount, applets(), handleWindowMessage(event) | The applet lifecycle. plan also installs the applet. |
destroy() | Tears the shell down. |
resolve → plan → mount
decodePointer(input) accepts naddr1…, nevent1…, note1…, a bare 64-hex event id, any of those behind a nostr: prefix, and plain coordinates in <pubkey>:<kind>:<identifier> or NIP-01 <kind>:<pubkey>:<identifier> order. An encoded address is also found inside a link or a sentence (https://applets.example/n/naddr1…?ref=share), because that is how addresses get shared. Anything else throws invalid:pointer. The pointer says which event to fetch and nothing more.
kernl:<id> is the one address that fetches nothing: it decodes to { kind: 'builtin', id } and names an applet the host ships in KernlOptions.builtins (D-027). resolve loads it with the BuiltinApplet's own load(), hashes the text with SHA-256 and refuses it with invalid:artifact-hash unless it matches the sha256 the host pinned; a name the host does not ship is invalid:pointer, and a load() that rejects is unavailable:blob. The summary is shaped like a published applet's (builtin: true, address: 'kernl:<id>', publisher BUILTIN_PUBLISHER, a manifest with the declared requires, optional and archetypes), so plan, mount, grants and intents treat it the same, and it is sealed in the same frame. The first time the user meets it, the domains in its grants that this shell offers are switched on; after that the grant record is the user's and is never touched again.
resolve(pointer) fetches the manifest in at most two rounds (newest event wins for addresses). The first asks the pointer's relay hints plus the apps tier; when that does not produce the manifest, because those relays do not hold it or because none answered, the second asks the super tier. Hints that are not relay URLs are dropped, and a relay that is down, silent or hangs up does not fail a round while another answers. Each round runs on three clocks, MANIFEST_TIMING: once a manifest is in, the other relays have settleMs (3 s) to offer a newer one; once a relay has answered with nothing, the others have patienceMs (8 s) to do better, so a slow catalogue is not cut off by a quick relay that holds nothing; and the round ends after timeoutMs (10 s) when nothing answers. A round whose relays all refused the connection outright, as a busy public relay does for a moment with a 502 or 503, is asked once more after retryDelayMs (1 s). It throws unavailable:manifest when a relay answered and none had it, and unavailable:network when no relay answered in either round. It then verifies the manifest and artifact through kehto's resolveNapplet, with host.fetchBlob or defaultFetchBlob for the bytes and the artifact cache when the page has Cache Storage. Failures are rethrown as unavailable:blob or invalid:<kehto code> (invalid:blob-hash-mismatch, invalid:invalid-signature, …). The verified index.html is kept inside the lifecycle, keyed by identity; the caller gets an AppletSummary whose requires and optional are advisory.
plan(summary) throws invalid:unresolved for a summary this shell did not resolve. It calls grants.ensure(identity) so the ACL holds the applet's record, numbers the window applet-1, applet-2, … and returns a MountPlan: the environment from kehto's resolveShellEnvironment narrowed by the grants, the srcdoc with the guard's zero-network CSP and the namespace prelude inserted by prepareNappletSrcdoc with kehto's experimental.nestedContextLock on (which adds the Trusted Types directives to that CSP), then kernl's insertion lock inserted right after the prelude (D-014), sandbox: 'allow-scripts', and missingRequired, the required domains the environment does not include. The host decides what to do with missingRequired; the core mounts whatever it is given.
mount(plan, frame) runs in this order:
applyRuntimeFirewallRules(bridge.runtime.firewallState, guard.config, dTag)installs the profile's rates in kehto's runtime firewall for this applet.- Harden the frame: the
sandboxattribute is set to exactlyallow-scripts, replacing any tokens the host set;denyNappletControllerAccesswhen the frame hasgetAttribute, otherwiseallowis set toCONTROLLER_DENY_POLICY. originRegistry.register(window, windowId, { dTag, aggregateHash }).originRegistry.setEnvironment(window, plan.environment).bridge.observeFrame(frame, windowId), so a second load revokes the binding.- Assign
frame.srcdoc = plan.srcdoc. The handle's status moves frommountingtorunningon the frame's firstload.
A frame without a contentWindow throws invalid:frame-detached. handle.close() calls bridge.revokeNapplet(windowId, 'host'), unregisters the window, removes the frame and forgets the window in the guard; a revocation from the bridge (window-message, second-hello, invalid-hello, second-load, port-closed, deferred-overflow, host) removes the frame, forgets the guard state and calls host.onRevoked. The frame is never reused. handle.setVisibility(visibility) feeds the guard's visibility gate; when the window becomes visible, the requests kehto held for it are released.
handleWindowMessage(event) resolves the sending window from originRegistry, never from the message, and runs the guard pipeline in front of bridge.handleMessage. Messages from windows the shell does not know are forwarded unguarded so kehto can report them as unrouted.
Messaging backends
createKernl registers all four providers through createDefaultMessaging and, unless KernlOptions.messagingBackends overrides them, wires four network backends built on the active account's signer (D-015, D-016). KernlOptions.messagingBackends is a MessagingBackendFactories: { private?: () => MessagingBackend; legacy?: () => MessagingBackend; ratchet?: () => MessagingBackend; group?: () => MessagingBackend }; a factory there replaces kernl's backend for that provider.
createPrivateMessagesBackend({ store, pool, tiers, signer, selfPubkey, now? }) is a MessagingBackend over NIP-17. signer and selfPubkey are functions, read on every call, so one backend follows sign-in and sign-out.
- Send. The message is a kind 14 rumour. It is sealed in a kind 13 event signed by
signer(), encrypted with itsnip44, and wrapped in a kind 1059 event signed by an ephemeral key generated inside an applesaucePrivateKeySignerand discarded after that wrap; the key is never persisted. Each recipient's wrap is published to their kind 10050 inbox relays, or to the user'soutboxandsupertiers when they publish none, and one more wrap is addressed to the user. - Receive.
subscribeasks for kind 1059 events with#pset toselfPubkey()on the user's own kind 10050 inbox relays, or the same tiers. Wraps are opened with applesauce's helpers and the account signer and filed in an in-memory conversation store that belongs to the active account; it is cleared when the account changes or signs out. The eventstoreis where inbox relay lists are read from. - Conversations. A conversation id is the sorted participant set, so the same people always meet in the same conversation, whoever wrote first.
createLegacyMessagesBackend(...) takes the same options and sends NIP-04 kind 4 events through signer().nip04, with the same normalisation into DmConversation and DmMessage. It is the metadata-exposing provider: sender, recipient and timing are public on the event.
createRatchetMessagesBackend({ pool, tiers, storage, signer, selfPubkey, accountChanges?, discoveryTimeouts? }) is a MessagingBackend over the nostr-double-ratchet library (D-016).
- Runtime. One
NdrRuntimeper signed-in account, built on first use and closed when the account changes or signs out. The account signer signs the owner's device list (the library's unsigned owner events are re-signed from their template); the library generates and holds this browser's device key and the per-session ratchet keys.discoveryTimeouts: { fetchMs, fastMs }shortens how long it waits for relays to return the device list (library defaults 10 s and 2 s). - Storage.
createSealedRatchetStorage({ storage, signer, owner })is the library'sStorageAdapter. Each value is JSON, split into chunks that fitnip44, encrypted toownerwithsigner.nip44, and kept atkernl:ratchet:v1:<owner>:<slot>; the map from the library's key names to slots is one more sealed entry at…:index. Values are decrypted once per page load and writes are applied in order. - Send and receive. A send names exactly one other person (
invalid:groups-unsupported,invalid:no-recipients,invalid:unknown-conversation); the library queues it until a session exists. Received rumours of kind 14 are filed with the same conversation ids as the other backends; typing notices, receipts and controls are dropped. Setup that cannot reach a relay fails withunavailable:offlineand is retried on the next call. - Relays. Reads and writes use the user's
outboxandsupertiers, so both sides must share one.
None of the backends holds key bytes outside a signer or a sealed store; signing, encryption and decryption all go through the account's applesauce signer. The shell draws no conversation itself: messages are shown by an applet over the dm domain grant, and the one kernl ships starts with that grant because it came with the shell (D-027). createDefaultMessaging takes the reply question as askReply, which createKernl wires to KernlHost.chooseReply, and switches private and legacy on for someone who has never chosen; each starts once an account that can run it signs in.
Availability follows the active account. An unavailable provider reports a reason from MESSAGING_UNAVAILABLE_REASONS, and enable(id) throws it as unavailable:<reason>:
| Provider | Available when | Reason otherwise |
|---|---|---|
private | The active signer exposes nip44 | sign-in-required with no active account, signer-cannot-encrypt without nip44 |
legacy | The active signer exposes nip04 | sign-in-required with no active account, signer-cannot-encrypt without nip04 |
ratchet | The active signer exposes nip44 | sign-in-required with no active account, signer-cannot-encrypt without nip44; no-runtime only when a host registers it without a backend |
group | The active signer exposes nip44 | sign-in-required with no active account, signer-cannot-encrypt without nip44; no-runtime only when a host registers it without a backend |
Group sessions
createGroupMessagesBackend({ store, pool, tiers, storage, signer, selfPubkey, now?, accountChanges? }) is a MessagingBackend over @internet-privacy/marmot-ts (D-018), the maintained TypeScript implementation of the Marmot protocol (MLS over nostr).
- Client. One
MarmotClientper signed-in account, started on first use and dropped when the account changes or signs out. The account signer signs identity proofs and key packages and opens invitations; the library holds its MLS keys and group secrets. - Storage.
createSealedGroupStore({ storage, signer, owner }, name)is the library'sGenericKeyValueStore. Each store is one JSON value (bytes and big integers tagged), split into parts NIP-44 can carry and encrypted to the account with itsnip44, underkernl:group:v1:<owner>:<name>. It is decrypted once per page load and served from memory; writes are applied in order. - Network.
createGroupNetwork(pool, inboxRelays)adapts the shell's one relay pool to the library'sNostrNetworkInterface. A publish no relay accepted answers with no acknowledgements, and a failed request with no events. - Sending. A send to people goes to the group with exactly those members, else founds one inviting each by their newest kind 30443 key package;
unavailable:recipient-not-readywhen someone has none. A send into a conversation needs a group this device belongs to (invalid:unknown-conversation), and a message no relay accepted is taken back withunavailable:offline. - Types.
GroupMessagingOptionsisSignerMessagingOptionsplusstorage;SealedGroupStoreOptionsis{ storage, signer, owner };InboxRelaysis(pubkey) => Promise<string[]>, the resolver the private and group backends share. - Ids. The conversation id is the group id in hex; through the composite it is
group:<hex>. Message ids are the kind 9 rumour id; other rumour kinds a group carries are not messages and are skipped.
Preferences persist at kernl:messaging:v1.
ts
import { createKernl, createLegacyMessagesBackend, createPrivateMessagesBackend, type KernlHost } from '@kernlize/core';
declare const host: KernlHost;
const kernl = createKernl({ host });
const account = () => kernl.accounts.active;
const deps = {
store: kernl.eventStore,
pool: kernl.pool,
tiers: kernl.relays,
signer: () => account()?.signer,
selfPubkey: () => account()?.pubkey,
};
const privateMessages = createPrivateMessagesBackend(deps);
const legacyMessages = createLegacyMessagesBackend(deps);
await privateMessages.send({ recipients: ['<hex pubkey>'], content: 'hello' });
legacyMessages.subscribe({}, (message) => console.log(message.content));Accounts
createKernlAccounts(storage) returns a KernlAccountManager with applesauce's common account types registered, state restored from kernl:accounts:v1 and kernl:active-account:v1, and every later change persisted. KernlAccountManager.addAccount throws denied:plaintext-key-persistence for the types in PLAINTEXT_KEY_ACCOUNT_TYPES, ['nsec', 'seed-words']: PrivateKeyAccount serialises the key itself and SeedWordsAccount the mnemonic. Any such entry found in storage is dropped on restore. The only persisted form of a local key is applesauce's PasswordAccount, whose ncryptsec is NIP-49 encrypted.
createAuthHooks(accounts, consent) is kehto's AuthHooks: getUserPubkey is the active pubkey or null; getSigner(windowId?) is null while nobody is signed in and for read-only accounts, so the runtime refuses writes instead of failing inside a signer. The identity service is the one exception to "no signer, no answer": for a read-only account createKernlServices hands it a view that answers getPublicKey and nothing else (from KernlServiceDeps.auth.getUserPubkey), so an applet the user granted identity learns which account is signed in, and every write still finds no signEvent. With a windowId, the returned signer's signEvent awaits consent(windowId, template) before the account signs; without one it is the account's signer, ungated. wrapAccountSigner(account) exposes getPublicKey, signEvent and the nip04/nip44 namespaces the account has, never the account.
Relay tiers
DEFAULT_RELAY_TIERS, used when nothing is persisted and options.relays says nothing for a tier:
| Tier | Default | Serves |
|---|---|---|
discovery | wss://purplepag.es, wss://relay.nos.social | kinds 0, 3 and 10002, together with outbox and super |
apps | wss://relay.napplet.soy, wss://relay.ditto.pub, wss://relay.nostr.com | applet manifests (the NIP-5D kinds and 32267) |
outbox | none | the user's own relays, merged with super for everything else |
super | wss://relay.damus.io, wss://nos.lol, wss://relay.primal.net | everything else |
selectRelayTier(tiers, filters) routes a filter set to discovery, outbox and super (deduplicated) when every kind is a discovery kind, because profiles, follows and relay lists live on indexers and on their authors' own servers (NIP-65), to apps when every kind is an applet kind, and to super plus outbox (deduplicated) otherwise, including filters without kinds. kehto's RelayConfigHooks sees discovery, super and outbox; apps is kernl's addition.
Grants
BASELINE_DOMAINS is ['shell', 'inc', 'theme', 'storage']: every applet gets the handshake, in-shell messaging, the theme and its own scoped storage, which it can read and write, and no network. identity is not in the baseline: an applet learns which account is signed in only after the user grants it ("your account" in the shell). DOMAIN_CAPABILITIES maps each NAP domain to the kehto capabilities it needs:
| Domain | Capabilities |
|---|---|
shell, inc | none |
relay | relay:read, relay:write |
identity | identity:read |
storage | state:read, state:write |
theme | theme:read |
keys | keys:bind, keys:forward |
media | media:control |
notify | notify:send, notify:channel |
config | config:read |
resource | resource:fetch |
cvm | cvm:call |
outbox | outbox:read, outbox:write |
upload | upload:write |
intent | intent:read, intent:write |
dm | dm:read, dm:write |
fs | fs:read, fs:write |
llm | llm:generate |
cache | cache:read, cache:write |
Unknown domains need no capability (capabilitiesForDomain('made-up') is []). createGrants keeps a per-identity record { dTag, hash, domains, blocked } at kernl:grants:v1 and, on every commit, writes it into kehto's ACL, which is restrictive (aclDefaultPolicy, D-020): every capability a baseline or allowed domain needs is granted, whatever the applet's stored entry holds beyond that is revoked, then block/unblock is applied and the ACL persisted. KernlGrants.ensure(identity) does this for an applet that has no record yet; plan calls it. createEnvironmentResolver(grants) is the capabilities.resolveEnvironment hook: each applet sees the baseline plus its allowed domains, intersected with what the shell offers; a blocked applet sees only shell. The calls are in Grant and revoke.
Consent summaries
describeChange(event, before, applet) turns a write into a ConsentSummary with a title, a severity and lines of { label, before?, after? }:
| Kind | Severity | Title | Lines |
|---|---|---|---|
0 | destructive | Update your profile | One line per changed field among name, display name, about, picture, banner, verified address, payment address and website; or "No profile fields change". |
3 | destructive | Change who you follow | "Follows added" and "Follows removed" with counts and the first three pubkeys abbreviated; or "No entries change". |
5 | destructive | Delete N item(s), or Delete N post(s) when the k tags say every target is kind 1 and no a tag is present | One "Delete" line per e tag, abbreviated, and one "Delete every version of" line per a tag, naming the address in plain words, for example record (type 30001) "books" by person 3bf0c63f…. |
62 | destructive | Ask services to erase your account | "Erase: Everything this account has published" and "Services asked": every service when the relay tags are absent or include ALL_RELAYS, else the first three named. |
10002 | destructive | Change your inbox and outbox | "What this is", then "Inbox added/removed" and "Outbox added/removed"; an unmarked relay is in both (NIP-65). |
| 10000–10099, 30000–30099 | destructive | Change the list "title or d tag", or "Change a list" | "Entries added" and "Entries removed", each entry in plain words: person 3bf0c63f…, item 3bf0c63f…, topic …, type N, server example.com, key 3bf0c63f…, or name value for any other tag. |
| any other replaceable (10100–19999) or addressable (30100–39999) kind | destructive | Replace one of your records (type N), with the title or d tag in quotes when there is one | "Entries added" and "Entries removed" over the tags other than d, title and description, and "Content: Replaced" when the content differs, without copying it. |
| anything else | routine | Publish a post, repost, reaction, comment, article, sealed message, legacy private message, payment receipt, or "record (type N)" | One "Content" line with a 140-character preview. |
isDestructiveKind(kind) is true for exactly the destructive rows: every replaceable kind (0, 3, 10000–19999), every addressable kind (30000–39999), 5 and 62.
LookupBefore is (kind, pubkey, identifier?) => Promise<NostrEvent | undefined>. lookupReplaceable(store, options?) builds one: the store answers first; on a miss, and only when options.loader is given, the applesauce address loader asks options.relays() and the lookup waits at most options.timeoutMs (default CURRENT_VERSION_TIMEOUT_MS, 4000 ms) and takes the newest version that arrived. A loader that fails or stays silent yields undefined. When a replaceable or addressable write is described and no current version was found, the summary's first line is UNKNOWN_CURRENT_VERSION, "Current version: could not be loaded; this replaces it entirely", so it never reads as a first version that removes nothing.
createSigningConsent(options) returns the SigningConsent gate, (windowId, template) => Promise<void>: it resolves at once for a routine kind; for a destructive one it builds an unsigned draft (empty id and sig; this draft is the summary's event), describes it against lookupBefore, and resolves only when host.presentConsent answers yes. It rejects with denied:consent when the user refuses, denied:unknown-applet for a destructive write from a window the shell did not mount, denied:signed-out for a destructive write while nobody is signed in, and invalid:event-kind when the kind is not a number.
createConsentHandler(host, lookupBefore, resolveApplet) answers kehto's own ConsentRequest. The kehto runtime kernl pins raises only firewall-policy requests, which carry no event, and the handler refuses those without asking. A request that does carry an event, from a window the shell can name, would be described and put to the user, so a later kehto release that adds such prompts is answered honestly; one from an unknown window is refused.
Layouts and the window manager
Everything in this section is also exported from @kernlize/core/wm, an entry that imports only rxjs. A shell that loads the core on demand, as apps/web does, can import its window management up front from there.
Pure LayoutStrategy values over @kehto/wm types. Minimised windows are never arranged, and rectangles are whole pixels rounded at their edges, so neighbours never overlap or leave a hole.
| Export | Behaviour |
|---|---|
floatingLayout | Every window keeps its rectangle. |
createDwindleLayout({ gap?, margin? }) | Each window after the first takes half the remaining area, splitting vertically then horizontally in turn. |
createColumnsLayout({ gap?, margin? }) | Equal-width columns in window order. |
createMasterStackLayout({ ratio?, gap?, margin? }) | The focused window (or the first) takes ratio of the width (default 0.6) on the left; the rest stack on the right. |
gap is the pixels between windows; margin is the pixels between the windows and the container edge and defaults to gap.
ts
import { createMasterStackLayout, type WindowState } from '@kernlize/core';
declare const windows: WindowState[];
createMasterStackLayout({ ratio: 0.5, gap: 8 }).arrange(windows, { x: 0, y: 0, w: 1200, h: 800 });A LayoutRegistry holds the layouts a window manager offers, as LayoutEntry values: { id, label, strategy, freeform? }.
| Export | Behaviour |
|---|---|
createLayoutRegistry(entries?) | A registry holding entries, in order. register(entry) throws invalid:duplicate-layout for an id that is taken; get(id) and list() read it. |
createDefaultLayouts(options?) | A registry with floating (freeform), dwindle (labelled "tiled"), columns and master-stack (labelled "main and stack"). options is the LayoutOptions for the three that arrange. |
createWindowManager(options?) returns a WindowManager: placement, focus, stacking, minimising and what the guard is told about each window, with no DOM of its own. Layouts describes its behaviour, and packages/core/features/window-manager.feature specifies it.
WindowManagerOptions | Default | Meaning |
|---|---|---|
layouts | createDefaultLayouts() | The registry on offer. An empty one throws invalid:no-layouts. |
layout | the first registered | The id to start in. One that is not registered is ignored. |
area | { x: 0, y: 0, w: 1280, h: 800 } | Where windows are laid out until setArea is called. |
compactWidth | 720 | At or below this area width every window fills the area. 0 turns it off. |
windowSize | { w: 720, h: 660 } | The size a new window opens at. |
minSize | { w: 280, h: 160 } | The smallest a window may be made. |
onLayout | none | Called with the id after every setLayout. |
now | Date.now | Clock for msSinceFocusGain. |
WindowManager member | Behaviour |
|---|---|
state$, snapshot() | The WindowManagerState, as an Observable that starts with the current value, and on demand: windows (each a ManagedWindow of id, rect, z, focused, minimised, stopped, in opening order), focusedId, layout, area, compact, freeform. |
layouts() | The { id, label } of every layout on offer, read from the registry on each call. |
open(id), close(id) | Add a window in front, focused; remove one and stop reporting to it. Opening an open id brings it to the front. |
focus(id), blur(), minimise(id) | Give a window the person's attention, showing it if minimised; take attention from every window; hide one. |
move(id, rect) | Move or resize a window, kept inside the area and no smaller than minSize. Ignored unless the state is freeform. |
stop(id) | The window's applet was stopped: it loses focus, is reported to no more and waits to be closed. |
attach(id, target) | Report the window's visibility to target.setVisibility, now and after every change. An AppletHandle is a VisibilityTarget. A second call for the same window is ignored. |
visibilityOf(id) | The WindowVisibility to answer KernlHost.getVisibility with. An unknown window is neither visible nor focused. |
setArea(rect), setLayout(id), setPageVisible(visible) | Change where windows are laid out, which layout arranges them (invalid:unknown-layout for an id nobody registered), and whether the page itself can be seen. |
destroy() | Stop reporting and complete state$. |
ts
import { createDefaultLayouts, createWindowManager, type AppletHandle } from '@kernlize/core';
declare const handle: AppletHandle;
const windows = createWindowManager({ layouts: createDefaultLayouts({ gap: 8, margin: 0 }), layout: 'columns' });
windows.setArea({ x: 0, y: 0, w: 1440, h: 900 });
windows.open(handle.windowId);
windows.attach(handle.windowId, handle);
windows.snapshot().windows[0]?.rect; // { x: 0, y: 0, w: 1440, h: 900 }Writing a manager of your own against the WindowManager interface, and the one rule it must keep, is in Write your own window manager.
Web Crypto outside a secure context
Over plain HTTP from an onion or overlay network address, the browser withholds crypto.subtle and crypto.randomUUID. installCryptoFallback fills in whichever is missing, from audited pure-JS primitives, and leaves the platform's own alone (D-024). Call it once before anything uses Web Crypto; @kernlize/core/crypto exports it without the network stack, so a shell can call it first.
ts
import { installCryptoFallback } from '@kernlize/core/crypto';
installCryptoFallback(); // { randomUUID: false, subtle: false } over HTTPSThe fallback SubtleCrypto covers SHA-1/256/384/512 digests; HMAC and Ed25519 sign and verify; AES-GCM (128-bit tag) and AES-CBC; X25519 and HKDF deriveBits; Ed25519 and X25519 keys in raw, pkcs8 and jwk. Anything else rejects with NotSupportedError. The mount plan gives every applet frame a randomUUID fallback too, before its own scripts; applets do not get the SubtleCrypto fallback.
Event cache
Everything in this section is exported from @kernlize/core/cache and from @kernlize/core. The cache keeps events between visits, through one contract with a backend per context (D-030). createKernl takes eventCache, an opener, and attaches what it opens to the shell's store through connectEventCache: the replaceable loader and the store's catalogue ask the cache before any relay, new events are written back in batches, and destroy() closes it. The shell starts at once; the cache opens in the background, and one that fails leaves the shell in memory. kernl's web shell passes () => openEventCache(defaultWebCacheAdapters()).
ts
import { EventStore } from 'applesauce-core';
import { attachEventCache, defaultWebCacheAdapters, openEventCache } from '@kernlize/core/cache';
const store = new EventStore();
const cache = await openEventCache(defaultWebCacheAdapters(), {
onFallback: (name, reason) => console.info(`${name} cache skipped: ${reason}`),
});
const { cacheRequest, stop } = attachEventCache(store, cache);
await cacheRequest([{ kinds: [0], limit: 1 }]); // events marked as from the cache
stop();
await cache.close();EventCache member | Behaviour |
|---|---|
name | sqlite-opfs, indexeddb, memory, or the name a custom adapter gave. |
add(events) | Stores events. A replaceable (0, 3, 10000–19999) or addressable (30000–39999, per d) event is kept only if it is the newest version, the lower id winning a tie, and the older versions are removed. A kind 5 deletion is stored and removes its author's targets, by e id and by a address up to its own created_at; a target that arrives later is dropped. Ephemeral kinds (20000–29999) are not stored. Writes run one after another. |
query(filters) | Events matching any filter, newest first (the lower id first on a tie), each filter's limit applied to that filter. ids, authors, kinds, since, until, limit and single-letter #x tags are supported; a filter with search matches nothing. |
removeByFilters(filters) | Removes what query(filters) returns and resolves how many. |
clear(), close() | Removes every event; releases the backend. |
| Export | Behaviour |
|---|---|
openEventCache(adapters, { onFallback? }) | Opens the first adapter that opens. Each one that rejects is reported as onFallback(name, reason); when none opens, the cache is memory. Never rejects. |
defaultWebCacheAdapters() | [sqlite-opfs, indexeddb, memory] with default options. |
createMemoryCacheAdapter() | applesauce's EventMemory, gone when the page closes. Always opens. |
createIndexedDbCacheAdapter({ name?, indexedDB? }) | One object store keyed by id in the database name (default kernl-events), with indexes on kind, author, time and name:value tags. Rejects unavailable:indexeddb without a factory, and unavailable:indexeddb (<error name>) when the factory refuses, for example a database from a newer version. A write the browser aborts rejects unavailable:indexeddb (aborted). |
createSqliteOpfsCacheAdapter(options?) | sqlite-wasm on the OPFS SAH pool VFS, in a dedicated worker, with applesauce-sqlite's schema. Options below. |
fromEventDatabase(name, database, close?) | Any applesauce IEventDatabase or IAsyncEventDatabase as a cache, with the rules above applied on top; close is called when the cache is closed. See Add a cache backend. |
createEventCache(name, records) | The rules layer over an EventRecords (put, query for one filter, remove by id, clear, close), for a backend that is not an applesauce database. |
attachEventCache(store, cache, { batchTime?, maxBatchSize? }) | Writes the store's new events in batches through applesauce's persistEventsToCache (default every 5 s), skipping events from the cache. cacheRequest(filters) is a loader cacheRequest: it marks what it returns with markFromCache and resolves [] when the cache rejects. stop() ends the writes. |
isCorruptionError(error) | True for SQLITE_CORRUPT, SQLITE_NOTADB, database disk image is malformed and file is not a database. |
SqliteOpfsOptions | Default | Meaning |
|---|---|---|
filename | /kernl-events.sqlite3 | The database in the SAH pool. A rotation moves to /kernl-events-1.sqlite3, /kernl-events-2.sqlite3, … |
storage | globalThis.localStorage | Keeps the current filename at kernl:event-cache:v1 after a rotation. |
locks | navigator.locks | Guards the database. Another tab holding it makes open reject unavailable:locked. Without a lock manager, the SAH pool's own exclusive handles are the only guard. |
connect | a module worker at ./sqlite-worker.js | Opens the database files: a SqliteFiles (open(filename) to a SqliteDriver of exec, query, close; wipe(filename); close()). Rejects unavailable:opfs where the page has no Worker or no navigator.storage.getDirectory. |
onRecover | none | Told { action, reason } for each repair: wiped, rotated or disabled. |
open rejects unavailable:opfs (…) when the worker cannot install the SAH pool, unavailable:worker (…) when the worker fails, unavailable:locked, unavailable:corrupt when every repair failed, and unavailable:sqlite (…) for any other failure to open. How the cache repairs a damaged database is in Persistence.
The worker is dist/sqlite-worker.js, started with new Worker(new URL('./sqlite-worker.js', import.meta.url), { type: 'module' }) beside the chunk that starts it; Vite and other bundlers that understand that form bundle it. Exclude @sqlite.org/sqlite-wasm from the bundler's dependency pre-bundling so its sqlite3.wasm resolves next to it.
Exports
Signatures are abbreviated; the generated reference has the full ones.
Composition
| Export | Signature | Description |
|---|---|---|
createKernl | (options: KernlOptions) => Kernl | The composition root described above. |
toSession | (account: IAccount) => Session | Describes an account for the UI; readOnly when it cannot sign. |
createAppletLifecycle | (context: LifecycleContext) => AppletLifecycle | resolve, plan, mount, applets, handleWindowMessage over the composed parts. |
resolveWindow | (handles: ReadonlyMap<string, MountedApplet>, event: MessageEvent) => WindowResolution | null | The sending window from originRegistry, never from the message. |
createShellAdapter | (deps: ShellAdapterDeps) => ShellAdapter | kehto's adapter from kernl's parts, with the fixed choices listed under Composition. deps.consent (a SigningConsent), deps.filterNappletMessage, deps.getFocusContext and deps.runtimeFirewall are required; the adapter always sets aclDefaultPolicy: 'restrictive'. |
createKernlServices | (deps: KernlServiceDeps) => KernlServices | { registry, theme, dm } with kehto's eight reference services wired. deps.consent is required; outbox.publish passes it before signing, and a refusal is answered as outbox.publish.result with ok: false and the reason. |
isTheme | (value: Record<string, unknown> | undefined) => value is Theme | True when colors.background, colors.text and colors.primary are strings. |
resourceInitFor | (deps, init: ResourceFetchInit) => ResourceResolveInit | Names the requesting applet, or 'shell', to the bridge resolver. |
LINK_PROTOCOLS | readonly string[] | ['https:', 'mailto:']. |
PACKAGE_NAME | '@kernlize/core' | |
KERNL_FRAME_POLICY_META | string | kehto's <meta> for the host page's <head>, so applet frames cannot navigate away from their srcdoc. |
Kernl, KernlOptions, KernlHost, GuardFacade, Session, StorageLike, MessagingBackendFactories, AppletLifecycle, LifecycleContext, ShellAdapterDeps, KernlServiceDeps, KernlServices | types |
Applets
| Export | Signature | Description |
|---|---|---|
decodePointer | (input: string) => AppletPointer | Address text to { kind: 'address' | 'event' | 'builtin', … }; throws invalid:pointer. |
resolveBuiltin | (builtins: readonly BuiltinApplet[], id: string) => Promise<ResolvedApplet> | Load an applet the host ships and check it against its pinned hash; throws invalid:pointer, unavailable:blob or invalid:artifact-hash. |
builtinAddress | (id: string) => string | 'kernl:messages' for 'messages'. |
BUILTIN_PUBLISHER | string | The all-zero key shown as the publisher of an applet nobody published. It cannot sign, so it never collides with a real publisher. |
BuiltinApplet | interface | { id; title; description; sha256; requires; optional; archetypes?; grants?; load(): Promise<string> }, an entry of KernlOptions.builtins. |
PublishedPointer | type | AppletPointer without the builtin kind: what fetchManifestEvent can look up. |
fetchManifestEvent | (pool: ApplesaucePool, tiers: RelayTiers, pointer: PublishedPointer, timing?: Partial<ManifestTiming>) => Promise<NostrEvent> | Newest manifest from the hints plus the apps tier, then from the super tier; throws unavailable:manifest when the relays had none and unavailable:network when none answered. |
defaultFetchBlob | (sha256: string, servers: readonly string[], fetchImpl?: typeof fetch) => Promise<Uint8Array> | Tries each server with a BLOB_TIMEOUT_MS (20 000 ms) budget; throws unavailable:blob. |
openDefaultArtifactCache | () => Promise<NappletArtifactCache | undefined> | kehto's verified-artifact cache over Cache Storage, or nothing. |
resolveApplet | (options: ResolveAppletOptions) => Promise<ResolvedApplet> | kehto's resolveNapplet with kernl's reasons: unavailable:blob or invalid:<code>. |
summarize | (resolved: ResolvedNapplet, address: string) => AppletSummary | The verified manifest reduced to what the shell shows, with its shareable address. |
planMount | (options: PlanMountOptions) => MountPlan | Environment, CSP-prepared srcdoc with kehto's nested-context lock and kernl's insertion lock, missingRequired. No DOM. Throws denied:nested-frame when kehto's assertNappletHasNoFrame finds a frame in the verified markup, and unavailable:frame-lock when kehto's CSP meta and prelude are not where it expects them (D-014). |
mountApplet | (plan: MountPlan, frame: FrameLike, deps: MountDeps) => MountedApplet | The NIP-5D mount order; throws invalid:frame-detached. Calls frame.setAttribute('sandbox', 'allow-scripts'). |
CONTROLLER_DENY_POLICY | string | gamepad 'none'; hid 'none'; usb 'none'. |
BLOB_TIMEOUT_MS | 20_000 | |
MANIFEST_TIMING | ManifestTiming | { settleMs: 3_000, patienceMs: 8_000, timeoutMs: 10_000, retryDelayMs: 1_000 }, the waits of one round of a manifest lookup. |
AppletPointer, AppletSummary, AppletIdentity, MountPlan, AppletHandle, AppletStatus, FrameLike, ResolveAppletOptions, ResolvedApplet, PlanMountOptions, MountDeps, MountGuardHooks, MountedApplet, ManifestTiming | types | MountedApplet adds revoked(reason) to AppletHandle for the bridge's revocation hook. FrameLike.sandbox is optional and not read: the mount replaces the attribute whatever it held. |
Store and intents
| Export | Signature | Description |
|---|---|---|
streamCatalog | (pool: ApplesaucePool, tiers: RelayTiers, options?: LoadCatalogOptions) => Observable<readonly CatalogEntry[]> | Root and named manifests from catalogRelays(tiers), signature-checked, newest per catalogue id, emitted again as relays answer (at most every emitMs, CATALOG_EMIT_MS, 120). A manifest its publisher deleted (kind 5, NIP-09) is left out. Completes when every relay finished, or after timeoutMs (CATALOG_TIMEOUT_MS, 30000), the outer bound; a failing relay never discards what others sent. Asks for limit (CATALOG_LIMIT, 500) per relay. |
loadCatalog | (pool: ApplesaucePool, tiers: RelayTiers, options?: LoadCatalogOptions) => Promise<readonly CatalogEntry[]> | The last list streamCatalog emits. |
catalogRelays | (tiers: RelayTiers) => readonly string[] | The apps tier, then super. |
toCatalogEntry | (event: NostrEvent, relays?: readonly string[]) => CatalogEntry | undefined | A store entry from a manifest whose signature verifies; undefined otherwise. |
addressOf | (event: NostrEvent, relays?: readonly string[]) => string | naddr1… for a root or named manifest, nevent1… for a snapshot. |
newestEntries | (entries: readonly CatalogEntry[]) => readonly CatalogEntry[] | One entry per catalogue id, newest first. |
searchCatalog | (entries, query?: CatalogQuery) => readonly CatalogEntry[] | Text (every word must match), topics, available domains and convention filters, best first. |
fuzzyScore | (word: string, field: string) => number | 4 whole word, 3 prefix, 2 substring, 1 letters in order within one word, 0 none. |
popularTopics | (entries, limit?: number) => readonly TopicCount[] | Topics by count, then name; 12 by default. |
createInstalledApplets | (storage: StorageLike, now?: () => number) => InstalledApplets | The installed set at kernl:installed:v1; catalog() is what kehto's resolver loads. |
createHandlerDefaults | (options: HandlerDefaultsOptions) => HandlerDefaults | Local defaults, the account's published preferences (kind 31129) and opt-in publishing to preferenceRelays(tiers). |
preferenceRelays | (tiers: RelayTiers) => readonly string[] | The outbox tier, then super. |
HANDLER_PREFERENCES_KIND | 31129 | |
parseHandlerPreferences | (event: NostrEvent) => HandlerPreferences | undefined | The public a, e and i entries; malformed entries are ignored. |
preferredHandlers | (preferences, convention: string) => readonly string[] | Catalogue ids: intent-specific entries when any, else archetype-wide ones. |
preferenceTemplate | (previous, archetype, catalogId, now) => EventTemplate | undefined | The next preference event with one handler first, every other tag and the content kept. |
catalogIdForRef, refForCatalogId | (value: string) => string | undefined | Between preference references (35129:<pubkey>:<d>, 15129:<pubkey>:, a snapshot id) and kehto's catalogue ids. |
createIntentRouter | (options: IntentRouterOptions) => IntentRouter | kernl's routing over kehto's createCatalogIntentResolver: defaults, preferences, chooser, store fallback with a one-time authorisation. |
createIntentDelivery | (options: IntentDeliveryOptions) => (params) => Promise<void> | Reuse or open the handler, retry until it can receive (DELIVERY_RETRY, 100 ms × 150), send intent.deliver. Throws unavailable:handler, unavailable:window or unavailable:delivery. |
createKernlIntents | (deps: KernlIntentDeps) => KernlIntents | The composition createKernl uses: the intent service handler, the Intents facade and the installed set. |
CatalogEntry, CatalogQuery, TopicCount, LoadCatalogOptions, AppletCatalog, InstalledApplet, InstalledApplets, InstalledContract, HandlerDefaults, HandlerDefaultsOptions, HandlerPreferences, PreferenceSigner, Intents, IntentRouter, IntentRouterOptions, IntentDeliveryOptions, KernlIntentDeps, KernlIntents, HandlerPrompt, HandlerOption, HandlerChoice | types |
Guard
| Export | Signature | Description |
|---|---|---|
createGuardPipeline | (options: GuardPipelineOptions) => GuardPipeline | Ingress guard, visibility gate and focus firewall in front of the bridge; handleWindowMessage for window-level messages, observe for port traffic, which returns kehto's NappletMessageDecision (dispatch, defer for a hidden window's non-control envelope, drop for what the guard will not let through). onRelease(windowId) is called when a window becomes visible. |
runtimeFirewallRules | (config: GuardConfig, napplet: string) => RuntimeFirewallRules | The profile's per-class rates translated to the runtime's capability keys, plus the global rate. |
applyRuntimeFirewallRules | (firewallState: RuntimeFirewallState, config: GuardConfig, napplet: string) => void | Installs those rules in bridge.runtime.firewallState and persists. Sets no policy: kehto's allow policy would skip every rate, matcher and burst check. |
applyRuntimeFirewallProfile | (firewallState: RuntimeFirewallState, config: GuardConfig) => void | Sets the profile's unfocusedMultiplier and burstGuard on the runtime firewall and persists. |
createRuntimeFirewallHooks | (options: RuntimeFirewallHooksOptions) => RuntimeFirewallHooks | firewallPersistence over the host storage at RUNTIME_FIREWALL_STORAGE_KEY and onFirewallEvent feeding options.onEvent through toGuardEvent. |
toGuardEvent | (event: FirewallEvent, at: number) => GuardEvent | A runtime firewall event in the guard's words (rate:<class>, flood:startup, runtime:<ruleId>), without the applet's message. |
RUNTIME_FIREWALL_STORAGE_KEY | 'kernl:firewall:v1' | Where the runtime firewall's configuration is kept. |
RUNTIME_OP_CLASSES | Readonly<Record<OpClass, string | null>> | Guard class to runtime key; null for relay:publish:delete, control and generic. See Guard. |
GuardPipeline, GuardPipelineOptions, PipelineDisposition, WindowResolution, RuntimeFirewallRules, RuntimeRateRule, RuntimeFirewallState, RuntimeFirewallHooks, RuntimeFirewallHooksOptions | types | PipelineDisposition is GuardDisposition | 'unrouted', the answer for window-level messages. Every use of the runtime firewall's own configuration is in this one module, guard/runtime-rules.ts, because kehto/web#336 replaces it (D-020). |
Grants and consent
| Export | Signature | Description |
|---|---|---|
createGrants | (options: GrantsOptions) => KernlGrants | The grants surface over bridge.runtime.aclState, persisted at kernl:grants:v1. |
createEnvironmentResolver | (grants: Grants) => CapabilityHooks['resolveEnvironment'] | Baseline plus allowed domains; shell only when blocked. |
BASELINE_DOMAINS | readonly string[] | ['shell', 'inc', 'theme', 'storage']. |
DOMAIN_CAPABILITIES | Readonly<Record<string, readonly Capability[]>> | The table above. |
capabilitiesForDomain | (domain: string) => readonly Capability[] | Empty for unknown domains. |
capabilitiesForDomains | (domains: readonly string[]) => readonly Capability[] | Union in table order. |
describeChange | (event: NostrEvent, before: NostrEvent | undefined, applet: AppletIdentity) => ConsentSummary | The kind table above. |
isDestructiveKind | (kind: number) => boolean | True for every replaceable kind (0, 3, 10000–19999), every addressable kind (30000–39999), 5 and 62. |
createSigningConsent | (options: SigningConsentOptions) => SigningConsent | The gate both signing routes pass; see Consent summaries. |
abbreviate | (hex: string) => string | First eight characters and an ellipsis. |
createConsentHandler | (host: Pick<KernlHost, 'presentConsent'>, lookupBefore: LookupBefore, resolveApplet: ResolveAppletIdentity) => (request: ConsentRequest) => void | For bridge.registerConsentHandler. |
lookupReplaceable | (store: Pick<EventStore, 'getReplaceable'>, options?: LookupReplaceableOptions) => LookupBefore | Finds the current version for the diff: the store first, then options.loader over options.relays() for up to options.timeoutMs. |
LookupReplaceableOptions | interface | { loader?: AddressPointerLoader; relays?: () => readonly string[]; timeoutMs?: number }. Without loader the lookup reads the store only. |
CURRENT_VERSION_TIMEOUT_MS | 4000 | How long a consent prompt waits for the network to return the current version. |
UNKNOWN_CURRENT_VERSION | ConsentLine | { label: 'Current version', after: 'could not be loaded; this replaces it entirely' }, the first line of a destructive summary whose current version was not found. |
Grants, GrantRecord, KernlGrants, GrantsOptions, ConsentSummary, ConsentLine, ConsentPrompt, LookupBefore, ResolveAppletIdentity, SigningConsent, SigningConsentOptions, SigningTemplate | types | GrantRecord is { identity, domains, capabilities, blocked, stateQuotaBytes }. |
Applesauce adapters
| Export | Signature | Description |
|---|---|---|
createKernlAccounts | (storage: StorageLike) => AccountManager | A KernlAccountManager with common types registered and persistence wired. |
KernlAccountManager | class extends AccountManager | addAccount throws denied:plaintext-key-persistence for plaintext-key types. |
PLAINTEXT_KEY_ACCOUNT_TYPES | readonly string[] | ['nsec', 'seed-words']. |
createAuthHooks | (accounts: AccountManager, consent: SigningConsent) => AuthHooks | kehto's auth over the account manager; a signer asked for with a window id passes consent before signing. |
wrapAccountSigner | (account: IAccount) => Signer | The NIP-07-shaped signer kehto expects. |
isReadOnlyAccount | (account: IAccount) => boolean | True for ReadonlyAccount. |
createReplaceableLoader | (store: EventStore, pool: ApplesaucePool, tiers: RelayTiers) => AddressPointerLoader | The applesauce address loader for replaceable and addressable events the store lacks: it asks the pointer's relays, then lookupRelaysOf(tiers) (the discovery tier, then super), and adds what it finds to the store. Used by createIdentityProviders and the consent lookup. |
createIdentityProviders | (store: EventStore, pool: ApplesaucePool, tiers: RelayTiers, options?: IdentityProviderOptions) => IdentityProviders | Profile, follows, mutes and relays from the store, loaded once from the lookup relays on a miss; lists, zaps, blocked and badges are empty. |
createRelayPoolHooks | (pool: ApplesaucePool, tiers: RelayTiers) => RelayPoolHooks | kehto's relay-pool hooks, including one scoped relay per window. |
createRelayPoolLike | (pool: ApplesaucePool) => RelayPoolLike | subscription, request, publish over the one pool. |
createAppletRelayBackend | (pool: ApplesaucePool, tiers: RelayTiers) => RelayPoolServiceOptions | For kehto's createRelayPoolService; publishes go to the tier for the event's kind and succeed when any relay accepts. |
publishAccepted | (pool: ApplesaucePool, relays: string[], event: NostrEvent) => Promise<number> | Publishes everywhere and resolves with how many accepted; throws unavailable:not-accepted:0/<n> only when none did (D-028). |
lookupRelaysOf | (config) => string[] | Where an unhinted replaceable is looked up: discovery, then super. |
createProfiles | (options: ProfilesOptions) => Profiles | card$(pubkey) streams a ProfileCard (name, https: picture, address, about, found) and starts a lookup on the lookup relays, then on the author's own write relays when their relay list names servers the lookup relays lacked. Nothing times out; a late profile replaces the short key. card(pubkey) reads the store only. settled(pubkey) resolves once the profile is found or every lookup server has answered, never on a timer. |
createAccountFileServers | (options: AccountFileServersOptions) => AccountFileServers | The account's file servers (BUD-03, kind 10063): state$, state() (loading, missing with the defaults, found), current() (waits only while the list is looked up), save(servers) => Promise<PublishOutcome> (publishes to the outboxes and lookup servers; denied:read-only, invalid:server-url). kernl.files is one (D-033). |
DEFAULT_FILE_SERVERS, fileServersFromEvent, fileServerTags | constant and helpers | The fallback servers, the servers a list names in order, and the tags of a new list with every other tag kept. |
createBlossomUploader | (options: BlossomUploaderOptions) => Uploader | kehto's Uploader over blossom-client-sdk: asks ask(windowId, details) once per file before anything is signed or sent, signs one authorization, uploads to the first server and mirrors to the rest; success when any server stores it, with every copy in fallbackUrls and NIP-94 tags. Refusals: denied:read-only, denied:consent (cancelled), unavailable:no-file-server, unavailable:not-accepted:0/<n>. transfer is injectable. |
describeUpload, askUploadConsent, sizeWords | helpers | The public consent summary for an upload, the host prompt that refuses an unnamed applet without asking, and sizes in words. |
createCommonActions, commonActionsFor | (options: CommonActionsOptions) => CommonServiceOptions | kehto's NAP-COMMON hooks (D-035). getProfile takes a key or shared address. follows reads the current list. follow/unfollow change the kind 3 list through the signing gate, keeping every other entry, tag and the content. react (kind 7) and report (kind 1984) publish. Writes succeed when any relay accepts, and a read-only session gets denied:read-only. createKernl registers them, so the common domain is offered. |
labelConversations | (adapter: DmAdapter, profiles: Pick<Profiles, 'settled'>) => DmAdapter | Names every conversation participant the dm domain lists from their profile; a provider's own label is kept, and a person with no profile keeps none. createKernlServices applies it when given profiles, so messaging applets show names without looking anyone up. |
toProfileCard | (pubkey: string, event: NostrEvent | undefined) => ProfileCard | Display name, then name, then the abbreviated key. |
createDmRelayPool | (pool: ApplesaucePool, tiers: RelayTiers) => DmRelayPool | For messaging backends. |
createOutboxRelayPool | (pool: ApplesaucePool) => OutboxRelayPoolLike | For kehto's createRelayPoolOutboxRouter, with per-relay publish outcomes. |
subscribeWithEose | (pool: Pick<ApplesaucePool, 'req'>, relays: string[], filters: NostrFilter[], callback: (item: NostrEvent | 'EOSE') => void) => EoseSubscription | Each event once, one EOSE after every relay answered or failed. |
selectRelayTier | (tiers: RelayTiers, filters: readonly NostrFilter[]) => string[] | The routing rule under Relay tiers. |
createRelayTiers | (storage: StorageLike, defaults?: Partial<Record<RelayTier, readonly string[]>>) => RelayTiers | Persisted at kernl:relays:v1; changes$ replays the current value. |
DEFAULT_RELAY_TIERS | Readonly<Record<RelayTier, readonly string[]>> | The table above. |
ApplesaucePool, EoseSubscription, OutboxRelayPoolLike, IdentityProfile, IdentityProviderOptions, IdentityProviders, RelayPermissions, RelayTier, RelayTiers | types | ApplesaucePool is Pick<RelayPool, 'req' | 'request' | 'publish'> so tests can supply a fake. |
Servers and mailboxes
Not yet composed by createKernl; Inbox, outbox and finding servers explains them.
| Export | Signature | Description |
|---|---|---|
summarizePublish | (responses: readonly PublishAnswer[]) => PublishOutcome | { accepted, refused: { url, reason }[], ok } from RelayPool.publish's answers; ok when any relay accepted, a relay counts once, an acceptance wins (D-028). |
describeOutcome | (outcome: PublishOutcome) => string | Saved to 2 of 3 servers, Saved to all 3 servers, Saved to 1 server, No server accepted it, There was no server to save it to. Never the relay's reason. |
toRelayUrl | (url: string) => string | undefined | The normalised ws:/wss: URL, or undefined for anything else. |
relayUrls | (urls: Iterable<string>) => string[] | Normalised, deduplicated, non-URLs dropped. |
createRelayDirectory | (options: RelayDirectoryOptions) => RelayDirectory | NIP-66 reports (kind 30166) streamed from options.relays (DEFAULT_MONITOR_RELAYS), folded per relay; listings$, listings(), search(text, limit?), suggest({ purpose, exclude?, limit? }), start(), stop() (D-029). Options: pool (DirectoryPool, Pick<RelayPool, 'subscription'>), relays (array or Observable), maxAgeSeconds (two days), now, fallback, throttleMs (250). |
DEFAULT_MONITOR_RELAYS | readonly string[] | wss://relay.nostr.watch, wss://relaypag.es, wss://monitorlizard.nostr1.com. |
DEFAULT_SUGGESTIONS | Readonly<Record<SuggestPurpose, readonly string[]>> | The fallback per purpose: relay.damus.io, nos.lol, relay.primal.net for inbox and outbox; purplepag.es, user.kindpag.es, indexer.coracle.social for lookup. |
createAccountMailboxes | (options: AccountMailboxesOptions) => AccountMailboxes | The active account's kind 10002 list: state$, state() (loading, missing with suggested, found with mailboxes and event) and save(next, onProgress?) => Promise<PublishOutcome>; with onProgress and a pool that has event(), the outcome so far is reported as each server answers, the list enters the store once one server holds it, and a server that never answers counts as refusing with no answer. Options: store, pool (MailboxPool, Pick<RelayPool, 'req' | 'publish'> and optionally event), pubkey$, lookupRelays, signer (MailboxSigner), directory?, patienceMs? (15 000). save rejects with denied:read-only or invalid:relay-url. |
mailboxesFromEvent | (event: NostrEvent | undefined) => Mailboxes | { inboxes, outboxes }; an unmarked r tag is both. |
mailboxTags | (mailboxes: Mailboxes, previous?: NostrEvent) => string[][] | r tags, unmarked for a relay in both lists, then the previous list's other tags. |
PublishOutcome, PublishAnswer, RelayDirectory, RelayDirectoryOptions, RelayListing, SuggestOptions, SuggestPurpose, DirectoryPool, AccountMailboxes, AccountMailboxesOptions, MailboxState, Mailboxes, MailboxPool, MailboxSigner | types | RelayListing is { url, monitors, lastSeen, rttOpen?, rttRead?, rttWrite?, network?, nips, types, requiresAuth, requiresPayment, restrictedWrites }. |
Registries, layouts and persistence
| Export | Signature | Description |
|---|---|---|
createDefaultMessaging | (options: DefaultMessagingOptions) => MessagingRegistry | The three providers with persisted preferences; each is available when its backend is supplied and the active account can serve it. DefaultMessagingOptions.signer (() => ISigner | undefined) makes private and legacy availability follow that signer, including for backends a host supplies. |
createPrivateMessagesBackend | (options: { store: EventStore; pool: ApplesaucePool; tiers: RelayTiers; signer: () => ISigner | undefined; selfPubkey: () => string | undefined; now?: () => number }) => MessagingBackend | NIP-17 messages over the account signer: seal signed by the account with nip44, wrap signed by a discarded ephemeral key, sent to inbox relays with the outbox and super tiers as fallback, plus a copy to self. See Messaging backends. |
createLegacyMessagesBackend | same options => MessagingBackend | NIP-04 kind 4 messages through signer().nip04, normalised the same way. Metadata-exposing. |
signerAvailability | (signer: ISigner | undefined, method: MessagingEncryption) => ProviderAvailability | sign-in-required with no signer, signer-cannot-encrypt when it lacks method, otherwise available. |
messagingSigner | (account: IAccount | undefined) => ISigner | undefined | The signer messaging uses for an account. A read-only account maps to a signer with no encryption, so it reports signer-cannot-encrypt. |
MessagingEncryption | 'nip44' | 'nip04' | The signer method a backend needs. |
SignerMessagingOptions | { store, pool, tiers, signer, selfPubkey, now?, accountChanges? } | Options for both backends. accountChanges clears the in-memory conversations when the account changes or signs out. |
MESSAGING_UNAVAILABLE_REASONS | Readonly<Record<'signInRequired' | 'signerCannotEncrypt' | 'noRuntime', string>> | sign-in-required, signer-cannot-encrypt, no-runtime. |
createDefaultBridges | (options?: DefaultBridgesOptions) => BridgeRegistry | The HTTP bridge plus torrent, nzb, soulseek and mail. Without options.handoff the last four report no-backend (D-010); with it they hand off to an app on the device. options.torrent replaces the hand-off for the torrent bridge only (D-017). options.http goes to the HTTP bridge; the rest to the registry. |
floatingLayout, createDwindleLayout, createColumnsLayout, createMasterStackLayout | LayoutStrategy and (options?) => LayoutStrategy | The layouts above. |
createLayoutRegistry, createDefaultLayouts | (entries?: readonly LayoutEntry[]) => LayoutRegistry, (options?: LayoutOptions) => LayoutRegistry | The layouts a window manager offers. |
createWindowManager | (options?: WindowManagerOptions) => WindowManager | kernl's window manager. |
installCryptoFallback | (scope?: { crypto?: Crypto }) => CryptoFallbackReport | Adds crypto.randomUUID and crypto.subtle where the platform withholds them. Also on @kernlize/core/crypto. |
createFallbackSubtle | () => SubtleCrypto | The pure-JS SubtleCrypto described above. |
randomUuidFallback | (scope: { crypto?: Crypto }) => boolean | Adds only crypto.randomUUID; self-contained, so it can be serialised into a frame. |
renderRandomUuidFallbackScript | () => string | The applet-side <script> the mount plan inserts. |
CryptoFallbackReport | type | { randomUUID, subtle }: what was added. |
createNamespacedStore | (storage: StorageLike, prefix: string) => NamespacedStore | getJson, setJson, remove under <prefix>:; corrupt JSON reads as the fallback. |
DefaultMessagingOptions, DefaultBridgesOptions, LayoutOptions, MasterStackOptions, NamespacedStore | types | |
LayoutChoice, LayoutEntry, LayoutRegistry, ManagedWindow, VisibilityTarget, WindowManager, WindowManagerOptions, WindowManagerState, WindowSize | types | The window-management surface. |
LayoutStrategy, Rect, WindowPlacement, WindowState | types | Re-exported from @kehto/wm, so a layout can be written against one import. |
Event cache
| Export | Signature | Description |
|---|---|---|
openEventCache | (adapters: readonly EventCacheAdapter[], options?: OpenEventCacheOptions) => Promise<EventCache> | The first adapter that opens, or memory. Also on @kernlize/core/cache, like everything in this table. |
defaultWebCacheAdapters | () => readonly EventCacheAdapter[] | [sqlite-opfs, indexeddb, memory]. |
createMemoryCacheAdapter, createIndexedDbCacheAdapter, createSqliteOpfsCacheAdapter | () => EventCacheAdapter, (options?: IndexedDbCacheOptions) => EventCacheAdapter, (options?: SqliteOpfsOptions) => EventCacheAdapter | The three web backends. |
fromEventDatabase | (name: string, database: IAsyncEventDatabase | IEventDatabase, close?: () => Promise<void> | void) => EventCache | Any applesauce event database as a cache. |
createEventCache | (name: string, records: EventRecords) => EventCache | The shared rules over a backend's rows. |
attachEventCache | (store: Pick<IEventStoreStreams, 'insert$'>, cache: EventCache, options?: AttachEventCacheOptions) => AttachedEventCache | Writes and cacheRequest for loaders. |
connectEventCache | (store, open?: EventCacheOpener) => ConnectedEventCache | Opens in the background and attaches; cacheRequest waits for the open and answers [] without a cache; close() stops writes and closes. createKernl uses it for options.eventCache. |
isCorruptionError | (error: unknown) => boolean | Whether a sqlite error means a damaged file. |
EventCache, EventCacheAdapter, EventRecords, OpenEventCacheOptions, IndexedDbCacheOptions, SqliteOpfsOptions, AttachEventCacheOptions, AttachedEventCache, CacheRecoveryEvent, SqliteDriver, SqliteFiles, SqlStatement, SqlValue, LockManagerLike, FilenameStorage | types |
Scope
- Composes; does not re-implement a kehto primitive. Local layers exist only where
ShellAdapterorcreateRuntimehas no hook, and each is named in Alpha status. - Imports events, relays, signing and accounts from applesauce only.
nostr-toolsis a peer dependency, required by kehto and applesauce, andpnpm check:depsfails if it is imported. - Holds no UI.
apps/websupplies the iframe, the visibility, the prompts and the notices throughKernlHost. - Serialises no private key.
PasswordAccountis the only persisted form of a local key and it is applesauce's.
API reference
Generated module: _kernlize_core