accounts
Version:
Tempo Accounts SDK
196 lines • 6.96 kB
JavaScript
import { Json } from 'ox';
/** Wrap an existing `Kv`-shaped object so the SDK accepts it as a `Kv`. */
export function from(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) {
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);
},
});
}
/**
* 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, options = {}) {
const instanceName = options.name ?? 'default';
const stub = () => namespace.get(namespace.idFromName(instanceName));
async function rpc(op, key, body) {
const url = `https://do.invalid/${op}?key=${encodeURIComponent(key)}`;
const init = body !== undefined
? {
method: 'POST',
body: Json.stringify(body),
headers: { 'content-type': 'application/json' },
}
: { method: 'POST' };
const res = await stub().fetch(url, init);
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));
return value;
},
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));
return value;
},
});
}
/**
* 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;
constructor(state, _env) {
this.state = state;
}
async fetch(request) {
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) => Boolean(entry?.expiresAt && Date.now() >= entry.expiresAt);
if (op === 'get') {
const entry = await this.state.storage.get(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(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());
const 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 });
}
}
/**
* 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 = {}) {
const now = options.now ?? Date.now;
const store = new Map();
function isExpired(entry) {
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;
},
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;
},
});
}
//# sourceMappingURL=Kv.js.map