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) andput'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):cryptofor service-auth JWTs, signed labels, record proofs and service-account passwords, andnetfor 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
Every import goes through the package's exports, so the resolver has to read them:
- TypeScript:
moduleResolutionnode16,nodenextorbundler. Under the oldnode(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/wasmloads the check throughtechne-sdk/wasm/load, whosereact-nativecondition picks the wasm2js build (Hermes has no WebAssembly); that file is ESM that calls Metro'srequire.
| 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:
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'sauthPermissionedpermission set with the platform as its audience (thepermissioned.*calls: private writes, reads and deletes); the app's own private space,space:pub.techne.draft.space.private?skey=<appKey>; and anrpc:<nsid>?aud=<platform>%23atproto_appviewscope for each generic read the SDK makes signed in:query.listRecords,query.countRecordsandquery.getRecords(techne-sdk/query,put's curation answer) andregistry.getTypeandregistry.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 astypes: [nsid, ...], and each adds arepo:<nsid>scope (create, update, delete), as the platform's own least-privilege list does, plusrpc:pub.techne.draft.sync.indexRecordsoputcan hint the platform what it wrote (a pending type's records are indexed no other way); withouttypesthe app writes no public record.query.composeRecordsis not in the default (the SDK makes no such call); an app that calls it asks foreverything: 'read'. Everything the person can reach (their personal space, their circles, beacons shared with them) is an opt-in:everything: 'read'adds theauthReadset,everything: 'write'addsauthRead,authWriteand arepo:scope for the publiccommunity.lexicon.*records (then our own public types need notypesentry). The consent page shows those with a large warning; ask only if the app needs them. Pass the sametypesandeverythingtosignInandclientMetadata, since the PDS refuses a request beyond what the metadata declares. Passscopeto 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 passespublish: true, which addsrpc:pub.techne.draft.registry.publishType(publishing a type is a public act). The type's NSID is the registry's answer, so itsrepo:scope can be asked for only after publishing: publish once (a setup sign-in withpublish: true), then sign in with the published NSID intypes. - A private record names its space: a record of a type with no public home is written privately, and it must name the
spaceit is for;putthrowsPutError(InvalidRequest) with nothing written when one does not, as the platform'spermissioned.putRecordsrefuses it. A record only the person reads goes in their ownspace.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)isat://{did}/pub.techne.draft.space.private/{appKey}, whereappKey(clientId)is the client id hashed (base32, lowercase, no padding, of its SHA-256; the platform'ssidecar_rkey). Write there withput(techne, [{ record, space: appSpace(did, clientId) }])and read withpermissioned.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 optionssignIntook: it carries the scope, so it changes with the port,typesandeverything, 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 (othertypes, another port's id) signs out first;restorehands back whichever session the origin holds. - Development: on
localhostor127.0.0.1you can leaveclientIdout. The SDK then uses atproto's loopback client, so you don't need to host anything. - Sessions: the library refreshes tokens itself.
restorereturns the session,nullwhen nobody is signed in, and rejects when a sign-in or refresh failed.signOut({ clientId, session })revokes the session.onSessionDeletedtells you when the library drops a session. Astatepassed tosignIncomes back fromredirectState({ clientId })on the page the redirect lands on. - Another deployment:
platformnames the platform's DID (TECHNE_PLATFORM_DIDby default).pds,plcUrlandhandleDomainname 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.