@nibling/sdk #
The browser SDK for nibling's platform layer (VISION.md D18 to D22). It reads and writes nibling records on atproto, and it never imports @nibling/kernel. File trees cross its API as Map<string, Uint8Array>, and the app adapts them to the kernel's filesystem.
Every write takes a Session, a Client authenticated as a DID against that DID's PDS. Every read takes a DID or an AT-URI and reads publicly from the owner's PDS.
Reading through the gateway #
configure({ gateway: 'https://api.nibling.dev' }) sends every read to nibling's gateway (packages/api, D22) instead of each service: repo reads and blobs, identity, Constellation backlinks, Slingshot's getRecordByUri, and tangled imports through /git/<repo DID>. The gateway caches, and it names us to each service with a User-Agent, which a browser cannot set.
- A gateway that does not answer, times out, or returns a 5xx gets one retry on the direct path for that call (
preferGatewayingateway.ts). A 4xx is an answer. The direct path for a repo read resolves the PDS without the gateway. - Reads of the signed-in account's own repo send
x-nib-fresh: 1, so the gateway skips its cache and a save shows up on the next read.freshFor()inidentity.tsmakes that call.auth.tssetsconfig.vieweron sign-in and clears it on sign-out. - Writes still go from the
Sessionto the PDS, and pushes from isomorphic-git to the knot. OAuth's DPoP tokens are bound to a key in the browser, and a push token is single-use and lasts 60 seconds, so neither can pass through the gateway. blobUrl(actor, cid)gives agetBlobURL for an<img>, on the gateway when one is set.
Unset, the default, every read goes straight to the services as before.
Outside a browser (no document), every default request and every git request sends User-Agent: nibling.dev/<version> (+https://nibling.dev/contact), from user-agent.ts. The worker and the lexicon publish script send the same string.
Modules #
identity.tsresolves a handle or DID to{ did, handle?, pds }. It asks the gateway'sresolveMiniDocfirst when a gateway is set, then Slingshot, and falls back to PLC or did:web, with handles resolved through the Bluesky appview.resolveServicereads a service entry such as#tangled_knotfrom a DID document.configuresets every endpoint, and it also takes anidentityresolver and ahandlerForhook so that tests can run with no network. Every default request, identity resolution included, aborts aftertimeoutMs(30 s). A resolved handle, DID, or DID document is reused for 60 s (IDENTITY_TTL_MS), a handle and its DID sharing one entry, andconfigureforgets them all. Identical GETs in flight at once share one request, each caller reading its own clone.records.tsreads records from a PDS.getRecordandlistRecordsbuild each record's URI from the repo, collection, and rkey that were asked for, and drop any record whose returned URI names something else, because a PDS can answer with any URI. Write results are checked the same way.paging.tsstops every listing at an empty page, a missing or repeated cursor, or 100 pages;listRecordsalso stops at a page shorter than the 100 it asked for, since a PDS sends a cursor even with its last page.microcosm.tsreads Constellation backlinks and Slingshot's record cache, through the gateway when one is set. Both services are optional, so each call returnsundefinedwhen neither the gateway nor the service answers.auth.tswraps@atcute/oauth-browser-client. It holds the D19 scope string and buildsclient-metadata.json, with thehttp://localhostloopback client for development onhttp://127.0.0.1.signIn,completeSignIn,resume, andsignOutrun only in the browser.projects/paths.tsrejects absolute paths, empty,., and..segments, and backslashes, and returns paths in NFC.projects/tree.tsholds pure functions.diffAgainstBasecomputes the overlay that turns a base tree into the current tree, andapplyOverlayapplies one.blobCidcomputes the raw CID that a PDS assigns to a blob.projects/store.tslists, loads, creates, saves, forks, and deletesdev.nibling.projectrecords. A save uploads only the blobs that the loaded record does not already reference, then writes withswapRecord. A lost swap throwsProjectConflictError. Before any upload, create, save, and fork check the record they are about to write against the lexicon (at most 1,000 entries, the name, description, and tag limits) and each file against 5 MB, and fail withERR_NIB_LIMIT. Records and blobs from a PDS are checked against the lexicon and their CIDs before the store returns them. Loading downloads blobs 8 at a time and stops any blob past 5 MB and the project pastmaxBytes(100 MB), whatever the record claims. A project with a git base needs aloadBasehook, which the tangled module supplies.feed.tscalls the index'sgetRecent,getTrending, andgetKnown. Each returnsundefinedwhen no index is configured or the index is down.social/is D21.likes.tshaslike,unlike,likeCount,likers, andmyLike, which finds the session's own like by listing itsdev.nibling.feed.likerecords, since there is no index to ask "did I like this."comments.tshascommentandcomments, which reads Constellation backlinks, fetches each record through Slingshot or its author's PDS, and orders the valid ones into a thread.follows.tshasfollow,unfollow,followers,following,myFollow, andforks;followingandmyFollowlist the account's own PDS, and the rest read Constellation backlinks.import.tshasimportFollows, which readsapp.bsky.graph.followororg.tangled.graph.followplus legacysh.tangled.graph.follow(merged without duplicates) from the session's own PDS, filters through the index'sgetKnownwhen one is configured, skips subjects already followed, caps what is left atmax(500), and writesdev.nibling.graph.followwithviaset to the source collection, inapplyWritesbatches of at most 200. It is idempotent: a second run writes nothing, and a run cut short bymaxcontinues where it stopped. An index that is configured but does not answer makes it return{ outcome: 'indexUnavailable' }without writing.page.tshasactorPage, which mergesdev.nibling.actor.profilewithapp.bsky.actor.profile'sdisplayName,description, andavataras a fallback, and lists the account's projects and itsorg.tangled.repo.declarationrepos; it works for tangled orgs, which are ordinary accounts.setProfilewrites the session's own profile. Every record this module reads from a PDS or Slingshot is checked against its lexicon before use.
Testing #
pnpm test runs node --test on Node 24's type stripping. tests/fake-pds.ts is an in-memory PDS that hosts several accounts and counts calls per method. Set pds.tamper.uri or pds.tamper.cursor to make it forge record URIs or repeat cursors, as a hostile PDS would. It stores whatever it is given, like a real PDS with an unknown lexicon, so tests can plant records that break limits. Pass fakeNetwork([pds, ...], services) to configure to route identity and XRPC to fakes. fakeGateway([pds]) answers the gateway's XRPC routes over the same fakes, and tests/gateway.test.ts checks that a configured gateway leaves no direct reads and that a failing one falls back.
node scripts/resolve.ts <handle> [--no-slingshot] resolves an actor against live infrastructure. It only reads.
Unverified #
- The PDS's maximum record size. A project without a base can list 1,000 files, and nobody has measured that record against a real PDS.
- How soon a PDS garbage-collects blobs that no record references. A save uploads blobs before it writes the record, so a failed or conflicting save leaves orphaned blobs.
- Whether
com.atproto.server.getServiceAuthhonors the OAuth scoperpc:org.tangled.git.receivePack?aud=*for a knot audience. - The form of a knot's service DID. The tangled module assumes
did:web:<knot host>.