techne-sdk 0.3.5

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          # optional peers: react >= 19 for techne-sdk/react, and @tanstack/react-query 5 too for techne-sdk/react/query
```

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` | `TechneProvider` / `useTechne` (the client in React), and `useEvent(uri)`: one commons event or place as its versions, with the check's verdict (React only) |
| `techne-sdk/react/query` | the client's reads as hooks over TanStack Query (`useRecordQuery`, `useRecord`, `useVersions`, `defaultQueryClient`); needs `@tanstack/react-query` |
| `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)` / `connectAnonymous(platform?)`: the client over one session, with its reads (`query`, `get`, `versions`, their options forms) and `put`; `bind(clients)` over clients made elsewhere |
| `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`, `compose` (several steps in one `composeRecords` call), hosts (`hostsOf`, `hostSteps`/`hostsFrom`) |
| `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):

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

## 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:

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

```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({ 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](https://github.com/technefoundation/sdk/blob/main/wiki/development/inputs-and-codegen.md) and
[wiki/development/gate-and-tests.md](https://github.com/technefoundation/sdk/blob/main/wiki/development/gate-and-tests.md).

## Releasing

See [RELEASING.md](https://github.com/technefoundation/sdk/blob/main/RELEASING.md).