techne-sdk 0.2.0

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 (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
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
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).

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() is what it asks for by default: atproto, the platform's authRead and authWrite permission sets with the platform as their audience, and a repo: scope for the public community.lexicon.* records. clientMetadata lists the same scopes, so the two always match. Pass scope to ask for other scopes, and pass it to both.
  • 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
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

  • A lexicon changed: tag the lexicons repo. Set lexicons.tag and lexicons.commit in inputs.pin.json and the techne-lexicon-check tag in Cargo.toml, then run cargo update -p techne-lexicon-check and node scripts/inputs-sync.mjs --lexicons <lexicons checkout> --platform <platform checkout>. Then run npm run codegen and commit.
  • The platform's API changed: set platformApi.rev (a platform tag for releases), then run the same sync and codegen.
  • The Rust logic changed: edit crates/techne-sdk and run npm run codegen (it rebuilds the WebAssembly). The fixtures must still agree.

npm run codegen needs the pinned Rust toolchain (rust-toolchain.toml) with the wasm32-unknown-unknown target, and npx for binaryen. The build is reproducible, so the gate can diff it against the committed one.

The gate

bash scripts/gate.sh     # inputs, codegen (no drift), typecheck, test, package, rust

package builds dist/, then loads every subpath through exports in plain Node and runs the fixtures through the built WebAssembly and wasm2js modules. It also checks the npm pack file list with a dry run. rust runs the same fixtures through the crate.

Releasing

See RELEASING.md.