~ knotless Search/
↑↓ move↵ open⇧↵ all results

peterullrich.com/hatk

AT Protocol app framework

Forked from chadtmiller.com/hatk

0
0
main
109 lines · 3.9 KB Raw History
# Hook & XRPC Record Helpers Design

**Goal:** Add `createRecord`, `deleteRecord`, `putRecord` helpers to `OnLoginCtx` and `XrpcContext` so server-side code can write records through the PDS with local indexing, without raw SQL.

**Motivation:** Writing records currently requires raw SQL with manual column mapping, JSON serialization, and AT URI construction. The framework already has PDS proxy functions (`pdsCreateRecord`, etc.) that validate against lexicons, write to the user's PDS, and index locally — but they're only wired up to the core HTTP handlers, not available to hook or XRPC authors.

## Design

### Where the helpers live

- `XrpcContext` — already has an authenticated viewer and PDS session access
- `OnLoginCtx` — has a viewer (the user who just logged in) with a fresh PDS session
- `BaseContext` — stays read-only (feeds can run without a viewer)

### New fields

```typescript
createRecord: (collection: string, record: Record<string, unknown>, opts?: { rkey?: string }) =>
  Promise<{ uri?: string; cid?: string }>

putRecord: (collection: string, rkey: string, record: Record<string, unknown>) =>
  Promise<{ uri?: string; cid?: string }>

deleteRecord: (collection: string, rkey: string) => Promise<void>
```

### What they do

Each helper wraps the existing `pdsCreateRecord` / `pdsPutRecord` / `pdsDeleteRecord` from `pds-proxy.ts`:

1. Validate record against lexicons
2. Authenticate to user's PDS via DPoP (with nonce retry + token refresh)
3. Write to PDS
4. Index locally (insert/delete in SQLite)
5. Log errors on index failure (record still exists on PDS, will sync via firehose)

### Wiring

Both `XrpcContext` and `OnLoginCtx` need `oauthConfig` to call the PDS proxy functions.

**XrpcContext:** `buildXrpcContext` in `xrpc.ts` already runs inside `initXrpc` which has `oauthConfig`. Add the helpers there.

**OnLoginCtx:** `fireOnLoginHook` is called from `oauth/server.ts` `handleCallback` which has `config: OAuthConfig`. Pass it through:

```typescript
// oauth/server.ts
await fireOnLoginHook(did, config)
```

### Usage in hooks

```typescript
export default defineHook('on-login', async (ctx) => {
  await ctx.ensureRepo(ctx.did)

  const existing = await ctx.lookup('social.grain.actor.profile', 'did', [ctx.did])
  if (existing.has(ctx.did)) return

  const bsky = await ctx.lookup('app.bsky.actor.profile', 'did', [ctx.did])
  const profile = bsky.get(ctx.did)
  if (!profile) return

  await ctx.createRecord(
    'social.grain.actor.profile',
    {
      displayName: profile.value.displayName,
      description: profile.value.description,
      avatar: profile.value.avatar,
      createdAt: new Date().toISOString(),
    },
    { rkey: 'self' },
  )
})
```

### Usage in XRPC handlers

```typescript
export default defineQuery('my.app.doThing', async (ctx) => {
  await ctx.createRecord('my.app.activity', {
    action: 'did-thing',
    createdAt: new Date().toISOString(),
  })
  return ctx.ok({ success: true })
})
```

## Files to change

**hatk framework:**

- `hooks.ts` — Add helpers to `OnLoginCtx`, update `fireOnLoginHook(did, oauthConfig)`
- `xrpc.ts` — Add helpers to `XrpcContext`, wire in `buildXrpcContext`
- `oauth/server.ts` — Pass `config` to `fireOnLoginHook`

**Templates:**

- `server/on-login.ts` — Replace raw SQL with `ctx.createRecord`

## Future: typed records

The helpers can later be typed with `RecordRegistry` so `createRecord('social.grain.actor.profile', ...)` validates the record shape at compile time, matching how `defineFeed` and `defineQuery` work. Not required for v1.

## Edge cases

- **No PDS session:** Throws `ProxyError(401)`. Caught by hook/XRPC error handling. Login/request still succeeds.
- **PDS write fails:** Error propagates to caller. In hooks, caught and logged. In XRPCs, returns error to client.
- **Local index fails after PDS write:** Already handled in pds-proxy.ts — logged, record syncs back via firehose.
- **No viewer (unauthenticated XRPC):** Helpers should not be called. Throws if no session exists.