techne-sdk 0.3.2

TechneSDK's logic written once: version ranking (eventState) and offline record verification over the published check, shipped to TypeScript as WebAssembly; with opt-in atproto plumbing (service auth, DID resolution, signed labels, proven record fetches, PDS password sessions) behind the `crypto` and `net` features.
Documentation

techne-sdk

TechneSDK for apps on the Techne platform: sign in, put, query, useEvent and the read-time check.

  • npm: one package, techne-sdk, with subpaths.
  • Rust: the crate techne-sdk (crates/techne-sdk). It holds the logic that must give the platform's answers exactly: the check, the version ranking (eventState) and put's private-first, public-last plan. The npm package runs the same crate as WebAssembly, or as wasm2js on hosts without WebAssembly (Hermes). Opt-in features add the atproto plumbing services share (no platform crate either): crypto for service-auth JWTs, signed labels, record proofs and service-account passwords, and net for DID resolution, verified record fetches and PDS password sessions.

The SDK depends on two things only: the published lexicons (the lexicons repo at a tag) and the platform's published API. It contains no platform code.

Install

npm i techne-sdk          # react >= 19 is an optional peer, needed only for techne-sdk/react

Every import goes through the package's exports, so the resolver has to read them:

  • TypeScript: moduleResolution node16, nodenext or bundler. Under the old node (node10) mode the subpath types do not resolve.
  • React Native: Metro with package exports on (unstable_enablePackageExports, the default from Expo SDK 53). techne-sdk/wasm loads the check through techne-sdk/wasm/load, whose react-native condition picks the wasm2js build (Hermes has no WebAssembly); that file is ESM that calls Metro's require.
Import What it holds
techne-sdk (also techne-sdk/generated) the generated XRPC client (AtpBaseClient) and every lexicon type
techne-sdk/lexicons the generated lexicon dictionary (schemaDict, schemas, lexicons, ids): every lexicon document the client was built from, for reading descriptions at run time (large; keep it server side)
techne-sdk/types/<nsid path> one lexicon's types, e.g. techne-sdk/types/social/scenius/getContactInvite
techne-sdk/react useEvent(uri): one commons event or place as its versions, with the check's verdict
techne-sdk/versions the same state without React: eventState, verifyAnswer, version labels
techne-sdk/auth sign-in ("Sign in with Techne"): signIn, signUp, restore, signOut, clientMetadata, scopes, appKey, appSpace (browser OAuth)
techne-sdk/connect connect(session, platform) / connectHosted(did, fetchHandler): the clients over one session
techne-sdk/put put(techne, records, { deletes }): the one write, deletes included (public deletes hinted to the platform); put(techne, [], { deletes }) for deletes alone
techne-sdk/query the platform's generic queries: listRecords, getRecords, hosts
techne-sdk/links link tokens: create, redeem, check, revoke
techne-sdk/wasm the check itself: validate, decide, verify (validate knows the built-in lexicons only; a runtime type is checked by the platform on write)
techne-sdk/catalog the browsable lexicon catalog
techne-sdk/nsid, techne-sdk/nsid.json every NSID as a constant (NSID, METHOD)

Datetime fields are plain strings: send Date.prototype.toISOString() (UTC, trailing Z).

Reading one event (the platform's getVersions is public, so an anonymous client will do):

import { AtpBaseClient } from 'techne-sdk'
import { useEvent } from 'techne-sdk/react'

const platform = new AtpBaseClient({ service: 'https://<platform host>' })
const load = async (uri: string) => (await platform.pub.techne.draft.commons.getVersions({ uri })).data

function Event({ uri }: { uri: string }) {
  const { loading, error, fields } = useEvent(uri, { load })
  if (loading) return <p>Loading</p>
  if (error) return <p>Could not load it</p>
  return <h1>{String(fields?.name?.value ?? '')}</h1>
}

Pass { claims } too, and verification holds the check's answer for every record (valid, signer, chain verified).

Types for any type

The generated client covers the platform's own lexicons. For a community draft, a requested type or any other published NSID, print its types:

npx techne-sdk types pub.techne.draft.c.<id>.toolListing > src/toolListing.ts

It asks the platform's registry (registry.getType) first, then atproto lexicon resolution (the NSID's _lexicon TXT record, then the schema record on that DID's PDS). --platform <url> (or TECHNE_PLATFORM_URL) picks the platform; --out <file> writes the file. A ref to another document comes out as unknown, with the ref in a comment.

Sign in with Techne

techne-sdk/auth signs a person in from the browser. Each app is its own atproto OAuth client, the way "Sign in with Google" works: the app serves its client metadata at its client id, the person signs in at their own PDS, and the app keeps the session (an OAuthSession, with did and fetchHandler) that connect takes. It runs on @atproto/oauth-client-browser, loaded the first time it is used.

import { clientMetadata, restore, signIn, TECHNE_PLATFORM_DID } from 'techne-sdk/auth'
import { connect } from 'techne-sdk/connect'

const clientId = 'https://cafes.example/oauth-client-metadata.json'

// Serve this JSON at clientId (a static file or a route).
const metadata = clientMetadata({ clientId, clientName: 'Cafe finder' })

// On every page load: finishes a sign-in when the page is the redirect back, else restores a kept session.
const session = await restore({ clientId })
if (!session) await signIn({ clientId, handle: 'alice' }) // alice.techne.town, once techne.town is live (see below)
else {
  const techne = connect(session, TECHNE_PLATFORM_DID)
}
  • Who: with no handle, the sign-in starts at the default PDS (techne.town), which asks who you are. A bare name (alice) is a handle on the default handle domain. A full handle, a DID or a PDS URL from any PDS is used as given. signUp({ clientId }) opens the PDS's create-account screen.
  • Scopes: scopes({ clientId }) is what it asks for by default, least privilege: atproto; the platform's authPermissioned permission set with the platform as its audience (the permissioned.* calls: private writes, reads and deletes); the app's own private space, space:pub.techne.draft.space.private?skey=<appKey>; and an rpc:<nsid>?aud=<platform>%23atproto_appview scope for each generic read the SDK makes signed in: query.listRecords, query.countRecords and query.getRecords (techne-sdk/query, put's curation answer) and registry.getType and registry.listTypes. The platform narrows those reads by the token's space scopes, so they reach public data and the app's own space. An app passes the types it writes as types: [nsid, ...], and each adds a repo:<nsid> scope (create, update, delete), as the platform's own least-privilege list does, plus rpc:pub.techne.draft.sync.indexRecord so put can hint the platform what it wrote (a pending type's records are indexed no other way); without types the app writes no public record. query.composeRecords is not in the default (the SDK makes no such call); an app that calls it asks for everything: 'read'. Everything the person can reach (their personal space, their circles, beacons shared with them) is an opt-in: everything: 'read' adds the authRead set, everything: 'write' adds authRead, authWrite and a repo: scope for the public community.lexicon.* records (then our own public types need no types entry). The consent page shows those with a large warning; ask only if the app needs them. Pass the same types and everything to signIn and clientMetadata, since the PDS refuses a request beyond what the metadata declares. Pass scope to ask for other scopes, and pass it to both. An app that publishes its own community type (pub.techne.draft.c.*) as the signed-in person passes publish: true, which adds rpc:pub.techne.draft.registry.publishType (publishing a type is a public act). The type's NSID is the registry's answer, so its repo: scope can be asked for only after publishing: publish once (a setup sign-in with publish: true), then sign in with the published NSID in types.
  • A private record names its space: a record of a type with no public home is written privately, and it must name the space it is for; put throws PutError (InvalidRequest) with nothing written when one does not, as the platform's permissioned.putRecords refuses it. A record only the person reads goes in their own space.personal: { record, space: `at://${did}/pub.techne.draft.space.personal/self` }. Public records name no space and go to the person's PDS.
  • The app's private space: appSpace(session.did, clientId) is at://{did}/pub.techne.draft.space.private/{appKey}, where appKey(clientId) is the client id hashed (base32, lowercase, no padding, of its SHA-256; the platform's sidecar_rkey). Write there with put(techne, [{ record, space: appSpace(did, clientId) }]) and read with permissioned.listRecords({ space }). Only that person reads it, and only through this app. In development the client id is the loopback client's, resolveClientId(options) with the same options signIn took: it carries the scope, so it changes with the port, types and everything, and so does the space. Its scope names the space type without a key (an id cannot carry its own hash); Techne's PDS reads that keyless scope from a loopback client as its own space, adds the key when it calls the platform, and shows the own-space line on the consent page. The browser keeps one stored session per origin, so a page that switches between client ids (other types, another port's id) signs out first; restore hands back whichever session the origin holds.
  • Development: on localhost or 127.0.0.1 you can leave clientId out. The SDK then uses atproto's loopback client, so you don't need to host anything.
  • Sessions: the library refreshes tokens itself. restore returns the session, null when nobody is signed in, and rejects when a sign-in or refresh failed. signOut({ clientId, session }) revokes the session. onSessionDeleted tells you when the library drops a session. A state passed to signIn comes back from redirectState({ clientId }) on the page the redirect lands on.
  • Another deployment: platform names the platform's DID (TECHNE_PLATFORM_DID by default). pds, plcUrl and handleDomain name the PDS people sign in at, its PLC directory and its handle domain. Pass them the same way to every call.

Before the Techne release: the techne.town defaults (TECHNE_PLATFORM_DID, the techne.town PDS and handle domain) go live at the Techne release. Until then, signIn() without pds fails: pass pds, plcUrl, handleDomain and platform explicitly, and ask the Techne team for the current staging values.

Layout

Path What
src/ the TypeScript package. src/generated/, src/catalog/catalog.gen.ts and src/wasm/*.gen.* are generated and committed
src/types-cli/ the techne-sdk types <nsid> bin: resolve (resolve.ts), emit (emit.ts), golden files in testdata/ (UPDATE_GOLDEN=1 npm run test:types-cli)
crates/techne-sdk/ the Rust crate, with the shared fixtures (fixtures/cases.json) both languages must answer identically
inputs/ the codegen inputs, copied by scripts/inputs-sync.mjs and pinned in inputs.pin.json: inputs/lexicons/ (the lexicons repo at lexicons.tag) and inputs/platform-api/ (the platform's routed method lexicons, the atproto methods it serves and its NSID rename map, at platformApi.rev)
lexicons-base/ standard atproto repo-op lexicons the generated client needs
dev-pds/ a real atproto PDS and PLC in one Node process, for end-to-end runs against a platform
scripts/ codegen (gen.sh), the WebAssembly build (build-wasm.sh), the package build (build.mjs), the gate

Changing it

Re-pinning a lexicon or platform API change, rebuilding the generated code and WebAssembly, and the gate are in wiki/development/inputs-and-codegen.md and wiki/development/gate-and-tests.md.

Releasing

See RELEASING.md.