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

```sh
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):

```tsx
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.

```ts
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

```sh
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](RELEASING.md).