NAP-CONFIG / PR #14
Per-Napplet Declarative Configuration
Full specification from the fetched upstream revision ↗. The document below is rendered from its original Markdown. Follow the latest source on GitHub ↗.
NAP-CONFIG
Per-Napplet Declarative Configuration
draft
NAP ID: NAP-CONFIG
Domain: config
Depends:
storage— layering · optional — the runtime MAY persist configuration through storage internally; this imposes no storage requirement on the napplet. Web binding (NIP-5D):window.napplet.config; domain presence signals availability. Parent: NIP-5D
Description
NAP-CONFIG provides per-napplet declarative configuration. A napplet declares a JSON Schema (draft-07 or later) describing its user-facing settings surface; the shell renders a settings UI, validates user input against the schema, persists values in a napplet-scoped store, and delivers live values back to the napplet via snapshot + push.
The shell is the sole writer of configuration values. Napplets cannot mutate configuration over the wire -- they may only read (config.get), subscribe to live updates (config.subscribe), register a schema (config.registerSchema), and request that the shell open its settings UI (config.openSettings). All values a napplet sees have been validated by the shell against the currently-registered schema, with declared defaults applied.
API Surface
| Operation | Parameters | Result | Wire |
|---|---|---|---|
registerSchema |
schema (ConfigSchema), optional version (uint) |
none; errors arrive through onSchemaError |
config.registerSchema / config.registerSchema.result |
get |
none | ConfigValues |
config.get / config.values |
subscribe |
handler for ConfigValues |
Subscription handle |
config.subscribe / config.values |
onSchemaError |
handler for ConfigSchemaError |
Subscription handle |
config.schemaError |
openSettings |
optional section (tstr) |
none | config.openSettings |
schema |
none | ConfigSchema or null |
local read of currently registered schema |
Schemas
Primitive references:
| Name | Meaning |
|---|---|
ConfigSchema |
JSON Schema draft-07 or later, restricted below. |
ConfigValues |
Shell-validated JSON-serializable configuration values. |
Enumerations:
| Name | Values |
|---|---|
ConfigSchemaErrorCode |
invalid-schema, version-conflict, unsupported-draft, ref-not-allowed, pattern-not-allowed, secret-with-default |
ConfigSchemaError:
| Field | Type | Required | Description |
|---|---|---|---|
code |
ConfigSchemaErrorCode |
yes | Machine-readable schema error code. |
message |
tstr |
yes | Human-readable error message. |
registerSchema(schema, version?) — Declares the configuration schema
through this domain. The optional version parameter is the $version
migration hint. The runtime returns config.registerSchema.result; errors also
surface via onSchemaError. The current napplet manifest has no configuration
schema field; callers MUST register a schema before requesting values.
get() -- Returns a one-shot snapshot of the current configuration values. Returned values are always validated by the shell and defaulted per the registered schema. Resolves with a ConfigValues object.
subscribe(callback) -- Subscribes to live configuration updates. The shell MUST deliver an immediate initial config.values push on subscription (snapshot delivery), followed by pushes whenever the shell's settings UI commits a change. Returns a Subscription with a close() method that detaches the callback. The reference shim implementation SHOULD ref-count local subscribers and only send a wire-level config.unsubscribe when the last local subscriber detaches.
onSchemaError(callback) -- Subscribes to schema-registration errors. The shell pushes config.schemaError messages when it rejects a registerSchema call or cannot validate a registered schema. Returns a Subscription with close().
openSettings(options?) -- Requests that the shell open its settings UI for this napplet. The optional options.section string deep-links the UI to a named section; the section MUST be declared via the x-napplet-section extension somewhere in the registered schema. Fire-and-forget -- the shell decides how to render the UI (modal, panel, new tab) and whether to honor the request.
schema -- Read-only accessor for the currently-registered schema. Returns the schema last accepted through registerSchema, or null if none has been declared. Useful for napplets that render capability-dependent UI based on which settings exist.
Identity Scope
The runtime MUST derive scope from the authenticated endpoint's verified
manifest and artifact. For a named manifest, the manifest key is its publisher,
kind, and d value; for a root manifest, publisher and kind; for a snapshot,
its signed event id. The scope also includes artifactHash, the verified
single-artifact hash defined by NIP-5D.
A bare d value MUST NOT identify a scope. Root and snapshot manifests require
no d tag. A napplet MUST NOT supply or override manifest or artifact identity fields in a request.
Different publishers, manifest keys, and artifact hashes MUST remain isolated.
Wire Protocol
config.* messages use the NIP-5D wire format ({ "type": "domain.action", ...payload }).
| Type | Direction | Payload fields |
|---|---|---|
config.registerSchema |
napplet -> shell | id, schema, version? |
config.registerSchema.result |
shell -> napplet | id, ok, error?, code? |
config.get |
napplet -> shell | id |
config.subscribe |
napplet -> shell | (none) |
config.unsubscribe |
napplet -> shell | (none) |
config.openSettings |
napplet -> shell | section? |
config.values |
shell -> napplet | id?, values |
config.schemaError |
shell -> napplet | error, code |
Key design notes:
- All correlation uses
id. config.valuesis dual-use: withidit answersconfig.get; withoutidit is a subscription push.config.registerSchemauses positive-ACK -- every call receives aconfig.registerSchema.resultwithok: true, orok: falseaccompanied bycodeanderrordescribing the rejection reason. It is never error-only.config.subscribeMUST produce an immediate initialconfig.valuespush (snapshot delivery) before any value-change pushes.config.unsubscribeis the clean-teardown path; iframe unmount also implicitly unsubscribes the source.
Examples
Register schema (success):
-> { "type": "config.registerSchema", "id": "r1", "schema": { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "theme": { "type": "string", "enum": ["light", "dark"], "default": "dark" } } }, "version": 1 }
<- { "type": "config.registerSchema.result", "id": "r1", "ok": true }
Register schema (rejection):
-> { "type": "config.registerSchema", "id": "r2", "schema": { "type": "object", "properties": { "username": { "type": "string", "pattern": "^[a-z]+$" } } } }
<- { "type": "config.registerSchema.result", "id": "r2", "ok": false, "code": "pattern-not-allowed", "error": "JSON Schema `pattern` keyword is not permitted in the Core Subset" }
Get values:
-> { "type": "config.get", "id": "g1" }
<- { "type": "config.values", "id": "g1", "values": { "theme": "dark" } }
Subscribe + initial snapshot:
-> { "type": "config.subscribe" }
<- { "type": "config.values", "values": { "theme": "dark" } }
Push update after user changes a value:
<- { "type": "config.values", "values": { "theme": "light" } }
Unsubscribe:
-> { "type": "config.unsubscribe" }
Open settings with section deep-link:
-> { "type": "config.openSettings", "section": "notifications" }
Schema error push (background rejection):
<- { "type": "config.schemaError", "code": "invalid-schema", "error": "schema root must be of type `object`" }
Schema Contract
NAP-CONFIG is a JSON-Schema-driven protocol, but it is NOT all of JSON Schema. Shells and napplets MUST agree on a bounded subset so that the same schema renders and validates consistently across every conformant shell. This section defines that subset (the Core Subset), the standardized annotations shells MAY act on (Potentialities), the migration-version signal ($version), and the set of features explicitly excluded from v1. Any schema feature not named in Core Subset or Potentialities is out of scope for NAP-CONFIG v1.
Core Subset
Every conformant shell MUST accept and render schemas that use only the types, keywords, and constraints enumerated below. Napplets MUST NOT rely on any feature outside this subset for correct behavior.
Supported types (Core Subset):
object-- top-level only. The schema root MUST be{ "type": "object", "properties": { ... } }.stringnumberintegerbooleanarrayof primitives -- homogeneous only. Tuple-typed arrays (items: [schemaA, schemaB]) are NOT in the Core Subset.- Nested
object-- bounded depth; see Depth Limit below. Recommended maximum depth is 4.
Supported keywords (Core Subset):
typepropertiesrequired(array form)items(homogeneous schema only)additionalProperties(see override below)defaulttitledescriptionenumenumDescriptions-- parallel-array hint whose entries correspond positionally toenumentries; shells SHOULD render each description alongside its enum value.
Supported constraints (Core Subset):
minimum,maximum-- apply tonumberandintegervaluesminLength,maxLength-- apply tostringvaluesminItems,maxItems-- apply toarrayvalues
additionalProperties override:
Shells MUST treat additionalProperties as false at the top-level schema object when the napplet does not specify it explicitly. This overrides the JSON Schema draft-07 default of true for NAP-CONFIG scope. Rationale: persisted user settings must not accrete unknown properties silently; schema drift between napplet versions should drop orphaned keys, not preserve them.
Default-resolution rule (deterministic):
For every property at config.values delivery time, the shell MUST resolve its value in the following order:
- If a persisted value exists AND validates against the current schema, use it.
- Else if the property has its own
default, use that. - Else if an ancestor
defaultobject supplies a value for this property, use the ancestor's value for this property. - Else the property is
undefinedin the deliveredconfig.values.
Standardized Extensions (Potentialities)
These are opt-in annotations napplets MAY declare on schema properties. Shells MAY act on them; shells MAY ignore them. Shells that do not recognize an extension MUST treat it as opaque metadata and MUST NOT reject the schema on that basis.
| Keyword | Applies to | Shell MAY |
|---|---|---|
x-napplet-secret (boolean) |
any string property |
mask input, suppress console/log output, decline to render a default value, route the stored value through a platform keychain or encrypted store |
x-napplet-section (string) |
any property | group the property under the declared section heading in the settings UI |
x-napplet-order (non-negative number) |
any property | sort properties within a section in ascending x-napplet-order; ties broken alphabetically by property key |
deprecationMessage (string) |
any property | render the message next to the field as a deprecation notice |
markdownDescription (string) |
any property | render the value as markdown; shells MAY fall back to plain text if markdown rendering is not available |
$version Potentiality
A schema MAY carry a top-level $version integer field. Shells MAY use $version to drive cross-hash migration when the napplet's artifactHash changes; shells MAY ignore it entirely and treat each hash as a fresh scope. NAP-CONFIG does NOT prescribe a migration strategy -- migration is a shell concern, and $version is a signal, not a contract.
Napplets MUST NOT receive values that violate the currently-registered schema at config.values delivery time, regardless of how the shell reconciles older persisted values. Any clamping, dropping, or user-prompted merging happens entirely shell-side; the napplet only ever sees post-migration values.
Exclusions (Not in v1 Core Subset)
The following are explicitly excluded from NAP-CONFIG v1:
patternkeyword -- excluded due to ReDoS (Regular Expression Denial of Service) risk. JavaScript's built-inRegExpengine is backtracking-based; a napplet-declared pattern such as^(a|a)*$against a 31-character input can consume tens of seconds of CPU on the shell's main thread. See CVE-2025-69873 (ajv validator) for a concrete real-world precedent of this class of vulnerability in JSON Schema validators. Readmission requires a shell-safe regex strategy (Worker + timeout, RE2-WASM, or equivalent) to be specified in a future revision of this NAP.$ref-- ALL forms forbidden, including same-document (#/definitions/foo), external URL (https://...), filesystem (file://), and cross-document relative references. Rationale: schemas are self-contained;$refresolution is a security surface (exfiltration, DoS via slow URLs, TOCTOU) and a renderer-complexity explosion.definitions/$defs-- follows from the$refexclusion; with no$refthere is nothing to reference.oneOf,anyOf,allOf,not-- combinatorial schemas are out of Core Subset v1. Shells MAY support them as an Extended Subset, but napplets MUST NOT rely on them for conformant behavior.if/then/else-- conditional schemas are out of scope for v1.patternProperties,propertyNames,dependencies,dependentSchemas,unevaluatedProperties-- draft 2019-09+ features out of scope.- Tuple-typed arrays (
items: [schemaA, schemaB]) -- only homogeneous arrays are in the Core Subset. - Recursive
$refof any kind -- forbidden by the$refexclusion, called out explicitly here so there is no ambiguity. - Expression-valued defaults (
"$now()","$random()", etc.) --defaultvalues are literal JSON per JSON Schema semantics. Napplets needing runtime-generated defaults MUST handle theundefinedcase in their own code. x-napplet-secret: truecombined with a declareddefault-- a secret with a hardcoded default is not a secret. Shells MUST reject any schema that declares both on the same property withcode: "secret-with-default".
Format as Hint Only
The JSON Schema format keyword (email, uri, date, date-time, color, ipv4, ipv6, and others) is annotative in NAP-CONFIG: shells MAY use format to pick an input widget but MUST NOT reject a value solely because it fails the format. Napplets requiring strict validation MUST use enum, minLength, maxLength, minimum, or maximum -- NOT format.
Depth Limit
Shells MUST reject schemas nesting object properties more than 4 levels deep at config.registerSchema time, returning config.registerSchema.result with ok: false and code: "schema-too-deep". Napplets MUST NOT assume arbitrary nesting is supported.
Shell Guarantees
The key words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY in this document are to be interpreted as described in RFC 2119.
MUST
| Behavior | Details |
|---|---|
| Validate every value before delivery | Every config.values delivery MUST contain only values that validate against the currently-registered schema. Invalid values MUST NOT be delivered. |
| Apply declared defaults | Missing properties MUST be populated from default per the deterministic default-resolution rule in Schema Contract. |
| Use the verified identity scope | Persisted values MUST be keyed on the napplet's verified manifest and artifact scope defined in Identity Scope. A napplet cannot read or write outside its own scope. |
| Be the sole writer | The shell MUST be the only entity that writes values. No napplet->shell wire message mutates persisted values. |
Mask x-napplet-secret: true fields (Tier 0) |
Shells MUST treat properties marked x-napplet-secret: true as secrets at the minimum Tier 0 level: mask input in the settings UI, do NOT include the default (napplets MUST NOT set default on such fields), and do NOT deliver the property in config.values if the value has never been explicitly set by the user. |
| Reject schemas exceeding the depth limit | Return config.registerSchema.result with ok: false, code: "schema-too-deep". |
| Reject schemas with forbidden features | $ref, pattern (in v1), definitions, combinatorial schemas (oneOf/anyOf/allOf/not), tuple-typed arrays, and conditional schemas (if/then/else) MUST cause config.registerSchema.result with ok: false and an appropriate code. |
Reject x-napplet-secret: true coexisting with default |
Return config.registerSchema.result with ok: false, code: "secret-with-default". |
| Produce initial snapshot after registerSchema is applied | config.subscribe MUST NOT deliver its first config.values until the most recent config.registerSchema from the same source has been fully applied (defaults resolved, storage scoped). If subscribe arrives before any schema has been registered (no accepted registerSchema), shells MUST emit config.schemaError with code: "no-schema". |
| Drop orphaned properties | Persisted values for properties not in the currently-registered schema MUST NOT be delivered. For x-napplet-secret: true properties, orphaned values MUST be deleted immediately on schema change; non-secret orphans MAY be retained briefly (grace period) but MUST NOT be delivered. |
SHOULD
| Behavior | Details |
|---|---|
Group by x-napplet-section |
Shells SHOULD render properties grouped under the declared section heading. |
Sort within a section by x-napplet-order |
Ascending; ties broken by property-key alphabetical order. Properties without x-napplet-order come after, alphabetically sorted. |
Surface deprecationMessage |
Display next to affected fields so users understand impending removal. |
Render markdownDescription as markdown |
Falling back to plain text. Never render as HTML. |
| Coalesce rapid value changes | Debounce user-side edits (recommend ~100ms) so napplets receive terminal values, not every keystroke. |
| Drop non-secret orphans after a grace period | One session is the recommended grace window. |
Honor config.openSettings for focused napplets |
Only when the calling napplet has (or would have) user focus. Rate-limit repeated requests. |
MAY
| Behavior | Details |
|---|---|
| Tier 2+ secret handling | Shells MAY store x-napplet-secret: true values encrypted at rest, in an OS keychain, or in another hardened backend. NOT required -- a Tier 0 in-process store is conformant. |
Render richer format widgets |
For format: "email" | "uri" | "date" | "date-time" | "color" | "ipv4" | "ipv6", shells MAY render a specialized widget; MUST NOT fail validation on format alone. |
| Render nested objects beyond one level | Up to the depth limit (4). JSON fallback UI is acceptable for deep nesting. |
| Back NAP-CONFIG storage with NAP-STORAGE internally | An implementation choice. The NAP-CONFIG wire surface does not depend on NAP-STORAGE. |
Use $version for cross-hash migration |
Shells MAY act on $version to migrate values across artifactHash changes; MAY ignore it and treat each hash as a fresh scope. |
| Retain a "graveyard" of orphaned non-secret values | For a single session in case the napplet author rolls back a schema change. Graveyard values MUST NOT be delivered. |
Emit config.settingsOpened after openSettings |
Optional ack so napplets can fall back when UI is unavailable. Napplets MUST NOT rely on this message. |
Anti-Features
The following ideas are deliberately excluded from NAP-CONFIG and MUST NOT appear in conformant implementations. They are listed here so that proposals to add them surface the prior rationale rather than re-litigating.
config.set(napplet->shell write message) -- violates the sole-writer invariant; defeats shell-UI validation and consent UX; enables silent settings override by compromised napplets.$ref/definitions/$defsin schemas -- schemas are self-contained; $ref resolution is a security surface (exfiltration, DoS, TOCTOU) and a renderer complexity explosion. Bundle refs at build time.patternkeyword in Core Subset v1 -- ReDoS exposure per CVE-2025-69873 class. Readmission requires a shell-safe regex strategy in a future revision.- napplet-rendered settings iframe -- defeats consistent UX across napplets and places the settings surface inside the same sandbox that renders the napplet.
- napplet-supplied validation code -- the shell executing napplet code is a sandbox violation and breaks validate-before-delivery.
- napplet-dictated UI widget hints (
ui:widget) -- turns NAP-CONFIG into a UI framework spec. - Migration-as-napplet-code -- shell is the risk carrier;
$versionas a signal suffices. - Cross-napplet config sharing -- violates per-napplet isolation (security property).
- Live two-way binding / streaming uncommitted keystrokes -- creates validation flicker and storage churn; shells debounce and push on commit instead.
- JSON Schema draft 2020-12 conditional features (
if/then/else,unevaluatedProperties,dependentSchemas,$dynamicRef) -- enormous validator/UI complexity, no precedent uses them for extension config. - OS-keychain secret handling as MUST -- unimplementable in browser-only shells; kept as MAY.
Security Considerations
Source-identity scope binding
Storage scope is derived from the authenticated endpoint's verified manifest and artifact, as specified in Identity Scope. NAP-CONFIG requests MUST NOT carry caller-selected identity or scope fields. A napplet cannot read, register, or mutate another napplet's configuration scope.
Cleartext secrets over postMessage
config.values payloads are transmitted cleartext over postMessage to the napplet iframe, including fields marked x-napplet-secret: true. This is a property of the sandbox model (no secure key-exchange channel exists between a sandboxed iframe and its host that isn't itself postMessage). Implementers MUST be aware that:
- Browser extensions with script access can observe the event
- Developer-tools inspection exposes the payload
- Crash reporters and analytics may capture it
Shells SHOULD suppress console logging of config.values deliveries containing x-napplet-secret: true fields. Napplets SHOULD read secrets on demand and avoid holding them in long-lived memory. NAP-CONFIG does NOT claim secrets are "secure" -- the honest framing is that the shell decides when to deliver.
additionalProperties: false override
JSON Schema draft-07 defaults additionalProperties to true. NAP-CONFIG overrides this: the top-level schema object is treated as if additionalProperties: false when the napplet does not specify otherwise. This prevents silent persisted-data accretion and makes schema evolution deterministic (orphaned properties drop out of delivery). Napplets SHOULD still declare additionalProperties: false explicitly for robustness across shell implementations.
External $ref forbidden
All $ref forms are forbidden in v1 (see Schema Contract Exclusions). The ban is particularly important for external references (http://, https://, file://, ftp://): they enable data exfiltration (the request URL can carry stolen values), denial-of-service (slow or hanging URLs stall validation), and privacy leaks (every validation pings the remote). Shells MUST reject any schema containing $ref at config.registerSchema time.
Error Envelopes
Every napplet->shell request type either returns a correlated result message with an ok: boolean field (positive ACK) or fires a matching push error. Result messages follow the NIP-5D envelope format { "type": "domain.action.result", "id", "ok", "code"?, "error"? }. Error code values are drawn from the catalogue below; error is a human-readable explanation.
Error codes
| Code | Emitted by | Meaning |
|---|---|---|
invalid-schema |
config.registerSchema.result, config.schemaError |
Napplet-supplied schema is not parseable as JSON Schema draft-07 or later. |
unsupported-draft |
config.registerSchema.result, config.schemaError |
Schema declares a JSON Schema draft the shell does not support. |
ref-not-allowed |
config.registerSchema.result, config.schemaError |
Schema uses $ref, definitions, or $defs. |
pattern-not-allowed |
config.registerSchema.result, config.schemaError |
Schema uses the pattern keyword (excluded in Core Subset v1). |
secret-with-default |
config.registerSchema.result, config.schemaError |
A property marked x-napplet-secret: true declares a default. |
schema-too-deep |
config.registerSchema.result, config.schemaError |
Schema nesting exceeds the depth limit (4). |
version-conflict |
config.registerSchema.result, config.schemaError |
Re-registration declares a $version the shell does not accept. |
no-schema |
config.schemaError |
config.subscribe or config.get received before any schema has been registered for this source (no accepted registerSchema). |
unknown-section |
(see below -- non-normative) | config.openSettings referenced a section that does not exist on the currently-registered schema. SHOULD NOT be surfaced as an error envelope; shells SHOULD silently ignore (see Shell Guarantees SHOULD row for openSettings). |
Error cases
Malformed schema at registerSchema time. A schema that is not parseable, declares an unsupported draft, uses forbidden features, or violates the depth limit is rejected. The shell emits config.registerSchema.result with ok: false and the appropriate code. Napplets that registered via window.napplet.config.registerSchema() observe the failure via the onSchemaError listener; the shim correlates the .result via id.
Undeclared section in openSettings. When config.openSettings({ section }) references a section not declared by any property's x-napplet-section in the currently-registered schema, shells SHOULD silently ignore the request (opening the settings UI to its default entry is acceptable). Shells MUST NOT return a wire error for this case -- that would leak shell internals. The section string is case-sensitive and compared as an opaque identifier.
Subscribe-before-schema. config.subscribe or config.get that arrives before any schema has been registered (no accepted config.registerSchema) receives a config.schemaError push with code: "no-schema". No config.values is emitted until a valid schema has been registered.
Implementations
- (none yet)
Changelog
8d671ab- Introduced NAP-CONFIG for per-napplet declarative configuration.36c209b- Adopted injected-domain availability and verified manifest/artifact isolation, and required registerSchema instead of an undefined manifest schema field.0246251- Declared optional internal storage layering without imposing a storage capability requirement on napplets.