napplet.
← All NAPs

NAP-SOULSEEK / PR #135

Runtime-Mediated Soulseek File Discovery and Downloads

Open PR

Full specification from the fetched upstream revision ↗. The document below is rendered from its original Markdown. Follow the latest source on GitHub ↗.

NAP-SOULSEEK

Runtime-Mediated Soulseek File Discovery and Downloads

draft

NAP ID: NAP-SOULSEEK Domain: soulseek Web binding (NIP-5D): window.napplet.soulseek; domain presence signals availability. This pointer is informational; the contract below is projection-neutral.

Description

NAP-SOULSEEK lets a napplet search the Soulseek network, browse a peer's shared files, and request downloads through the runtime. The shell mediates account use, network activity, storage, content disclosure, and saving. The napplet designs discovery and transfer views using bounded results and opaque handles. The runtime executes Soulseek operations through its own client or a backend such as slskd; backend credentials and addresses never become napplet authority.

The user configures accounts, shared folders, and upload policy in the shell. This contract does not expose login, arbitrary protocol messages, local share administration, chat, or unsolicited uploads. Downloading MUST NOT automatically add a file to the user's shares. No other NAP is required. This domain owns reads of its downloaded data; its handles are not URLs or grants to other domains.

API Surface

An exposed soulseek domain MUST implement every operation below. save MAY be unavailable as reported by Info.canSave. Availability does not imply a connected account or a policy grant. Manifests name the bare soulseek domain.

Operation Parameters Result Wire
info none Info soulseek.info / soulseek.info.result
search request (SearchRequest) Query soulseek.search / soulseek.search.result
browse username (text) Query soulseek.browse / soulseek.browse.result
results queryId (text), optional after (integer), optional limit (integer) QueryPage soulseek.results / soulseek.results.result
stop queryId (text) Query soulseek.stop / soulseek.stop.result
download fileId (text) Transfer soulseek.download / soulseek.download.result
transfers none list of Transfer soulseek.transfers / soulseek.transfers.result
status transferId (text) Transfer soulseek.status / soulseek.status.result
pause transferId (text) Transfer soulseek.pause / soulseek.pause.result
resume transferId (text) Transfer soulseek.resume / soulseek.resume.result
cancel transferId (text) Transfer soulseek.cancel / soulseek.cancel.result
read transferId (text), offset (text), length (integer) Chunk soulseek.read / soulseek.read.result
save transferId (text) Saved soulseek.save / soulseek.save.result
release kind ("query" or "transfer"), handle (text) Released soulseek.release / soulseek.release.result

Schemas

All fields marked required MUST be present. Optional fields MUST be omitted when unavailable; null is invalid everywhere. An empty list means no entries. Requests with unknown fields, invalid types, or invalid ranges MUST fail with invalid_request before effects. Receivers MUST ignore unknown result fields for forward compatibility.

Byte counts and offsets use canonical unsigned decimal text ("0", or digits without a leading zero), from 0 through 18446744073709551615. This preserves Soulseek's 64-bit file sizes across projections. Other integers are between 0 and 9007199254740991 unless a narrower range is specified. Text MUST be valid Unicode without U+0000. Usernames are nonempty, case-preserving peer names; napplets MUST NOT normalize them into Nostr identities. Handles and request IDs are nonempty opaque text, compared exactly.

Info
Field Required Type Notes
state yes "unconfigured", "offline", or "online" Availability of the shell-selected Soulseek account.
canSave yes boolean Whether user-mediated export is implemented; not an authorization grant.
maxQueries yes integer Positive maximum retained queries per endpoint.
maxTransfers yes integer Positive maximum retained transfers per endpoint, including terminal ones.
maxResults yes integer Positive maximum files retained per query.
maxPageSize yes integer Positive maximum entries per results page.
maxReadBytes yes integer Positive maximum decoded bytes per read.
maxTextBytes yes integer Positive maximum UTF-8 bytes per input text value, including IDs; excludes fixed field names.
queryTimeoutMs yes integer Positive maximum query collection time in milliseconds from acceptance.
pollIntervalMs yes integer Positive recommended minimum interval between polls of the same query or transfer.

Limits are runtime policy, not constants of the Soulseek network. A runtime MUST honor the limits captured when it accepts a query or transfer; tightening policy requires explicit termination or revocation, not silent truncation. maxTextBytes MUST remain fixed for the endpoint lifetime so issued handles stay usable. info works offline. A policy denial returns an error rather than fake limits.

SearchRequest
Field Required Type Notes
text yes text Nonempty search expression; at least one non-whitespace character.
username no text Target one peer; omission searches the network.

The runtime MUST pass the expression as a Soulseek search, never as a shell command, backend URL, or local filename. It MAY apply Soulseek tokenization; this NAP does not promise exact matching, ranking, or exhaustive discovery.

Query
Field Required Type Notes
queryId yes text Endpoint-owned handle.
kind yes "search" or "browse" Operation that created the query.
state yes "collecting", "complete", "stopped", or "failed" collecting is the only nonterminal state.
reason no "deadline", "limit", "peer_complete", or "cancelled" Required for complete or stopped; forbidden otherwise.
count yes integer Number of immutable retained entries.
maxResults yes integer Effective collection bound at acceptance.
maxPageSize yes integer Positive page bound captured at acceptance.
timeoutMs yes integer Effective collection deadline duration at acceptance.
error no Failure Required exactly when state is failed.
File
Field Required Type Notes
fileId yes text Opaque handle bound to this query, peer, remote path, size, and account context.
username yes text Peer claiming to offer the file; not an authenticated content author.
path yes text Nonempty remote virtual path, preserved for display; never a local path.
size yes text Advertised byte count.
locked yes boolean Peer reports restricted access. false does not promise download permission.
extension no text Peer-reported format hint; not a trusted media type.
bitrateKbps no integer Positive peer-reported kilobits per second.
durationSeconds no integer Peer-reported duration in seconds.
sampleRateHz no integer Positive peer-reported sample rate.
bitDepth no integer Positive peer-reported bits per sample.

Unrepresentable or malformed peer entries MUST be discarded before issuing a file handle. A browse listing that loses entries this way MUST end failed with backend_error, preserving its valid partial results. Search responses are best effort and MAY omit malformed peer entries.

QueryPage
Field Required Type Notes
query yes Query Snapshot taken atomically with this page.
files yes list of File Entries starting at the requested zero-based offset.
next yes integer Requested after plus the number returned.

after defaults to 0. limit defaults to Query.maxPageSize; it MUST be positive and no greater than that bound. after greater than the current count is invalid. Entries MUST be appended in arrival order, never reordered or overwritten. Repeated listings of the same exact username, path, and size MUST NOT create another entry in that query. File metadata is the first retained observation. A size change creates a distinct entry.

results MUST return exactly the smaller of limit and query.count - after entries from that snapshot, without waiting for more. next < query.count means another page is available. When equal, the napplet can poll again if collecting, or stop if terminal. An empty page from a collecting query does not mean the search finished.

Transfer
Field Required Type Notes
transferId yes text Endpoint-owned handle.
file yes File Frozen source descriptor, retained independently of its query.
state yes "queued", "transferring", "paused", "complete", "cancelled", or "failed" See lifecycle below.
maxReadBytes yes integer Positive read bound captured at admission.
received yes text Contiguous bytes retained from offset zero, no greater than file.size.
queuePosition no integer Positive peer-reported position; omitted when unavailable or not queued.
bytesPerSecond no integer Observed nonnegative byte rate; omitted when unavailable.
error no Failure Required exactly when state is failed.
Chunk
Field Required Type Notes
offset yes text Requested offset.
data yes text Standard padded base64, without whitespace, of the returned bytes.
length yes integer Decoded byte count.
eof yes boolean Whether offset plus returned length equals file.size.
Saved, Released, and Failure
Record Field Required Type Notes
Saved saved yes boolean Always true; the shell completed the export.
Released released yes boolean true if a live owned handle was removed; otherwise false.
Failure code yes text One of the codes in Error Handling.
Failure message yes text Nonempty diagnostic, not machine-readable; no credentials, addresses, or local paths.

Wire Protocol

All requests travel napplet → runtime; all results travel runtime → the same authenticated endpoint. These envelopes are transport-neutral. The web projection supplies their web binding.

Every request carries type, id, and exactly the parameters in its API row. Every result carries the matching type with .result appended, the same id, and exactly one of value (the API row's result) or error (Failure). For example, soulseek.transfers.result.value is a list of Transfer. The fields inside request for search follow SearchRequest; the other operations place their parameters directly in the envelope.

The runtime MUST answer each validly enveloped request from a live authenticated endpoint once, including rejected operations, except for the duplicate-ID rule below. It MUST apply a finite policy deadline to backend work and user prompts; expiration returns timeout. Unrouteable or malformed envelopes MAY be discarded. No unsolicited messages are defined: callers poll snapshots. Local listener helpers do not create wire subscriptions.

IDs are unique per endpoint lifetime. Napplets MUST NOT reuse them. The runtime MUST NOT execute a repeated ID again; it MAY replay the original result or discard the duplicate. IDs do not provide recovery across endpoint teardown. After a local timeout without a received download result, call transfers to reconcile the source file before retrying. After ownership and policy checks, the runtime MUST serialize downloads per fileId and return an existing non-released transfer for that exact handle, including a terminal transfer. Releasing it allows a new download. A different file handle does not imply the same content, even if its filename and size match.

Examples

Network search, first page, and queue admission (acceptance is not completion):

{"type":"soulseek.search","id":"q1","request":{"text":"field recording"}}
{"type":"soulseek.search.result","id":"q1","value":{"queryId":"s1","kind":"search","state":"collecting","count":0,"maxResults":500,"timeoutMs":30000,"maxPageSize":100}}
{"type":"soulseek.results","id":"q2","queryId":"s1","limit":10}
{"type":"soulseek.results.result","id":"q2","value":{"query":{"queryId":"s1","kind":"search","state":"collecting","count":1,"maxResults":500,"timeoutMs":30000,"maxPageSize":100},"files":[{"fileId":"f1","username":"field-recorder","path":"Recordings\\rain.txt","size":"3","locked":false}],"next":1}}
{"type":"soulseek.download","id":"q3","fileId":"f1"}
{"type":"soulseek.download.result","id":"q3","value":{"transferId":"t1","file":{"fileId":"f1","username":"field-recorder","path":"Recordings\\rain.txt","size":"3","locked":false},"state":"queued","received":"0","maxReadBytes":65536}}

Completed data can be read without granting local filesystem access. Here the three downloaded bytes are abc; they are not an integrity proof:

{"type":"soulseek.status","id":"q4","transferId":"t1"}
{"type":"soulseek.status.result","id":"q4","value":{"transferId":"t1","file":{"fileId":"f1","username":"field-recorder","path":"Recordings\\rain.txt","size":"3","locked":false},"state":"complete","received":"3","maxReadBytes":65536}}
{"type":"soulseek.read","id":"q5","transferId":"t1","offset":"0","length":3}
{"type":"soulseek.read.result","id":"q5","value":{"offset":"0","data":"YWJj","length":3,"eof":true}}
{"type":"soulseek.save","id":"q6","transferId":"t1"}
{"type":"soulseek.save.result","id":"q6","value":{"saved":true}}

Invalid input and independent content-disclosure denial have no requested effect:

{"type":"soulseek.read","id":"q7","transferId":"t1","offset":"00","length":3}
{"type":"soulseek.read.result","id":"q7","error":{"code":"invalid_request","message":"Offset is not canonical decimal text"}}
{"type":"soulseek.read","id":"q8","transferId":"t1","offset":"0","length":3}
{"type":"soulseek.read.result","id":"q8","error":{"code":"denied","message":"Content disclosure is not authorized"}}

Stopping retains results; releasing destroys query handles. A late peer response after stop MUST NOT append an entry. Already accepted transfers remain usable:

{"type":"soulseek.stop","id":"q9","queryId":"s1"}
{"type":"soulseek.stop.result","id":"q9","value":{"queryId":"s1","kind":"search","state":"stopped","reason":"cancelled","count":1,"maxResults":500,"timeoutMs":30000,"maxPageSize":100}}
{"type":"soulseek.release","id":"q10","kind":"query","handle":"s1"}
{"type":"soulseek.release.result","id":"q10","value":{"released":true}}
{"type":"soulseek.download","id":"q11","fileId":"f1"}
{"type":"soulseek.download.result","id":"q11","error":{"code":"not_found","message":"File handle is unavailable"}}

A separate endpoint browses a peer and controls its own transfer. These example limits are runtime choices, not protocol constants:

{"type":"soulseek.info","id":"b0"}
{"type":"soulseek.info.result","id":"b0","value":{"state":"online","canSave":true,"maxQueries":4,"maxTransfers":8,"maxResults":500,"maxPageSize":100,"maxReadBytes":65536,"maxTextBytes":1024,"queryTimeoutMs":30000,"pollIntervalMs":1000}}
{"type":"soulseek.browse","id":"b1","username":"field-recorder"}
{"type":"soulseek.browse.result","id":"b1","value":{"queryId":"b1","kind":"browse","state":"collecting","count":0,"maxResults":500,"maxPageSize":100,"timeoutMs":30000}}
{"type":"soulseek.results","id":"b2","queryId":"b1"}
{"type":"soulseek.results.result","id":"b2","value":{"query":{"queryId":"b1","kind":"browse","state":"complete","count":1,"maxResults":500,"maxPageSize":100,"timeoutMs":30000,"reason":"peer_complete"},"files":[{"fileId":"f2","username":"field-recorder","path":"Recordings\\wind.txt","size":"3","locked":false}],"next":1}}
{"type":"soulseek.download","id":"b3","fileId":"f2"}
{"type":"soulseek.download.result","id":"b3","value":{"transferId":"t2","file":{"fileId":"f2","username":"field-recorder","path":"Recordings\\wind.txt","size":"3","locked":false},"state":"queued","maxReadBytes":65536,"received":"0"}}
{"type":"soulseek.pause","id":"b4","transferId":"t2"}
{"type":"soulseek.pause.result","id":"b4","value":{"transferId":"t2","file":{"fileId":"f2","username":"field-recorder","path":"Recordings\\wind.txt","size":"3","locked":false},"state":"paused","maxReadBytes":65536,"received":"0"}}
{"type":"soulseek.resume","id":"b5","transferId":"t2"}
{"type":"soulseek.resume.result","id":"b5","value":{"transferId":"t2","file":{"fileId":"f2","username":"field-recorder","path":"Recordings\\wind.txt","size":"3","locked":false},"state":"queued","maxReadBytes":65536,"received":"0"}}
{"type":"soulseek.cancel","id":"b6","transferId":"t2"}
{"type":"soulseek.cancel.result","id":"b6","value":{"transferId":"t2","file":{"fileId":"f2","username":"field-recorder","path":"Recordings\\wind.txt","size":"3","locked":false},"state":"cancelled","maxReadBytes":65536,"received":"0"}}
{"type":"soulseek.transfers","id":"b7"}
{"type":"soulseek.transfers.result","id":"b7","value":[{"transferId":"t2","file":{"fileId":"f2","username":"field-recorder","path":"Recordings\\wind.txt","size":"3","locked":false},"state":"cancelled","maxReadBytes":65536,"received":"0"}]}

If endpoint B presents endpoint A's t1, B receives not_found for status, read, save, or transfer controls; its transfers result excludes A's jobs. B may use A's request IDs without accessing A's results. Releasing A's handle from B returns released: false. No message is delivered to A as a consequence.

Error Handling

Code Meaning
invalid_request Invalid parameters, unknown fields, ranges, or enum values.
denied Shell policy or the user refused this operation.
unavailable No configured online account for a new search, browse, download, or resume requiring network work.
unsupported save is not implemented, as reported by info.
not_found Handle absent, released, revoked, or owned by another endpoint. These cases MUST be indistinguishable.
invalid_state Operation is not allowed in the current query or transfer state.
limit_exceeded Runtime capacity, storage, size, or rate bound prevents admission or terminates accepted work.
peer_unavailable Peer cannot be reached within backend policy.
peer_denied Peer refused the listing or transfer, including restricted shares.
source_changed Peer-reported transfer size differs from the frozen source descriptor.
timeout Request or browse deadline expired.
cancelled User dismissed a save prompt, or release stopped an in-flight request.
revoked Authority for already accepted work was withdrawn.
backend_error Backend failed or produced unusable protocol data.

The runtime MUST validate input before requested effects and check ownership before disclosing handle state. A failed admission creates no query or transfer. After an explicit error result, pending backend work MUST NOT later create that query or transfer. A lost response is different from an explicit error. After admission, backend failures appear in the retained Query or Transfer snapshot; reading that snapshot is a successful operation with value. Failure MUST NOT be reported as an empty successful listing or completed file.

Shell Behavior

Discovery and query lifetime

search starts one network or peer search. browse requests a flat snapshot of one peer's shared files, including remote paths for folder grouping; it is not a local directory enumeration. Both operations return after admission, without waiting for peer replies. Only policy-authorized entries may be disclosed.

The runtime MUST issue handles within Info.maxTextBytes, so callers can return them unchanged. It MUST bind each query to the initiating endpoint and the selected account context. It MUST retain immutable results and file handles until query release or endpoint teardown. Active work and retained metadata count against the advertised bounds. A query reaching maxResults terminates complete with reason limit; this is explicitly a truncated result set. Hitting a metadata byte bound also terminates collection as complete with limit. Other resource exhaustion after admission ends the affected query or transfer as failed with limit_exceeded, preserving valid query entries.

A search terminates complete with reason deadline when its collection window ends. That means local collection ended, not that every peer answered. A browse terminates complete with peer_complete only after the peer's full accessible listing arrives; browse timeout is failed with timeout, preserving partial entries. Restricted files MAY be included as locked, but their metadata MUST NOT be treated as permission to transfer them. Hidden shares need not appear.

stop freezes a collecting query as stopped with reason cancelled. For any terminal query it returns the unchanged snapshot. The runtime MUST discard late responses after any terminal transition. A stopped or failed query's retained files remain eligible for a separately authorized download until release.

Transfer lifetime

download accepts only a live file handle issued to the caller. The runtime MUST bind the new transfer to its frozen peer, remote path, size, and account context. The caller cannot replace these fields or supply a destination path. The shell MUST authorize network transfer and storage before admission. Queue admission does not promise peer availability, position, or completion.

Current state Trigger Outcome
queued Peer starts sending transferring.
queued or transferring pause paused; cease data acquisition before returning.
paused pause Unchanged.
paused resume queued; recheck policy and request the same source.
queued or transferring resume Unchanged.
queued, transferring, or paused cancel cancelled; cease work and discard partial bytes before returning.
complete, cancelled, or failed cancel Unchanged; cancellation never deletes a completed file.
complete, cancelled, or failed pause or resume invalid_state; release and request a new transfer to retry.
Any nonterminal state Unrecoverable peer/backend/storage failure failed with the appropriate error.
queued or transferring Exactly the advertised bytes received and retained complete.

Terminal transfer states are immutable. Operations on a transfer MUST be serialized at their state transitions and effect commits; no serialization lock may remain held while waiting for user input or backend work. Release and revocation MUST be able to cancel pending work. When completion races cancellation, whichever transition commits first determines the returned state. Automatic retries MAY keep a transfer queued, but MUST be bounded by runtime policy; exhausting retries ends it failed. Paused transfers do not expire merely for being paused.

The runtime MAY resume from a retained offset with the same peer or restart from zero; received may decrease on restart. It MUST NOT combine bytes from different peers. A changed advertised size MUST fail with source_changed. Equal sizes do not prove equal content; offset resume cannot establish integrity. complete means all expected bytes were received, not hash verification, authenticity, safety, or media decodability. Zero-byte files may complete directly from queued. Snapshots MUST NOT expose bytes beyond the advertised size.

transfers returns all non-released transfers owned by the current endpoint, including terminal records, ordered by admission. status returns one snapshot. Accepted transfers MUST survive release of their source query; its frozen file.fileId remains descriptive but is no longer usable for new admissions.

Reading, saving, and release

read requires a complete transfer and a separate shell grant to disclose its content to this napplet. Transfer permission alone MUST NOT authorize reads. length MUST be positive and at most the transfer's captured maxReadBytes. offset greater than file size is invalid. A non-complete transfer returns invalid_state. The runtime MUST return exactly the smaller of requested length and remaining bytes; at EOF it returns empty base64, length 0, and eof: true. Reads MUST NOT initiate another network transfer.

save requires a complete transfer, otherwise it returns invalid_state. The shell mediates destination selection and overwrite consent without exposing local paths. Success means the bytes were exported, not merely that a dialog opened. Dismissal returns cancelled. Saving does not grant byte disclosure to the napplet, launch an application, execute the file, or change the user's shares. An exported copy is independent of the transfer's temporary data. A timeout MUST NOT be followed by a late export; the runtime MUST resolve a save committed before its deadline as success. The export commit is the point at which the complete copy becomes available at the approved destination. Before that point the runtime MUST recheck ownership, state, and consent. A failed or cancelled save MUST remove its staging data and MUST NOT replace an existing destination or leave a partial exported copy.

release stops work and removes the caller's query or transfer. Releasing a query invalidates its file handles; releasing a transfer deletes its temporary bytes and removes it from transfers. It MUST NOT delete exported copies or other owners' data. Unknown or unowned handles return released: false. The runtime MUST serialize release with query collection, download admission, and transfer operations. It MUST reject pending operations on a released object with cancelled and prevent late work from recreating it. Operations already committed before release keep their result. Release is allowed even when new work is denied, and all teardown effects complete before its result.

Handles are endpoint-scoped, nontransferable, and never reused within that endpoint's lifetime. Endpoint teardown stops all its work and releases temporary data. Reloading does not recover jobs; persistent background transfers are not defined by this draft. Shells SHOULD make this lifetime clear before admission. Other endpoints, including another instance of the same napplet, cannot recover these handles. Shared backend sessions do not change this ownership rule.

On capability revocation or account-context change, the runtime MUST stop old work, invalidate its handles, and dispose of temporary data. Pending requests receive revoked while the endpoint remains reachable; later handle requests receive not_found. New work MUST NOT silently continue on another account. A temporary network outage is not an account change: local results, reads, saves, and controls remain usable subject to policy. Network work may retry or fail.

Security Considerations

  • The runtime MUST authenticate the endpoint against the verified manifest and artifact through its projection. Payload identities, file handles alone, and manifest declarations do not grant authority.
  • The shell MUST keep Soulseek passwords, backend tokens, listening ports, peer addresses, and local filesystem paths outside this interface. The user's Soulseek account, Nostr identity, and napplet identity are distinct; this NAP asserts no cryptographic binding between them.
  • The runtime MUST gate searches, browsing, downloads, content disclosure, and saving separately under per-napplet policy. User consent may cover a bounded session rather than requiring a prompt for every call.
  • Peer names, paths, metadata, and file bytes are untrusted. The runtime MUST store data under runtime-selected names and prevent path traversal, absolute path escape, symlink escape, and overwrites outside authorized destinations. Napplets SHOULD render peer metadata as text, never executable markup.
  • The runtime MUST bound pending requests, query count, result metadata bytes, decompression, transfer storage, bandwidth, and peer connection work. It MUST reject excess admission with limit_exceeded; if an accepted browse cannot retain a complete listing, it MUST report a limit or failure, not peer_complete.
  • Soulseek servers, peers, and a remote backend can observe account activity, search terms, filenames, or network addresses. The shell SHOULD explain that exposure before authorizing activity. This NAP makes no end-to-end encryption or anonymity guarantee.
  • The runtime MUST NOT label downloaded content cryptographically verified on the strength of a username, filename, advertised size, or transfer completion. A locally calculated digest alone does not authenticate a peer's content.
  • A backend adapter MUST scope privileged backend responses to the requesting endpoint; daemon-wide search history, transfers, shares, or tokens MUST NOT leak through enumeration or error messages.

References

  • Nicotine+ Soulseek protocol documentation — implementation-maintained, unofficial protocol reference for searches, share listings, queued transfers, offsets, and the absence of chunk hashes.
  • slskd — an example backend implementation, not a NAP binding or a required dependency.

Implementations

No implementation of this NAP has been verified. Existing Soulseek clients and backends do not imply support for the soulseek domain.

Changelog

  • 252a8f4 - Introduced runtime-mediated Soulseek search, share browsing, endpoint-owned downloads, bounded file reads, and user-mediated saving.