accounts
Version:
Tempo Accounts SDK
302 lines (282 loc) • 10.4 kB
text/typescript
import { Json } from 'ox'
/**
* Minimal key-value store interface used by the SDK's server primitives
* (e.g. SIWE nonce store, session store).
*
* Values are JSON-serialized when stored. TTLs are optional; consumers that
* need expiry pass `{ ttl }` (in seconds) to `set` and the implementation
* lazily evicts (memory) or relies on the backing store's native expiry
* (Cloudflare KV).
*/
export type Kv = {
/** Read a value by key. Returns `undefined` when missing or expired. */
get: <value = unknown>(key: string) => Promise<value | undefined>
/** Write a value. When `ttl` is set, the entry expires after the given duration in seconds. */
set: (key: string, value: unknown, options?: set.Options | undefined) => Promise<void>
/** Delete a value by key. */
delete: (key: string) => Promise<void>
/**
* Atomic read-and-delete. Returns the value if present, `undefined` if
* missing or expired. Across concurrent callers, exactly one observer
* receives a non-`undefined` return for a given key, and the key is
* removed exactly once.
*
* Optional. Required for one-time-consume semantics (e.g. SIWE
* challenge nonces). Backends without a linearizable read+delete
* primitive (e.g. eventually-consistent stores like Cloudflare KV)
* should leave this undefined; the consuming handler will refuse to
* accept the store at construction time and fall back to a different
* backend (e.g. a Durable Object).
*/
take?: <value = unknown>(key: string) => Promise<value | undefined>
}
export declare namespace set {
type Options = {
/** Time-to-live in seconds. After this duration, `get` returns `undefined`. */
ttl?: number | undefined
}
}
/** Wrap an existing `Kv`-shaped object so the SDK accepts it as a `Kv`. */
export function from<kv extends Kv>(kv: kv): kv {
return kv
}
/**
* Adapt a Cloudflare Workers KV namespace (or compatible binding) into a
* `Kv`. Uses the underlying store's native `expirationTtl` for TTL.
*
* Cloudflare KV's minimum TTL is 60 seconds; the platform enforces its own
* minimum independent of what's passed here.
*
* **Not safe for one-time-consume semantics.** Cloudflare KV is eventually
* consistent across data centers — concurrent read+delete races can let
* the same key be "consumed" twice. `take` is intentionally NOT
* implemented. Use a Durable Object (or another linearizable backend)
* for the SIWE challenge nonce store.
*/
export function cloudflare(kv: cloudflare.Parameters): Kv {
return from({
delete: kv.delete.bind(kv),
async get(key) {
return (await kv.get(key, 'json')) ?? undefined
},
async set(key, value, options) {
const expirationTtl = options?.ttl
await kv.put(key, Json.stringify(value), expirationTtl ? { expirationTtl } : undefined)
},
})
}
export declare namespace cloudflare {
type Parameters = {
get: <value = unknown>(key: string, format: 'json') => Promise<value | null>
put: (
key: string,
value: string,
options?: { expirationTtl?: number } | undefined,
) => Promise<void>
delete: (key: string) => Promise<void>
}
}
/**
* Adapt a Cloudflare Durable Object namespace into a `Kv` with atomic
* `take`. Unlike `Kv.cloudflare`, a Durable Object's storage is
* single-actor and linearizable — `take` (read+delete) is guaranteed
* atomic across concurrent callers, which makes this the recommended
* backend for SIWE challenge nonce storage on Cloudflare Workers.
*
* Pair with `Kv.NonceStorage` (or your own DO class implementing the
* same fetch protocol).
*
* Example:
*
* ```ts
* // wrangler.jsonc
* // {
* // "durable_objects": {
* // "bindings": [{ "name": "NONCE_DO", "class_name": "NonceStorage" }]
* // },
* // "migrations": [{ "tag": "v1", "new_classes": ["NonceStorage"] }]
* // }
*
* // worker.ts
* export { NonceStorage } from 'accounts/server'
*
* export default {
* fetch(req, env) {
* const handler = Handler.auth({
* store: Kv.durableObject(env.NONCE_DO),
* origin: 'https://app.example.com',
* })
* return handler.fetch(req)
* }
* }
* ```
*/
export function durableObject(
namespace: durableObject.Namespace,
options: durableObject.Options = {},
): Kv {
const instanceName = options.name ?? 'default'
const stub = () => namespace.get(namespace.idFromName(instanceName))
async function rpc(op: string, key: string, body?: unknown): Promise<unknown> {
const url = `https://do.invalid/${op}?key=${encodeURIComponent(key)}`
const init: RequestInit =
body !== undefined
? {
method: 'POST',
body: Json.stringify(body),
headers: { 'content-type': 'application/json' },
}
: { method: 'POST' }
const res = await stub().fetch(url, init as never)
if (!res.ok) throw new Error(`Kv.durableObject ${op} failed: ${res.status}`)
return await res.json()
}
return from({
async get(key) {
const { value } = (await rpc('get', key)) as { value: unknown }
return value as never
},
async set(key, value, options) {
await rpc('set', key, { value, ttl: options?.ttl })
},
async delete(key) {
await rpc('delete', key)
},
async take(key) {
const { value } = (await rpc('take', key)) as { value: unknown }
return value as never
},
})
}
export declare namespace durableObject {
/**
* Minimal shape of a Cloudflare Durable Object namespace binding.
* Compatible with `DurableObjectNamespace` from `@cloudflare/workers-types`.
*
* `get`'s parameter is typed as `any` (rather than `unknown`) so the
* stricter `(id: DurableObjectId) => ...` signature on Cloudflare's
* `DurableObjectNamespace` is structurally assignable here without an
* intermediate cast on the caller.
*/
type Namespace = {
idFromName: (name: string) => unknown
// biome-ignore lint/suspicious/noExplicitAny: contravariant id parameter — see JSDoc above.
get: (id: any) => { fetch: (input: string, init?: unknown) => Promise<Response> }
}
type Options = {
/**
* Durable Object instance name. Defaults to `'default'` (a single
* shared actor). Use a per-tenant name if you need isolation.
*/
name?: string | undefined
}
}
/**
* Reference Durable Object class implementing the `Kv.durableObject`
* fetch protocol. Export from your Worker entry and bind it under
* `class_name: "NonceStorage"` in `wrangler.jsonc`.
*
* The class is framework-agnostic — it doesn't import `cloudflare:workers`
* so it works with both the legacy DO API (`fetch(req)` only) and the
* newer `extends DurableObject` API.
*/
export class NonceStorage {
state: NonceStorage.State
constructor(state: NonceStorage.State, _env?: unknown) {
this.state = state
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url)
const op = url.pathname.replace(/^\//, '')
const key = url.searchParams.get('key')
if (!key) return Response.json({ error: 'missing `key`' }, { status: 400 })
const isExpired = (entry: { expiresAt?: number } | undefined) =>
Boolean(entry?.expiresAt && Date.now() >= entry.expiresAt)
if (op === 'get') {
const entry = await this.state.storage.get<NonceStorage.Entry>(key)
if (!entry || isExpired(entry)) return Response.json({ value: undefined })
return Response.json({ value: entry.value })
}
if (op === 'take') {
const entry = await this.state.storage.get<NonceStorage.Entry>(key)
if (!entry || isExpired(entry)) {
if (entry) await this.state.storage.delete(key)
return Response.json({ value: undefined })
}
await this.state.storage.delete(key)
return Response.json({ value: entry.value })
}
if (op === 'set') {
const body = (await request.json()) as { value: unknown; ttl?: number }
const entry: NonceStorage.Entry = body.ttl
? { value: body.value, expiresAt: Date.now() + body.ttl * 1000 }
: { value: body.value }
await this.state.storage.put(key, entry)
return Response.json({})
}
if (op === 'delete') {
await this.state.storage.delete(key)
return Response.json({})
}
return Response.json({ error: `unknown op: ${op}` }, { status: 400 })
}
}
export declare namespace NonceStorage {
/** Subset of `DurableObjectState` actually used by `NonceStorage`. */
type State = {
storage: {
get: <T = unknown>(key: string) => Promise<T | undefined>
put: (key: string, value: unknown) => Promise<void>
delete: (key: string) => Promise<void>
}
}
/** Internal storage shape: value plus optional absolute expiry timestamp (ms). */
type Entry = { value: unknown; expiresAt?: number }
}
/**
* In-memory `Kv` for tests and single-process deployments. Lazily evicts
* expired entries on read/write.
*
* Pass `now` to control the clock in tests.
*/
export function memory(options: memory.Options = {}): Kv {
const now = options.now ?? Date.now
const store = new Map<string, { value: unknown; expiresAt?: number }>()
function isExpired(entry: { expiresAt?: number }) {
return entry.expiresAt !== undefined && now() >= entry.expiresAt
}
return from({
async delete(key) {
store.delete(key)
},
async get(key) {
const entry = store.get(key)
if (!entry) return undefined
if (isExpired(entry)) {
store.delete(key)
return undefined
}
return entry.value as never
},
async set(key, value, options) {
const expiresAt = options?.ttl ? now() + options.ttl * 1000 : undefined
store.set(key, expiresAt !== undefined ? { value, expiresAt } : { value })
},
// Atomic in-process: the synchronous `Map.get` + `Map.delete` runs
// in a single microtask, so concurrent `take(key)` callers (within
// the same Node/Bun/Worker process) cannot both observe the value.
async take(key) {
const entry = store.get(key)
if (!entry) return undefined
store.delete(key)
if (isExpired(entry)) return undefined
return entry.value as never
},
})
}
export declare namespace memory {
type Options = {
/** Clock function for TTL accounting. Defaults to `Date.now`. */
now?: (() => number) | undefined
}
}