Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,23 @@
# `identity`
Authentication, authorization, identity resolution, and credential management.
<!-- begin:auto-docs -->
## Subcategories
1 subcategory, 7 packages.
### `auth`
Authentication providers, JWT, WebAuthn, OAuth, and session resolution.
| Package | Description |
|---|---|
| [`@suaveplan/auth`](./auth/README.md) | Provider-agnostic authentication library for Suaveplan. Ships JWT token lifecycle, API key management, session management, and framework-agnostic middleware —… |
| [`@suaveplan/better-auth-surrealdb`](./better-auth-surrealdb/README.md) | Better-Auth Adapter for SurrealDB v3 — bundles the canonical 17-table SurrealQL schema, compiles Better-Auth `Where[]` filters to parameterised SurrealQL, auto… |
| [`@suaveplan/jwt-tenant`](./jwt-tenant/README.md) | JWT-based multi-tenant token issuance and verification for Suaveplan. Ships an Ed25519 / ECDSA-P256 signing pipeline, in-memory JWKS store with rotation and ex… |
| [`@suaveplan/macos-keychain`](./macos-keychain/README.md) | macOS Keychain bridge for generic-password credentials. Wraps the system `/usr/bin/security` CLI behind a strongly-typed CRUD surface with typed errors, AbortS… |
| [`@suaveplan/oauth-client`](./oauth-client/README.md) | Universal OAuth 2.0 / OIDC client for Node.js, browsers, Bun, Deno, and edge runtimes. |
| [`@suaveplan/permissions`](./permissions/README.md) | Full RBAC/ABAC permissions engine for Suaveplan. Ships role management, policy evaluation, CASL-inspired ability builder, TTL permission cache, condition evalu… |
| [`@suaveplan/webauthn`](./webauthn/README.md) | W3C WebAuthn Level 3 client + parser + verifier for Suaveplan. Browser-tier registration / authentication entry points sit on the package root; framework-agnos… |
<!-- end:auto-docs -->

View File

@@ -0,0 +1,3 @@
# `identity`
Authentication, authorization, identity resolution, and credential management.

View File

@@ -0,0 +1,19 @@
# `auth`
Authentication providers, JWT, WebAuthn, OAuth, and session resolution.
<!-- begin:auto-docs -->
## Packages in `auth`
7 packages.
| Package | Description |
|---|---|
| [`@suaveplan/auth`](./auth/README.md) | Provider-agnostic authentication library for Suaveplan. Ships JWT token lifecycle, API key management, session management, and framework-agnostic middleware —… |
| [`@suaveplan/better-auth-surrealdb`](./better-auth-surrealdb/README.md) | Better-Auth Adapter for SurrealDB v3 — bundles the canonical 17-table SurrealQL schema, compiles Better-Auth `Where[]` filters to parameterised SurrealQL, auto… |
| [`@suaveplan/jwt-tenant`](./jwt-tenant/README.md) | JWT-based multi-tenant token issuance and verification for Suaveplan. Ships an Ed25519 / ECDSA-P256 signing pipeline, in-memory JWKS store with rotation and ex… |
| [`@suaveplan/macos-keychain`](./macos-keychain/README.md) | macOS Keychain bridge for generic-password credentials. Wraps the system `/usr/bin/security` CLI behind a strongly-typed CRUD surface with typed errors, AbortS… |
| [`@suaveplan/oauth-client`](./oauth-client/README.md) | Universal OAuth 2.0 / OIDC client for Node.js, browsers, Bun, Deno, and edge runtimes. |
| [`@suaveplan/permissions`](./permissions/README.md) | Full RBAC/ABAC permissions engine for Suaveplan. Ships role management, policy evaluation, CASL-inspired ability builder, TTL permission cache, condition evalu… |
| [`@suaveplan/webauthn`](./webauthn/README.md) | W3C WebAuthn Level 3 client + parser + verifier for Suaveplan. Browser-tier registration / authentication entry points sit on the package root; framework-agnos… |
<!-- end:auto-docs -->

View File

@@ -0,0 +1 @@
Authentication providers, JWT, WebAuthn, OAuth, and session resolution.

View File

@@ -0,0 +1,65 @@
# @suaveplan/auth - Provider-agnostic authentication: JWT lifecycle, API key management, session management, and request middleware with pluggable storage adapters
## Summary
Provider-agnostic authentication library for Suaveplan. Ships JWT token lifecycle, API key management, session management, and framework-agnostic middleware — all backed by a pluggable `AuthAdapter` interface.
<!-- begin:auto-docs -->
## Documentation tree
- [auth](./src/adapters/auth/auth.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [auth](./src/adapters/auth/auth.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [memory](./src/adapters/memory/memory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [memory](./src/adapters/memory/memory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [api-keys](./src/api-keys/api-keys.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [api-keys](./src/api-keys/api-keys.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/auth-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [auth-error](./src/errors/auth-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [jwt](./src/jwt/token-lifecycle.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [token-lifecycle](./src/jwt/token-lifecycle.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [middleware](./src/middleware/auth-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [auth-middleware](./src/middleware/auth-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [schemas](./src/schemas/auth.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [auth.schemas](./src/schemas/auth.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [auth](./src/adapters/auth/auth.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory](./src/adapters/memory/memory.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory](./src/adapters/memory/memory.md) — <!-- begin:auto-docs --> _(no submodules)_- [api-keys](./src/api-keys/api-keys.md) — <!-- begin:auto-docs --> _(no submodules)_- [api-keys](./src/api-keys/api-keys.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/auth-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [auth-error](./src/errors/auth-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [jwt](./src/jwt/token-lifecycle.md) — <!-- begin:auto-docs --> _(no submodules)_- [token-lifecycle](./src/jwt/token-lifecycle.md) — <!-- begin:auto-docs --> _(no submodules)_- [middleware](./src/middleware/auth-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [auth-middleware](./src/middleware/auth-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/auth.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [auth.schemas](./src/schemas/auth.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [auth](./src/adapters/auth/auth.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory](./src/adapters/memory/memory.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory](./src/adapters/memory/memory.md) — <!-- begin:auto-docs --> _(no submodules)_- [api-keys](./src/api-keys/api-keys.md) — <!-- begin:auto-docs --> _(no submodules)_- [api-keys](./src/api-keys/api-keys.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/auth-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [auth-error](./src/errors/auth-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [jwt](./src/jwt/token-lifecycle.md) — <!-- begin:auto-docs --> _(no submodules)_- [token-lifecycle](./src/jwt/token-lifecycle.md) — <!-- begin:auto-docs --> _(no submodules)_- [middleware](./src/middleware/auth-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [auth-middleware](./src/middleware/auth-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/auth.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [auth.schemas](./src/schemas/auth.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [auth](./src/adapters/auth/auth.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory](./src/adapters/memory/memory.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory](./src/adapters/memory/memory.md) — <!-- begin:auto-docs --> _(no submodules)_- [api-keys](./src/api-keys/api-keys.md) — <!-- begin:auto-docs --> _(no submodules)_- [api-keys](./src/api-keys/api-keys.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/auth-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [auth-error](./src/errors/auth-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [jwt](./src/jwt/token-lifecycle.md) — <!-- begin:auto-docs --> _(no submodules)_- [token-lifecycle](./src/jwt/token-lifecycle.md) — <!-- begin:auto-docs --> _(no submodules)_- [middleware](./src/middleware/auth-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [auth-middleware](./src/middleware/auth-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/auth.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [auth.schemas](./src/schemas/auth.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [auth](./src/adapters/auth/auth.md) — <!-- begin:auto-docs --> _(no submodules)_- [memory](./src/adapters/memory/memory.md) — <!-- begin:auto-docs --> _(no submodules)_ - [memory](./src/adapters/memory/memory.md) — <!-- begin:auto-docs --> _(no submodules)_- [api-keys](./src/api-keys/api-keys.md) — <!-- begin:auto-docs --> _(no submodules)_ - [api-keys](./src/api-keys/api-keys.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/auth-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [auth-error](./src/errors/auth-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [jwt](./src/jwt/token-lifecycle.md) — <!-- begin:auto-docs --> _(no submodules)_ - [token-lifecycle](./src/jwt/token-lifecycle.md) — <!-- begin:auto-docs --> _(no submodules)_- [middleware](./src/middleware/auth-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_ - [auth-middleware](./src/middleware/auth-middleware.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/auth.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ - [auth.schemas](./src/schemas/auth.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_
## Packages
| Module | Description |
|---|---|
| [errors/auth-error](src/errors/auth-error.md) | Stable AUTH.* error code registry and concrete error classes |
| [types/auth.types](src/types/auth.types.md) | All public interfaces and type aliases |
| [schemas/auth.schemas](src/schemas/auth.schemas.md) | Zod validation schemas for option types |
| [adapters/auth-adapter](src/adapters/auth-adapter/auth-adapter.md) | AuthAdapter interface definition |
| [adapters/memory-adapter](src/adapters/memory-adapter/memory-adapter.md) | In-memory AuthAdapter implementation |
| [api-keys](src/api-keys/api-keys.md) | Full API key lifecycle |
| [jwt/token-lifecycle](src/jwt/token-lifecycle.md) | JWT issue, validate, refresh, revoke |
| [middleware/auth-middleware](src/middleware/auth-middleware.md) | Permission middleware helpers |
## Quick Start
```ts
import {
MemoryAuthAdapter,
issueToken,
validateSessionToken,
createApiKey,
validateApiKey,
requirePermission,
} from "@suaveplan/auth";
const adapter = new MemoryAuthAdapter();
const jwtOpts = { secret: process.env["JWT_SECRET"] ?? "" };
// Issue JWT tokens
const { accessToken, refreshToken } = await issueToken(adapter, userId, jwtOpts);
// Validate
const result = await validateSessionToken(adapter, accessToken, jwtOpts);
// API keys
const { rawKey } = await createApiKey(adapter, { name: "CI Key", userId });
const v = await validateApiKey(adapter, rawKey);
// Middleware
const adminOnly = requirePermission("admin:write", adapter, jwtOpts);
```

View File

@@ -0,0 +1,128 @@
{
"name": "@suaveplan/auth",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Provider-agnostic authentication: JWT lifecycle, API key management, session management, and request middleware with pluggable storage adapters",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./errors": {
"import": {
"types": "./dist/errors/auth-error.d.ts",
"default": "./dist/errors/auth-error.js"
},
"require": {
"types": "./dist/errors/auth-error.d.ts",
"default": "./dist/errors/auth-error.js"
}
},
"./adapters/auth": {
"import": {
"types": "./dist/adapters/auth/auth.d.ts",
"default": "./dist/adapters/auth/auth.js"
},
"require": {
"types": "./dist/adapters/auth/auth.d.ts",
"default": "./dist/adapters/auth/auth.js"
}
},
"./adapters/memory": {
"import": {
"types": "./dist/adapters/memory/memory.d.ts",
"default": "./dist/adapters/memory/memory.js"
},
"require": {
"types": "./dist/adapters/memory/memory.d.ts",
"default": "./dist/adapters/memory/memory.js"
}
},
"./api-keys": {
"import": {
"types": "./dist/api-keys/api-keys.d.ts",
"default": "./dist/api-keys/api-keys.js"
},
"require": {
"types": "./dist/api-keys/api-keys.d.ts",
"default": "./dist/api-keys/api-keys.js"
}
},
"./jwt": {
"import": {
"types": "./dist/jwt/token-lifecycle.d.ts",
"default": "./dist/jwt/token-lifecycle.js"
},
"require": {
"types": "./dist/jwt/token-lifecycle.d.ts",
"default": "./dist/jwt/token-lifecycle.js"
}
},
"./middleware": {
"import": {
"types": "./dist/middleware/auth-middleware.d.ts",
"default": "./dist/middleware/auth-middleware.js"
},
"require": {
"types": "./dist/middleware/auth-middleware.d.ts",
"default": "./dist/middleware/auth-middleware.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"bench": "bun run src/__benchmarks__/auth.bench.ts",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"@suaveplan/crypto": "workspace:^",
"@suaveplan/logger": "workspace:^",
"@suaveplan/retry": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms",
"@opentelemetry/api": "catalog:observability"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"mitata": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,94 @@
# auth-adapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the `AuthAdapter` interface — the single integration point between `@suaveplan/auth` business logic and any storage backend. By programming against this interface rather than a concrete implementation, the package remains fully provider-agnostic: swap from `MemoryAuthAdapter` (development) to a Redis or PostgreSQL adapter (production) without touching a single line of auth logic.
The interface groups operations into three logical categories: API key storage, token blacklisting, and session management. All methods accept an optional `AbortSignal` to support cancellation of long-running storage operations.
## Features
- Complete API key lifecycle: store, lookup by hash, revoke, list, touch (update lastUsedAt)
- Token blacklist: add JTI with expiry, check membership
- Session management: create, find, delete, delete-all-for-user, list
- `AbortSignal` on every async method for cancellation support
- Optional `ApiKeyFactory` extension interface for adapters embedding crypto at the storage layer
## Usage: Basic Example
```ts
import type { AuthAdapter } from "@suaveplan/auth";
import { MemoryAuthAdapter } from "@suaveplan/auth";
const adapter: AuthAdapter = new MemoryAuthAdapter();
await adapter.storeApiKey({ id: "k1", keyHash: "...", /* ... */ });
const key = await adapter.findApiKeyByHash("sha256-hash");
```
## Usage: Advanced Example
```ts
import type { AuthAdapter, ApiKey, SessionInfo } from "@suaveplan/auth";
class RedisAuthAdapter implements AuthAdapter {
constructor(private readonly redis: RedisClient) {}
async storeApiKey(apiKey: ApiKey): Promise<void> {
await this.redis.set(`apikey:hash:${apiKey.keyHash}`, JSON.stringify(apiKey));
await this.redis.sadd(`apikeys:user:${apiKey.userId}`, apiKey.id);
}
async findApiKeyByHash(hash: string): Promise<ApiKey | null> {
const raw = await this.redis.get(`apikey:hash:${hash}`);
return raw ? (JSON.parse(raw) as ApiKey) : null;
}
async blacklistToken(jti: string, expiresAt: Date): Promise<void> {
const ttl = Math.ceil((expiresAt.getTime() - Date.now()) / 1000);
if (ttl > 0) await this.redis.setex(`blacklist:${jti}`, ttl, "1");
}
async isBlacklisted(jti: string): Promise<boolean> {
return (await this.redis.exists(`blacklist:${jti}`)) === 1;
}
// ... remaining methods
}
```
## API Reference
### `AuthAdapter` interface
#### API key methods
- `storeApiKey(apiKey: ApiKey, signal?): Promise<void>` — persist a new key record.
- `findApiKeyByHash(hash: string, signal?): Promise<ApiKey | null>` — lookup by SHA-256 hex hash.
- `revokeApiKey(keyId: string, signal?): Promise<void>` — set `revokedAt` on the record.
- `listApiKeysByUser(userId: string, signal?): Promise<ApiKey[]>` — all keys for a user.
- `touchApiKey(keyId: string, now: Date, signal?): Promise<void>` — update `lastUsedAt`.
#### Blacklist methods
- `blacklistToken(jti: string, expiresAt: Date, signal?): Promise<void>` — add JTI to revocation list.
- `isBlacklisted(jti: string, signal?): Promise<boolean>` — check if JTI is revoked.
#### Session methods
- `createSession(session: SessionInfo, signal?): Promise<void>` — persist a new session.
- `findSession(sessionId: string, signal?): Promise<SessionInfo | null>` — lookup or null.
- `deleteSession(sessionId: string, signal?): Promise<void>` — remove one session.
- `deleteUserSessions(userId: string, signal?): Promise<void>` — remove all sessions for a user.
- `listUserSessions(userId: string, signal?): Promise<SessionInfo[]>` — all sessions for a user.
### `ApiKeyFactory` interface
Optional extension for adapters that embed crypto. Methods: `createApiKey(request, signal?)`, `validateApiKey(rawKey, signal?)`.
## Implementation Notes
The interface intentionally avoids any mention of JWT signing, HMAC secrets, or token formats — those belong in the `jwt` module. The adapter is purely a storage contract. The `keyHash` field on `ApiKey` is always a SHA-256 hex digest of the raw key; the adapter stores this digest and never the plain-text value. `touchApiKey` exists separately from `storeApiKey` to allow adapters to issue a lightweight update (e.g. `UPDATE SET last_used_at = ?`) rather than replacing the entire record.

View File

@@ -0,0 +1,91 @@
import type {
ApiKey,
ApiKeyGeneration,
ApiKeyRequest,
ApiKeyValidationResult,
SessionInfo,
} from "../../types/auth.types.js";
/**
* Contract that every auth storage backend must satisfy.
*
* Implementations are responsible for persistence only — business logic such
* as token signing and expiry checking lives in the higher-level helpers
* (`api-keys`, `jwt`, `middleware`). The adapter is the single integration
* point; replace it to switch backends without touching any auth logic.
*/
export interface AuthAdapter {
// ----- API key storage -----
/** Persist a newly generated API key record. */
storeApiKey(apiKey: ApiKey, signal?: AbortSignal): Promise<void>;
/** Retrieve an API key record by its SHA-256 hash. Returns `null` if not found. */
findApiKeyByHash(
hash: string,
signal?: AbortSignal
): Promise<ApiKey | null>;
/** Retrieve an API key record by its ID. Returns `null` if not found. */
findApiKeyById(id: string, signal?: AbortSignal): Promise<ApiKey | null>;
/** Mark an API key as revoked (set `revokedAt`). */
revokeApiKey(keyId: string, signal?: AbortSignal): Promise<void>;
/** List all API keys belonging to a user. */
listApiKeysByUser(userId: string, signal?: AbortSignal): Promise<ApiKey[]>;
/** Update the `lastUsedAt` timestamp on an API key. */
touchApiKey(keyId: string, now: Date, signal?: AbortSignal): Promise<void>;
// ----- Token blacklist -----
/** Add a JWT ID to the blacklist with an explicit expiry. */
blacklistToken(
jti: string,
expiresAt: Date,
signal?: AbortSignal
): Promise<void>;
/** Check whether a JWT ID is on the blacklist. */
isBlacklisted(jti: string, signal?: AbortSignal): Promise<boolean>;
// ----- Sessions -----
/** Persist a new session record. */
createSession(session: SessionInfo, signal?: AbortSignal): Promise<void>;
/** Retrieve a session by ID. Returns `null` if not found. */
findSession(
sessionId: string,
signal?: AbortSignal
): Promise<SessionInfo | null>;
/** Delete a specific session by ID. */
deleteSession(sessionId: string, signal?: AbortSignal): Promise<void>;
/** Delete all sessions for a user (e.g. on password change). */
deleteUserSessions(userId: string, signal?: AbortSignal): Promise<void>;
/** List all sessions for a user. */
listUserSessions(
userId: string,
signal?: AbortSignal
): Promise<SessionInfo[]>;
}
/**
* A higher-level factory interface for auth adapters that need to produce
* API keys directly (e.g. adapters embedding crypto at the storage layer).
* Most adapters should implement only `AuthAdapter`; this extension is optional.
*/
export interface ApiKeyFactory {
createApiKey(
request: ApiKeyRequest,
signal?: AbortSignal
): Promise<ApiKeyGeneration>;
validateApiKey(
rawKey: string,
signal?: AbortSignal
): Promise<ApiKeyValidationResult>;
}

View File

@@ -0,0 +1 @@
export type { ApiKeyFactory, AuthAdapter } from "./auth.js";

View File

@@ -0,0 +1,4 @@
export {
MemoryAuthAdapter,
type MemoryAuthAdapterOptions,
} from "./memory.js";

View File

@@ -0,0 +1,115 @@
# memory-adapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`MemoryAuthAdapter` is the built-in in-memory implementation of `AuthAdapter`. It stores all data in `Map` and `Set` structures — no external dependencies, no I/O, zero latency. It ships for two purposes: as the reference implementation that demonstrates how to satisfy the `AuthAdapter` contract, and as the default adapter for unit tests and single-process deployments.
The adapter accepts an injectable `Clock` and `IdGenerator` via its constructor, ensuring timestamps and IDs are fully deterministic in test environments without the need for mocks.
## Features
- Complete `AuthAdapter` implementation across all three categories (API keys, blacklist, sessions)
- Injectable `Clock` for deterministic expiry checking and timestamp recording
- Injectable `IdGenerator` for deterministic ID generation
- Bounded blacklist with LRU-style expired-entry eviction (configurable `maxBlacklistSize`)
- Test helper methods: `getAllApiKeys()`, `getAllSessions()`, `getBlacklistSize()`
- `protected` fields for straightforward subclassing in integration test fixtures
## Usage: Basic Example
```ts
import { MemoryAuthAdapter } from "@suaveplan/auth";
const adapter = new MemoryAuthAdapter();
await adapter.createSession({
sessionId: "sess-1",
userId: "user-1",
createdAt: new Date(),
expiresAt: new Date(Date.now() + 3_600_000),
});
const session = await adapter.findSession("sess-1");
console.log(session?.userId); // "user-1"
```
## Usage: Advanced Example
```ts
import { MemoryAuthAdapter } from "@suaveplan/auth";
let tick = 0;
const fakeClock = () => tick;
const fakeId = () => `id-${++tick}`;
const adapter = new MemoryAuthAdapter({ maxBlacklistSize: 500 }, fakeClock, fakeId);
// Store an API key
await adapter.storeApiKey({
id: "k1",
key: "sp_...",
keyHash: "abc123",
name: "Test key",
userId: "u1",
permissions: ["read"],
createdAt: new Date(0),
});
// Simulate time passing for expiry tests
tick = 99_999_999_999;
const jti = "token-jti";
await adapter.blacklistToken(jti, new Date(tick - 1)); // already expired
const isBlacklisted = await adapter.isBlacklisted(jti); // false — evicted
console.log(isBlacklisted); // false
```
## API Reference
### `constructor(options?, clock?, idGenerator?)`
- `options.maxBlacklistSize` — maximum number of JTIs to retain before eviction (default: 10 000)
- `clock` — `() => number` returning milliseconds since epoch (default: `Date.now`)
- `idGenerator` — `() => string` (default: `crypto.randomUUID`)
### `storeApiKey(apiKey, signal?): Promise<void>`
Indexes the key by `id`, `keyHash`, and `userId`. Overwrites any existing record with the same id/hash.
### `findApiKeyByHash(hash, signal?): Promise<ApiKey | null>`
O(1) hash-map lookup. Returns `null` if not found.
### `revokeApiKey(keyId, signal?): Promise<void>`
Sets `revokedAt` on the stored record. Throws `AuthStorageError` if not found, `ApiKeyRevokedError` if already revoked.
### `listApiKeysByUser(userId, signal?): Promise<ApiKey[]>`
Returns all keys registered for the user. O(n) where n is the number of keys for that user.
### `touchApiKey(keyId, now, signal?): Promise<void>`
Updates `lastUsedAt` on the stored record. No-op if the key is not found.
### `blacklistToken(jti, expiresAt, signal?): Promise<void>`
Adds the JTI to the blacklist. If the list exceeds `maxBlacklistSize`, expired entries are evicted first.
### `isBlacklisted(jti, signal?): Promise<boolean>`
Checks if the JTI is on the blacklist and not yet expired. Auto-evicts expired entries on access.
### `createSession / findSession / deleteSession / deleteUserSessions / listUserSessions`
Standard CRUD over the in-memory session store. `deleteSession` throws `SessionNotFoundError` if the session does not exist.
### `getAllApiKeys(): ApiKey[]` / `getAllSessions(): SessionInfo[]` / `getBlacklistSize(): number`
Test helpers exposing internal state without breaking the `AuthAdapter` interface contract.
## Implementation Notes
The `revokeApiKey` method updates both the `apiKeysById` and `apiKeysByHash` maps to keep them consistent. The blacklist eviction strategy is "evict expired entries when the size ceiling is hit" rather than LRU — this avoids O(n) scanning on every insert while still preventing unbounded growth in long-running processes. For production use with thousands of concurrent tokens, a Redis adapter with native TTL expiry is strongly preferred.

View File

@@ -0,0 +1,304 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
ApiKeyRevokedError,
AuthStorageError,
SessionNotFoundError,
} from "../../errors/auth-error.js";
import type { ApiKey, SessionInfo } from "../../types/auth.types.js";
import { MemoryAuthAdapter } from "./memory.js";
function makeKey(overrides: Partial<ApiKey> = {}): ApiKey {
return {
id: "key-1",
key: "sp_abc",
keyHash: "hash-1",
name: "Test Key",
userId: "user-1",
permissions: ["read"],
createdAt: new Date(1000),
...overrides,
};
}
function makeSession(overrides: Partial<SessionInfo> = {}): SessionInfo {
return {
sessionId: "sess-1",
userId: "user-1",
createdAt: new Date(1000),
expiresAt: new Date(99999999),
...overrides,
};
}
describe("MemoryAuthAdapter — API keys", () => {
it("stores and retrieves a key by hash", { timeout: 5000 }, async () => {
const adapter = new MemoryAuthAdapter();
const key = makeKey();
await adapter.storeApiKey(key);
const found = await adapter.findApiKeyByHash("hash-1");
expect(found).toEqual(key);
});
it("returns null for unknown hash", { timeout: 5000 }, async () => {
const adapter = new MemoryAuthAdapter();
expect(await adapter.findApiKeyByHash("nope")).toBeNull();
});
it("lists keys by user", { timeout: 5000 }, async () => {
const adapter = new MemoryAuthAdapter();
await adapter.storeApiKey(makeKey({ id: "k1", keyHash: "h1" }));
await adapter.storeApiKey(
makeKey({ id: "k2", keyHash: "h2", userId: "user-1" })
);
await adapter.storeApiKey(
makeKey({ id: "k3", keyHash: "h3", userId: "user-2" })
);
const keys = await adapter.listApiKeysByUser("user-1");
expect(keys).toHaveLength(2);
});
it("returns empty array for unknown user", { timeout: 5000 }, async () => {
const adapter = new MemoryAuthAdapter();
expect(await adapter.listApiKeysByUser("nobody")).toEqual([]);
});
it("revokes a key", { timeout: 5000 }, async () => {
const tick = 5000;
const adapter = new MemoryAuthAdapter({}, () => tick);
await adapter.storeApiKey(makeKey());
await adapter.revokeApiKey("key-1");
const found = await adapter.findApiKeyByHash("hash-1");
expect(found?.revokedAt).toEqual(new Date(5000));
});
it("throws AuthStorageError when revoking unknown key", {
timeout: 5000,
}, async () => {
const adapter = new MemoryAuthAdapter();
await expect(adapter.revokeApiKey("nope")).rejects.toBeInstanceOf(
AuthStorageError
);
});
it("throws ApiKeyRevokedError when revoking again", {
timeout: 5000,
}, async () => {
const adapter = new MemoryAuthAdapter();
await adapter.storeApiKey(makeKey());
await adapter.revokeApiKey("key-1");
await expect(adapter.revokeApiKey("key-1")).rejects.toBeInstanceOf(
ApiKeyRevokedError
);
});
it("touches lastUsedAt", { timeout: 5000 }, async () => {
const adapter = new MemoryAuthAdapter();
await adapter.storeApiKey(makeKey());
const now = new Date(9999);
await adapter.touchApiKey("key-1", now);
const found = await adapter.findApiKeyByHash("hash-1");
expect(found?.lastUsedAt).toEqual(now);
});
it("touch is no-op for unknown key", { timeout: 5000 }, async () => {
const adapter = new MemoryAuthAdapter();
await expect(
adapter.touchApiKey("nope", new Date())
).resolves.toBeUndefined();
});
it("getAllApiKeys returns all stored keys", { timeout: 5000 }, async () => {
const adapter = new MemoryAuthAdapter();
await adapter.storeApiKey(makeKey({ id: "k1", keyHash: "h1" }));
await adapter.storeApiKey(makeKey({ id: "k2", keyHash: "h2" }));
expect(adapter.getAllApiKeys()).toHaveLength(2);
});
});
describe("MemoryAuthAdapter — Blacklist", () => {
it("blacklists a token and detects it", { timeout: 5000 }, async () => {
const tick = 1000;
const adapter = new MemoryAuthAdapter({}, () => tick);
await adapter.blacklistToken("jti-1", new Date(9999));
expect(await adapter.isBlacklisted("jti-1")).toBe(true);
});
it("returns false for unknown jti", { timeout: 5000 }, async () => {
const adapter = new MemoryAuthAdapter();
expect(await adapter.isBlacklisted("nope")).toBe(false);
});
it("auto-evicts expired entries on isBlacklisted", {
timeout: 5000,
}, async () => {
const tick = 1000;
const adapter = new MemoryAuthAdapter({}, () => tick);
await adapter.blacklistToken("jti-exp", new Date(500)); // expired
expect(await adapter.isBlacklisted("jti-exp")).toBe(false);
});
it("evicts when max size is hit", { timeout: 5000 }, async () => {
let tick = 1;
const adapter = new MemoryAuthAdapter(
{ maxBlacklistSize: 2 },
() => tick
);
await adapter.blacklistToken("j1", new Date(100));
await adapter.blacklistToken("j2", new Date(200));
// Trigger eviction: j1 and j2 are not expired at tick=1; no room for j3
// Advance time past j1 expiry
tick = 150;
await adapter.blacklistToken("j3", new Date(1000));
// j1 should be evicted; j2 still valid; j3 added
expect(adapter.getBlacklistSize()).toBeLessThanOrEqual(3);
});
it("monotonicity: once blacklisted always returns true until expiry", {
timeout: 5000,
}, async () => {
let tick = 0;
const adapter = new MemoryAuthAdapter({}, () => tick);
await adapter.blacklistToken("jti-x", new Date(9999));
for (let i = 0; i < 10; i++) {
tick += 100;
expect(await adapter.isBlacklisted("jti-x")).toBe(true);
}
});
});
describe("MemoryAuthAdapter — Sessions", () => {
it("creates and finds a session", { timeout: 5000 }, async () => {
const adapter = new MemoryAuthAdapter();
await adapter.createSession(makeSession());
const found = await adapter.findSession("sess-1");
expect(found?.userId).toBe("user-1");
});
it("returns null for unknown session", { timeout: 5000 }, async () => {
const adapter = new MemoryAuthAdapter();
expect(await adapter.findSession("nope")).toBeNull();
});
it("deletes a session", { timeout: 5000 }, async () => {
const adapter = new MemoryAuthAdapter();
await adapter.createSession(makeSession());
await adapter.deleteSession("sess-1");
expect(await adapter.findSession("sess-1")).toBeNull();
});
it("throws SessionNotFoundError when deleting unknown session", {
timeout: 5000,
}, async () => {
const adapter = new MemoryAuthAdapter();
await expect(adapter.deleteSession("nope")).rejects.toBeInstanceOf(
SessionNotFoundError
);
});
it("deletes all sessions for a user", { timeout: 5000 }, async () => {
const adapter = new MemoryAuthAdapter();
await adapter.createSession(makeSession({ sessionId: "s1" }));
await adapter.createSession(makeSession({ sessionId: "s2" }));
await adapter.deleteUserSessions("user-1");
expect(await adapter.listUserSessions("user-1")).toEqual([]);
});
it("is a no-op deleteUserSessions for user with no sessions", {
timeout: 5000,
}, async () => {
const adapter = new MemoryAuthAdapter();
// no sessions created for "nobody"
await adapter.deleteUserSessions("nobody");
expect(await adapter.listUserSessions("nobody")).toEqual([]);
});
it("lists sessions for a user", { timeout: 5000 }, async () => {
const adapter = new MemoryAuthAdapter();
await adapter.createSession(makeSession({ sessionId: "s1" }));
await adapter.createSession(makeSession({ sessionId: "s2" }));
await adapter.createSession(
makeSession({ sessionId: "s3", userId: "user-2" })
);
const sessions = await adapter.listUserSessions("user-1");
expect(sessions).toHaveLength(2);
});
it("returns empty for unknown user sessions", {
timeout: 5000,
}, async () => {
const adapter = new MemoryAuthAdapter();
expect(await adapter.listUserSessions("nobody")).toEqual([]);
});
it("getAllSessions returns all stored sessions", {
timeout: 5000,
}, async () => {
const adapter = new MemoryAuthAdapter();
await adapter.createSession(makeSession({ sessionId: "s1" }));
await adapter.createSession(makeSession({ sessionId: "s2" }));
expect(adapter.getAllSessions()).toHaveLength(2);
});
});
describe("MemoryAuthAdapter — defensive guard branches", () => {
/** Subclass that exposes Maps for direct manipulation. */
class TestableMem extends MemoryAuthAdapter {
removeKeyFromMap(keyId: string): void {
const key = this.apiKeysById.get(keyId);
if (key) {
this.apiKeysById.delete(keyId);
this.apiKeysByHash.delete(key.keyHash);
// Deliberately leave the ID in apiKeysByUser to trigger the guard
}
}
removeSessionFromMap(sessionId: string): void {
const session = this.sessions.get(sessionId);
if (session) {
this.sessions.delete(sessionId);
// Deliberately leave sessionsByUser intact to trigger the guard
}
}
removeUserSessionIndex(userId: string): void {
this.sessionsByUser.delete(userId);
// Leave sessions Map intact — triggers the if (userSet) guard in deleteSession
}
}
it("listApiKeysByUser handles orphaned key IDs gracefully", {
timeout: 5000,
}, async () => {
const adapter = new TestableMem();
await adapter.storeApiKey(makeKey({ id: "k1", userId: "u1" }));
// Remove key from Map but keep ID in user Set
adapter.removeKeyFromMap("k1");
// Should return empty, not throw
const keys = await adapter.listApiKeysByUser("u1");
expect(keys).toHaveLength(0);
});
it("listUserSessions handles orphaned session IDs gracefully", {
timeout: 5000,
}, async () => {
const adapter = new TestableMem();
await adapter.createSession(makeSession({ sessionId: "s1" }));
// Remove session from Map but keep ID in user Set
adapter.removeSessionFromMap("s1");
// Should return empty, not throw
const sessions = await adapter.listUserSessions("user-1");
expect(sessions).toHaveLength(0);
});
it("deleteSession handles missing user session index gracefully", {
timeout: 5000,
}, async () => {
const adapter = new TestableMem();
await adapter.createSession(makeSession({ sessionId: "s1" }));
// Remove the user session index (sessionsByUser) but keep the session itself
adapter.removeUserSessionIndex("user-1");
// deleteSession should still succeed without throwing
await adapter.deleteSession("s1");
expect(await adapter.findSession("s1")).toBeNull();
});
});

View File

@@ -0,0 +1,216 @@
import type { Clock } from "@suaveplan/types";
import {
ApiKeyRevokedError,
AuthStorageError,
SessionNotFoundError,
} from "../../errors/auth-error.js";
import type { ApiKey, SessionInfo } from "../../types/auth.types.js";
import type { AuthAdapter } from "../auth/auth.js";
export interface MemoryAuthAdapterOptions {
/** Maximum number of blacklisted JTIs to retain before eviction (default: 10 000). */
readonly maxBlacklistSize?: number;
}
/**
* In-memory implementation of `AuthAdapter`.
*
* Stores all data in plain `Map` / `Set` structures. This adapter is suitable
* for tests and single-process deployments. It does not persist across process
* restarts and is not safe for multi-node deployments.
*/
export class MemoryAuthAdapter implements AuthAdapter {
protected readonly apiKeysByHash: Map<string, ApiKey> = new Map();
protected readonly apiKeysByUser: Map<string, Set<string>> = new Map();
protected readonly apiKeysById: Map<string, ApiKey> = new Map();
protected readonly blacklist: Map<string, Date> = new Map();
protected readonly sessions: Map<string, SessionInfo> = new Map();
protected readonly sessionsByUser: Map<string, Set<string>> = new Map();
protected readonly clock: Clock;
protected readonly maxBlacklistSize: number;
constructor(
options: MemoryAuthAdapterOptions = {},
clock: Clock = () => Date.now()
) {
this.clock = clock;
this.maxBlacklistSize = options.maxBlacklistSize ?? 10_000;
}
// ----- API Keys -----
async storeApiKey(apiKey: ApiKey, _signal?: AbortSignal): Promise<void> {
this.apiKeysByHash.set(apiKey.keyHash, apiKey);
this.apiKeysById.set(apiKey.id, apiKey);
const userSet = this.apiKeysByUser.get(apiKey.userId) ?? new Set();
userSet.add(apiKey.id);
this.apiKeysByUser.set(apiKey.userId, userSet);
}
async findApiKeyByHash(
hash: string,
_signal?: AbortSignal
): Promise<ApiKey | null> {
return this.apiKeysByHash.get(hash) ?? null;
}
async findApiKeyById(
id: string,
_signal?: AbortSignal
): Promise<ApiKey | null> {
return this.apiKeysById.get(id) ?? null;
}
async revokeApiKey(keyId: string, _signal?: AbortSignal): Promise<void> {
const existing = this.apiKeysById.get(keyId);
if (!existing) {
throw new AuthStorageError({
message: `API key ${keyId} not found`,
});
}
if (existing.revokedAt !== undefined) {
throw new ApiKeyRevokedError(keyId);
}
const updated: ApiKey = {
...existing,
revokedAt: new Date(this.clock()),
};
this.apiKeysById.set(keyId, updated);
this.apiKeysByHash.set(existing.keyHash, updated);
}
async listApiKeysByUser(
userId: string,
_signal?: AbortSignal
): Promise<ApiKey[]> {
const ids = this.apiKeysByUser.get(userId);
if (!ids) return [];
const keys: ApiKey[] = [];
for (const id of ids) {
const key = this.apiKeysById.get(id);
if (key !== undefined) keys.push(key);
}
return keys;
}
async touchApiKey(
keyId: string,
now: Date,
_signal?: AbortSignal
): Promise<void> {
const existing = this.apiKeysById.get(keyId);
if (!existing) return;
const updated: ApiKey = { ...existing, lastUsedAt: now };
this.apiKeysById.set(keyId, updated);
this.apiKeysByHash.set(existing.keyHash, updated);
}
// ----- Blacklist -----
async blacklistToken(
jti: string,
expiresAt: Date,
_signal?: AbortSignal
): Promise<void> {
if (this.blacklist.size >= this.maxBlacklistSize) {
// Evict expired entries first
const now = this.clock();
for (const [key, exp] of this.blacklist.entries()) {
if (exp.getTime() <= now) {
this.blacklist.delete(key);
}
}
}
this.blacklist.set(jti, expiresAt);
}
async isBlacklisted(jti: string, _signal?: AbortSignal): Promise<boolean> {
const exp = this.blacklist.get(jti);
if (exp === undefined) return false;
if (exp.getTime() <= this.clock()) {
this.blacklist.delete(jti);
return false;
}
return true;
}
// ----- Sessions -----
async createSession(
session: SessionInfo,
_signal?: AbortSignal
): Promise<void> {
this.sessions.set(session.sessionId, session);
const userSet =
this.sessionsByUser.get(session.userId) ?? new Set<string>();
userSet.add(session.sessionId);
this.sessionsByUser.set(session.userId, userSet);
}
async findSession(
sessionId: string,
_signal?: AbortSignal
): Promise<SessionInfo | null> {
return this.sessions.get(sessionId) ?? null;
}
async deleteSession(
sessionId: string,
_signal?: AbortSignal
): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) {
throw new SessionNotFoundError(sessionId);
}
this.sessions.delete(sessionId);
const userSet = this.sessionsByUser.get(session.userId);
if (userSet) {
userSet.delete(sessionId);
}
}
async deleteUserSessions(
userId: string,
_signal?: AbortSignal
): Promise<void> {
const ids = this.sessionsByUser.get(userId);
if (ids) {
for (const id of ids) {
this.sessions.delete(id);
}
}
this.sessionsByUser.delete(userId);
}
async listUserSessions(
userId: string,
_signal?: AbortSignal
): Promise<SessionInfo[]> {
const ids = this.sessionsByUser.get(userId);
if (!ids) return [];
const result: SessionInfo[] = [];
for (const id of ids) {
const s = this.sessions.get(id);
if (s !== undefined) result.push(s);
}
return result;
}
// ----- Test helpers -----
/** Returns all API keys stored in this adapter (for testing). */
getAllApiKeys(): ApiKey[] {
return Array.from(this.apiKeysById.values());
}
/** Returns all sessions stored in this adapter (for testing). */
getAllSessions(): SessionInfo[] {
return Array.from(this.sessions.values());
}
/** Returns the current blacklist size (for testing). */
getBlacklistSize(): number {
return this.blacklist.size;
}
}

View File

@@ -0,0 +1,114 @@
# api-keys
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the complete API key lifecycle for `@suaveplan/auth`: creation with one-time raw key delivery, validation against a hashed store, revocation, listing, rotation, permission updating, and a framework-agnostic middleware factory. All operations delegate to an injected `AuthAdapter` for storage, keeping business logic completely decoupled from the persistence layer.
API keys use a `sp_` prefix for easy identification, followed by 32 cryptographically-random bytes encoded as a hex string. Only the SHA-256 hash of the raw key is ever stored; the plain-text value is returned once at creation time and must never be persisted by the caller.
## Features
- `createApiKey` — generates a random key, hashes it, stores via adapter, emits OTel counter
- `validateApiKey` — hash lookup, revocation/expiry checks, `lastUsedAt` update, OTel counter
- `revokeApiKey` — delegates to `adapter.revokeApiKey`
- `listApiKeys` — delegates to `adapter.listApiKeysByUser`
- `rotateApiKey` — revokes old key and issues a replacement with same metadata
- `updateApiKeyPermissions` — modifies the permissions array on an existing key
- `hasPermission` — checks if a key grants a specific permission (with wildcard `*` support)
- `validateApiKeyMiddleware` — framework-agnostic `(req, res, next)` factory
## Usage: Basic Example
```ts
import { createApiKey, validateApiKey, hasPermission } from "@suaveplan/auth";
import { MemoryAuthAdapter } from "@suaveplan/auth";
const adapter = new MemoryAuthAdapter();
const { apiKey, rawKey } = await createApiKey(adapter, {
name: "CI Key",
userId: "user-42",
permissions: ["deploy:write"],
});
// Validate on subsequent requests
const result = await validateApiKey(adapter, rawKey);
if (result.valid && result.apiKey) {
console.log(hasPermission(result.apiKey, "deploy:write")); // true
}
```
## Usage: Advanced Example
```ts
import {
createApiKey,
validateApiKey,
rotateApiKey,
validateApiKeyMiddleware,
} from "@suaveplan/auth";
import { MemoryAuthAdapter } from "@suaveplan/auth";
const adapter = new MemoryAuthAdapter();
let tick = 0;
const clock = () => tick;
// Create key expiring in 1 hour
const { apiKey, rawKey } = await createApiKey(
adapter,
{ name: "Temp Key", userId: "u1", expiresIn: 3_600_000 },
{ clock },
);
// Validate (key still valid)
let v = await validateApiKey(adapter, rawKey, { clock });
console.log(v.valid); // true
// Advance time past expiry
tick = 3_600_001;
v = await validateApiKey(adapter, rawKey, { clock });
console.log(v.valid); // false — expired
// Middleware usage
const mw = validateApiKeyMiddleware(adapter, { headerName: "x-api-key" });
```
## API Reference
### `createApiKey(adapter, request, options?, signal?): Promise<ApiKeyGeneration>`
Creates and stores a new API key. Returns `{ apiKey, rawKey }`. `rawKey` must be delivered to the caller and never stored.
### `validateApiKey(adapter, rawKey, options?, signal?): Promise<ApiKeyValidationResult>`
Validates a raw key. Returns `{ valid, apiKey?, error? }`. Updates `lastUsedAt` on success.
### `revokeApiKey(adapter, keyId, signal?): Promise<void>`
Revokes a key by ID. Throws `ApiKeyRevokedError` if already revoked.
### `listApiKeys(adapter, userId, signal?): Promise<ApiKey[]>`
Returns all keys for a user including revoked/expired ones.
### `rotateApiKey(adapter, keyId, options?, signal?): Promise<ApiKeyGeneration>`
Revokes the named key and issues a replacement with the same metadata.
### `updateApiKeyPermissions(adapter, keyId, permissions, options?, signal?): Promise<ApiKey>`
Updates the permissions array. Throws `ApiKeyInvalidError` if not found.
### `hasPermission(apiKey, permission): boolean`
Returns `true` if the key has the given permission, or has the wildcard `*` permission.
### `validateApiKeyMiddleware(adapter, options?): MiddlewareFn`
Returns a `(req, res, next)` function. Reads the key from `req.headers[headerName]` (default `x-api-key`), validates it, and attaches `req.apiKey` on success.
## Implementation Notes
SHA-256 hashing uses the Web Crypto API (`crypto.subtle.digest`) so the module works in both Node.js and browser runtimes. The `rotateApiKey` implementation works with `MemoryAuthAdapter` and any adapter whose `listApiKeysByUser` returns all keys for a user. The `updateApiKeyPermissions` function uses the same approach; production adapters with indexed storage should override this with a direct UPDATE query.

View File

@@ -0,0 +1,370 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MemoryAuthAdapter } from "../adapters/memory/memory.js";
import {
ApiKeyExpiredError,
ApiKeyInvalidError,
ApiKeyRevokedError,
} from "../errors/auth-error.js";
import type { ApiKey } from "../types/auth.types.js";
import {
createApiKey,
hasPermission,
listApiKeys,
revokeApiKey,
rotateApiKey,
updateApiKeyPermissions,
validateApiKey,
validateApiKeyMiddleware,
} from "./api-keys.js";
/** Helper: captures only truthy next(error) calls; bare next() is ignored. */
function makeNext(): [(e?: unknown) => void, unknown[]] {
const errors: unknown[] = [];
return [
(e?: unknown) => {
if (e !== undefined) errors.push(e);
},
errors,
];
}
function makeAdapter(): MemoryAuthAdapter {
return new MemoryAuthAdapter();
}
describe("createApiKey", () => {
it("returns a valid generation with rawKey", {
timeout: 30000,
}, async () => {
const adapter = makeAdapter();
const gen = await createApiKey(adapter, { name: "k1", userId: "u1" });
expect(gen.rawKey).toMatch(/^sp_/);
expect(gen.apiKey.name).toBe("k1");
expect(gen.apiKey.userId).toBe("u1");
});
it("stores hashed key in adapter", { timeout: 30000 }, async () => {
const adapter = makeAdapter();
const gen = await createApiKey(adapter, { name: "k", userId: "u" });
// rawKey should validate
const v = await validateApiKey(adapter, gen.rawKey);
expect(v.valid).toBe(true);
});
it("respects expiresIn", { timeout: 30000 }, async () => {
const tick = 0;
const adapter = makeAdapter();
const gen = await createApiKey(
adapter,
{ name: "k", userId: "u", expiresIn: 1000 },
{ clock: () => tick }
);
expect(gen.apiKey.expiresAt).toBeDefined();
});
it("stores permissions", { timeout: 30000 }, async () => {
const adapter = makeAdapter();
const gen = await createApiKey(adapter, {
name: "k",
userId: "u",
permissions: ["read", "write"],
});
expect(gen.apiKey.permissions).toEqual(["read", "write"]);
});
});
describe("validateApiKey", () => {
it("returns valid:true for a fresh key", { timeout: 30000 }, async () => {
const adapter = makeAdapter();
const { rawKey } = await createApiKey(adapter, {
name: "k",
userId: "u",
});
const result = await validateApiKey(adapter, rawKey);
expect(result.valid).toBe(true);
expect(result.apiKey).toBeDefined();
});
it("returns valid:false for unknown key", { timeout: 30000 }, async () => {
const adapter = makeAdapter();
const result = await validateApiKey(adapter, "sp_notfound");
expect(result.valid).toBe(false);
});
it("returns valid:false for malformed key", {
timeout: 30000,
}, async () => {
const adapter = makeAdapter();
const result = await validateApiKey(adapter, "badformat");
expect(result.valid).toBe(false);
});
it("returns valid:false for expired key", { timeout: 30000 }, async () => {
let tick = 0;
const adapter = makeAdapter();
const { rawKey } = await createApiKey(
adapter,
{ name: "k", userId: "u", expiresIn: 100 },
{ clock: () => tick }
);
tick = 200;
const result = await validateApiKey(adapter, rawKey, {
clock: () => tick,
});
expect(result.valid).toBe(false);
expect(result.error).toContain("expired");
});
it("returns valid:false for revoked key", { timeout: 30000 }, async () => {
const adapter = makeAdapter();
const { rawKey, apiKey } = await createApiKey(adapter, {
name: "k",
userId: "u",
});
await adapter.revokeApiKey(apiKey.id);
const result = await validateApiKey(adapter, rawKey);
expect(result.valid).toBe(false);
expect(result.error).toContain("revoked");
});
it("updates lastUsedAt on success", { timeout: 30000 }, async () => {
const tick = 1000;
const adapter = makeAdapter();
const { rawKey } = await createApiKey(adapter, {
name: "k",
userId: "u",
});
await validateApiKey(adapter, rawKey, { clock: () => tick });
const keys = await adapter.getAllApiKeys();
expect(keys[0]?.lastUsedAt).toEqual(new Date(1000));
});
});
describe("revokeApiKey", () => {
it("revokes a key", { timeout: 30000 }, async () => {
const adapter = makeAdapter();
const { rawKey, apiKey } = await createApiKey(adapter, {
name: "k",
userId: "u",
});
await revokeApiKey(adapter, apiKey.id);
const result = await validateApiKey(adapter, rawKey);
expect(result.valid).toBe(false);
});
it("throws on second revocation", { timeout: 30000 }, async () => {
const adapter = makeAdapter();
const { apiKey } = await createApiKey(adapter, {
name: "k",
userId: "u",
});
await revokeApiKey(adapter, apiKey.id);
await expect(revokeApiKey(adapter, apiKey.id)).rejects.toBeInstanceOf(
ApiKeyRevokedError
);
});
});
describe("listApiKeys", () => {
it("lists keys for a user", { timeout: 30000 }, async () => {
const adapter = makeAdapter();
await createApiKey(adapter, { name: "k1", userId: "u1" });
await createApiKey(adapter, { name: "k2", userId: "u1" });
await createApiKey(adapter, { name: "k3", userId: "u2" });
const keys = await listApiKeys(adapter, "u1");
expect(keys).toHaveLength(2);
});
});
describe("rotateApiKey", () => {
it("revokes old key and creates a new one", {
timeout: 30000,
}, async () => {
const adapter = makeAdapter();
const { rawKey: oldRaw, apiKey } = await createApiKey(adapter, {
name: "k",
userId: "u",
});
const gen = await rotateApiKey(adapter, apiKey.id);
// old key now invalid
const oldResult = await validateApiKey(adapter, oldRaw);
expect(oldResult.valid).toBe(false);
// new key valid
const newResult = await validateApiKey(adapter, gen.rawKey);
expect(newResult.valid).toBe(true);
});
it("throws ApiKeyInvalidError for unknown keyId", {
timeout: 30000,
}, async () => {
const adapter = makeAdapter();
await expect(rotateApiKey(adapter, "nope")).rejects.toBeInstanceOf(
ApiKeyInvalidError
);
});
it("preserves expiresAt window when rotating", {
timeout: 30000,
}, async () => {
const adapter = makeAdapter();
const { apiKey } = await createApiKey(adapter, {
name: "k",
userId: "u",
expiresIn: 60_000,
});
const gen = await rotateApiKey(adapter, apiKey.id);
// New key should have a future expiry
expect(gen.apiKey.expiresAt).toBeDefined();
});
});
describe("updateApiKeyPermissions", () => {
it("updates permissions on an existing key", {
timeout: 30000,
}, async () => {
const adapter = makeAdapter();
const { apiKey } = await createApiKey(adapter, {
name: "k",
userId: "u",
permissions: ["read"],
});
const updated = await updateApiKeyPermissions(adapter, apiKey.id, [
"read",
"write",
]);
expect(updated.permissions).toEqual(["read", "write"]);
});
it("throws ApiKeyInvalidError for unknown key", {
timeout: 30000,
}, async () => {
const adapter = makeAdapter();
await expect(
updateApiKeyPermissions(adapter, "unknown-id", ["write"])
).rejects.toBeInstanceOf(ApiKeyInvalidError);
});
});
describe("hasPermission", () => {
const key: ApiKey = {
id: "k",
key: "sp_xxx",
keyHash: "h",
name: "n",
userId: "u",
permissions: ["read", "write"],
createdAt: new Date(),
};
it("returns true for an allowed permission", { timeout: 5000 }, () => {
expect(hasPermission(key, "read")).toBe(true);
});
it("returns false for a denied permission", { timeout: 5000 }, () => {
expect(hasPermission(key, "admin")).toBe(false);
});
it("returns true for wildcard *", { timeout: 5000 }, () => {
const wildcard: ApiKey = { ...key, permissions: ["*"] };
expect(hasPermission(wildcard, "anything")).toBe(true);
});
});
describe("validateApiKeyMiddleware", () => {
it("calls next with error for missing header", {
timeout: 30000,
}, async () => {
const adapter = makeAdapter();
const mw = validateApiKeyMiddleware(adapter);
const [next, errors] = makeNext();
await mw({ headers: {} }, {}, next);
expect(errors[0]).toBeInstanceOf(ApiKeyInvalidError);
});
it("calls next with error when req has no headers property", {
timeout: 30000,
}, async () => {
const adapter = makeAdapter();
const mw = validateApiKeyMiddleware(adapter);
const [next, errors] = makeNext();
await mw({}, {}, next);
expect(errors[0]).toBeInstanceOf(ApiKeyInvalidError);
});
it("calls next with error for invalid key", {
timeout: 30000,
}, async () => {
const adapter = makeAdapter();
const mw = validateApiKeyMiddleware(adapter);
const [next, errors] = makeNext();
await mw({ headers: { "x-api-key": "bad" } }, {}, next);
expect(errors[0]).toBeInstanceOf(ApiKeyInvalidError);
});
it("attaches apiKey to req for valid key", { timeout: 30000 }, async () => {
const adapter = makeAdapter();
const { rawKey } = await createApiKey(adapter, {
name: "k",
userId: "u",
});
const mw = validateApiKeyMiddleware(adapter);
const req: Record<string, unknown> = {
headers: { "x-api-key": rawKey },
};
const [next, errors] = makeNext();
await mw(req, {}, next);
expect(errors).toHaveLength(0);
expect(req["apiKey"]).toBeDefined();
});
it("calls next with ApiKeyRevokedError for revoked key", {
timeout: 30000,
}, async () => {
const adapter = makeAdapter();
const { rawKey, apiKey } = await createApiKey(adapter, {
name: "k",
userId: "u",
});
await adapter.revokeApiKey(apiKey.id);
const mw = validateApiKeyMiddleware(adapter);
const [next, errors] = makeNext();
await mw({ headers: { "x-api-key": rawKey } }, {}, next);
expect(errors[0]).toBeInstanceOf(ApiKeyRevokedError);
});
it("calls next with ApiKeyExpiredError for expired key", {
timeout: 30000,
}, async () => {
let tick = 0;
const adapter = makeAdapter();
const { rawKey } = await createApiKey(
adapter,
{ name: "k", userId: "u", expiresIn: 1 },
{ clock: () => tick }
);
tick = 5000;
const mw = validateApiKeyMiddleware(adapter, { clock: () => tick });
const [next, errors] = makeNext();
await mw({ headers: { "x-api-key": rawKey } }, {}, next);
expect(errors[0]).toBeInstanceOf(ApiKeyExpiredError);
});
it("propagates thrown exceptions via catch", {
timeout: 30000,
}, async () => {
const faultyAdapter = {
...makeAdapter(),
findApiKeyByHash: async () => {
throw new Error("storage failure");
},
} as ReturnType<typeof makeAdapter>;
const mw = validateApiKeyMiddleware(faultyAdapter);
const [next, errors] = makeNext();
await mw(
{ headers: { "x-api-key": "sp_validprefixforfaulttest" } },
{},
next
);
expect(errors[0]).toBeInstanceOf(Error);
});
});

View File

@@ -0,0 +1,279 @@
import { metrics, trace } from "@opentelemetry/api";
import type { Clock, IdGenerator } from "@suaveplan/types";
import type { AuthAdapter } from "../adapters/auth/auth.js";
import { API_KEY_BYTES, API_KEY_PREFIX } from "../constants/index.js";
import {
ApiKeyExpiredError,
ApiKeyInvalidError,
ApiKeyRevokedError,
} from "../errors/auth-error.js";
import type {
ApiKey,
ApiKeyGeneration,
ApiKeyMiddlewareOptions,
ApiKeyOptions,
ApiKeyRequest,
ApiKeyValidationResult,
MiddlewareFn,
} from "../types/auth.types.js";
const tracer = trace.getTracer("@suaveplan/auth");
const meter = metrics.getMeter("@suaveplan/auth");
const apiKeyCreatedCounter = meter.createCounter("auth.apikeys_created_total");
const apiKeyValidateCounter = meter.createCounter("auth.apikey_validate_total");
/** Encode bytes to a hex string. */
function toHex(bytes: Uint8Array): string {
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
/** Compute a SHA-256 hex digest of the given string. */
async function sha256Hex(value: string): Promise<string> {
const enc = new TextEncoder();
const data = enc.encode(value);
const hash = await crypto.subtle.digest("SHA-256", data);
return toHex(new Uint8Array(hash));
}
/** Generate a cryptographically-random API key string. */
function generateRawKey(_idGen: IdGenerator): string {
const bytes = new Uint8Array(API_KEY_BYTES);
crypto.getRandomValues(bytes);
return API_KEY_PREFIX + toHex(bytes);
}
/**
* Create a new API key for a user.
*
* The raw key is returned once in `ApiKeyGeneration.rawKey`; the adapter
* stores only the SHA-256 hash via `AuthAdapter.storeApiKey`.
*/
export async function createApiKey(
adapter: AuthAdapter,
request: ApiKeyRequest,
options: ApiKeyOptions = {},
signal?: AbortSignal
): Promise<ApiKeyGeneration> {
return tracer.startActiveSpan("auth.apikey.create", async (span) => {
try {
const clock: Clock = options.clock ?? (() => Date.now());
const idGen: IdGenerator =
options.idGenerator ?? (() => crypto.randomUUID());
const now = new Date(clock());
const rawKey = generateRawKey(idGen);
const keyHash = await sha256Hex(rawKey);
const expiresAt =
request.expiresIn !== undefined
? new Date(clock() + request.expiresIn)
: undefined;
const apiKey: ApiKey = {
id: idGen(),
key: `${API_KEY_PREFIX}***`,
keyHash,
name: request.name,
userId: request.userId,
permissions: request.permissions ?? [],
...(expiresAt !== undefined && { expiresAt }),
createdAt: now,
};
await adapter.storeApiKey(apiKey, signal);
apiKeyCreatedCounter.add(1, { "user.id": request.userId });
span.setAttribute("auth.key_id", apiKey.id);
return { apiKey, rawKey };
} finally {
span.end();
}
});
}
/**
* Validate a raw API key string against the adapter.
*
* Returns a `ApiKeyValidationResult` indicating whether the key is valid,
* along with the stored record. Never throws on expected validation failures
* (invalid, expired, revoked) — only on unexpected storage errors.
*/
export async function validateApiKey(
adapter: AuthAdapter,
rawKey: string,
options: ApiKeyOptions = {},
signal?: AbortSignal
): Promise<ApiKeyValidationResult> {
return tracer.startActiveSpan("auth.apikey.validate", async (span) => {
try {
const clock: Clock = options.clock ?? (() => Date.now());
if (!rawKey.startsWith(API_KEY_PREFIX)) {
apiKeyValidateCounter.add(1, { valid: "false" });
return { valid: false, error: "Invalid API key format" };
}
const hash = await sha256Hex(rawKey);
const apiKey = await adapter.findApiKeyByHash(hash, signal);
if (!apiKey) {
apiKeyValidateCounter.add(1, { valid: "false" });
return { valid: false, error: "API key not found" };
}
if (apiKey.revokedAt !== undefined) {
apiKeyValidateCounter.add(1, { valid: "false" });
return { valid: false, error: "API key has been revoked" };
}
if (
apiKey.expiresAt !== undefined &&
apiKey.expiresAt.getTime() <= clock()
) {
apiKeyValidateCounter.add(1, { valid: "false" });
return { valid: false, error: "API key has expired" };
}
await adapter.touchApiKey(apiKey.id, new Date(clock()), signal);
apiKeyValidateCounter.add(1, { valid: "true" });
span.setAttribute("auth.key_id", apiKey.id);
return { valid: true, apiKey };
} finally {
span.end();
}
});
}
/**
* Revoke an API key by its ID.
*
* @throws `ApiKeyRevokedError` if the key is already revoked
* @throws `AuthStorageError` if the key is not found
*/
export async function revokeApiKey(
adapter: AuthAdapter,
keyId: string,
signal?: AbortSignal
): Promise<void> {
await adapter.revokeApiKey(keyId, signal);
}
/**
* List all API keys for a user.
*/
export async function listApiKeys(
adapter: AuthAdapter,
userId: string,
signal?: AbortSignal
): Promise<ApiKey[]> {
return adapter.listApiKeysByUser(userId, signal);
}
/**
* Rotate an API key: revoke the old one and issue a new one with the same
* name, userId, and permissions.
*/
export async function rotateApiKey(
adapter: AuthAdapter,
keyId: string,
options: ApiKeyOptions = {},
signal?: AbortSignal
): Promise<ApiKeyGeneration> {
const existing = await adapter.findApiKeyById(keyId, signal);
if (!existing) {
throw new ApiKeyInvalidError({ message: `API key ${keyId} not found` });
}
await adapter.revokeApiKey(keyId, signal);
return createApiKey(
adapter,
{
name: existing.name,
userId: existing.userId,
permissions: existing.permissions,
...(existing.expiresAt !== undefined && {
expiresIn: existing.expiresAt.getTime() - Date.now(),
}),
},
options,
signal
);
}
/**
* Update the permissions on an existing API key.
*
* @throws `ApiKeyInvalidError` if the key is not found
*/
export async function updateApiKeyPermissions(
adapter: AuthAdapter,
keyId: string,
permissions: string[],
_options: ApiKeyOptions = {},
signal?: AbortSignal
): Promise<ApiKey> {
const existing = await adapter.findApiKeyById(keyId, signal);
if (!existing) {
throw new ApiKeyInvalidError({
message: `API key ${keyId} not found for permission update`,
});
}
const updated: ApiKey = { ...existing, permissions };
await adapter.storeApiKey(updated, signal);
return updated;
}
/**
* Check whether an API key has a specific permission.
*/
export function hasPermission(apiKey: ApiKey, permission: string): boolean {
return (
apiKey.permissions.includes("*") ||
apiKey.permissions.includes(permission)
);
}
/**
* Factory for an API key authentication middleware.
*
* Reads the raw key from the configured header, validates it via the adapter,
* and attaches the found `ApiKey` to `req.apiKey`. Calls `next(error)` on
* failure.
*/
export function validateApiKeyMiddleware(
adapter: AuthAdapter,
options: ApiKeyMiddlewareOptions = {}
): MiddlewareFn {
const headerName = options.headerName ?? "x-api-key";
return async (req, _res, next) => {
try {
const r = req as Record<string, unknown>;
const headers = (r["headers"] ?? {}) as Record<string, string>;
const rawKey =
headers[headerName] ?? headers[headerName.toLowerCase()];
if (!rawKey) {
next(
new ApiKeyInvalidError({
message: "Missing API key header",
})
);
return;
}
const result = await validateApiKey(
adapter,
rawKey,
options.clock !== undefined ? { clock: options.clock } : {}
);
if (!result.valid || !result.apiKey) {
if (result.error?.includes("revoked")) {
next(new ApiKeyRevokedError("unknown"));
} else if (result.error?.includes("expired")) {
next(new ApiKeyExpiredError());
} else {
next(
new ApiKeyInvalidError(
result.error !== undefined
? { message: result.error }
: /* istanbul ignore next */ {}
)
);
}
return;
}
(r as Record<string, unknown>)["apiKey"] = result.apiKey;
next();
} catch (err) {
next(err);
}
};
}

View File

@@ -0,0 +1,10 @@
export {
createApiKey,
hasPermission,
listApiKeys,
revokeApiKey,
rotateApiKey,
updateApiKeyPermissions,
validateApiKey,
validateApiKeyMiddleware,
} from "./api-keys.js";

View File

@@ -0,0 +1,17 @@
/** Default access token TTL: 15 minutes in seconds. */
export const DEFAULT_ACCESS_TOKEN_TTL = 900;
/** Default refresh token TTL: 30 days in seconds. */
export const DEFAULT_REFRESH_TOKEN_TTL = 2_592_000;
/** Minimum HMAC secret length in bytes. */
export const MIN_SECRET_LENGTH = 32;
/** API key prefix used to identify Suaveplan API keys. */
export const API_KEY_PREFIX = "sp_";
/** Default API key byte length before encoding. */
export const API_KEY_BYTES = 32;
/** Header name used by default for API key authentication. */
export const DEFAULT_API_KEY_HEADER = "x-api-key";

View File

@@ -0,0 +1,112 @@
# auth-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the complete error taxonomy for `@suaveplan/auth`. Every thrown error in the package is an instance of `AuthError`, which extends `SuaveplanError` from `@suaveplan/error`. Stable numeric codes are registered via `defineCodes("AUTH", [...])` so they survive refactors and remain wire-compatible across package versions.
Each concrete subclass captures relevant context (e.g. `jti`, `sessionId`, `keyId`) as typed properties and passes a structured `context` object to the base class so OTel span events and log sinks receive machine-readable metadata alongside the human-readable message.
## Features
- Stable error code registry via `AUTH_CODES` (ten codes covering the full lifecycle)
- `AuthError` base class extending `SuaveplanError<AuthErrorCode>` — catch-all for the package
- Ten concrete error classes covering tokens, sessions, API keys, permissions, and storage
- Context-carrying constructors: typed properties + structured `context` objects
- Compatible with `@suaveplan/error` cause-chain traversal and redaction utilities
## Usage: Basic Example
```ts
import { TokenExpiredError, AUTH_CODES } from "@suaveplan/auth/errors";
try {
await manager.validateToken(token);
} catch (err) {
if (err instanceof TokenExpiredError) {
console.error("Token expired at", err.context?.expiredAt);
}
}
```
## Usage: Advanced Example
```ts
import { AuthError, AUTH_CODES } from "@suaveplan/auth/errors";
function isAuthError(err: unknown): err is AuthError {
return err instanceof AuthError;
}
function handleAuthFailure(err: unknown): Response {
if (!isAuthError(err)) throw err;
switch (err.code) {
case AUTH_CODES.TOKEN_EXPIRED:
return new Response(null, { status: 401 });
case AUTH_CODES.PERMISSION_DENIED:
return new Response(null, { status: 403 });
case AUTH_CODES.API_KEY_REVOKED:
return new Response(null, { status: 403 });
default:
return new Response(null, { status: 500 });
}
}
```
## API Reference
### `AUTH_CODES`
`Record<string, string>` — stable code map produced by `defineCodes("AUTH", [...])`.
Keys: `TOKEN_INVALID`, `TOKEN_EXPIRED`, `TOKEN_BLACKLISTED`, `SESSION_NOT_FOUND`, `SESSION_EXPIRED`, `API_KEY_INVALID`, `API_KEY_EXPIRED`, `API_KEY_REVOKED`, `PERMISSION_DENIED`, `STORAGE_ERROR`.
### `AuthError`
Base class. Extends `SuaveplanError<AuthErrorCode>`. Catch this to handle any error from the package.
### `TokenInvalidError`
Thrown when a JWT or session token fails structural validation. Constructor: `{ message?, cause? }`.
### `TokenExpiredError`
Thrown when a token has passed its `exp` timestamp. Constructor: `{ message?, expiredAt? }`.
### `TokenBlacklistedError`
Thrown when a token `jti` is on the revocation list. Property `jti: string`.
### `SessionNotFoundError`
Thrown when a session lookup returns no result. Property `sessionId: string`.
### `SessionExpiredError`
Thrown when a session's `expiresAt` is in the past. Property `sessionId: string`.
### `ApiKeyInvalidError`
Thrown when an API key fails hash lookup or structural validation.
### `ApiKeyExpiredError`
Thrown when an API key's `expiresAt` has passed. Constructor: `{ keyId? }`.
### `ApiKeyRevokedError`
Thrown when an API key has a non-null `revokedAt`. Property `keyId: string`.
### `PermissionDeniedError`
Thrown when a user lacks a required permission. Constructor: `{ permission?, userId? }`.
### `AuthStorageError`
Thrown when the underlying adapter encounters a storage failure. Constructor: `{ message?, cause? }`.
## Implementation Notes
All codes are derived from `defineCodes("AUTH", [...])`, ensuring they follow the `AUTH.<NAME>` pattern and are unique across the registry. Cause wrapping follows the `@suaveplan/error` convention: only `Error` instances are set as `cause`; unknown values are silently dropped to avoid serialisation issues. Context objects are always plain key-value records compatible with JSON serialisation and OTel attribute maps.

View File

@@ -0,0 +1,165 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
ApiKeyExpiredError,
ApiKeyInvalidError,
ApiKeyRevokedError,
AUTH_CODES,
AuthError,
AuthStorageError,
PermissionDeniedError,
SessionExpiredError,
SessionNotFoundError,
TokenBlacklistedError,
TokenExpiredError,
TokenInvalidError,
} from "./auth-error.js";
describe("AUTH_CODES", () => {
it("has all expected keys", { timeout: 5000 }, () => {
expect(AUTH_CODES.TOKEN_INVALID).toBeDefined();
expect(AUTH_CODES.TOKEN_EXPIRED).toBeDefined();
expect(AUTH_CODES.TOKEN_BLACKLISTED).toBeDefined();
expect(AUTH_CODES.SESSION_NOT_FOUND).toBeDefined();
expect(AUTH_CODES.SESSION_EXPIRED).toBeDefined();
expect(AUTH_CODES.API_KEY_INVALID).toBeDefined();
expect(AUTH_CODES.API_KEY_EXPIRED).toBeDefined();
expect(AUTH_CODES.API_KEY_REVOKED).toBeDefined();
expect(AUTH_CODES.PERMISSION_DENIED).toBeDefined();
expect(AUTH_CODES.STORAGE_ERROR).toBeDefined();
});
it("codes start with AUTH prefix", { timeout: 5000 }, () => {
for (const code of Object.values(AUTH_CODES)) {
expect(code).toMatch(/^AUTH\./);
}
});
});
describe("TokenInvalidError", () => {
it("is an AuthError", { timeout: 5000 }, () => {
const err = new TokenInvalidError();
expect(err).toBeInstanceOf(AuthError);
expect(err.code).toBe(AUTH_CODES.TOKEN_INVALID);
});
it("uses default message", { timeout: 5000 }, () => {
const err = new TokenInvalidError();
expect(err.message).toBe("Token is invalid");
});
it("accepts custom message", { timeout: 5000 }, () => {
const err = new TokenInvalidError({ message: "bad jwt" });
expect(err.message).toBe("bad jwt");
});
it("wraps Error cause", { timeout: 5000 }, () => {
const cause = new Error("parse fail");
const err = new TokenInvalidError({ cause });
expect(err.cause).toBe(cause);
});
it("ignores non-Error cause", { timeout: 5000 }, () => {
const err = new TokenInvalidError({ cause: "string cause" });
expect(err.cause).toBeUndefined();
});
});
describe("TokenExpiredError", () => {
it("has correct code", { timeout: 5000 }, () => {
const err = new TokenExpiredError();
expect(err.code).toBe(AUTH_CODES.TOKEN_EXPIRED);
});
it("stores expiredAt in context", { timeout: 5000 }, () => {
const ts = 1700000000000;
const err = new TokenExpiredError({ expiredAt: ts });
expect(err.context).toMatchObject({ expiredAt: ts });
});
it("works without options", { timeout: 5000 }, () => {
const err = new TokenExpiredError({});
expect(err.message).toBe("Token has expired");
});
});
describe("TokenBlacklistedError", () => {
it("stores jti", { timeout: 5000 }, () => {
const err = new TokenBlacklistedError("abc-123");
expect(err.jti).toBe("abc-123");
expect(err.code).toBe(AUTH_CODES.TOKEN_BLACKLISTED);
});
});
describe("SessionNotFoundError", () => {
it("stores sessionId", { timeout: 5000 }, () => {
const err = new SessionNotFoundError("sess-1");
expect(err.sessionId).toBe("sess-1");
expect(err.code).toBe(AUTH_CODES.SESSION_NOT_FOUND);
});
});
describe("SessionExpiredError", () => {
it("stores sessionId", { timeout: 5000 }, () => {
const err = new SessionExpiredError("sess-2");
expect(err.sessionId).toBe("sess-2");
expect(err.code).toBe(AUTH_CODES.SESSION_EXPIRED);
});
});
describe("ApiKeyInvalidError", () => {
it("has correct code", { timeout: 5000 }, () => {
const err = new ApiKeyInvalidError();
expect(err.code).toBe(AUTH_CODES.API_KEY_INVALID);
});
it("accepts message", { timeout: 5000 }, () => {
const err = new ApiKeyInvalidError({ message: "not found" });
expect(err.message).toBe("not found");
});
});
describe("ApiKeyExpiredError", () => {
it("has correct code", { timeout: 5000 }, () => {
const err = new ApiKeyExpiredError();
expect(err.code).toBe(AUTH_CODES.API_KEY_EXPIRED);
});
it("includes keyId in message", { timeout: 5000 }, () => {
const err = new ApiKeyExpiredError({ keyId: "key-1" });
expect(err.message).toContain("key-1");
});
});
describe("ApiKeyRevokedError", () => {
it("stores keyId", { timeout: 5000 }, () => {
const err = new ApiKeyRevokedError("key-99");
expect(err.keyId).toBe("key-99");
expect(err.code).toBe(AUTH_CODES.API_KEY_REVOKED);
});
});
describe("PermissionDeniedError", () => {
it("has correct code without args", { timeout: 5000 }, () => {
const err = new PermissionDeniedError();
expect(err.code).toBe(AUTH_CODES.PERMISSION_DENIED);
expect(err.message).toBe("Permission denied");
});
it("includes permission name when given", { timeout: 5000 }, () => {
const err = new PermissionDeniedError({ permission: "admin:read" });
expect(err.message).toContain("admin:read");
});
});
describe("AuthStorageError", () => {
it("has correct code", { timeout: 5000 }, () => {
const err = new AuthStorageError();
expect(err.code).toBe(AUTH_CODES.STORAGE_ERROR);
});
it("wraps Error cause", { timeout: 5000 }, () => {
const cause = new Error("db fail");
const err = new AuthStorageError({ cause });
expect(err.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,127 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/** Stable error codes for the auth package. */
export const AUTH_CODES = defineCodes("AUTH", [
"TOKEN_INVALID",
"TOKEN_EXPIRED",
"TOKEN_BLACKLISTED",
"SESSION_NOT_FOUND",
"SESSION_EXPIRED",
"API_KEY_INVALID",
"API_KEY_EXPIRED",
"API_KEY_REVOKED",
"PERMISSION_DENIED",
"STORAGE_ERROR",
] as const);
export type AuthErrorCode = (typeof AUTH_CODES)[keyof typeof AUTH_CODES];
/** Base error class for all auth-package errors. */
export class AuthError extends SuaveplanError<AuthErrorCode> {}
/** Thrown when a JWT or session token is structurally invalid. */
export class TokenInvalidError extends AuthError {
constructor(opts: { message?: string; cause?: unknown } = {}) {
super(AUTH_CODES.TOKEN_INVALID, opts.message ?? "Token is invalid", {
cause: opts.cause instanceof Error ? opts.cause : undefined,
});
}
}
/** Thrown when a token has passed its expiry timestamp. */
export class TokenExpiredError extends AuthError {
constructor(opts: { message?: string; expiredAt?: number } = {}) {
super(AUTH_CODES.TOKEN_EXPIRED, opts.message ?? "Token has expired", {
context:
opts.expiredAt !== undefined
? { expiredAt: opts.expiredAt }
: {},
});
}
}
/** Thrown when a token has been explicitly blacklisted. */
export class TokenBlacklistedError extends AuthError {
readonly jti: string;
constructor(jti: string) {
super(AUTH_CODES.TOKEN_BLACKLISTED, `Token ${jti} has been revoked`, {
context: { jti },
});
this.jti = jti;
}
}
/** Thrown when a session cannot be found in the adapter. */
export class SessionNotFoundError extends AuthError {
readonly sessionId: string;
constructor(sessionId: string) {
super(AUTH_CODES.SESSION_NOT_FOUND, `Session ${sessionId} not found`, {
context: { sessionId },
});
this.sessionId = sessionId;
}
}
/** Thrown when a session has exceeded its expiry date. */
export class SessionExpiredError extends AuthError {
readonly sessionId: string;
constructor(sessionId: string) {
super(AUTH_CODES.SESSION_EXPIRED, `Session ${sessionId} has expired`, {
context: { sessionId },
});
this.sessionId = sessionId;
}
}
/** Thrown when an API key is structurally invalid or not found. */
export class ApiKeyInvalidError extends AuthError {
constructor(opts: { message?: string } = {}) {
super(AUTH_CODES.API_KEY_INVALID, opts.message ?? "API key is invalid");
}
}
/** Thrown when an API key has passed its expiry date. */
export class ApiKeyExpiredError extends AuthError {
constructor(opts: { keyId?: string } = {}) {
super(
AUTH_CODES.API_KEY_EXPIRED,
`API key${opts.keyId ? ` ${opts.keyId}` : ""} has expired`,
opts.keyId ? { context: { keyId: opts.keyId } } : {}
);
}
}
/** Thrown when an API key has been explicitly revoked. */
export class ApiKeyRevokedError extends AuthError {
readonly keyId: string;
constructor(keyId: string) {
super(AUTH_CODES.API_KEY_REVOKED, `API key ${keyId} has been revoked`, {
context: { keyId },
});
this.keyId = keyId;
}
}
/** Thrown when a permission check fails for a given user. */
export class PermissionDeniedError extends AuthError {
constructor(opts: { permission?: string; userId?: string } = {}) {
super(
AUTH_CODES.PERMISSION_DENIED,
opts.permission
? `Permission denied: ${opts.permission}`
: "Permission denied",
{ context: { ...opts } }
);
}
}
/** Thrown when the underlying auth adapter encounters a storage error. */
export class AuthStorageError extends AuthError {
constructor(opts: { message?: string; cause?: unknown } = {}) {
super(
AUTH_CODES.STORAGE_ERROR,
opts.message ?? "Auth storage operation failed",
{ cause: opts.cause instanceof Error ? opts.cause : undefined }
);
}
}

View File

@@ -0,0 +1,15 @@
export {
ApiKeyExpiredError,
ApiKeyInvalidError,
ApiKeyRevokedError,
AUTH_CODES,
AuthError,
type AuthErrorCode,
AuthStorageError,
PermissionDeniedError,
SessionExpiredError,
SessionNotFoundError,
TokenBlacklistedError,
TokenExpiredError,
TokenInvalidError,
} from "./auth-error.js";

View File

@@ -0,0 +1,91 @@
// Adapters
export type {
ApiKeyFactory,
AuthAdapter,
} from "./adapters/auth/auth.js";
export {
MemoryAuthAdapter,
type MemoryAuthAdapterOptions,
} from "./adapters/memory/memory.js";
// API Keys
export {
createApiKey,
hasPermission,
listApiKeys,
revokeApiKey,
rotateApiKey,
updateApiKeyPermissions,
validateApiKey,
validateApiKeyMiddleware,
} from "./api-keys/api-keys.js";
// Errors
export {
ApiKeyExpiredError,
ApiKeyInvalidError,
ApiKeyRevokedError,
AUTH_CODES,
AuthError,
type AuthErrorCode,
AuthStorageError,
PermissionDeniedError,
SessionExpiredError,
SessionNotFoundError,
TokenBlacklistedError,
TokenExpiredError,
TokenInvalidError,
} from "./errors/auth-error.js";
// JWT / Token lifecycle
export {
getUserSessions,
issueToken,
isTokenBlacklisted,
optionalTokenMiddleware,
refreshToken,
revokeAllUserTokens,
revokeToken,
validateSessionToken,
verifyTokenMiddleware,
} from "./jwt/token-lifecycle.js";
// Middleware helpers
export {
checkPermission,
getUserFromSession,
requireAllPermissions,
requireAnyPermission,
requirePermission,
withPermissions,
} from "./middleware/auth-middleware.js";
// Schemas
export {
ApiKeyMiddlewareOptionsSchema,
ApiKeyOptionsSchema,
type ApiKeyRequestInput,
ApiKeyRequestSchema,
type JWTOptionsInput,
JWTOptionsSchema,
MiddlewareOptionsSchema,
type SessionInfoInput,
SessionInfoSchema,
} from "./schemas/auth.schemas.js";
// Types
export type {
ApiKey,
ApiKeyGeneration,
ApiKeyMiddlewareOptions,
ApiKeyOptions,
ApiKeyRequest,
ApiKeyValidationResult,
AuthenticatedJWTRequest,
AuthenticatedRequest,
JWTOptions,
JWTPayload,
MiddlewareFn,
MiddlewareOptions,
PermissionCheckResult,
SessionInfo,
TokenValidationResult,
UserContext,
} from "./types/auth.types.js";

View File

@@ -0,0 +1,11 @@
export {
getUserSessions,
issueToken,
isTokenBlacklisted,
optionalTokenMiddleware,
refreshToken,
revokeAllUserTokens,
revokeToken,
validateSessionToken,
verifyTokenMiddleware,
} from "./token-lifecycle.js";

View File

@@ -0,0 +1,104 @@
# token-lifecycle
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements the full JWT token lifecycle for `@suaveplan/auth`: issuing HS256-signed access and refresh token pairs, validating tokens against an `AuthAdapter` blacklist, rotating refresh tokens, revoking individual JTIs, and managing user sessions. All cryptographic operations use the Web Crypto API (`crypto.subtle`) so the module runs in both Node.js and browser environments without external dependencies.
The implementation uses pure HS256 (HMAC-SHA256) signing rather than an external JWT library, keeping the dependency footprint minimal and making the signing and verification paths fully auditable within the package.
## Features
- `issueToken` — creates a HS256 access + refresh token pair and persists the session
- `validateSessionToken` — verifies signature, checks expiry, checks blacklist, returns discriminated result
- `refreshToken` — validates, blacklists old refresh JTI, issues new pair
- `revokeToken` — blacklists a specific JTI with explicit expiry
- `revokeAllUserTokens` — deletes all sessions for a user
- `isTokenBlacklisted` — delegates to `adapter.isBlacklisted`
- `getUserSessions` — delegates to `adapter.listUserSessions`
- `verifyTokenMiddleware` / `optionalTokenMiddleware` — framework-agnostic factories
## Usage: Basic Example
```ts
import { issueToken, validateSessionToken } from "@suaveplan/auth";
import { MemoryAuthAdapter } from "@suaveplan/auth";
const adapter = new MemoryAuthAdapter();
const opts = { secret: "super-secret-key-that-is-at-least-32-chars!!" };
const { accessToken } = await issueToken(adapter, "user-1", opts);
const result = await validateSessionToken(adapter, accessToken, opts);
if (result.valid) {
console.log("User ID:", result.payload?.sub);
}
```
## Usage: Advanced Example
```ts
import { issueToken, refreshToken, revokeAllUserTokens } from "@suaveplan/auth";
import { MemoryAuthAdapter } from "@suaveplan/auth";
const adapter = new MemoryAuthAdapter();
const opts = {
secret: "my-production-secret-at-least-32-chars",
accessTokenTtl: 900,
refreshTokenTtl: 2_592_000,
};
// Issue tokens
const { accessToken, refreshToken: rt } = await issueToken(adapter, "user-1", opts);
// Rotate on refresh
const tokens = await refreshToken(adapter, rt, opts);
console.log("New access token:", tokens.accessToken);
// Log user out everywhere
await revokeAllUserTokens(adapter, "user-1");
```
## API Reference
### `issueToken(adapter, userId, options, extra?): Promise<{ accessToken, refreshToken, sessionId }>`
Issues new HS256 access and refresh tokens. Creates a session via `adapter.createSession`. Extra options: `sessionId`, `sessionInfo`, `signal`.
### `validateSessionToken(adapter, token, options, signal?): Promise<TokenValidationResult>`
Verifies the HS256 signature, checks `exp`, and checks `adapter.isBlacklisted`. Returns `{ valid, payload?, error? }`.
### `refreshToken(adapter, refreshTokenStr, options, signal?): Promise<{ accessToken, refreshToken }>`
Validates the refresh token, blacklists the old JTI, and issues a new pair. Throws `TokenInvalidError`, `TokenExpiredError`, or `TokenBlacklistedError` on failure.
### `revokeToken(adapter, jti, expiresAt, signal?): Promise<void>`
Adds the JTI to the blacklist with explicit expiry.
### `revokeAllUserTokens(adapter, userId, signal?): Promise<void>`
Removes all sessions for the user from the adapter.
### `isTokenBlacklisted(adapter, jti, signal?): Promise<boolean>`
Delegates to `adapter.isBlacklisted`.
### `getUserSessions(adapter, userId, signal?): Promise<SessionInfo[]>`
Delegates to `adapter.listUserSessions`.
### `verifyTokenMiddleware(adapter, options): MiddlewareFn`
Returns a `(req, res, next)` function that reads the `Authorization: Bearer <token>` header, validates it, and attaches `req.user: JWTPayload`.
### `optionalTokenMiddleware(adapter, options): MiddlewareFn`
Like `verifyTokenMiddleware` but skips silently if no token is present, allowing anonymous access alongside authenticated access on the same route.
## Implementation Notes
Base64url encoding/decoding is implemented without `Buffer` to maintain browser compatibility. The HS256 implementation follows RFC 7515: `base64url(header) + "." + base64url(payload)` is the signing input; the signature is appended as a third component. Secrets shorter than 32 characters are accepted at the implementation level but the Zod schema in `auth.schemas.ts` enforces a 32-character minimum to prevent weak keys in production. Refresh token rotation blacklists the old JTI immediately on use, so replay attacks with a stolen refresh token are limited to the window between theft and next rotation.

View File

@@ -0,0 +1,454 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MemoryAuthAdapter } from "../adapters/memory/memory.js";
import {
TokenBlacklistedError,
TokenExpiredError,
TokenInvalidError,
} from "../errors/auth-error.js";
import type { JWTOptions } from "../types/auth.types.js";
import {
getUserSessions,
issueToken,
isTokenBlacklisted,
optionalTokenMiddleware,
refreshToken,
revokeAllUserTokens,
revokeToken,
validateSessionToken,
verifyTokenMiddleware,
} from "./token-lifecycle.js";
/** Helper: captures only truthy next(error) calls; bare next() is ignored. */
function makeNext(): [(e?: unknown) => void, unknown[]] {
const errors: unknown[] = [];
return [
(e?: unknown) => {
if (e !== undefined) errors.push(e);
},
errors,
];
}
const SECRET = "a-secret-key-that-is-at-least-32-chars-long!!";
function makeOpts(overrides: Partial<JWTOptions> = {}): JWTOptions {
return { secret: SECRET, ...overrides };
}
describe("issueToken", () => {
it("returns access and refresh tokens", { timeout: 30000 }, async () => {
const adapter = new MemoryAuthAdapter();
const result = await issueToken(adapter, "u1", makeOpts());
expect(result.accessToken).toBeTruthy();
expect(result.refreshToken).toBeTruthy();
expect(result.sessionId).toBeTruthy();
});
it("creates a session in adapter", { timeout: 30000 }, async () => {
const adapter = new MemoryAuthAdapter();
const { sessionId } = await issueToken(adapter, "u1", makeOpts());
const session = await adapter.findSession(sessionId);
expect(session?.userId).toBe("u1");
});
it("uses custom sessionId if provided", { timeout: 30000 }, async () => {
const adapter = new MemoryAuthAdapter();
const { sessionId } = await issueToken(adapter, "u1", makeOpts(), {
sessionId: "custom-sess",
});
expect(sessionId).toBe("custom-sess");
});
});
describe("validateSessionToken", () => {
it("validates a fresh token", { timeout: 30000 }, async () => {
const adapter = new MemoryAuthAdapter();
const { accessToken } = await issueToken(adapter, "u1", makeOpts());
const result = await validateSessionToken(
adapter,
accessToken,
makeOpts()
);
expect(result.valid).toBe(true);
expect(result.payload?.sub).toBe("u1");
});
it("rejects expired token", { timeout: 30000 }, async () => {
let tick = 0;
const adapter = new MemoryAuthAdapter();
const opts = makeOpts({ clock: () => tick, accessTokenTtl: 10 });
const { accessToken } = await issueToken(adapter, "u1", opts);
tick = 20_000; // 20 seconds later
const result = await validateSessionToken(adapter, accessToken, opts);
expect(result.valid).toBe(false);
expect(result.error).toContain("expired");
});
it("rejects blacklisted token", { timeout: 30000 }, async () => {
const adapter = new MemoryAuthAdapter();
const { accessToken } = await issueToken(adapter, "u1", makeOpts());
const result1 = await validateSessionToken(
adapter,
accessToken,
makeOpts()
);
expect(result1.payload?.jti).toBeTruthy();
const jti = result1.payload!.jti;
await adapter.blacklistToken(jti, new Date(Date.now() + 999999));
const result2 = await validateSessionToken(
adapter,
accessToken,
makeOpts()
);
expect(result2.valid).toBe(false);
expect(result2.error).toContain("revoked");
});
it("rejects malformed token", { timeout: 30000 }, async () => {
const adapter = new MemoryAuthAdapter();
const result = await validateSessionToken(
adapter,
"not.a.jwt",
makeOpts()
);
expect(result.valid).toBe(false);
});
it("rejects token with wrong secret", { timeout: 30000 }, async () => {
const adapter = new MemoryAuthAdapter();
const { accessToken } = await issueToken(adapter, "u1", makeOpts());
const result = await validateSessionToken(
adapter,
accessToken,
makeOpts({ secret: "totally-wrong-secret-that-is-32-chars!" })
);
expect(result.valid).toBe(false);
});
it("rejects token whose body is valid-signature but non-JSON", {
timeout: 30000,
}, async () => {
// Construct a JWT with valid HMAC but non-JSON payload
const secret = SECRET;
const enc = new TextEncoder();
const b64url = (s: string) =>
btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
const header = b64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
// Body is valid base64 but not JSON
const body = b64url("not-json-at-all!!!");
const signingInput = `${header}.${body}`;
const key = await crypto.subtle.importKey(
"raw",
enc.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const sigBuf = await crypto.subtle.sign(
"HMAC",
key,
enc.encode(signingInput)
);
const sigB64 = b64url(String.fromCharCode(...new Uint8Array(sigBuf)));
const token = `${signingInput}.${sigB64}`;
const adapter = new MemoryAuthAdapter();
const result = await validateSessionToken(adapter, token, makeOpts());
expect(result.valid).toBe(false);
});
});
describe("refreshToken", () => {
it("issues new tokens from a valid refresh token", {
timeout: 30000,
}, async () => {
const adapter = new MemoryAuthAdapter();
const { refreshToken: rt } = await issueToken(
adapter,
"u1",
makeOpts()
);
const tokens = await refreshToken(adapter, rt, makeOpts());
expect(tokens.accessToken).toBeTruthy();
expect(tokens.refreshToken).toBeTruthy();
});
it("blacklists old refresh token after rotation", {
timeout: 30000,
}, async () => {
const adapter = new MemoryAuthAdapter();
const { refreshToken: rt } = await issueToken(
adapter,
"u1",
makeOpts()
);
// Parse old JTI from the token
const [, body] = rt.split(".");
const payload = JSON.parse(
atob(body!.replace(/-/g, "+").replace(/_/g, "/"))
) as {
jti: string;
};
await refreshToken(adapter, rt, makeOpts());
expect(await adapter.isBlacklisted(payload.jti)).toBe(true);
});
it("throws TokenInvalidError for malformed token", {
timeout: 30000,
}, async () => {
const adapter = new MemoryAuthAdapter();
await expect(
refreshToken(adapter, "bad.token.here", makeOpts())
).rejects.toBeInstanceOf(TokenInvalidError);
});
it("throws TokenExpiredError for expired refresh token", {
timeout: 30000,
}, async () => {
let tick = 0;
const adapter = new MemoryAuthAdapter();
const opts = makeOpts({ clock: () => tick, refreshTokenTtl: 5 });
const { refreshToken: rt } = await issueToken(adapter, "u1", opts);
tick = 10_000;
await expect(refreshToken(adapter, rt, opts)).rejects.toBeInstanceOf(
TokenExpiredError
);
});
it("throws TokenBlacklistedError for already-used refresh token", {
timeout: 30000,
}, async () => {
const adapter = new MemoryAuthAdapter();
const { refreshToken: rt } = await issueToken(
adapter,
"u1",
makeOpts()
);
await refreshToken(adapter, rt, makeOpts()); // first rotation ok
await expect(
refreshToken(adapter, rt, makeOpts())
).rejects.toBeInstanceOf(TokenBlacklistedError);
});
});
describe("revokeToken", () => {
it("blacklists a JTI", { timeout: 30000 }, async () => {
const adapter = new MemoryAuthAdapter();
await revokeToken(adapter, "jti-test", new Date(Date.now() + 99999));
expect(await adapter.isBlacklisted("jti-test")).toBe(true);
});
});
describe("revokeAllUserTokens", () => {
it("removes all sessions for a user", { timeout: 30000 }, async () => {
const adapter = new MemoryAuthAdapter();
await issueToken(adapter, "u1", makeOpts());
await issueToken(adapter, "u1", makeOpts());
await revokeAllUserTokens(adapter, "u1");
const sessions = await adapter.listUserSessions("u1");
expect(sessions).toHaveLength(0);
});
});
describe("isTokenBlacklisted", () => {
it("returns false for unknown jti", { timeout: 30000 }, async () => {
const adapter = new MemoryAuthAdapter();
expect(await isTokenBlacklisted(adapter, "unknown")).toBe(false);
});
it("returns true after blacklisting", { timeout: 30000 }, async () => {
const adapter = new MemoryAuthAdapter();
await adapter.blacklistToken("jti-bl", new Date(Date.now() + 9999));
expect(await isTokenBlacklisted(adapter, "jti-bl")).toBe(true);
});
});
describe("getUserSessions", () => {
it("returns sessions for a user", { timeout: 30000 }, async () => {
const adapter = new MemoryAuthAdapter();
await issueToken(adapter, "u1", makeOpts());
const sessions = await getUserSessions(adapter, "u1");
expect(sessions).toHaveLength(1);
});
});
describe("verifyTokenMiddleware", () => {
it("attaches user to req for valid token", { timeout: 30000 }, async () => {
const adapter = new MemoryAuthAdapter();
const { accessToken } = await issueToken(adapter, "u1", makeOpts());
const mw = verifyTokenMiddleware(adapter, makeOpts());
const req: Record<string, unknown> = {
headers: { authorization: `Bearer ${accessToken}` },
};
const [next, errors] = makeNext();
await mw(req, {}, next);
expect(errors).toHaveLength(0);
expect(req["user"]).toBeDefined();
});
it("calls next with TokenInvalidError when no header", {
timeout: 30000,
}, async () => {
const adapter = new MemoryAuthAdapter();
const mw = verifyTokenMiddleware(adapter, makeOpts());
const [next, errors] = makeNext();
await mw({ headers: {} }, {}, next);
expect(errors[0]).toBeInstanceOf(TokenInvalidError);
});
it("calls next with TokenInvalidError when req has no headers property", {
timeout: 30000,
}, async () => {
const adapter = new MemoryAuthAdapter();
const mw = verifyTokenMiddleware(adapter, makeOpts());
const [next, errors] = makeNext();
await mw({}, {}, next);
expect(errors[0]).toBeInstanceOf(TokenInvalidError);
});
it("calls next with TokenExpiredError for expired token", {
timeout: 30000,
}, async () => {
let tick = 0;
const adapter = new MemoryAuthAdapter();
const opts = makeOpts({ clock: () => tick, accessTokenTtl: 1 });
const { accessToken } = await issueToken(adapter, "u1", opts);
tick = 5000;
const mw = verifyTokenMiddleware(adapter, opts);
const [next, errors] = makeNext();
await mw(
{ headers: { authorization: `Bearer ${accessToken}` } },
{},
next
);
expect(errors[0]).toBeInstanceOf(TokenExpiredError);
});
it("calls next with TokenBlacklistedError for revoked token", {
timeout: 30000,
}, async () => {
const adapter = new MemoryAuthAdapter();
const opts = makeOpts();
const { accessToken } = await issueToken(adapter, "u1", opts);
const r1 = await validateSessionToken(adapter, accessToken, opts);
const jti = r1.payload!.jti;
await adapter.blacklistToken(jti, new Date(Date.now() + 99999));
const mw = verifyTokenMiddleware(adapter, opts);
const [next, errors] = makeNext();
await mw(
{ headers: { authorization: `Bearer ${accessToken}` } },
{},
next
);
expect(errors[0]).toBeInstanceOf(TokenBlacklistedError);
});
it("calls next with TokenInvalidError for token with bad signature", {
timeout: 30000,
}, async () => {
const adapter = new MemoryAuthAdapter();
const mw = verifyTokenMiddleware(adapter, makeOpts());
// Valid JWT structure but wrong signature → "Invalid token" (not expired/revoked)
const [next, errors] = makeNext();
await mw(
{ headers: { authorization: "Bearer header.body.badsig" } },
{},
next
);
expect(errors[0]).toBeInstanceOf(TokenInvalidError);
});
it("propagates thrown exceptions via catch", {
timeout: 30000,
}, async () => {
const adapter = new MemoryAuthAdapter();
const faultyAdapter = {
...adapter,
isBlacklisted: async () => {
throw new Error("db failure");
},
} as typeof adapter;
const { accessToken } = await issueToken(adapter, "u1", makeOpts());
const mw = verifyTokenMiddleware(faultyAdapter, makeOpts());
const [next, errors] = makeNext();
await mw(
{ headers: { authorization: `Bearer ${accessToken}` } },
{},
next
);
expect(errors[0]).toBeInstanceOf(Error);
});
});
describe("optionalTokenMiddleware", () => {
it("continues without user when no token", { timeout: 30000 }, async () => {
const adapter = new MemoryAuthAdapter();
const mw = optionalTokenMiddleware(adapter, makeOpts());
const req: Record<string, unknown> = { headers: {} };
const [next, errors] = makeNext();
await mw(req, {}, next);
expect(errors).toHaveLength(0);
expect(req["user"]).toBeUndefined();
});
it("continues without user when req has no headers property", {
timeout: 30000,
}, async () => {
const adapter = new MemoryAuthAdapter();
const mw = optionalTokenMiddleware(adapter, makeOpts());
const req: Record<string, unknown> = {};
const [next, errors] = makeNext();
await mw(req, {}, next);
expect(errors).toHaveLength(0);
expect(req["user"]).toBeUndefined();
});
it("attaches user when valid token present", {
timeout: 30000,
}, async () => {
const adapter = new MemoryAuthAdapter();
const { accessToken } = await issueToken(adapter, "u1", makeOpts());
const mw = optionalTokenMiddleware(adapter, makeOpts());
const req: Record<string, unknown> = {
headers: { authorization: `Bearer ${accessToken}` },
};
const [next, errors] = makeNext();
await mw(req, {}, next);
expect(errors).toHaveLength(0);
expect(req["user"]).toBeDefined();
});
it("continues without user when invalid token", {
timeout: 30000,
}, async () => {
const adapter = new MemoryAuthAdapter();
const mw = optionalTokenMiddleware(adapter, makeOpts());
const req: Record<string, unknown> = {
headers: { authorization: "Bearer bad.token" },
};
const [next, errors] = makeNext();
await mw(req, {}, next);
expect(errors).toHaveLength(0);
expect(req["user"]).toBeUndefined();
});
it("propagates thrown exceptions via catch", {
timeout: 30000,
}, async () => {
const adapter = new MemoryAuthAdapter();
const faultyAdapter = {
...adapter,
isBlacklisted: async () => {
throw new Error("db failure");
},
} as typeof adapter;
const { accessToken } = await issueToken(adapter, "u1", makeOpts());
const mw = optionalTokenMiddleware(faultyAdapter, makeOpts());
const [next, errors] = makeNext();
await mw(
{ headers: { authorization: `Bearer ${accessToken}` } },
{},
next
);
expect(errors[0]).toBeInstanceOf(Error);
});
});

View File

@@ -0,0 +1,431 @@
import { metrics, trace } from "@opentelemetry/api";
import type { Clock, IdGenerator } from "@suaveplan/types";
import type { AuthAdapter } from "../adapters/auth/auth.js";
import {
DEFAULT_ACCESS_TOKEN_TTL,
DEFAULT_REFRESH_TOKEN_TTL,
} from "../constants/index.js";
import {
TokenBlacklistedError,
TokenExpiredError,
TokenInvalidError,
} from "../errors/auth-error.js";
import type {
JWTOptions,
JWTPayload,
SessionInfo,
TokenValidationResult,
} from "../types/auth.types.js";
const tracer = trace.getTracer("@suaveplan/auth");
const meter = metrics.getMeter("@suaveplan/auth");
const tokensIssuedCounter = meter.createCounter("auth.tokens_issued_total");
const tokenVerifyCounter = meter.createCounter("auth.token_verify_total");
/** Encode to base64url without padding. */
function b64url(input: Uint8Array | string): string {
const bytes =
typeof input === "string" ? new TextEncoder().encode(input) : input;
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}
/** Decode base64url to Uint8Array. */
function b64urlDecode(input: string): Uint8Array {
const padded = input
.replace(/-/g, "+")
.replace(/_/g, "/")
.padEnd(input.length + ((4 - (input.length % 4)) % 4), "=");
const binary = atob(padded);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
/** Import a secret key for HMAC-SHA256. */
async function importHmacKey(secret: string): Promise<CryptoKey> {
const keyBytes = new TextEncoder().encode(secret);
return crypto.subtle.importKey(
"raw",
keyBytes,
{ name: "HMAC", hash: "SHA-256" },
false,
["sign", "verify"]
);
}
/** Sign a message with HMAC-SHA256, returning a base64url signature. */
async function hmacSign(key: CryptoKey, message: string): Promise<string> {
const sig = await crypto.subtle.sign(
"HMAC",
key,
new TextEncoder().encode(message)
);
return b64url(new Uint8Array(sig));
}
/** Verify a HMAC-SHA256 signature. */
async function hmacVerify(
key: CryptoKey,
message: string,
sig: string
): Promise<boolean> {
const sigBytes = b64urlDecode(sig);
return crypto.subtle.verify(
"HMAC",
key,
sigBytes.buffer as ArrayBuffer,
new TextEncoder().encode(message)
);
}
/** Build a minimal HS256 JWT from a payload. */
async function buildJwt(payload: JWTPayload, secret: string): Promise<string> {
const header = b64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
const body = b64url(JSON.stringify(payload));
const signingInput = `${header}.${body}`;
const key = await importHmacKey(secret);
const sig = await hmacSign(key, signingInput);
return `${signingInput}.${sig}`;
}
/** Parse and verify a HS256 JWT, returning the payload or throwing. */
async function parseJwt(token: string, secret: string): Promise<JWTPayload> {
const parts = token.split(".");
if (parts.length !== 3) {
throw new TokenInvalidError({ message: "Malformed JWT" });
}
const [headerB64, bodyB64, sig] = parts as [string, string, string];
const signingInput = `${headerB64}.${bodyB64}`;
const key = await importHmacKey(secret);
const valid = await hmacVerify(key, signingInput, sig);
if (!valid) {
throw new TokenInvalidError({ message: "JWT signature invalid" });
}
let payload: JWTPayload;
try {
payload = JSON.parse(
new TextDecoder().decode(b64urlDecode(bodyB64))
) as JWTPayload;
} catch {
throw new TokenInvalidError({
message: "JWT payload could not be parsed",
});
}
return payload;
}
/**
* Issue a new JWT access token and (optionally) a refresh token.
* Records the session in the adapter if `sessionId` is provided.
*/
export async function issueToken(
adapter: AuthAdapter,
userId: string,
options: JWTOptions,
extra: {
sessionId?: string;
sessionInfo?: Omit<
SessionInfo,
"sessionId" | "userId" | "createdAt" | "expiresAt"
>;
signal?: AbortSignal;
} = {}
): Promise<{ accessToken: string; refreshToken: string; sessionId: string }> {
return tracer.startActiveSpan("auth.token.issue", async (span) => {
try {
const clock: Clock = options.clock ?? (() => Date.now());
const idGen: IdGenerator =
options.idGenerator ?? (() => crypto.randomUUID());
const now = Math.floor(clock() / 1000);
const accessTtl =
options.accessTokenTtl ?? DEFAULT_ACCESS_TOKEN_TTL;
const refreshTtl =
options.refreshTokenTtl ?? DEFAULT_REFRESH_TOKEN_TTL;
const sessionId = extra.sessionId ?? idGen();
const jti = idGen();
const refreshJti = idGen();
const accessPayload: JWTPayload = {
sub: userId,
iat: now,
exp: now + accessTtl,
jti,
sessionId,
};
const refreshPayload: JWTPayload = {
sub: userId,
iat: now,
exp: now + refreshTtl,
jti: refreshJti,
sessionId,
};
const accessToken = await buildJwt(accessPayload, options.secret);
const refreshToken = await buildJwt(refreshPayload, options.secret);
await adapter.createSession(
{
sessionId,
userId,
createdAt: new Date(clock()),
expiresAt: new Date((now + refreshTtl) * 1000),
...extra.sessionInfo,
},
extra.signal
);
tokensIssuedCounter.add(1, { "user.id": userId });
span.setAttribute("auth.session_id", sessionId);
return { accessToken, refreshToken, sessionId };
} finally {
span.end();
}
});
}
/**
* Validate a JWT access token.
*
* Checks signature, expiry, and blacklist. Returns a discriminated result
* rather than throwing for expected validation failures.
*/
export async function validateSessionToken(
adapter: AuthAdapter,
token: string,
options: JWTOptions,
signal?: AbortSignal
): Promise<TokenValidationResult> {
return tracer.startActiveSpan("auth.token.verify", async (span) => {
try {
const clock: Clock = options.clock ?? (() => Date.now());
const nowSec = Math.floor(clock() / 1000);
let payload: JWTPayload;
try {
payload = await parseJwt(token, options.secret);
} catch (e) {
tokenVerifyCounter.add(1, { valid: "false" });
return {
valid: false,
error:
e instanceof Error
? e.message
: /* istanbul ignore next */
"Invalid token",
};
}
if (payload.exp <= nowSec) {
tokenVerifyCounter.add(1, { valid: "false" });
return { valid: false, error: "Token has expired" };
}
const blacklisted = await adapter.isBlacklisted(
payload.jti,
signal
);
if (blacklisted) {
tokenVerifyCounter.add(1, { valid: "false" });
return { valid: false, error: "Token has been revoked" };
}
tokenVerifyCounter.add(1, { valid: "true" });
span.setAttribute("auth.jti", payload.jti);
return { valid: true, payload };
} finally {
span.end();
}
});
}
/**
* Refresh an access token using a valid refresh token.
* Revokes the old refresh token's JTI and issues new access + refresh tokens.
*/
export async function refreshToken(
adapter: AuthAdapter,
refreshTokenStr: string,
options: JWTOptions,
signal?: AbortSignal
): Promise<{ accessToken: string; refreshToken: string }> {
const clock: Clock = options.clock ?? (() => Date.now());
const nowSec = Math.floor(clock() / 1000);
let payload: JWTPayload;
try {
payload = await parseJwt(refreshTokenStr, options.secret);
} catch {
throw new TokenInvalidError({ message: "Invalid refresh token" });
}
if (payload.exp <= nowSec) {
throw new TokenExpiredError({ expiredAt: payload.exp * 1000 });
}
const blacklisted = await adapter.isBlacklisted(payload.jti, signal);
if (blacklisted) {
throw new TokenBlacklistedError(payload.jti);
}
// Blacklist the old refresh token
await adapter.blacklistToken(
payload.jti,
new Date(payload.exp * 1000),
signal
);
const idGen: IdGenerator =
options.idGenerator ?? (() => crypto.randomUUID());
const newJti = idGen();
const newRefreshJti = idGen();
const accessTtl = options.accessTokenTtl ?? DEFAULT_ACCESS_TOKEN_TTL;
const refreshTtl = options.refreshTokenTtl ?? DEFAULT_REFRESH_TOKEN_TTL;
const now = Math.floor(clock() / 1000);
const accessPayload: JWTPayload = {
sub: payload.sub,
iat: now,
exp: now + accessTtl,
jti: newJti,
...(payload.sessionId !== undefined && {
sessionId: payload.sessionId,
}),
};
const newRefreshPayload: JWTPayload = {
sub: payload.sub,
iat: now,
exp: now + refreshTtl,
jti: newRefreshJti,
...(payload.sessionId !== undefined && {
sessionId: payload.sessionId,
}),
};
const accessToken = await buildJwt(accessPayload, options.secret);
const newRefreshToken = await buildJwt(newRefreshPayload, options.secret);
return { accessToken, refreshToken: newRefreshToken };
}
/**
* Revoke a specific token by its JTI (JWT ID).
*/
export async function revokeToken(
adapter: AuthAdapter,
jti: string,
expiresAt: Date,
signal?: AbortSignal
): Promise<void> {
await adapter.blacklistToken(jti, expiresAt, signal);
}
/**
* Revoke all tokens for a user by deleting their sessions.
* Active JTIs are not individually blacklisted; instead the session is removed
* so any middleware that checks session validity will reject the tokens.
*/
export async function revokeAllUserTokens(
adapter: AuthAdapter,
userId: string,
signal?: AbortSignal
): Promise<void> {
await adapter.deleteUserSessions(userId, signal);
}
/**
* Check whether a JTI is on the blacklist.
*/
export async function isTokenBlacklisted(
adapter: AuthAdapter,
jti: string,
signal?: AbortSignal
): Promise<boolean> {
return adapter.isBlacklisted(jti, signal);
}
/**
* Retrieve all sessions for a user.
*/
export async function getUserSessions(
adapter: AuthAdapter,
userId: string,
signal?: AbortSignal
): Promise<SessionInfo[]> {
return adapter.listUserSessions(userId, signal);
}
/**
* Factory for a JWT verification middleware.
*
* Reads the Bearer token from `Authorization` header, validates it, and
* attaches the decoded `JWTPayload` to `req.user`. Calls `next(error)` on
* failure.
*/
export function verifyTokenMiddleware(
adapter: AuthAdapter,
options: JWTOptions
): import("../types/auth.types.js").MiddlewareFn {
return async (req, _res, next) => {
try {
const r = req as Record<string, unknown>;
const headers = (r["headers"] ?? {}) as Record<string, string>;
const auth =
headers["authorization"] ?? headers["Authorization"] ?? "";
const token = auth.replace(/^Bearer\s+/i, "");
if (!token) {
next(
new TokenInvalidError({
message: "Missing Authorization header",
})
);
return;
}
const result = await validateSessionToken(adapter, token, options);
if (!result.valid || !result.payload) {
if (result.error?.includes("expired")) {
next(new TokenExpiredError());
} else if (result.error?.includes("revoked")) {
next(new TokenBlacklistedError("unknown"));
} else {
next(
new TokenInvalidError(
result.error !== undefined
? { message: result.error }
: /* istanbul ignore next */ {}
)
);
}
return;
}
(r as Record<string, unknown>)["user"] = result.payload;
next();
} catch (err) {
next(err);
}
};
}
/**
* Factory for an optional JWT verification middleware.
*
* Like `verifyTokenMiddleware` but continues (without attaching `req.user`)
* if no token is provided.
*/
export function optionalTokenMiddleware(
adapter: AuthAdapter,
options: JWTOptions
): import("../types/auth.types.js").MiddlewareFn {
return async (req, _res, next) => {
try {
const r = req as Record<string, unknown>;
const headers = (r["headers"] ?? {}) as Record<string, string>;
const auth =
headers["authorization"] ?? headers["Authorization"] ?? "";
const token = auth.replace(/^Bearer\s+/i, "");
if (!token) {
next();
return;
}
const result = await validateSessionToken(adapter, token, options);
if (result.valid && result.payload) {
(r as Record<string, unknown>)["user"] = result.payload;
}
next();
} catch (err) {
next(err);
}
};
}

View File

@@ -0,0 +1,85 @@
# auth-middleware
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides framework-agnostic authentication and authorization middleware helpers for `@suaveplan/auth`. All functions return a `MiddlewareFn` — a `(req, res, next)` triple that is compatible with Express, Hono (via a thin adapter), Fastify, and any other Node.js-style middleware stack. The helpers extract the authenticated user from the request (either from a pre-attached `req.user` object or by validating a Bearer token), then assert permission requirements before calling `next()`.
These functions compose with `verifyTokenMiddleware` from the `jwt` module: run `verifyTokenMiddleware` first to attach `req.user`, then use `requirePermission` or `requireAllPermissions` to gate specific routes.
## Features
- `getUserFromSession` — derives a `UserContext` from a raw JWT string using the adapter
- `checkPermission` — pure function checking a single permission on a `UserContext`
- `requirePermission` — middleware asserting one permission
- `requireAllPermissions` — middleware asserting all permissions in a list
- `requireAnyPermission` — middleware asserting at least one permission in a list
- `withPermissions` — alias for `requireAllPermissions` with intuitive naming
- OTel histogram `auth.permission.check` recording duration and outcome
## Usage: Basic Example
```ts
import { checkPermission } from "@suaveplan/auth/middleware";
import type { UserContext } from "@suaveplan/auth";
const user: UserContext = { userId: "u1", permissions: ["read:data"] };
const result = checkPermission(user, "read:data");
console.log(result.allowed); // true
```
## Usage: Advanced Example
```ts
import { requirePermission, requireAllPermissions } from "@suaveplan/auth/middleware";
import { MemoryAuthAdapter } from "@suaveplan/auth";
const adapter = new MemoryAuthAdapter();
const jwtOpts = { secret: "a-very-long-secret-key-at-least-32-chars" };
// Single permission
const adminOnly = requirePermission("admin:write", adapter, jwtOpts);
// Multiple permissions
const editorAccess = requireAllPermissions(
["content:read", "content:write"],
adapter,
jwtOpts,
);
// Compose in middleware stack (Express-style)
// app.delete("/article", verifyToken, adminOnly, handler);
// app.post("/article", verifyToken, editorAccess, handler);
```
## API Reference
### `getUserFromSession(token, adapter, options, signal?): Promise<UserContext | null>`
Validates a JWT string and returns a `UserContext`, or `null` if invalid/expired/revoked.
### `checkPermission(user, permission): PermissionCheckResult`
Checks `user.permissions` for the given permission or the `*` wildcard. Returns `{ allowed, reason? }`.
### `requirePermission(permission, adapter, options, middlewareOptions?): MiddlewareFn`
Middleware that denies requests without the given permission.
### `requireAllPermissions(permissions, adapter, options, middlewareOptions?): MiddlewareFn`
Middleware that denies requests unless ALL listed permissions are present.
### `requireAnyPermission(permissions, adapter, options, middlewareOptions?): MiddlewareFn`
Middleware that denies requests unless at least ONE of the listed permissions is present.
### `withPermissions(permissions, adapter, options, middlewareOptions?): MiddlewareFn`
Alias for `requireAllPermissions`.
## Implementation Notes
The user-extraction logic checks `req.user` first (set by a preceding `verifyTokenMiddleware`) before falling back to re-validating the `Authorization` header. This allows the middleware functions to work standalone without requiring a dedicated verification middleware upstream — at the cost of one extra token validation per request. For performance in high-throughput routes, run `verifyTokenMiddleware` once at the route group level and then use `checkPermission` directly in route handlers rather than chaining multiple permission middlewares.

View File

@@ -0,0 +1,437 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MemoryAuthAdapter } from "../adapters/memory/memory.js";
import { PermissionDeniedError } from "../errors/auth-error.js";
import { issueToken } from "../jwt/token-lifecycle.js";
import type { JWTOptions, UserContext } from "../types/auth.types.js";
import {
checkPermission,
getUserFromSession,
requireAllPermissions,
requireAnyPermission,
requirePermission,
withPermissions,
} from "./auth-middleware.js";
const SECRET = "a-secret-key-that-is-at-least-32-chars-long!!";
const opts: JWTOptions = { secret: SECRET };
function makeUser(permissions: string[] = []): UserContext {
return { userId: "u1", permissions };
}
/**
* Middleware next() collector: only captures truthy error arguments.
* A bare next() call (success) never pushes to the array.
*/
function makeNext(): [(e?: unknown) => void, unknown[]] {
const errors: unknown[] = [];
return [
(e?: unknown) => {
if (e !== undefined) errors.push(e);
},
errors,
];
}
describe("checkPermission", () => {
it("allows a matching permission", { timeout: 5000 }, () => {
expect(checkPermission(makeUser(["read"]), "read").allowed).toBe(true);
});
it("denies a missing permission", { timeout: 5000 }, () => {
const result = checkPermission(makeUser(["read"]), "write");
expect(result.allowed).toBe(false);
expect(result.reason).toContain("write");
});
it("allows wildcard *", { timeout: 5000 }, () => {
expect(checkPermission(makeUser(["*"]), "anything").allowed).toBe(true);
});
it("returns no reason when allowed", { timeout: 5000 }, () => {
expect(
checkPermission(makeUser(["read"]), "read").reason
).toBeUndefined();
});
});
/** Build a minimal HS256 JWT with arbitrary extra claims. */
async function buildCustomJwt(
payload: Record<string, unknown>,
secret: string
): Promise<string> {
function b64url(s: string): string {
return btoa(unescape(encodeURIComponent(s)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}
const header = b64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
const body = b64url(JSON.stringify(payload));
const input = `${header}.${body}`;
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const sigBuf = await crypto.subtle.sign(
"HMAC",
key,
new TextEncoder().encode(input)
);
const sigArr = new Uint8Array(sigBuf);
let binary = "";
for (const b of sigArr) binary += String.fromCharCode(b);
const sig = btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
return `${input}.${sig}`;
}
describe("getUserFromSession", () => {
it("returns UserContext for valid token", { timeout: 30000 }, async () => {
const adapter = new MemoryAuthAdapter();
const { accessToken } = await issueToken(adapter, "u-42", opts);
const ctx = await getUserFromSession(accessToken, adapter, opts);
expect(ctx?.userId).toBe("u-42");
});
it("returns null for invalid token", { timeout: 30000 }, async () => {
const adapter = new MemoryAuthAdapter();
const ctx = await getUserFromSession("not-a-token", adapter, opts);
expect(ctx).toBeNull();
});
it("returns null for empty string", { timeout: 30000 }, async () => {
const adapter = new MemoryAuthAdapter();
const ctx = await getUserFromSession("", adapter, opts);
expect(ctx).toBeNull();
});
it("extracts permissions and metadata from JWT claims", {
timeout: 30000,
}, async () => {
const adapter = new MemoryAuthAdapter();
const now = Math.floor(Date.now() / 1000);
const token = await buildCustomJwt(
{
sub: "u-ext",
iat: now,
exp: now + 3600,
jti: crypto.randomUUID(),
sessionId: crypto.randomUUID(),
permissions: ["read", "write"],
metadata: { role: "admin" },
},
SECRET
);
const ctx = await getUserFromSession(token, adapter, opts);
expect(ctx?.userId).toBe("u-ext");
expect(ctx?.permissions).toEqual(["read", "write"]);
expect(ctx?.metadata).toEqual({ role: "admin" });
});
it("handles token with no sessionId claim", {
timeout: 30000,
}, async () => {
const adapter = new MemoryAuthAdapter();
const now = Math.floor(Date.now() / 1000);
// Build a JWT without a sessionId claim — exercises the false-arm spread
const token = await buildCustomJwt(
{
sub: "u-nosess",
iat: now,
exp: now + 3600,
jti: crypto.randomUUID(),
},
SECRET
);
const ctx = await getUserFromSession(token, adapter, opts);
expect(ctx?.userId).toBe("u-nosess");
expect(ctx?.sessionId).toBeUndefined();
});
});
describe("requirePermission", () => {
it("calls next() when user has permission", {
timeout: 30000,
}, async () => {
const adapter = new MemoryAuthAdapter();
const { accessToken } = await issueToken(adapter, "u1", opts);
const mw = requirePermission("read", adapter, opts);
const req: Record<string, unknown> = {
user: { userId: "u1", permissions: ["read"] },
headers: { authorization: `Bearer ${accessToken}` },
};
const [next, errors] = makeNext();
await mw(req, {}, next);
expect(errors).toHaveLength(0);
});
it("calls next(PermissionDeniedError) when missing", {
timeout: 30000,
}, async () => {
const adapter = new MemoryAuthAdapter();
const mw = requirePermission("admin", adapter, opts);
const req = {
user: { userId: "u1", permissions: ["read"] },
headers: {},
};
const [next, errors] = makeNext();
await mw(req, {}, next);
expect(errors[0]).toBeInstanceOf(PermissionDeniedError);
});
it("denies when no user and no token", { timeout: 30000 }, async () => {
const adapter = new MemoryAuthAdapter();
const mw = requirePermission("read", adapter, opts);
const [next, errors] = makeNext();
await mw({ headers: {} }, {}, next);
expect(errors[0]).toBeInstanceOf(PermissionDeniedError);
});
});
describe("requireAllPermissions", () => {
it("allows when all permissions present", { timeout: 30000 }, async () => {
const adapter = new MemoryAuthAdapter();
const mw = requireAllPermissions(["read", "write"], adapter, opts);
const req = {
user: { userId: "u1", permissions: ["read", "write"] },
headers: {},
};
const [next, errors] = makeNext();
await mw(req, {}, next);
expect(errors).toHaveLength(0);
});
it("denies when one permission missing", { timeout: 30000 }, async () => {
const adapter = new MemoryAuthAdapter();
const mw = requireAllPermissions(["read", "delete"], adapter, opts);
const req = {
user: { userId: "u1", permissions: ["read"] },
headers: {},
};
const [next, errors] = makeNext();
await mw(req, {}, next);
expect(errors[0]).toBeInstanceOf(PermissionDeniedError);
});
it("denies when no user and no token", { timeout: 30000 }, async () => {
const adapter = new MemoryAuthAdapter();
const mw = requireAllPermissions(["read"], adapter, opts);
const [next, errors] = makeNext();
await mw({ headers: {} }, {}, next);
expect(errors[0]).toBeInstanceOf(PermissionDeniedError);
});
});
describe("requireAnyPermission", () => {
it("allows when at least one permission present", {
timeout: 30000,
}, async () => {
const adapter = new MemoryAuthAdapter();
const mw = requireAnyPermission(["admin", "editor"], adapter, opts);
const req = {
user: { userId: "u1", permissions: ["editor"] },
headers: {},
};
const [next, errors] = makeNext();
await mw(req, {}, next);
expect(errors).toHaveLength(0);
});
it("denies when none of the permissions present", {
timeout: 30000,
}, async () => {
const adapter = new MemoryAuthAdapter();
const mw = requireAnyPermission(["admin", "superadmin"], adapter, opts);
const req = {
user: { userId: "u1", permissions: ["viewer"] },
headers: {},
};
const [next, errors] = makeNext();
await mw(req, {}, next);
expect(errors[0]).toBeInstanceOf(PermissionDeniedError);
});
it("denies when no user and no token", { timeout: 30000 }, async () => {
const adapter = new MemoryAuthAdapter();
const mw = requireAnyPermission(["read"], adapter, opts);
const [next, errors] = makeNext();
await mw({ headers: {} }, {}, next);
expect(errors[0]).toBeInstanceOf(PermissionDeniedError);
});
});
describe("withPermissions", () => {
it("behaves like requireAllPermissions", { timeout: 30000 }, async () => {
const adapter = new MemoryAuthAdapter();
const mw = withPermissions(["read", "write"], adapter, opts);
const req = {
user: { userId: "u1", permissions: ["read", "write"] },
headers: {},
};
const [next, errors] = makeNext();
await mw(req, {}, next);
expect(errors).toHaveLength(0);
});
});
describe("JWT payload-shaped user (sub/sessionId)", () => {
it("extracts userId from sub field", { timeout: 30000 }, async () => {
const adapter = new MemoryAuthAdapter();
const mw = requirePermission("read", adapter, opts);
// Attach a JWT-payload-shaped user (has sub instead of userId)
const req = {
user: { sub: "jwt-user", permissions: ["read"] },
headers: {},
};
const [next, errors] = makeNext();
await mw(req, {}, next);
expect(errors).toHaveLength(0);
});
it("extracts sessionId from JWT-shaped user when present", {
timeout: 30000,
}, async () => {
const adapter = new MemoryAuthAdapter();
const mw = requirePermission("read", adapter, opts);
// Attach a JWT-payload-shaped user WITH sessionId — covers true arm for sessionId spread
const req = {
user: {
sub: "jwt-user",
sessionId: "sess-123",
permissions: ["read"],
},
headers: {},
};
const [next, errors] = makeNext();
await mw(req, {}, next);
expect(errors).toHaveLength(0);
});
it("defaults permissions to [] when not an array", {
timeout: 30000,
}, async () => {
const adapter = new MemoryAuthAdapter();
const mw = requirePermission("read", adapter, opts);
// No permissions on the JWT-shaped user → empty array → deny
const req = {
user: { sub: "jwt-user" },
headers: {},
};
const [next, errors] = makeNext();
await mw(req, {}, next);
expect(errors[0]).toBeInstanceOf(PermissionDeniedError);
});
});
describe("no headers property on request", () => {
it("treats missing headers as empty object", {
timeout: 30000,
}, async () => {
const adapter = new MemoryAuthAdapter();
const mw = requirePermission("read", adapter, opts);
// No headers property at all — exercises the headers ?? {} fallback
const [next, errors] = makeNext();
await mw({}, {}, next);
expect(errors[0]).toBeInstanceOf(PermissionDeniedError);
});
it("denies when req.user has neither userId nor sub", {
timeout: 30000,
}, async () => {
const adapter = new MemoryAuthAdapter();
const mw = requirePermission("read", adapter, opts);
// req.user exists but has no userId or sub — falls through to header check
const req = { user: { role: "viewer" }, headers: {} };
const [next, errors] = makeNext();
await mw(req, {}, next);
expect(errors[0]).toBeInstanceOf(PermissionDeniedError);
});
});
describe("Bearer token extraction (no req.user)", () => {
it("extracts user from Authorization header when no req.user", {
timeout: 30000,
}, async () => {
const adapter = new MemoryAuthAdapter();
const { accessToken } = await issueToken(adapter, "u-bearer", opts);
const mw = requirePermission("read", adapter, {
...opts,
// Token has no permissions claim — will get empty array
});
const req: Record<string, unknown> = {
headers: { authorization: `Bearer ${accessToken}` },
};
// No permissions in token, but read is required — expect denial
const [next, errors] = makeNext();
await mw(req, {}, next);
// Token is valid but has no "read" permission → PermissionDeniedError
expect(errors[0]).toBeInstanceOf(PermissionDeniedError);
});
it("requirePermission propagates thrown errors via catch", {
timeout: 30000,
}, async () => {
// Create a broken adapter that throws on isBlacklisted
const adapter = new MemoryAuthAdapter();
const faultyAdapter = {
...adapter,
isBlacklisted: async () => {
throw new Error("storage failure");
},
} as typeof adapter;
const { accessToken } = await issueToken(adapter, "u1", opts);
const mw = requirePermission("read", faultyAdapter, opts);
const req: Record<string, unknown> = {
headers: { authorization: `Bearer ${accessToken}` },
};
const [next, errors] = makeNext();
await mw(req, {}, next);
expect(errors[0]).toBeInstanceOf(Error);
});
it("requireAllPermissions propagates thrown errors via catch", {
timeout: 30000,
}, async () => {
const adapter = new MemoryAuthAdapter();
const faultyAdapter = {
...adapter,
isBlacklisted: async () => {
throw new Error("storage failure");
},
} as typeof adapter;
const { accessToken } = await issueToken(adapter, "u1", opts);
const mw = requireAllPermissions(["read"], faultyAdapter, opts);
const req: Record<string, unknown> = {
headers: { authorization: `Bearer ${accessToken}` },
};
const [next, errors] = makeNext();
await mw(req, {}, next);
expect(errors[0]).toBeInstanceOf(Error);
});
it("requireAnyPermission propagates thrown errors via catch", {
timeout: 30000,
}, async () => {
const adapter = new MemoryAuthAdapter();
const faultyAdapter = {
...adapter,
isBlacklisted: async () => {
throw new Error("storage failure");
},
} as typeof adapter;
const { accessToken } = await issueToken(adapter, "u1", opts);
const mw = requireAnyPermission(["read"], faultyAdapter, opts);
const req: Record<string, unknown> = {
headers: { authorization: `Bearer ${accessToken}` },
};
const [next, errors] = makeNext();
await mw(req, {}, next);
expect(errors[0]).toBeInstanceOf(Error);
});
});

View File

@@ -0,0 +1,256 @@
import { metrics } from "@opentelemetry/api";
import type { AuthAdapter } from "../adapters/auth/auth.js";
import { PermissionDeniedError } from "../errors/auth-error.js";
import { validateSessionToken } from "../jwt/token-lifecycle.js";
import type {
JWTOptions,
MiddlewareFn,
MiddlewareOptions,
PermissionCheckResult,
UserContext,
} from "../types/auth.types.js";
const meter = metrics.getMeter("@suaveplan/auth");
const permissionCheckHistogram = meter.createHistogram(
"auth.permission.check",
{ unit: "ms" }
);
/**
* Derive a `UserContext` from a session token string.
*
* Returns `null` if the token is missing, expired, or blacklisted. The
* `permissions` array on the returned context is populated from the JWT
* payload's optional `permissions` claim if present.
*/
export async function getUserFromSession(
sessionToken: string,
adapter: AuthAdapter,
options: JWTOptions,
signal?: AbortSignal
): Promise<UserContext | null> {
const result = await validateSessionToken(
adapter,
sessionToken,
options,
signal
);
if (!result.valid || !result.payload) return null;
const payload = result.payload as unknown as Record<string, unknown>;
const rawPerms = payload["permissions"];
const rawMeta = payload["metadata"];
// Defensive defaults: external JWTs may include permissions/metadata claims
// that our own issueToken does not emit; treat as empty/undefined if absent.
const permissions: string[] = Array.isArray(rawPerms)
? (rawPerms as string[])
: [];
const metadata: Record<string, unknown> | undefined =
typeof rawMeta === "object" && rawMeta !== null
? (rawMeta as Record<string, unknown>)
: undefined;
return {
userId: result.payload.sub,
...(result.payload.sessionId !== undefined
? { sessionId: result.payload.sessionId }
: {}),
permissions,
...(metadata !== undefined ? { metadata } : {}),
};
}
/**
* Check whether a user has a specific permission.
*
* Returns a discriminated result with `allowed` and optional `reason`.
*/
export function checkPermission(
user: UserContext,
permission: string
): PermissionCheckResult {
const start = Date.now();
const allowed =
user.permissions.includes("*") || user.permissions.includes(permission);
permissionCheckHistogram.record(Date.now() - start, {
"permission.name": permission,
allowed: String(allowed),
});
return {
allowed,
...(allowed ? {} : { reason: `Missing permission: ${permission}` }),
};
}
/**
* Middleware factory that requires the request user to have a specific
* permission. Calls `next(PermissionDeniedError)` if the check fails.
*/
export function requirePermission(
permission: string,
adapter: AuthAdapter,
options: JWTOptions,
middlewareOptions: MiddlewareOptions = {}
): MiddlewareFn {
return async (req, _res, next) => {
try {
const r = req as Record<string, unknown>;
const user = await _extractUser(
r,
adapter,
options,
middlewareOptions
);
if (!user) {
next(new PermissionDeniedError({ permission }));
return;
}
const result = checkPermission(user, permission);
if (!result.allowed) {
next(
new PermissionDeniedError({
permission,
userId: user.userId,
})
);
return;
}
next();
} catch (err) {
next(err);
}
};
}
/**
* Middleware factory that requires the user to have ALL listed permissions.
*/
export function requireAllPermissions(
permissions: string[],
adapter: AuthAdapter,
options: JWTOptions,
middlewareOptions: MiddlewareOptions = {}
): MiddlewareFn {
return async (req, _res, next) => {
try {
const r = req as Record<string, unknown>;
const user = await _extractUser(
r,
adapter,
options,
middlewareOptions
);
if (!user) {
next(new PermissionDeniedError());
return;
}
for (const perm of permissions) {
const result = checkPermission(user, perm);
if (!result.allowed) {
next(
new PermissionDeniedError({
permission: perm,
userId: user.userId,
})
);
return;
}
}
next();
} catch (err) {
next(err);
}
};
}
/**
* Middleware factory that requires the user to have ANY of the listed
* permissions.
*/
export function requireAnyPermission(
permissions: string[],
adapter: AuthAdapter,
options: JWTOptions,
middlewareOptions: MiddlewareOptions = {}
): MiddlewareFn {
return async (req, _res, next) => {
try {
const r = req as Record<string, unknown>;
const user = await _extractUser(
r,
adapter,
options,
middlewareOptions
);
if (!user) {
next(new PermissionDeniedError());
return;
}
const hasAny = permissions.some(
(p) => checkPermission(user, p).allowed
);
if (!hasAny) {
next(new PermissionDeniedError({ userId: user.userId }));
return;
}
next();
} catch (err) {
next(err);
}
};
}
/**
* Alias for `requireAllPermissions` — attaches the full permissions list to
* the middleware and enforces all must be present.
*/
export function withPermissions(
permissions: string[],
adapter: AuthAdapter,
options: JWTOptions,
middlewareOptions: MiddlewareOptions = {}
): MiddlewareFn {
return requireAllPermissions(
permissions,
adapter,
options,
middlewareOptions
);
}
// Internal helper: extract user from req.user or validate a bearer token
async function _extractUser(
r: Record<string, unknown>,
adapter: AuthAdapter,
options: JWTOptions,
_middlewareOptions: MiddlewareOptions
): Promise<UserContext | null> {
// If a previous middleware already attached req.user, use it
if (r["user"] && typeof r["user"] === "object") {
const u = r["user"] as Record<string, unknown>;
if (typeof u["userId"] === "string") {
return r["user"] as UserContext;
}
// JWT payload shape
if (typeof u["sub"] === "string") {
const payload = u as {
sub: string;
sessionId?: string;
permissions?: string[];
};
return {
userId: payload.sub,
...(payload.sessionId !== undefined
? { sessionId: payload.sessionId }
: {}),
permissions: Array.isArray(payload.permissions)
? payload.permissions
: [],
};
}
}
// Otherwise try to extract from Authorization header
const headers = (r["headers"] ?? {}) as Record<string, string>;
const auth = headers["authorization"] ?? headers["Authorization"] ?? "";
const token = auth.replace(/^Bearer\s+/i, "");
if (!token) return null;
return getUserFromSession(token, adapter, options);
}

View File

@@ -0,0 +1,8 @@
export {
checkPermission,
getUserFromSession,
requireAllPermissions,
requireAnyPermission,
requirePermission,
withPermissions,
} from "./auth-middleware.js";

View File

@@ -0,0 +1,79 @@
# auth.schemas
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Zod validation schemas for all public option types in `@suaveplan/auth`. By co-locating schemas with the package rather than relying purely on TypeScript structural typing, the library can validate runtime inputs at system boundaries (e.g. environment variable injection, configuration files, network payloads) and produce structured, field-level error messages compatible with `@suaveplan/error`.
These schemas mirror the TypeScript interfaces in `auth.types.ts` but add runtime constraints such as minimum string lengths, positive-integer ranges, and required/optional distinctions that the type system alone cannot enforce.
## Features
- `ApiKeyRequestSchema` — validates name, userId, optional permissions array and expiresIn ms
- `JWTOptionsSchema` — enforces minimum 32-character secret and positive integer TTLs
- `ApiKeyOptionsSchema` / `MiddlewareOptionsSchema` / `ApiKeyMiddlewareOptionsSchema` — optional config bundles
- `SessionInfoSchema` — validates the persisted session shape including Date fields
- Exported inferred TypeScript types for each schema
## Usage: Basic Example
```ts
import { ApiKeyRequestSchema } from "@suaveplan/auth";
const result = ApiKeyRequestSchema.safeParse({
name: "My API Key",
userId: "user-123",
permissions: ["read:data"],
expiresIn: 86_400_000,
});
if (!result.success) {
console.error(result.error.flatten());
}
```
## Usage: Advanced Example
```ts
import { JWTOptionsSchema } from "@suaveplan/auth";
function loadJwtConfig(): z.infer<typeof JWTOptionsSchema> {
return JWTOptionsSchema.parse({
secret: process.env["JWT_SECRET"],
accessTokenTtl: 900,
refreshTokenTtl: 2_592_000,
});
}
```
## API Reference
### `ApiKeyRequestSchema`
Validates `ApiKeyRequest`. Requires `name` (1–128 chars), `userId` (≥1 char), optional `permissions` (string array), optional `expiresIn` (positive integer ms).
### `JWTOptionsSchema`
Validates `JWTOptions`. Requires `secret` (≥32 chars), optional `accessTokenTtl` (positive int), optional `refreshTokenTtl` (positive int).
### `ApiKeyOptionsSchema`
Optional wrapper with optional `clock` and `idGenerator` function fields.
### `MiddlewareOptionsSchema`
Optional wrapper with optional `secret` string field.
### `ApiKeyMiddlewareOptionsSchema`
Optional wrapper with optional `headerName` string field.
### `SessionInfoSchema`
Validates `SessionInfo`. Requires `sessionId`, `userId`, `createdAt: Date`, `expiresAt: Date`, optional `userAgent`, `ip`.
## Implementation Notes
Schemas use `z.function()` for injectable dependencies (`clock`, `idGenerator`) rather than strict signatures, allowing any callable to satisfy the constraint at runtime while preserving type inference. The `JWTOptionsSchema` enforces a 32-character minimum on `secret` to guard against weak HMAC keys; callers should use a cryptographically random 64-byte secret in production.

View File

@@ -0,0 +1,140 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
ApiKeyMiddlewareOptionsSchema,
ApiKeyOptionsSchema,
ApiKeyRequestSchema,
JWTOptionsSchema,
MiddlewareOptionsSchema,
SessionInfoSchema,
} from "./auth.schemas.js";
describe("ApiKeyRequestSchema", () => {
it("accepts valid input", { timeout: 5000 }, () => {
const result = ApiKeyRequestSchema.safeParse({
name: "Test Key",
userId: "user-1",
permissions: ["read"],
expiresIn: 86400000,
});
expect(result.success).toBe(true);
});
it("rejects empty name", { timeout: 5000 }, () => {
const result = ApiKeyRequestSchema.safeParse({
name: "",
userId: "u1",
});
expect(result.success).toBe(false);
});
it("rejects empty userId", { timeout: 5000 }, () => {
const result = ApiKeyRequestSchema.safeParse({ name: "k", userId: "" });
expect(result.success).toBe(false);
});
it("rejects negative expiresIn", { timeout: 5000 }, () => {
const result = ApiKeyRequestSchema.safeParse({
name: "k",
userId: "u",
expiresIn: -1,
});
expect(result.success).toBe(false);
});
it("accepts without optional fields", { timeout: 5000 }, () => {
const result = ApiKeyRequestSchema.safeParse({
name: "k",
userId: "u",
});
expect(result.success).toBe(true);
});
});
describe("JWTOptionsSchema", () => {
it("accepts valid config", { timeout: 5000 }, () => {
const result = JWTOptionsSchema.safeParse({
secret: "a".repeat(32),
accessTokenTtl: 900,
refreshTokenTtl: 2592000,
});
expect(result.success).toBe(true);
});
it("rejects short secret", { timeout: 5000 }, () => {
const result = JWTOptionsSchema.safeParse({ secret: "short" });
expect(result.success).toBe(false);
});
it("accepts without optional ttl fields", { timeout: 5000 }, () => {
const result = JWTOptionsSchema.safeParse({ secret: "a".repeat(32) });
expect(result.success).toBe(true);
});
it("rejects zero accessTokenTtl", { timeout: 5000 }, () => {
const result = JWTOptionsSchema.safeParse({
secret: "a".repeat(32),
accessTokenTtl: 0,
});
expect(result.success).toBe(false);
});
});
describe("ApiKeyOptionsSchema", () => {
it("accepts undefined", { timeout: 5000 }, () => {
const result = ApiKeyOptionsSchema.safeParse(undefined);
expect(result.success).toBe(true);
});
it("accepts empty object", { timeout: 5000 }, () => {
const result = ApiKeyOptionsSchema.safeParse({});
expect(result.success).toBe(true);
});
});
describe("MiddlewareOptionsSchema", () => {
it("accepts undefined", { timeout: 5000 }, () => {
expect(MiddlewareOptionsSchema.safeParse(undefined).success).toBe(true);
});
it("accepts secret string", { timeout: 5000 }, () => {
expect(
MiddlewareOptionsSchema.safeParse({ secret: "mysecret" }).success
).toBe(true);
});
});
describe("ApiKeyMiddlewareOptionsSchema", () => {
it("accepts undefined", { timeout: 5000 }, () => {
expect(ApiKeyMiddlewareOptionsSchema.safeParse(undefined).success).toBe(
true
);
});
it("accepts headerName", { timeout: 5000 }, () => {
expect(
ApiKeyMiddlewareOptionsSchema.safeParse({ headerName: "x-key" })
.success
).toBe(true);
});
});
describe("SessionInfoSchema", () => {
it("accepts valid session", { timeout: 5000 }, () => {
const result = SessionInfoSchema.safeParse({
sessionId: "s-1",
userId: "u-1",
createdAt: new Date(),
expiresAt: new Date(Date.now() + 3600000),
});
expect(result.success).toBe(true);
});
it("rejects missing sessionId", { timeout: 5000 }, () => {
const result = SessionInfoSchema.safeParse({
userId: "u-1",
createdAt: new Date(),
expiresAt: new Date(),
});
expect(result.success).toBe(false);
});
});

View File

@@ -0,0 +1,52 @@
import z from "zod";
/** Schema for `ApiKeyRequest`. */
export const ApiKeyRequestSchema = z.object({
name: z.string().min(1).max(128),
userId: z.string().min(1),
permissions: z.array(z.string()).optional(),
expiresIn: z.number().int().positive().optional(),
});
/** Schema for `JWTOptions`. */
export const JWTOptionsSchema = z.object({
secret: z.string().min(32),
accessTokenTtl: z.number().int().positive().optional(),
refreshTokenTtl: z.number().int().positive().optional(),
});
/** Schema for `ApiKeyOptions`. */
export const ApiKeyOptionsSchema = z
.object({
clock: z.function().optional(),
idGenerator: z.function().optional(),
})
.optional();
/** Schema for `MiddlewareOptions`. */
export const MiddlewareOptionsSchema = z
.object({
secret: z.string().optional(),
})
.optional();
/** Schema for `ApiKeyMiddlewareOptions`. */
export const ApiKeyMiddlewareOptionsSchema = z
.object({
headerName: z.string().optional(),
})
.optional();
/** Schema for `SessionInfo`. */
export const SessionInfoSchema = z.object({
sessionId: z.string().min(1),
userId: z.string().min(1),
createdAt: z.date(),
expiresAt: z.date(),
userAgent: z.string().optional(),
ip: z.string().optional(),
});
export type ApiKeyRequestInput = z.infer<typeof ApiKeyRequestSchema>;
export type JWTOptionsInput = z.infer<typeof JWTOptionsSchema>;
export type SessionInfoInput = z.infer<typeof SessionInfoSchema>;

View File

@@ -0,0 +1,11 @@
export {
ApiKeyMiddlewareOptionsSchema,
ApiKeyOptionsSchema,
type ApiKeyRequestInput,
ApiKeyRequestSchema,
type JWTOptionsInput,
JWTOptionsSchema,
MiddlewareOptionsSchema,
type SessionInfoInput,
SessionInfoSchema,
} from "./auth.schemas.js";

View File

@@ -0,0 +1,104 @@
# auth.types
## Purpose
Central type definitions for `@suaveplan/auth`. All public interfaces, type aliases, and option bundles live in this file so that consumers can import lightweight type-only shapes without pulling in implementation code. The types are deliberately framework-agnostic: no Node.js `IncomingMessage`, no Hono `Context`, no Express `Request` — only plain structural interfaces that any runtime can satisfy.
## Features
- `SessionInfo` — persisted session record (stored in the adapter)
- `JWTPayload` — decoded JWT claims subset (`sub`, `iat`, `exp`, `jti`, optional `sessionId`)
- `TokenValidationResult` / `ApiKeyValidationResult` — discriminated result envelopes
- `ApiKey` / `ApiKeyRequest` / `ApiKeyGeneration` — full API-key lifecycle shapes
- `UserContext` / `AuthenticatedRequest` / `AuthenticatedJWTRequest` — request-augmentation types
- `PermissionCheckResult` — outcome of a permission assertion
- `MiddlewareFn` — framework-agnostic `(req, res, next)` signature
- Option bundles: `ApiKeyOptions`, `JWTOptions`, `MiddlewareOptions`, `ApiKeyMiddlewareOptions`
## Usage: Basic Example
```ts
import type { ApiKey, ApiKeyGeneration, UserContext } from "@suaveplan/auth";
function printKey(gen: ApiKeyGeneration): void {
console.log("Store this once:", gen.rawKey);
console.log("Key ID:", gen.apiKey.id);
}
function getPermissions(ctx: UserContext): string[] {
return ctx.permissions;
}
```
## Usage: Advanced Example
```ts
import type { JWTOptions, JWTPayload, MiddlewareFn } from "@suaveplan/auth";
const options: JWTOptions = {
secret: process.env["JWT_SECRET"] ?? "",
accessTokenTtl: 900,
refreshTokenTtl: 2_592_000,
};
function createAuthMiddleware(opts: JWTOptions): MiddlewareFn {
return async (req, _res, next) => {
const r = req as { headers?: Record<string, string>; user?: JWTPayload };
const header = r.headers?.["authorization"] ?? "";
const token = header.replace("Bearer ", "");
if (!token) { next(new Error("Missing token")); return; }
// ... validate token and attach r.user
next();
};
}
```
## API Reference
### `SessionInfo`
Persisted session record. Fields: `sessionId`, `userId`, `createdAt: Date`, `expiresAt: Date`, optional `userAgent`, `ip`.
### `JWTPayload`
Standard JWT claims used by this package: `sub` (userId), `iat`, `exp`, `jti`, optional `sessionId`.
### `TokenValidationResult`
`{ valid: boolean; payload?: JWTPayload; error?: string }` — returned by `validateSessionToken`.
### `ApiKey`
Full API key record including the stored `keyHash`, `permissions[]`, optional `expiresAt`, `lastUsedAt`, `revokedAt`.
### `ApiKeyRequest`
`{ name, userId, permissions?, expiresIn? }` — input to `createApiKey`.
### `ApiKeyGeneration`
`{ apiKey: ApiKey; rawKey: string }` — raw key is returned once only.
### `ApiKeyValidationResult`
`{ valid: boolean; apiKey?: ApiKey; error?: string }` — returned by `validateApiKey`.
### `UserContext`
`{ userId, sessionId?, permissions[], metadata? }` — attached to authenticated requests.
### `PermissionCheckResult`
`{ allowed: boolean; reason?: string }` — returned by `checkPermission`.
### `MiddlewareFn`
`(req: unknown, res: unknown, next: (err?: unknown) => void) => void | Promise<void>`.
### `JWTOptions`
`{ secret, accessTokenTtl?, refreshTokenTtl?, clock?, idGenerator? }`.
## Implementation Notes
All interfaces use `readonly` modifiers where the package never mutates the value after creation. `ApiKey.keyHash` stores the SHA-256 hex digest; the `rawKey` in `ApiKeyGeneration` is the plain-text value that must never be persisted. Option bundles accept injectable `Clock` and `IdGenerator` from `@suaveplan/types` for deterministic testing without mocks.

View File

@@ -0,0 +1,131 @@
import type { Clock, IdGenerator } from "@suaveplan/types";
/** Represents an authenticated user returned from the auth adapter. */
export interface SessionInfo {
readonly sessionId: string;
readonly userId: string;
readonly createdAt: Date;
readonly expiresAt: Date;
readonly userAgent?: string;
readonly ip?: string;
}
/** Decoded payload stored inside a JWT access token. */
export interface JWTPayload {
/** Subject (userId). */
sub: string;
/** Issued-at timestamp (seconds since epoch). */
iat: number;
/** Expiry timestamp (seconds since epoch). */
exp: number;
/** JWT ID — unique token identifier used for blacklisting. */
jti: string;
/** Optional session identifier from which the token was issued. */
sessionId?: string;
}
/** Result returned by `validateSessionToken`. */
export interface TokenValidationResult {
readonly valid: boolean;
readonly payload?: JWTPayload;
readonly error?: string;
}
/** A generated API key record (stored representation). */
export interface ApiKey {
readonly id: string;
readonly key: string;
/** SHA-256 hex digest of the raw key — stored in the adapter. */
readonly keyHash: string;
readonly name: string;
readonly userId: string;
readonly permissions: string[];
readonly expiresAt?: Date;
readonly createdAt: Date;
readonly lastUsedAt?: Date;
readonly revokedAt?: Date;
}
/** Parameters required to create a new API key. */
export interface ApiKeyRequest {
readonly name: string;
readonly userId: string;
readonly permissions?: string[];
/** Validity window in milliseconds. */
readonly expiresIn?: number;
}
/** Returned from `createApiKey` — contains the stored record and the one-time raw key. */
export interface ApiKeyGeneration {
readonly apiKey: ApiKey;
/** Plain-text key returned once only; the adapter stores only the hash. */
readonly rawKey: string;
}
/** Result returned by `validateApiKey`. */
export interface ApiKeyValidationResult {
readonly valid: boolean;
readonly apiKey?: ApiKey;
readonly error?: string;
}
/** User context attached to an authenticated request. */
export interface UserContext {
readonly userId: string;
readonly sessionId?: string;
readonly permissions: string[];
readonly metadata?: Record<string, unknown>;
}
/** Request augmented with an authenticated JWT user context. */
export interface AuthenticatedJWTRequest {
user: JWTPayload;
}
/** Request augmented with a full user context. */
export interface AuthenticatedRequest {
user: UserContext;
}
/** Result of a permission check. */
export interface PermissionCheckResult {
readonly allowed: boolean;
readonly reason?: string;
}
/** Framework-agnostic middleware function signature. */
export type MiddlewareFn = (
req: unknown,
res: unknown,
next: (err?: unknown) => void
) => void | Promise<void>;
/** Options passed to API-key helpers. */
export interface ApiKeyOptions {
readonly clock?: Clock;
readonly idGenerator?: IdGenerator;
}
/** Options passed to JWT helpers. */
export interface JWTOptions {
/** HMAC secret used to sign and verify tokens. */
readonly secret: string;
/** Access token TTL in seconds (default: 900 — 15 min). */
readonly accessTokenTtl?: number;
/** Refresh token TTL in seconds (default: 2592000 — 30 days). */
readonly refreshTokenTtl?: number;
readonly clock?: Clock;
readonly idGenerator?: IdGenerator;
}
/** Options for middleware helpers. */
export interface MiddlewareOptions {
readonly secret?: string;
readonly clock?: Clock;
}
/** Options for the `validateApiKeyMiddleware` factory. */
export interface ApiKeyMiddlewareOptions {
readonly headerName?: string;
readonly clock?: Clock;
}

View File

@@ -0,0 +1,18 @@
export type {
ApiKey,
ApiKeyGeneration,
ApiKeyMiddlewareOptions,
ApiKeyOptions,
ApiKeyRequest,
ApiKeyValidationResult,
AuthenticatedJWTRequest,
AuthenticatedRequest,
JWTOptions,
JWTPayload,
MiddlewareFn,
MiddlewareOptions,
PermissionCheckResult,
SessionInfo,
TokenValidationResult,
UserContext,
} from "./auth.types.js";

View File

@@ -0,0 +1,125 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MemoryAuthAdapter } from "../src/adapters/memory/memory.js";
import {
createApiKey,
hasPermission,
validateApiKey,
} from "../src/api-keys/api-keys.js";
import { PermissionDeniedError } from "../src/errors/auth-error.js";
import {
issueToken,
refreshToken,
revokeAllUserTokens,
validateSessionToken,
} from "../src/jwt/token-lifecycle.js";
import {
checkPermission,
getUserFromSession,
requirePermission,
} from "../src/middleware/auth-middleware.js";
/** Helper: captures only truthy next(error) calls; bare next() is ignored. */
function makeNext(): [(e?: unknown) => void, unknown[]] {
const errors: unknown[] = [];
return [
(e?: unknown) => {
if (e !== undefined) errors.push(e);
},
errors,
];
}
const SECRET = "integration-test-secret-32-chars-long!!";
const opts = { secret: SECRET };
describe("End-to-end auth flow", () => {
it("full token lifecycle", { timeout: 30000 }, async () => {
const adapter = new MemoryAuthAdapter();
// Issue tokens
const {
accessToken,
refreshToken: rt,
sessionId,
} = await issueToken(adapter, "user-int-1", opts);
expect(accessToken).toBeTruthy();
expect(rt).toBeTruthy();
expect(sessionId).toBeTruthy();
// Validate access token
const v = await validateSessionToken(adapter, accessToken, opts);
expect(v.valid).toBe(true);
expect(v.payload?.sub).toBe("user-int-1");
// Refresh tokens
const refreshed = await refreshToken(adapter, rt, opts);
expect(refreshed.accessToken).toBeTruthy();
expect(refreshed.refreshToken).toBeTruthy();
// Old refresh token now invalid
await expect(refreshToken(adapter, rt, opts)).rejects.toThrow();
// Revoke all user tokens
await revokeAllUserTokens(adapter, "user-int-1");
const sessions = await adapter.listUserSessions("user-int-1");
expect(sessions).toHaveLength(0);
});
it("API key + permission check flow", { timeout: 30000 }, async () => {
const adapter = new MemoryAuthAdapter();
// Create API key with specific permissions
const { rawKey, apiKey } = await createApiKey(adapter, {
name: "Integration Key",
userId: "user-int-2",
permissions: ["read:articles", "write:comments"],
});
// Validate the key
const result = await validateApiKey(adapter, rawKey);
expect(result.valid).toBe(true);
expect(result.apiKey?.permissions).toContain("read:articles");
// Check permissions
expect(hasPermission(apiKey, "read:articles")).toBe(true);
expect(hasPermission(apiKey, "admin:delete")).toBe(false);
});
it("middleware chains with JWT", { timeout: 30000 }, async () => {
const adapter = new MemoryAuthAdapter();
const { accessToken } = await issueToken(adapter, "user-int-3", opts);
// Get user context from token
const ctx = await getUserFromSession(accessToken, adapter, opts);
expect(ctx?.userId).toBe("user-int-3");
// Attach user to request
const req = {
user: { userId: "user-int-3", permissions: ["admin:write"] },
headers: {},
};
// requirePermission middleware
const mw = requirePermission("admin:write", adapter, opts);
const [next, errors] = makeNext();
await mw(req, {}, next);
expect(errors).toHaveLength(0);
// requirePermission denies unauthorized
const mw2 = requirePermission("superadmin", adapter, opts);
const errors2: unknown[] = [];
await mw2(req, {}, (e) => errors2.push(e));
expect(errors2[0]).toBeInstanceOf(PermissionDeniedError);
});
it("checkPermission associativity", { timeout: 5000 }, () => {
const user = { userId: "u", permissions: ["a", "b", "c"] };
// requireAllPermissions([a,b]) iff checkPermission(user,a) && checkPermission(user,b)
const a = checkPermission(user, "a").allowed;
const b = checkPermission(user, "b").allowed;
expect(a && b).toBe(true);
const d = checkPermission(user, "d").allowed;
expect(a && d).toBe(false);
});
});

View File

@@ -0,0 +1,120 @@
import { describe, it } from "@suaveplan/testing/runner";
import * as fc from "fast-check";
import { MemoryAuthAdapter } from "../src/adapters/memory/memory.js";
import {
createApiKey,
hasPermission,
validateApiKey,
} from "../src/api-keys/api-keys.js";
import { checkPermission } from "../src/middleware/auth-middleware.js";
import type { UserContext } from "../src/types/auth.types.js";
const SECRET = "property-test-secret-at-least-32-chars-yes";
const _opts = { secret: SECRET };
describe("Property: API key round-trip", () => {
it("createApiKey → validateApiKey always returns valid:true", {
timeout: 30000,
}, async () => {
await fc.assert(
fc.asyncProperty(
fc.string({ minLength: 1, maxLength: 64 }),
fc.string({ minLength: 1, maxLength: 64 }),
fc.array(fc.string({ minLength: 1, maxLength: 32 }), {
minLength: 0,
maxLength: 5,
}),
async (name, userId, permissions) => {
const adapter = new MemoryAuthAdapter();
const { rawKey } = await createApiKey(adapter, {
name,
userId,
permissions,
});
const result = await validateApiKey(adapter, rawKey);
return result.valid === true;
}
),
{ numRuns: 10 }
);
});
});
describe("Property: blacklist monotonicity", () => {
it("once blacklisted, isBlacklisted always returns true until expiry", {
timeout: 30000,
}, async () => {
await fc.assert(
fc.asyncProperty(
fc.string({ minLength: 1, maxLength: 32 }),
async (jti) => {
const adapter = new MemoryAuthAdapter();
const futureExpiry = new Date(Date.now() + 999_999_999);
await adapter.blacklistToken(jti, futureExpiry);
// Multiple reads should all return true
const r1 = await adapter.isBlacklisted(jti);
const r2 = await adapter.isBlacklisted(jti);
const r3 = await adapter.isBlacklisted(jti);
return r1 && r2 && r3;
}
),
{ numRuns: 20 }
);
});
});
describe("Property: permission check associativity", () => {
it("requireAllPermissions([a,b]) iff checkPermission(a) && checkPermission(b)", {
timeout: 5000,
}, () => {
fc.assert(
fc.property(
fc.array(fc.string({ minLength: 1, maxLength: 16 }), {
minLength: 0,
maxLength: 8,
}),
fc.string({ minLength: 1, maxLength: 16 }),
fc.string({ minLength: 1, maxLength: 16 }),
(userPerms, pA, pB) => {
const user: UserContext = {
userId: "u",
permissions: userPerms,
};
const allOf =
checkPermission(user, pA).allowed &&
checkPermission(user, pB).allowed;
// requireAllPermissions([pA, pB]) iff both checks pass
const permsSet = new Set(userPerms);
const wildcard = permsSet.has("*");
const manualAll =
(wildcard || permsSet.has(pA)) &&
(wildcard || permsSet.has(pB));
return allOf === manualAll;
}
),
{ numRuns: 200 }
);
});
});
describe("Property: hasPermission with wildcard", () => {
it("key with * permission always returns true for any permission string", {
timeout: 5000,
}, () => {
fc.assert(
fc.property(fc.string({ minLength: 1, maxLength: 32 }), (perm) => {
const key = {
id: "k",
key: "sp_x",
keyHash: "h",
name: "n",
userId: "u",
permissions: ["*"],
createdAt: new Date(),
};
return hasPermission(key, perm) === true;
}),
{ numRuns: 100 }
);
});
});

View File

@@ -0,0 +1,7 @@
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,40 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/auth",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/types",
"@suaveplan/crypto",
"@suaveplan/logger",
"@suaveplan/retry",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,26 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/__benchmarks__/**",
"tests/**",
],
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
testTimeout: 30000,
},
});

View File

@@ -0,0 +1,177 @@
# @suaveplan/better-auth-surrealdb - Better-Auth Adapter for SurrealDB v3 — bundles the canonical 17-table SurrealQL schema, compiles Better-Auth Where[] filters to parameterised SurrealQL, auto-prefixes record-link fields on write, strips prefixes on read, supports record-id/plain/ulid id strategies, and ships an OTel-instrumented conformance harness for cross-package consumption.
Better-Auth Adapter for SurrealDB v3 — bundles the canonical 17-table
SurrealQL schema, compiles Better-Auth `Where[]` filters to parameterised
SurrealQL, auto-prefixes record-link fields on write, strips prefixes on
read, supports `record-id` / `plain` / `ulid` id strategies, and ships an
OTel-instrumented conformance harness for cross-package reuse.
<!-- begin:auto-docs -->
## Documentation tree
- [adapter](./src/adapter/adapter.md) — The package's flagship export. `createBetterAuthSurrealAdapter(options)` returns a Better-Auth `Adapter` whose 8 contra…
- [adapter](./src/adapter/adapter.md) — The package's flagship export. `createBetterAuthSurrealAdapter(options)` returns a Better-Auth `Adapter` whose 8 contra…
- [internals](./src/adapter/internals.md) — Shared internal helpers consumed by the adapter shell (`adapter.ts`) and the per-method operation runners (`operations.…
- [model-to-table](./src/adapter/model-to-table.md) — Memoising mapper from Better-Auth model name to SurrealDB table name. The mapper function is invoked once per distinct…
- [operations](./src/adapter/operations.md) — Hosts the five primitive runners (`runCreate`, `runFindMany`, `runUpdateMany`, `runDeleteMany`, `runCount`) that transl…
- [v1.6](./src/adapter/v1.6.md) — `betterAuthSurrealAdapter(options)` is the Better-Auth **v1.6** integration surface for `@suaveplan/better-auth-surreal…
- [compile-select](./src/compile/compile-select.md) — Compiles a Better-Auth `select?: string[]` projection into a SurrealQL column list. Used inside the adapter's `SELECT`…
- [compile-sort](./src/compile/compile-sort.md) — Compiles a Better-Auth `sortBy?: { field; direction }` argument into a SurrealQL `ORDER BY` fragment, validating the fi…
- [compile-where](./src/compile/compile-where.md) — Translates Better-Auth's `Where[]` filter union into a parameterised SurrealQL `WHERE` fragment plus a binding record.…
- [error-codes](./src/error-codes/error-codes.md) — Defines the closed registry of error codes raised from every public path of `@suaveplan/better-auth-surrealdb`. Stable…
- [error-codes](./src/error-codes/error-codes.md) — Defines the closed registry of error codes raised from every public path of `@suaveplan/better-auth-surrealdb`. Stable…
- [id-strategy](./src/id-strategy/id-strategy.md) — Encapsulates the three Better-Auth record-id strategies the adapter supports. SurrealDB's native record-id format is `<…
- [id-strategy](./src/id-strategy/id-strategy.md) — Encapsulates the three Better-Auth record-id strategies the adapter supports. SurrealDB's native record-id format is `<…
- [link-fields](./src/link-fields/link-fields.md) — Encapsulates the central design decision of this package: SurrealDB stores record references as typed `record<target>`…
- [link-fields](./src/link-fields/link-fields.md) — Encapsulates the central design decision of this package: SurrealDB stores record references as typed `record<target>`…
- [otel](./src/otel/instruments.md) — Wires the package's OpenTelemetry metrics surface and exposes a thin `instrument(op)` helper the adapter wraps around e…
- [instruments](./src/otel/instruments.md) — Wires the package's OpenTelemetry metrics surface and exposes a thin `instrument(op)` helper the adapter wraps around e…
- [schema](./src/schema/schema.md) — Bundles the canonical 17-table Better-Auth SurrealQL schema as a frozen string constant `BETTER_AUTH_SURREAL_SCHEMA` an…
- [schema](./src/schema/schema.md) — Bundles the canonical 17-table Better-Auth SurrealQL schema as a frozen string constant `BETTER_AUTH_SURREAL_SCHEMA` an…
- [link-fields](./src/schemas/link-fields.md) — Zod boundary schema asserting the structural shape of a `LinkFieldMap`. Used to validate the optional `linkFields` argu…
- [options](./src/schemas/options.md) — Zod boundary schema for `BetterAuthSurrealAdapterOptions`. Drives the runtime validation step inside `createBetterAuthS…
- [testing](./src/testing/run-adapter-conformance.md) — Exposes `runAdapterConformance(factory)` — the reusable suite that pins every Better-Auth Adapter scenario this package…
- [run-adapter-conformance](./src/testing/run-adapter-conformance.md) — Exposes `runAdapterConformance(factory)` — the reusable suite that pins every Better-Auth Adapter scenario this package…
- [types](./src/types/adapter.md) — Type-only foundation for the entire package. Defines the public option shape (`BetterAuthSurrealAdapterOptions`), the r…
- [adapter](./src/types/adapter.md) — Type-only foundation for the entire package. Defines the public option shape (`BetterAuthSurrealAdapterOptions`), the r…
<!-- end:auto-docs -->
## Quick start
```ts
import { SurrealAdapter } from "@suaveplan/surrealdb";
import { applyBetterAuthSchema } from "@suaveplan/better-auth-surrealdb/schema";
import { betterAuthSurrealAdapter } from "@suaveplan/better-auth-surrealdb/adapter/v1.6";
import { betterAuth } from "better-auth";
const client = new SurrealAdapter({
url: "ws://localhost:8000",
namespace: "auth",
database: "auth",
username: "root",
password: process.env.SURREAL_PASSWORD,
});
await client.connect();
await applyBetterAuthSchema(client);
export const auth = betterAuth({
database: betterAuthSurrealAdapter({ client }),
secret: process.env.BETTER_AUTH_SECRET,
baseURL: "http://localhost:3000",
emailAndPassword: { enabled: true },
});
```
### Advanced — direct conformance harness usage
The v1.4-shape `createBetterAuthSurrealAdapter` returns the 10-method
adapter directly (without the `createAdapterFactory` wrapper). This is the
surface the `./testing` conformance harness consumes and the right
choice when you want to bypass Better-Auth's field-transformation layer:
```ts
import { createBetterAuthSurrealAdapter } from "@suaveplan/better-auth-surrealdb/adapter";
const adapter = createBetterAuthSurrealAdapter({ client });
await adapter.create({ model: "user", data: { id: "u1", email: "a@b" } });
```
## Bundled schema — 17 Better-Auth tables
Mirrored byte-for-byte from
`/mnt/f/surrealdb/surql/features/02-auth/02-better-auth.surql`. Schema
version moves in lockstep with this package version. Upstream changes to
the canonical schema require a coordinated package version bump.
| Table | `userId` shape | Notes |
|-----------------------|-------------------------|--------------------------------------------------------|
| `user` (extended) | n/a | Extends host's user table with `emailVerified`/`name`/`image` |
| `session` | `record<user>` | Has default `linkFields.session.userId = "user"` |
| `account` | `string` | Flat-string userId — NOT in `DEFAULT_BETTER_AUTH_LINK_FIELDS` |
| `verification` | n/a | Single-use token storage |
| `passkey` | `record<user>` | WebAuthn credentials |
| `jwks` | n/a | Server JWK pairs |
| `sso_provider` | `option<record<user>>` | Optional record-link |
| `oauth_application` | `string` (option) | Flat-string userId |
| `oauth_access_token` | `string` (option) | Flat-string userId |
| `oauth_consent` | `string` (option) | Flat-string userId |
| `organization` | n/a | Slug-unique tenant root |
| `member` | record × 2 | `userId`+`organizationId` both record-links |
| `invitation` | record × 3 | `inviterId`/`organizationId`/`teamId` record-links |
| `team` | `record<organization>` | Belongs to org |
| `team_member` | record × 2 | `userId`+`teamId` |
| `two_factor` | `record<user>` | TOTP secret + backup codes |
| `api_key` | `record<user>` | Long-lived API keys with rate-limit fields |
Plus 13 indexes (account provider, verification identifier, SSO provider
id, oauth client/access/refresh tokens, organization slug, passkey
credential, user phone, api_key prefix/user, member/team uniqueness,
invitation email/org).
## `linkFields` — overriding the default record-link map
`DEFAULT_BETTER_AUTH_LINK_FIELDS` covers every `record<...>` column in
the bundled schema. Pass your own `linkFields` when your schema differs:
```ts
import { createBetterAuthSurrealAdapter } from "@suaveplan/better-auth-surrealdb/adapter";
// Flat-string schema everywhere — disable prefix manipulation:
createBetterAuthSurrealAdapter({ client, linkFields: {} });
// Mixed override:
createBetterAuthSurrealAdapter({
client,
linkFields: {
session: { userId: "user" }, // keep default
member: { userId: "user", organizationId: "tenant" }, // rename target
},
});
```
## `idStrategy` — record-id format
| Strategy | Behaviour |
|-------------|--------------------------------------------------------------------------------------------|
| `record-id` (default) | `data.id` becomes the SurrealDB record component (`<table>:<id>`). Rejects `id` containing `:`. Returned `id` is bare. |
| `plain` | `id` stored as a normal field; SurrealDB auto-generates the storage id. |
| `ulid` | When `data.id` missing, generates a 26-char Crockford-base32 ULID and applies `record-id`. |
## Conformance harness
```ts
import { SurrealDBFixture } from "@suaveplan/testing-integration-surrealdb";
import { runAdapterConformance } from "@suaveplan/better-auth-surrealdb/testing";
const fixture = new SurrealDBFixture();
let adapter: ReturnType<typeof createBetterAuthSurrealAdapter>;
let client: SurrealAdapter;
beforeAll(async () => {
const cfg = await fixture.start();
client = new SurrealAdapter(cfg);
await client.connect();
await applyBetterAuthSchema(client);
adapter = createBetterAuthSurrealAdapter({ client });
}, 120_000);
afterAll(async () => {
await client.disconnect();
await fixture.stop();
});
runAdapterConformance(() => ({ client, adapter }));
```
## Peer deps
- `better-auth` ^1.4.0 — supplied by the consuming app. Type-only at this
package's boundary; never imported at runtime so the adapter ships
cleanly to apps using any 1.4+ minor.
## Subpath exports
`.`, `./adapter`, `./adapter/v1.6`, `./compile`, `./id-strategy`,
`./link-fields`, `./schema`, `./schemas/options`, `./schemas/link-fields`,
`./testing`, `./error-codes`, `./otel`, `./types`.

View File

@@ -0,0 +1,141 @@
{
"name": "@suaveplan/better-auth-surrealdb",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Better-Auth Adapter for SurrealDB v3 — bundles the canonical 17-table SurrealQL schema, compiles Better-Auth Where[] filters to parameterised SurrealQL, auto-prefixes record-link fields on write, strips prefixes on read, supports record-id/plain/ulid id strategies, and ships an OTel-instrumented conformance harness for cross-package consumption.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./adapter": {
"import": {
"types": "./dist/adapter/adapter.d.ts",
"default": "./dist/adapter/adapter.js"
}
},
"./adapter/v1.6": {
"import": {
"types": "./dist/adapter/v1.6.d.ts",
"default": "./dist/adapter/v1.6.js"
}
},
"./compile": {
"import": {
"types": "./dist/compile/index.d.ts",
"default": "./dist/compile/index.js"
}
},
"./id-strategy": {
"import": {
"types": "./dist/id-strategy/id-strategy.d.ts",
"default": "./dist/id-strategy/id-strategy.js"
}
},
"./link-fields": {
"import": {
"types": "./dist/link-fields/link-fields.d.ts",
"default": "./dist/link-fields/link-fields.js"
}
},
"./schema": {
"import": {
"types": "./dist/schema/schema.d.ts",
"default": "./dist/schema/schema.js"
}
},
"./schemas/options": {
"import": {
"types": "./dist/schemas/options.d.ts",
"default": "./dist/schemas/options.js"
}
},
"./schemas/link-fields": {
"import": {
"types": "./dist/schemas/link-fields.d.ts",
"default": "./dist/schemas/link-fields.js"
}
},
"./testing": {
"import": {
"types": "./dist/testing/index.d.ts",
"default": "./dist/testing/index.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/error-codes.d.ts",
"default": "./dist/error-codes/error-codes.js"
}
},
"./otel": {
"import": {
"types": "./dist/otel/instruments.d.ts",
"default": "./dist/otel/instruments.js"
}
},
"./types": {
"import": {
"types": "./dist/types/adapter.d.ts",
"default": "./dist/types/adapter.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/surrealdb": "workspace:^",
"@suaveplan/types": "workspace:^",
"surrealdb": "catalog:database",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"peerDependencies": {
"better-auth": "catalog:auth"
},
"devDependencies": {
"@opentelemetry/sdk-metrics": "1.30.0",
"@suaveplan/testing": "workspace:^",
"@suaveplan/testing-integration": "workspace:^",
"@suaveplan/testing-integration-surrealdb": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"better-auth": "catalog:auth",
"fast-check": "catalog:testing",
"surrealdb": "catalog:database",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "README.md"]
}

View File

@@ -0,0 +1,98 @@
# adapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The package's flagship export. `createBetterAuthSurrealAdapter(options)`
returns a Better-Auth `Adapter` whose 8 contract methods plus
`consumeOne` and `id` are wired against a SurrealDB v3 connection. The
adapter validates options through Zod at the boundary, dispatches every
CRUD method through OTel-instrumented spans, applies link-field
prefix/strip on writes/reads, and compiles `Where[]` filters into
parameterised SurrealQL — no string interpolation, no mocks, no
`vi.mock`.
## Features
- Stable `id` constant `"@suaveplan/better-auth-surrealdb"`.
- All 8 Better-Auth methods: `create`, `findOne`, `findMany`, `count`,
`update`, `updateMany`, `delete`, `deleteMany`, plus the `consumeOne`
primitive Better-Auth v1.6+ adds atop the older 8 methods.
- Link-field-aware writes and reads — `userId: "u1"` becomes
`userId: "user:u1"` at rest for `record<user>` columns.
- Three id-strategies (`record-id` default, `plain`, `ulid`).
- OTel counter + histogram via `instrument()`.
- Zod-validated options at the boundary; failures raise
`BetterAuthSurrealError(INVALID_INPUT)` with the `ZodError` as `cause`.
- Connection-readiness check: every method validates `client.isConnected`
and raises `CONNECTION_NOT_READY` when the underlying SurrealAdapter
has not connected.
- Underlying `SurrealError` failures wrap in `QUERY_FAILED` preserving the
`cause` chain.
## Usage: Basic
```ts
import { SurrealAdapter } from "@suaveplan/surrealdb";
import { applyBetterAuthSchema } from "@suaveplan/better-auth-surrealdb/schema";
import { createBetterAuthSurrealAdapter } from "@suaveplan/better-auth-surrealdb/adapter";
const client = new SurrealAdapter({ url: "ws://localhost:8000" });
await client.connect();
await applyBetterAuthSchema(client);
const adapter = createBetterAuthSurrealAdapter({ client });
await adapter.create({
model: "session",
data: { id: "s1", userId: "u1", token: "t" },
});
```
## Usage: Advanced
```ts
import { createBetterAuthSurrealAdapter } from "@suaveplan/better-auth-surrealdb/adapter";
const adapter = createBetterAuthSurrealAdapter({
client: surrealAdapter,
idStrategy: "ulid",
modelToTable: (m) => `bauth_${m}`,
linkFields: {
// override default — match a flat-string schema:
session: {},
member: { organizationId: "organization" },
},
});
const user = await adapter.findOne({
model: "user",
where: [{ field: "email", value: "x@y" }],
select: ["id", "email"],
});
```
## API Reference
- `createBetterAuthSurrealAdapter(options): BetterAuthSurrealAdapter`
- `BETTER_AUTH_SURREAL_ADAPTER_ID = "@suaveplan/better-auth-surrealdb"`
- `BetterAuthSurrealAdapter` — structural type satisfying Better-Auth's
`DBAdapter` minus `transaction` / `createSchema` (the host's own
transaction primitive remains reachable via `SurrealAdapter.transaction`).
## Implementation Notes
The adapter's `client.query()` calls pass the binding record as
`[vars]` so both `SurrealAdapter` (which flattens a trailing record) and
hand-rolled `SurrealClientView` implementations receive the same shape.
`update` is implemented atop `updateMany`'s `RETURN AFTER` row stream and
returns the first row — `count() FROM <table> WHERE … GROUP ALL` returns
a `{ c: <num> }` row by aliasing; bare `count()` returns the count
unnamed which trips SurrealDB v3's stricter row shape. `consumeOne`
fetches the row first, then deletes by the same predicate; this is the
fallback path Better-Auth's factory installs when an adapter does not
expose a native consume primitive, and gives the same race semantics
under SurrealDB's read-then-delete pipeline.

View File

@@ -0,0 +1,90 @@
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import { ZodError } from "zod";
import { FakeClient } from "../../tests/utils/fake-client.js";
import { BetterAuthSurrealError } from "../error-codes/error-codes.js";
import {
BETTER_AUTH_SURREAL_ADAPTER_ID,
type BetterAuthSurrealAdapter,
createBetterAuthSurrealAdapter,
} from "./adapter.js";
describe("createBetterAuthSurrealAdapter (factory)", () => {
it("factory produces every Better-Auth Adapter method", {
timeout: 5000,
}, () => {
const adapter = createBetterAuthSurrealAdapter({
client: new FakeClient(),
});
expect(adapter.id).toBe(BETTER_AUTH_SURREAL_ADAPTER_ID);
for (const m of [
"create",
"findOne",
"findMany",
"update",
"updateMany",
"delete",
"deleteMany",
"count",
"consumeOne",
]) {
expect(
typeof (adapter as unknown as Record<string, unknown>)[m]
).toBe("function");
}
});
it("invalid options raise INVALID_INPUT carrying ZodError", {
timeout: 5000,
}, () => {
try {
createBetterAuthSurrealAdapter(
{} as unknown as Parameters<
typeof createBetterAuthSurrealAdapter
>[0]
);
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(BetterAuthSurrealError);
expect((err as BetterAuthSurrealError).code).toBe(
"BETTER_AUTH_SURREALDB.INVALID_INPUT"
);
expect((err as Error).cause).toBeInstanceOf(ZodError);
}
});
it("rejects malformed linkFields with INVALID_INPUT", {
timeout: 5000,
}, () => {
expect(() =>
createBetterAuthSurrealAdapter({
client: new FakeClient(),
linkFields: {
session: { userId: 5 as unknown as string },
},
})
).toThrow(BetterAuthSurrealError);
});
it("satisfies the BetterAuthSurrealAdapter type via expectTypeOf", {
timeout: 5000,
}, () => {
const adapter = createBetterAuthSurrealAdapter({
client: new FakeClient(),
});
expectTypeOf(adapter).toMatchTypeOf<BetterAuthSurrealAdapter>();
});
it("raises CONNECTION_NOT_READY when client is not connected", {
timeout: 5000,
}, async () => {
const client = new FakeClient();
client.isConnected = false;
const adapter = createBetterAuthSurrealAdapter({ client });
await expect(
adapter.create({
model: "user",
data: { id: "u1" } as unknown as Record<string, unknown>,
})
).rejects.toThrow(/CONNECTION_NOT_READY|not connected/i);
});
});

View File

@@ -0,0 +1,161 @@
import type { SortBy, WhereClause } from "../compile/index.js";
import { instrument } from "../otel/instruments.js";
import type { BetterAuthSurrealAdapterOptions, Row } from "../types/adapter.js";
import { normaliseOptions } from "./internals.js";
import {
runCount,
runCreate,
runDeleteMany,
runFindMany,
runUpdateMany,
} from "./operations.js";
export const BETTER_AUTH_SURREAL_ADAPTER_ID =
"@suaveplan/better-auth-surrealdb";
/**
* Public Better-Auth Adapter surface this package implements.
*
* Mirrors the 8 core methods + `id` + `consumeOne` from Better-Auth v1.4+.
*/
export interface BetterAuthSurrealAdapter {
readonly id: string;
create: <T extends Row, R = T>(args: {
model: string;
data: Omit<T, "id">;
select?: string[] | undefined;
forceAllowId?: boolean | undefined;
}) => Promise<R>;
findOne: <T>(args: {
model: string;
where: WhereClause[];
select?: string[] | undefined;
}) => Promise<T | null>;
findMany: <T>(args: {
model: string;
where?: WhereClause[] | undefined;
limit?: number | undefined;
select?: string[] | undefined;
sortBy?: SortBy | undefined;
offset?: number | undefined;
}) => Promise<T[]>;
count: (args: {
model: string;
where?: WhereClause[] | undefined;
}) => Promise<number>;
update: <T>(args: {
model: string;
where: WhereClause[];
update: Record<string, unknown>;
}) => Promise<T | null>;
updateMany: (args: {
model: string;
where: WhereClause[];
update: Record<string, unknown>;
}) => Promise<number>;
delete: <_T>(args: {
model: string;
where: WhereClause[];
}) => Promise<void>;
deleteMany: (args: {
model: string;
where: WhereClause[];
}) => Promise<number>;
consumeOne: <T>(args: {
model: string;
where: WhereClause[];
}) => Promise<T | null>;
}
/**
* Construct a Better-Auth `Adapter` backed by a SurrealDB client.
*/
export function createBetterAuthSurrealAdapter(
options: BetterAuthSurrealAdapterOptions
): BetterAuthSurrealAdapter {
const opts = normaliseOptions(options);
return {
id: BETTER_AUTH_SURREAL_ADAPTER_ID,
create: ((args: { model: string; data: Record<string, unknown> }) =>
instrument({
model: args.model,
operation: "create",
run: () => runCreate(opts, args),
})) as BetterAuthSurrealAdapter["create"],
findOne: ((args: {
model: string;
where: WhereClause[];
select?: string[] | undefined;
}) =>
instrument({
model: args.model,
operation: "findOne",
run: async () => {
const rows = await runFindMany(opts, {
...args,
limit: 1,
});
return rows.length > 0 ? rows[0] : null;
},
})) as BetterAuthSurrealAdapter["findOne"],
findMany: ((
args: Parameters<BetterAuthSurrealAdapter["findMany"]>[0]
) =>
instrument({
model: args.model,
operation: "findMany",
run: () => runFindMany(opts, args),
})) as BetterAuthSurrealAdapter["findMany"],
count: (args) =>
instrument({
model: args.model,
operation: "count",
run: () => runCount(opts, args),
}),
update: ((args: Parameters<BetterAuthSurrealAdapter["update"]>[0]) =>
instrument({
model: args.model,
operation: "update",
run: async () => {
const rows = await runUpdateMany(opts, args);
return rows.length > 0 ? rows[0] : null;
},
})) as BetterAuthSurrealAdapter["update"],
updateMany: (args) =>
instrument({
model: args.model,
operation: "updateMany",
run: async () => (await runUpdateMany(opts, args)).length,
}),
delete: ((args: { model: string; where: WhereClause[] }) =>
instrument({
model: args.model,
operation: "delete",
run: async () => {
await runDeleteMany(opts, args);
},
})) as BetterAuthSurrealAdapter["delete"],
deleteMany: (args) =>
instrument({
model: args.model,
operation: "deleteMany",
run: () => runDeleteMany(opts, args),
}),
consumeOne: ((args: { model: string; where: WhereClause[] }) =>
instrument({
model: args.model,
operation: "consumeOne",
run: async () => {
const rows = await runFindMany(opts, {
model: args.model,
where: args.where,
limit: 1,
});
if (rows.length === 0) return null;
const deleted = await runDeleteMany(opts, args);
return deleted > 0 ? rows[0] : null;
},
})) as BetterAuthSurrealAdapter["consumeOne"],
};
}

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { FakeClient } from "../../tests/utils/fake-client.js";
import { createBetterAuthSurrealAdapter } from "./adapter.js";
describe("adapter.consumeOne", () => {
it("returns null when no row matches", { timeout: 5000 }, async () => {
const fake = new FakeClient(() => []);
const adapter = createBetterAuthSurrealAdapter({ client: fake });
const out = await adapter.consumeOne({
model: "verification",
where: [{ field: "id", value: "missing" }],
});
expect(out).toBeNull();
});
it("returns the row and issues a follow-up delete", {
timeout: 5000,
}, async () => {
let phase = 0;
const fake = new FakeClient(() => {
phase++;
if (phase === 1) {
return [{ id: "verification:`v1`", identifier: "i", val: "v" }];
}
return [{ id: "verification:`v1`" }];
});
const adapter = createBetterAuthSurrealAdapter({ client: fake });
const out = (await adapter.consumeOne({
model: "verification",
where: [{ field: "id", value: "v1" }],
})) as { val: string } | null;
expect(out?.val).toBe("v");
expect(fake.log.length).toBeGreaterThanOrEqual(2);
});
it("returns null when the delete affects 0 rows (race lost)", {
timeout: 5000,
}, async () => {
let phase = 0;
const fake = new FakeClient(() => {
phase++;
if (phase === 1) {
return [{ id: "verification:`v1`", val: "v" }];
}
return [];
});
const adapter = createBetterAuthSurrealAdapter({ client: fake });
const out = await adapter.consumeOne({
model: "verification",
where: [{ field: "id", value: "v1" }],
});
expect(out).toBeNull();
});
});

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { FakeClient } from "../../tests/utils/fake-client.js";
import { BetterAuthSurrealError } from "../error-codes/error-codes.js";
import { createBetterAuthSurrealAdapter } from "./adapter.js";
describe("adapter.count", () => {
it("count without where returns total", { timeout: 5000 }, async () => {
const fake = new FakeClient(() => [{ c: 10 }]);
const adapter = createBetterAuthSurrealAdapter({ client: fake });
expect(await adapter.count({ model: "session" })).toBe(10);
expect(fake.log[0]?.sql).toContain("GROUP ALL");
});
it("count with where returns filtered (pre-resolves link value)", {
timeout: 5000,
}, async () => {
const fake = new FakeClient(() => [{ c: 3 }]);
const adapter = createBetterAuthSurrealAdapter({ client: fake });
const n = await adapter.count({
model: "session",
where: [{ field: "userId", value: "u1" }],
});
expect(n).toBe(3);
const param = fake.log[0]?.params[0];
expect(String(param)).toBe("user:u1");
});
it("count returns 0 when SurrealDB returns no group row", {
timeout: 5000,
}, async () => {
const fake = new FakeClient(() => []);
const adapter = createBetterAuthSurrealAdapter({ client: fake });
expect(await adapter.count({ model: "session" })).toBe(0);
});
it("count coerces non-numeric c via Number()", {
timeout: 5000,
}, async () => {
const fake = new FakeClient(() => [{ c: "5" }]);
const adapter = createBetterAuthSurrealAdapter({ client: fake });
expect(await adapter.count({ model: "session" })).toBe(5);
});
it("count handles missing c field as 0", { timeout: 5000 }, async () => {
const fake = new FakeClient(() => [{}]);
const adapter = createBetterAuthSurrealAdapter({ client: fake });
expect(await adapter.count({ model: "session" })).toBe(0);
});
it("count wraps query errors as QUERY_FAILED", {
timeout: 5000,
}, async () => {
const fake = new FakeClient(() => {
throw new Error("boom");
});
const adapter = createBetterAuthSurrealAdapter({ client: fake });
await expect(adapter.count({ model: "session" })).rejects.toThrow(
BetterAuthSurrealError
);
});
});

View File

@@ -0,0 +1,163 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { FakeClient } from "../../tests/utils/fake-client.js";
import { BetterAuthSurrealError } from "../error-codes/error-codes.js";
import { createBetterAuthSurrealAdapter } from "./adapter.js";
describe("adapter.create", () => {
it("create stores under table:id and strips prefix on return", {
timeout: 5000,
}, async () => {
const fake = new FakeClient((_sql, vars) => {
const data = (vars["p0"] as Record<string, unknown>) ?? {};
return [{ id: "user:`u1`", ...data }];
});
const adapter = createBetterAuthSurrealAdapter({ client: fake });
const row = (await adapter.create({
model: "user",
data: {
id: "u1",
email: "x@y",
} as unknown as Record<string, unknown>,
})) as { id: string; email: string };
expect(row.id).toBe("u1");
expect(row.email).toBe("x@y");
expect(fake.log[0]?.sql).toContain("CREATE user:`u1`");
expect(fake.log[0]?.sql).toContain("CONTENT $p0 RETURN AFTER");
});
it("session.userId write wraps into record-link form", {
timeout: 5000,
}, async () => {
const fake = new FakeClient((_sql, vars) => {
const data = (vars["p0"] as Record<string, unknown>) ?? {};
return [{ id: "session:`s1`", ...data }];
});
const adapter = createBetterAuthSurrealAdapter({ client: fake });
const row = (await adapter.create({
model: "session",
data: {
id: "s1",
userId: "u1",
token: "t",
} as unknown as Record<string, unknown>,
})) as { id: string; userId: string };
const userIdParam = (fake.log[0]?.params[0] as { userId: unknown })
?.userId;
expect(String(userIdParam)).toBe("user:u1");
expect(row.userId).toBe("u1");
expect(row.id).toBe("s1");
});
it("ulid strategy fills missing id", { timeout: 5000 }, async () => {
const fake = new FakeClient((_sql, vars) => {
const data = (vars["p0"] as Record<string, unknown>) ?? {};
return [{ ...data }];
});
const adapter = createBetterAuthSurrealAdapter({
client: fake,
idStrategy: "ulid",
});
const row = (await adapter.create({
model: "session",
data: {
userId: "u1",
} as unknown as Record<string, unknown>,
})) as { id: string };
expect(row.id).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/);
});
it("account.userId is not prefixed under default config", {
timeout: 5000,
}, async () => {
const fake = new FakeClient((_sql, vars) => {
const data = (vars["p0"] as Record<string, unknown>) ?? {};
return [{ id: "account:`a1`", ...data }];
});
const adapter = createBetterAuthSurrealAdapter({ client: fake });
await adapter.create({
model: "account",
data: {
id: "a1",
userId: "u1",
providerId: "p",
accountId: "a",
password: "",
} as unknown as Record<string, unknown>,
});
expect((fake.log[0]?.params[0] as { userId: string })?.userId).toBe(
"u1"
);
});
it("empty linkFields disables prefix manipulation", {
timeout: 5000,
}, async () => {
const fake = new FakeClient((_sql, vars) => {
const data = (vars["p0"] as Record<string, unknown>) ?? {};
return [{ id: "session:`s1`", ...data }];
});
const adapter = createBetterAuthSurrealAdapter({
client: fake,
linkFields: {},
});
await adapter.create({
model: "session",
data: {
id: "s1",
userId: "u1",
} as unknown as Record<string, unknown>,
});
expect((fake.log[0]?.params[0] as { userId: string })?.userId).toBe(
"u1"
);
});
it("plain strategy stores id as a field", { timeout: 5000 }, async () => {
const fake = new FakeClient((_sql, vars) => {
const data = (vars["p0"] as Record<string, unknown>) ?? {};
return [{ ...data }];
});
const adapter = createBetterAuthSurrealAdapter({
client: fake,
idStrategy: "plain",
});
const row = (await adapter.create({
model: "user",
data: {
id: "u1",
email: "x@y",
} as unknown as Record<string, unknown>,
})) as { id: string };
expect(row.id).toBe("u1");
expect((fake.log[0]?.params[0] as { id: string })?.id).toBe("u1");
expect(fake.log[0]?.sql).toContain("CREATE user CONTENT");
});
it("create throws QUERY_FAILED when underlying query rejects", {
timeout: 5000,
}, async () => {
const fake = new FakeClient(() => {
throw new Error("kaput");
});
const adapter = createBetterAuthSurrealAdapter({ client: fake });
await expect(
adapter.create({
model: "user",
data: { id: "u1" } as unknown as Record<string, unknown>,
})
).rejects.toThrow(BetterAuthSurrealError);
});
it("create throws QUERY_FAILED when query returns no rows", {
timeout: 5000,
}, async () => {
const fake = new FakeClient(() => []);
const adapter = createBetterAuthSurrealAdapter({ client: fake });
await expect(
adapter.create({
model: "user",
data: { id: "u1" } as unknown as Record<string, unknown>,
})
).rejects.toThrow(/no rows/);
});
});

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { FakeClient } from "../../tests/utils/fake-client.js";
import { BetterAuthSurrealError } from "../error-codes/error-codes.js";
import { createBetterAuthSurrealAdapter } from "./adapter.js";
describe("adapter.delete / deleteMany", () => {
it("delete removes the row", { timeout: 5000 }, async () => {
const fake = new FakeClient(() => [{ id: "session:`s1`" }]);
const adapter = createBetterAuthSurrealAdapter({ client: fake });
const out = await adapter.delete({
model: "session",
where: [{ field: "id", value: "s1" }],
});
expect(out).toBeUndefined();
expect(fake.log[0]?.sql).toContain("DELETE session");
});
it("deleteMany returns affected count via RETURN BEFORE row stream", {
timeout: 5000,
}, async () => {
const fake = new FakeClient(() => [
{ id: "session:`s1`" },
{ id: "session:`s2`" },
{ id: "session:`s3`" },
{ id: "session:`s4`" },
]);
const adapter = createBetterAuthSurrealAdapter({ client: fake });
const count = await adapter.deleteMany({
model: "session",
where: [{ field: "expiresAt", value: 0, operator: "lt" }],
});
expect(count).toBe(4);
});
it("deleteMany with empty where omits WHERE clause", {
timeout: 5000,
}, async () => {
const fake = new FakeClient(() => []);
const adapter = createBetterAuthSurrealAdapter({ client: fake });
await adapter.deleteMany({ model: "user", where: [] });
expect(fake.log[0]?.sql).not.toContain("WHERE");
});
it("delete wraps query errors as QUERY_FAILED", {
timeout: 5000,
}, async () => {
const fake = new FakeClient(() => {
throw new Error("nope");
});
const adapter = createBetterAuthSurrealAdapter({ client: fake });
await expect(
adapter.delete({
model: "session",
where: [{ field: "id", value: "s1" }],
})
).rejects.toThrow(BetterAuthSurrealError);
});
});

View File

@@ -0,0 +1,87 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { FakeClient } from "../../tests/utils/fake-client.js";
import { BetterAuthSurrealError } from "../error-codes/error-codes.js";
import { createBetterAuthSurrealAdapter } from "./adapter.js";
describe("adapter.findOne / findMany", () => {
it("findOne returns null on no match", { timeout: 5000 }, async () => {
const adapter = createBetterAuthSurrealAdapter({
client: new FakeClient(() => []),
});
const result = await adapter.findOne({
model: "user",
where: [{ field: "email", value: "missing@x" }],
});
expect(result).toBeNull();
});
it("findOne returns the first row stripped", {
timeout: 5000,
}, async () => {
const fake = new FakeClient(() => [{ id: "user:`u1`", email: "x@y" }]);
const adapter = createBetterAuthSurrealAdapter({ client: fake });
const row = await adapter.findOne<{ id: string; email: string }>({
model: "user",
where: [{ field: "email", value: "x@y" }],
});
expect(row).toEqual({ id: "u1", email: "x@y" });
});
it("where-filter pre-resolves link prefix", { timeout: 5000 }, async () => {
const fake = new FakeClient(() => []);
const adapter = createBetterAuthSurrealAdapter({ client: fake });
await adapter.findMany({
model: "session",
where: [{ field: "userId", value: "u1" }],
});
const param = fake.log[0]?.params[0];
expect(String(param)).toBe("user:u1");
});
it("findMany honours limit/offset/sortBy", { timeout: 5000 }, async () => {
const fake = new FakeClient(() => []);
const adapter = createBetterAuthSurrealAdapter({ client: fake });
await adapter.findMany({
model: "user",
where: [{ field: "email", value: "x" }],
limit: 5,
offset: 2,
sortBy: { field: "createdAt", direction: "desc" },
select: ["id", "email"],
});
const sql = fake.log[0]?.sql ?? "";
expect(sql).toContain("SELECT id, email FROM user");
expect(sql).toContain("WHERE email = $p0");
expect(sql).toContain("ORDER BY createdAt DESC");
expect(sql).toContain("LIMIT 5");
expect(sql).toContain("START 2");
});
it("findMany with no where works", { timeout: 5000 }, async () => {
const fake = new FakeClient(() => []);
const adapter = createBetterAuthSurrealAdapter({ client: fake });
await adapter.findMany({ model: "user" });
expect(fake.log[0]?.sql).toContain("SELECT * FROM user");
});
it("findMany surfaces query errors as QUERY_FAILED", {
timeout: 5000,
}, async () => {
const fake = new FakeClient(() => {
throw new Error("nope");
});
const adapter = createBetterAuthSurrealAdapter({ client: fake });
await expect(adapter.findMany({ model: "user" })).rejects.toThrow(
BetterAuthSurrealError
);
});
it("findMany with offset === 0 omits START clause", {
timeout: 5000,
}, async () => {
const fake = new FakeClient(() => []);
const adapter = createBetterAuthSurrealAdapter({ client: fake });
await adapter.findMany({ model: "user", offset: 0 });
expect(fake.log[0]?.sql).not.toContain("START");
});
});

View File

@@ -0,0 +1,13 @@
export {
BETTER_AUTH_SURREAL_ADAPTER_ID,
type BetterAuthSurrealAdapter,
createBetterAuthSurrealAdapter,
} from "./adapter.js";
export {
ModelToTableCache,
type ModelToTableResolver,
} from "./model-to-table.js";
export {
type BetterAuthSurrealAdapterV16Options,
betterAuthSurrealAdapter,
} from "./v1.6.js";

View File

@@ -0,0 +1,66 @@
# adapter/internals
## Purpose
Shared internal helpers consumed by the adapter shell (`adapter.ts`) and the
per-method operation runners (`operations.ts`). Encapsulates option
normalisation (Zod validation + default fill-in), connection-readiness
assertion, parameter packaging for the SurrealDB client surface, error
wrapping, and the read-side row post-processing that strips link prefixes
and normalises the returned `id`.
## Features
- `normaliseOptions(options): NormalisedOptions` — Zod-parses the boundary
options, applies the default `LinkFieldMap` when none supplied, defaults
`idStrategy` to `"record-id"`, instantiates a `ModelToTableCache`.
- `ensureConnected(client)` — throws `CONNECTION_NOT_READY` when the client
reports `isConnected === false`.
- `paramsFromVars(vars)` — packs the binding record as a single trailing
argument so both `SurrealAdapter.query` and bespoke `SurrealClientView`
implementations see identical call shapes.
- `wrapQuery(err)` — converts an underlying `SurrealError` (or any throw) to
`BetterAuthSurrealError(QUERY_FAILED)` preserving the cause chain.
- `postRead(row, table, linkFields, model, idStrategy)` — strips link
prefixes, normalises the returned `id` per the strategy.
## Usage: Basic
```ts
import { normaliseOptions, ensureConnected } from "./internals.js";
const opts = normaliseOptions(userSuppliedOptions);
ensureConnected(opts.client);
```
## Usage: Advanced
```ts
import { wrapQuery, postRead } from "./internals.js";
try {
const rows = await opts.client.query("SELECT * FROM user;");
return rows.map((r) => postRead(r, "user", opts.linkFields, "user", opts.idStrategy));
} catch (err) {
wrapQuery(err); // never returns
}
```
## API Reference
- `interface NormalisedOptions { client; idStrategy; linkFields; modelCache; }`
- `normaliseOptions(opts): NormalisedOptions`
- `ensureConnected(client): void`
- `paramsFromVars(vars): unknown[]`
- `wrapQuery(err): never`
- `postRead(row, table, linkFields, model, idStrategy): Row`
## Implementation Notes
`normaliseOptions` re-validates through Zod on every call so test seams
that mutate options between adapter instantiations still surface boundary
issues. The function returns the originally-supplied `client` reference
(not Zod's parsed clone) to preserve method identity — Zod's structural
`custom` validator does not reconstruct the input. `wrapQuery` is typed
`never` so callers can call it inside a `try/catch` and TypeScript still
narrows the surrounding block to the success path.

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { FakeClient } from "../../tests/utils/fake-client.js";
import { BetterAuthSurrealError } from "../error-codes/error-codes.js";
import { createBetterAuthSurrealAdapter } from "./adapter.js";
describe("adapter internals — error wrapping", () => {
it("wrapQuery rethrows BetterAuthSurrealError unchanged", {
timeout: 5000,
}, async () => {
const passthrough = new BetterAuthSurrealError(
"BETTER_AUTH_SURREALDB.INVALID_LINK_FIELD_VALUE",
"passthrough"
);
const fake = new FakeClient(() => {
throw passthrough;
});
const adapter = createBetterAuthSurrealAdapter({ client: fake });
try {
await adapter.findMany({ model: "user" });
expect.fail("should have thrown");
} catch (err) {
expect(err).toBe(passthrough);
}
});
it("wrapQuery coerces non-Error throws via String()", {
timeout: 5000,
}, async () => {
const fake = new FakeClient(() => {
throw "boom-string";
});
const adapter = createBetterAuthSurrealAdapter({ client: fake });
await expect(adapter.findMany({ model: "user" })).rejects.toMatchObject(
{
code: "BETTER_AUTH_SURREALDB.QUERY_FAILED",
message: "boom-string",
}
);
});
});

View File

@@ -0,0 +1,114 @@
import { SurrealError } from "@suaveplan/surrealdb";
import {
BETTER_AUTH_SURREALDB_CODES,
BetterAuthSurrealError,
} from "../error-codes/error-codes.js";
import { normaliseReturnedId } from "../id-strategy/id-strategy.js";
import {
DEFAULT_BETTER_AUTH_LINK_FIELDS,
stripLinkPrefixesFromRead,
} from "../link-fields/link-fields.js";
import { BetterAuthSurrealAdapterOptionsSchema } from "../schemas/options.js";
import type {
BetterAuthSurrealAdapterOptions,
IdStrategy,
LinkFieldMap,
Row,
SurrealClientView,
} from "../types/adapter.js";
import { ModelToTableCache } from "./model-to-table.js";
export interface NormalisedOptions {
readonly client: SurrealClientView;
readonly idStrategy: IdStrategy;
readonly linkFields: LinkFieldMap;
readonly modelCache: ModelToTableCache;
}
export function normaliseOptions(
options: BetterAuthSurrealAdapterOptions
): NormalisedOptions {
const parsed = BetterAuthSurrealAdapterOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new BetterAuthSurrealError(
BETTER_AUTH_SURREALDB_CODES.INVALID_INPUT,
"createBetterAuthSurrealAdapter received invalid options",
{ cause: parsed.error }
);
}
return {
client: options.client as SurrealClientView,
idStrategy: options.idStrategy ?? "record-id",
linkFields: options.linkFields ?? DEFAULT_BETTER_AUTH_LINK_FIELDS,
modelCache: new ModelToTableCache(options.modelToTable),
};
}
export function ensureConnected(client: SurrealClientView): void {
if (!client.isConnected) {
throw new BetterAuthSurrealError(
BETTER_AUTH_SURREALDB_CODES.CONNECTION_NOT_READY,
"SurrealDB client is not connected — call .connect() first"
);
}
}
/**
* Compile a named-placeholder binding record into the positional shape
* `SurrealAdapter.query()` expects.
*
* `SurrealAdapter` flattens positional params into `$p0/$p1/...` named
* bindings. We accept named placeholders for ergonomics (the WHERE
* compiler emits `$w0/$w1/...`, CREATE emits `$data`, UPDATE emits
* `$update`+`$p0/$p1/...`), then rewrite the SQL to use `$pN` while
* passing the values in the matching positional order.
*
* Returns `{ sql, params }` ready for `client.query(sql, params)`.
*/
export function bindNamed(
sql: string,
vars: Record<string, unknown>
): { sql: string; params: unknown[] } {
const names = Object.keys(vars);
if (names.length === 0) return { sql, params: [] };
let rewritten = sql;
const params: unknown[] = [];
for (let i = 0; i < names.length; i++) {
const name = names[i] as string;
// Replace `$name` token boundaries — `\b` doesn't match `$`,
// so anchor on the dollar literal and the next non-identifier char.
const re = new RegExp(`\\$${name}\\b`, "g");
rewritten = rewritten.replace(re, `$p${i}`);
params.push(vars[name]);
}
return { sql: rewritten, params };
}
export function wrapQuery(err: unknown): never {
if (err instanceof BetterAuthSurrealError) throw err;
if (err instanceof SurrealError) {
throw new BetterAuthSurrealError(
BETTER_AUTH_SURREALDB_CODES.QUERY_FAILED,
"SurrealDB query failed",
{ cause: err }
);
}
throw new BetterAuthSurrealError(
BETTER_AUTH_SURREALDB_CODES.QUERY_FAILED,
err instanceof Error ? err.message : String(err),
{ cause: err }
);
}
export function postRead(
row: Row,
table: string,
linkFields: LinkFieldMap,
model: string,
idStrategy: IdStrategy
): Row {
const stripped = stripLinkPrefixesFromRead(linkFields, model, row);
if (idStrategy === "plain") return stripped;
const id = normaliseReturnedId(stripped["id"], table);
return { ...stripped, id };
}

View File

@@ -0,0 +1,72 @@
# adapter/model-to-table
## Purpose
Memoising mapper from Better-Auth model name to SurrealDB table name. The
mapper function is invoked once per distinct model and the resolved name
is validated against the SurrealQL identifier rule
(`/^[a-zA-Z_][a-zA-Z0-9_]*$/`). Caching avoids re-running operator-supplied
prefix logic on every call site while validation prevents accidental
injection through a hand-rolled `modelToTable` that returns SurrealQL
syntax.
## Features
- Identity-default — `new ModelToTableCache()` maps model→model verbatim.
- Caches resolved name in a `Map<string,string>`.
- Identifier-rule validation: invalid names raise `INVALID_MODEL` on first
use of the offending model.
- Decoupled from the adapter shell so unit tests can exercise mapping
semantics in isolation.
## Usage: Basic
```ts
import { ModelToTableCache } from "@suaveplan/better-auth-surrealdb/adapter";
const cache = new ModelToTableCache();
cache.resolve("user"); // "user"
cache.resolve("session"); // "session"
```
## Usage: Advanced
```ts
import { ModelToTableCache } from "@suaveplan/better-auth-surrealdb/adapter";
let calls = 0;
const cache = new ModelToTableCache((m) => {
calls++;
return `bauth_${m}`;
});
cache.resolve("user"); // "bauth_user" — calls === 1
cache.resolve("user"); // "bauth_user" — calls still 1 (cached)
cache.resolve("session"); // "bauth_session" — calls === 2
```
## API Reference
- `class ModelToTableCache`
- `constructor(resolver?: ModelToTableResolver)` — defaults to identity.
- `resolve(model: string): string` — cached + validated.
- `type ModelToTableResolver = (model: string) => string`.
## Implementation Notes
The cache is a plain `Map` because invalidation is unnecessary: model
names are stable Better-Auth contracts and the adapter is constructed per
process. Throwing on first use of an invalid name (rather than at adapter
construction) preserves the "lazy" semantics so callers wiring a
multi-tenant prefix function can return different names per request
without paying the validation cost upfront. The identifier check uses
the same regex as every other validator in this package
(`/^[a-zA-Z_][a-zA-Z0-9_]*$/`) so operators get consistent feedback when
a name shape is wrong — whether the offending input is a model alias, a
SurrealDB table name, a link-field target, or a `select` projection
entry.
The class field is `protected` rather than `private` so subclasses can
introspect the cache during debugging without paying the indirection cost
of an additional accessor method — useful when wiring per-tenant audit
trails that need to log every resolved table name on first use.

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { BetterAuthSurrealError } from "../error-codes/error-codes.js";
import { ModelToTableCache } from "./model-to-table.js";
describe("ModelToTableCache", () => {
it("defaults to identity mapping", { timeout: 5000 }, () => {
const cache = new ModelToTableCache();
expect(cache.resolve("user")).toBe("user");
});
it("custom mapping prefixes tables", { timeout: 5000 }, () => {
const cache = new ModelToTableCache((m) => `bauth_${m}`);
expect(cache.resolve("user")).toBe("bauth_user");
});
it("mapping cached after first call", { timeout: 5000 }, () => {
let calls = 0;
const cache = new ModelToTableCache((m) => {
calls++;
return m;
});
cache.resolve("user");
cache.resolve("user");
cache.resolve("user");
expect(calls).toBe(1);
});
it("invalid table name raises INVALID_MODEL", { timeout: 5000 }, () => {
const cache = new ModelToTableCache(() => "weird-name");
expect(() => cache.resolve("user")).toThrow(BetterAuthSurrealError);
try {
cache.resolve("user");
} catch (err) {
expect((err as BetterAuthSurrealError).code).toBe(
"BETTER_AUTH_SURREALDB.INVALID_MODEL"
);
}
});
});

View File

@@ -0,0 +1,36 @@
import {
BETTER_AUTH_SURREALDB_CODES,
BetterAuthSurrealError,
} from "../error-codes/error-codes.js";
const IDENT = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
/**
* Per-adapter caching mapper from Better-Auth model name → SurrealDB
* table name. The mapper is invoked once per distinct model and the
* resolved name is validated against the identifier rule.
*/
export type ModelToTableResolver = (model: string) => string;
export class ModelToTableCache {
protected readonly resolver: ModelToTableResolver;
protected readonly cache = new Map<string, string>();
public constructor(resolver: ModelToTableResolver = (m) => m) {
this.resolver = resolver;
}
public resolve(model: string): string {
const hit = this.cache.get(model);
if (hit !== undefined) return hit;
const out = this.resolver(model);
if (!IDENT.test(out)) {
throw new BetterAuthSurrealError(
BETTER_AUTH_SURREALDB_CODES.INVALID_MODEL,
`modelToTable(${model}) returned "${out}" which is not a valid SurrealQL identifier`
);
}
this.cache.set(model, out);
return out;
}
}

View File

@@ -0,0 +1,73 @@
# adapter/operations
## Purpose
Hosts the five primitive runners (`runCreate`, `runFindMany`,
`runUpdateMany`, `runDeleteMany`, `runCount`) that translate Better-Auth
method arguments into parameterised SurrealQL and dispatch through the
configured `SurrealClientView`. Decoupling the runners from the
`createBetterAuthSurrealAdapter` shell keeps each Better-Auth public method
trivially thin (it composes runners + OTel instrumentation) and makes per-
operation behaviour testable in isolation.
## Features
- `runCreate(opts, { model, data })` — `CREATE <target> CONTENT $data RETURN
AFTER`. Applies link-field prefixes before binding and strips them on
return. Honours the configured `idStrategy`.
- `runFindMany(opts, args)` — full `SELECT … FROM <table> [WHERE …] [ORDER
BY …] [LIMIT …] [START …]` pipeline. Empty trailing clauses are elided
by post-template whitespace collapse.
- `runUpdateMany(opts, args)` — `UPDATE <table> MERGE $update [WHERE …]
RETURN AFTER` with predicate placeholders namespaced `$p0/$p1/…` to
avoid colliding with the `$update` MERGE binding.
- `runDeleteMany(opts, args)` — `DELETE <table> [WHERE …] RETURN BEFORE`;
returns the number of pre-image rows so the public `delete`/`deleteMany`
methods can derive their semantics.
- `runCount(opts, args)` — `SELECT count() AS c FROM <table> [WHERE …]
GROUP ALL`; tolerates SurrealDB returning an empty result set or a
string-typed count.
## Usage: Basic
```ts
import { runCreate, runFindMany } from "./operations.js";
const row = await runCreate(opts, { model: "user", data: { id: "u1" } });
const list = await runFindMany(opts, {
model: "user",
where: [{ field: "email", value: "x" }],
});
```
## Usage: Advanced
```ts
import { runCount, runUpdateMany } from "./operations.js";
const updated = await runUpdateMany(opts, {
model: "session",
where: [{ field: "expiresAt", value: now, operator: "lt" }],
update: { active: false },
});
const total = await runCount(opts, { model: "session" });
```
## API Reference
- `runCreate(opts, args): Promise<Row>`
- `runFindMany(opts, args): Promise<Row[]>`
- `runUpdateMany(opts, args): Promise<Row[]>`
- `runDeleteMany(opts, args): Promise<number>`
- `runCount(opts, args): Promise<number>`
## Implementation Notes
All runners single-step replace whitespace runs with one space then trim
to keep generated SurrealQL readable in EXPLAIN output without sprinkling
conditionals at every splice site. `runUpdateMany` uses an explicit
`placeholderPrefix: "p"` so the merged binding object can keep `$update`
disjoint from the predicate bindings. `runCount` aliases `count() AS c`
because SurrealDB v3's `GROUP ALL` projection returns the aggregate under
the function name otherwise, which is fragile across point releases.

View File

@@ -0,0 +1,198 @@
import {
compileSelect,
compileSortBy,
compileWhere,
type SortBy,
type WhereClause,
} from "../compile/index.js";
import {
BETTER_AUTH_SURREALDB_CODES,
BetterAuthSurrealError,
} from "../error-codes/error-codes.js";
import { prepareCreate } from "../id-strategy/id-strategy.js";
import { applyLinkPrefixesToWrite } from "../link-fields/link-fields.js";
import type { Row } from "../types/adapter.js";
import {
bindNamed,
ensureConnected,
type NormalisedOptions,
postRead,
wrapQuery,
} from "./internals.js";
export async function runCreate(
opts: NormalisedOptions,
args: {
model: string;
data: Record<string, unknown>;
}
): Promise<Row> {
ensureConnected(opts.client);
const tbl = opts.modelCache.resolve(args.model);
const withLinks = applyLinkPrefixesToWrite(
opts.linkFields,
args.model,
args.data
);
const prepared = prepareCreate(opts.idStrategy, tbl, withLinks);
const compiled = bindNamed(
`CREATE ${prepared.target} CONTENT $data RETURN AFTER;`,
{ data: prepared.data }
);
let rows: Row[];
try {
rows = await opts.client.query<Row>(compiled.sql, compiled.params);
} catch (err) {
wrapQuery(err);
}
const first = rows[0];
if (first === undefined) {
throw new BetterAuthSurrealError(
BETTER_AUTH_SURREALDB_CODES.QUERY_FAILED,
`CREATE on ${tbl} returned no rows`
);
}
const out = postRead(
first,
tbl,
opts.linkFields,
args.model,
opts.idStrategy
);
if (opts.idStrategy !== "plain") {
out["id"] = prepared.assignedId as string;
}
return out;
}
export async function runFindMany(
opts: NormalisedOptions,
args: {
model: string;
where?: WhereClause[] | undefined;
limit?: number | undefined;
select?: string[] | undefined;
sortBy?: SortBy | undefined;
offset?: number | undefined;
}
): Promise<Row[]> {
ensureConnected(opts.client);
const tbl = opts.modelCache.resolve(args.model);
const projection = compileSelect(args.select);
const compiled = compileWhere(args.where ?? [], {
model: args.model,
linkFields: opts.linkFields,
});
const whereFragment =
compiled.clause.length > 0 ? `WHERE ${compiled.clause}` : "";
const orderFragment = compileSortBy(args.sortBy);
const limitFragment =
args.limit !== undefined ? `LIMIT ${Math.floor(args.limit)}` : "";
const startFragment =
args.offset !== undefined && args.offset > 0
? `START ${Math.floor(args.offset)}`
: "";
const sql =
`SELECT ${projection} FROM ${tbl} ${whereFragment} ${orderFragment} ${limitFragment} ${startFragment};`
.replace(/\s+/g, " ")
.trim();
const bound = bindNamed(sql, compiled.vars);
let rows: Row[];
try {
rows = await opts.client.query<Row>(bound.sql, bound.params);
} catch (err) {
wrapQuery(err);
}
return rows.map((r) =>
postRead(r, tbl, opts.linkFields, args.model, opts.idStrategy)
);
}
export async function runUpdateMany(
opts: NormalisedOptions,
args: {
model: string;
where: WhereClause[];
update: Record<string, unknown>;
}
): Promise<Row[]> {
ensureConnected(opts.client);
const tbl = opts.modelCache.resolve(args.model);
const merge = applyLinkPrefixesToWrite(
opts.linkFields,
args.model,
args.update
);
const compiled = compileWhere(args.where, {
model: args.model,
linkFields: opts.linkFields,
});
const whereFragment =
compiled.clause.length > 0 ? `WHERE ${compiled.clause}` : "";
const sql = `UPDATE ${tbl} MERGE $update ${whereFragment} RETURN AFTER;`
.replace(/\s+/g, " ")
.trim();
const bound = bindNamed(sql, { update: merge, ...compiled.vars });
let rows: Row[];
try {
rows = await opts.client.query<Row>(bound.sql, bound.params);
} catch (err) {
wrapQuery(err);
}
return rows.map((r) =>
postRead(r, tbl, opts.linkFields, args.model, opts.idStrategy)
);
}
export async function runDeleteMany(
opts: NormalisedOptions,
args: { model: string; where: WhereClause[] }
): Promise<number> {
ensureConnected(opts.client);
const tbl = opts.modelCache.resolve(args.model);
const compiled = compileWhere(args.where, {
model: args.model,
linkFields: opts.linkFields,
});
const whereFragment =
compiled.clause.length > 0 ? `WHERE ${compiled.clause}` : "";
const sql = `DELETE ${tbl} ${whereFragment} RETURN BEFORE;`
.replace(/\s+/g, " ")
.trim();
const bound = bindNamed(sql, compiled.vars);
let rows: Row[];
try {
rows = await opts.client.query<Row>(bound.sql, bound.params);
} catch (err) {
wrapQuery(err);
}
return rows.length;
}
export async function runCount(
opts: NormalisedOptions,
args: { model: string; where?: WhereClause[] | undefined }
): Promise<number> {
ensureConnected(opts.client);
const tbl = opts.modelCache.resolve(args.model);
const compiled = compileWhere(args.where ?? [], {
model: args.model,
linkFields: opts.linkFields,
});
const whereFragment =
compiled.clause.length > 0 ? `WHERE ${compiled.clause}` : "";
const sql = `SELECT count() AS c FROM ${tbl} ${whereFragment} GROUP ALL;`
.replace(/\s+/g, " ")
.trim();
const bound = bindNamed(sql, compiled.vars);
let rows: Row[];
try {
rows = await opts.client.query<Row>(bound.sql, bound.params);
} catch (err) {
wrapQuery(err);
}
const first = rows[0];
if (first === undefined) return 0;
const raw = (first as { c?: unknown }).c;
return typeof raw === "number" ? raw : Number(raw ?? 0);
}

View File

@@ -0,0 +1,110 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { FakeClient } from "../../tests/utils/fake-client.js";
import { BetterAuthSurrealError } from "../error-codes/error-codes.js";
import { createBetterAuthSurrealAdapter } from "./adapter.js";
describe("adapter.update / updateMany", () => {
it("update returns merged row", { timeout: 5000 }, async () => {
const fake = new FakeClient((_sql, vars) => {
const update = vars["p0"] as Record<string, unknown>;
return [{ id: "user:`u1`", email: update["email"] }];
});
const adapter = createBetterAuthSurrealAdapter({ client: fake });
const row = await adapter.update<{ id: string; email: string }>({
model: "user",
where: [{ field: "id", value: "u1" }],
update: { email: "new" },
});
expect(row).toEqual({ id: "u1", email: "new" });
});
it("update returns null when no rows match", {
timeout: 5000,
}, async () => {
const fake = new FakeClient(() => []);
const adapter = createBetterAuthSurrealAdapter({ client: fake });
const out = await adapter.update({
model: "user",
where: [{ field: "id", value: "missing" }],
update: { email: "x" },
});
expect(out).toBeNull();
});
it("update applies link-field prefix on merge payload", {
timeout: 5000,
}, async () => {
const fake = new FakeClient(() => [{ id: "session:`s1`" }]);
const adapter = createBetterAuthSurrealAdapter({ client: fake });
await adapter.update({
model: "session",
where: [{ field: "id", value: "s1" }],
update: { userId: "u9" },
});
const update = fake.log[0]?.params[0] as { userId: unknown };
expect(String(update.userId)).toBe("user:u9");
});
it("updateMany returns affected count", { timeout: 5000 }, async () => {
const fake = new FakeClient(() => [
{ id: "u:1" },
{ id: "u:2" },
{ id: "u:3" },
]);
const adapter = createBetterAuthSurrealAdapter({ client: fake });
const count = await adapter.updateMany({
model: "user",
where: [{ field: "role", value: "free" }],
update: { role: "pro" },
});
expect(count).toBe(3);
});
it("update binds the merge payload first and predicates after", {
timeout: 5000,
}, async () => {
const fake = new FakeClient(() => [{ id: "user:`u1`" }]);
const adapter = createBetterAuthSurrealAdapter({ client: fake });
await adapter.update({
model: "user",
where: [{ field: "id", value: "u1" }],
update: { email: "new" },
});
const sql = fake.log[0]?.sql ?? "";
const params = fake.log[0]?.params ?? [];
// $update was bound first (positional index 0), then $p0 (first where).
expect(sql).toContain("MERGE $p0");
expect(sql).toContain("WHERE id = $p1");
expect(params[0]).toEqual({ email: "new" });
expect(String(params[1])).toBe("user:u1");
});
it("updateMany with empty where omits WHERE clause", {
timeout: 5000,
}, async () => {
const fake = new FakeClient(() => []);
const adapter = createBetterAuthSurrealAdapter({ client: fake });
await adapter.updateMany({
model: "user",
where: [],
update: { name: "X" },
});
expect(fake.log[0]?.sql).not.toContain("WHERE");
});
it("update wraps underlying errors as QUERY_FAILED", {
timeout: 5000,
}, async () => {
const fake = new FakeClient(() => {
throw new Error("boom");
});
const adapter = createBetterAuthSurrealAdapter({ client: fake });
await expect(
adapter.update({
model: "user",
where: [{ field: "id", value: "u1" }],
update: { email: "new" },
})
).rejects.toThrow(BetterAuthSurrealError);
});
});

View File

@@ -0,0 +1,121 @@
# `betterAuthSurrealAdapter` — Better-Auth v1.6 factory
## Purpose
`betterAuthSurrealAdapter(options)` is the Better-Auth **v1.6** integration
surface for `@suaveplan/better-auth-surrealdb`. It returns a
`DBAdapterInstance` — a function `(BetterAuthOptions) => DBAdapter` —
that `betterAuth({ database })` accepts directly. The factory is a thin,
type-safe wrapper around `createBetterAuthSurrealAdapter` (the v1.4 shape
this package has always exported) plus `createAdapterFactory` from
`@better-auth/core/db`, the official escape-hatch Better-Auth provides for
custom database adapters.
The wrapper does no real work of its own beyond schema validation,
forwarding, transaction setup, and exposing the bundled SurrealQL DDL via
`createSchema()` so Better-Auth's CLI generator (`@better-auth/cli
generate`) can emit a canonical schema file.
## Features
- Better-Auth v1.6 `DBAdapterInstance` return type — works with
`betterAuth({ database: betterAuthSurrealAdapter({ client }) })` out of
the box.
- Same `BetterAuthSurrealAdapterOptions` shape and Zod schema as the v1.4
surface — `client`, `modelToTable`, `idStrategy`, `linkFields`,
`logger` are all honoured identically.
- Transaction support: when `options.client` is a `SurrealAdapter`, the
factory wires `config.transaction` to delegate into
`SurrealAdapter.transaction` (a `BEGIN/COMMIT/CANCEL TRANSACTION`
block). The transaction-scoped `DBTransactionAdapter` is built by
re-instantiating the underlying adapter against the transaction's
`DatabaseLike` handle so writes inside `transaction(fn)` participate in
the surrounding transaction.
- `createSchema()` returns `BETTER_AUTH_SURREAL_SCHEMA` verbatim — the
same 17-table DDL string this package ships under `./schema`. Wrapped
in `DBAdapterSchemaCreation` shape (`{ code, path: "./schema.surql",
overwrite: true }`).
- OTel: emits a `better_auth.surrealdb.operations_total{model:
"_transaction", operation: "transaction", outcome}` counter point per
`transaction(...)` invocation. The underlying CRUD methods continue to
emit their own per-operation counter points unchanged; the wrapper
layer never double-counts.
## Usage: Basic
```ts
import { SurrealAdapter } from "@suaveplan/surrealdb";
import { betterAuthSurrealAdapter } from "@suaveplan/better-auth-surrealdb/adapter/v1.6";
import { applyBetterAuthSchema } from "@suaveplan/better-auth-surrealdb/schema";
import { betterAuth } from "better-auth";
const client = new SurrealAdapter({
url: "ws://localhost:8000",
namespace: "auth",
database: "auth",
username: "root",
password: process.env.SURREAL_PASSWORD,
});
await client.connect();
await applyBetterAuthSchema(client);
export const auth = betterAuth({
database: betterAuthSurrealAdapter({ client }),
secret: process.env.BETTER_AUTH_SECRET,
baseURL: "http://localhost:3000",
emailAndPassword: { enabled: true },
});
```
## Usage: Advanced
```ts
import { betterAuthSurrealAdapter } from "@suaveplan/better-auth-surrealdb/adapter/v1.6";
// Custom model-to-table mapping + link-field overrides
const adapter = betterAuthSurrealAdapter({
client,
modelToTable: (model) => (model === "user" ? "people" : model),
linkFields: {
session: { userId: "people" },
member: { userId: "people", organizationId: "tenant" },
},
idStrategy: "ulid",
});
// Pass to Better-Auth — supports the full v1.6 surface including
// `auth.api.signUpEmail`, `auth.api.signInEmail`, `auth.api.getSession`,
// transactions, schema generation via `@better-auth/cli generate`, etc.
```
## API Reference
- `betterAuthSurrealAdapter(options: BetterAuthSurrealAdapterV16Options):
DBAdapterInstance` — the factory. Options are validated through
`BetterAuthSurrealAdapterOptionsSchema`; failures throw
`BetterAuthSurrealError(BETTER_AUTH_SURREALDB_INVALID_INPUT)`.
- `BetterAuthSurrealAdapterV16Options` — type alias for
`BetterAuthSurrealAdapterOptions`. Re-exported for clarity at the v1.6
call site.
## Implementation Notes
- The wrapper construction is two-step: (1) build the 10-method underlying
adapter via `createBetterAuthSurrealAdapter` once at factory call time;
(2) inside the per-call `(options) => DBAdapter` returned by
`createAdapterFactory`, return a `CustomAdapter` whose methods forward
to the cached underlying adapter. Better-Auth's factory adds
`transformInput`/`transformOutput` field whitelisting, default-value
application, and date/boolean/JSON normalisation on top — that's the
whole point of the wrapper layer.
- `disableIdGeneration: true` is set in the factory config so user-provided
`id` values survive Better-Auth's id-handling layer. The underlying
adapter still applies its `idStrategy` (record-id / plain / ulid).
- `create` calls forward with `forceAllowId: true` so the user-supplied
`id` field is preserved end-to-end (Better-Auth otherwise nulls it on
the v1.6 `create` path).
- `transaction` is only wired when the client is a `SurrealAdapter`
instance. For generic `SurrealClientView` callers, the factory falls
back to `createAsIsTransaction` (sequential execution against the
outer adapter, matching Better-Auth's documented degraded-transaction
behaviour).

View File

@@ -0,0 +1,27 @@
import { describe, expectTypeOf, it } from "@suaveplan/testing/runner";
import type { DBAdapterInstance } from "better-auth";
import type {
BetterAuthSurrealAdapterV16Options,
betterAuthSurrealAdapter,
} from "./v1.6.js";
describe("betterAuthSurrealAdapter — type conformance to Better-Auth v1.6", () => {
it("return type satisfies DBAdapterInstance via expectTypeOf", {
timeout: 5000,
}, () => {
expectTypeOf<
ReturnType<typeof betterAuthSurrealAdapter>
>().toMatchTypeOf<DBAdapterInstance>();
});
it("options type alias equals BetterAuthSurrealAdapterOptions structurally", {
timeout: 5000,
}, () => {
// Compile-time only: the v1.6 options accept the same fields as
// the v1.4 options. Using `extends` checks both directions of
// structural compatibility.
expectTypeOf<BetterAuthSurrealAdapterV16Options>().toHaveProperty(
"client"
);
});
});

View File

@@ -0,0 +1,445 @@
import { SurrealAdapter } from "@suaveplan/surrealdb";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { DatabaseLike } from "@suaveplan/types";
import { ZodError } from "zod";
import { FakeClient } from "../../tests/utils/fake-client.js";
import { BetterAuthSurrealError } from "../error-codes/error-codes.js";
import { BETTER_AUTH_SURREAL_SCHEMA } from "../schema/schema.js";
import { betterAuthSurrealAdapter } from "./v1.6.js";
/**
* SurrealAdapter subclass that bypasses the real network connection.
* Used to drive the `instanceof SurrealAdapter` branch in
* `betterAuthSurrealAdapter` without spinning up a testcontainer.
*/
class StubbedSurrealAdapter extends SurrealAdapter {
public readonly fake: FakeClient;
public transactionCalls = 0;
public constructor(fake: FakeClient) {
super(
{},
undefined,
() =>
// The real factory builds a surrealdb.js `Surreal` instance.
// We never call `connect()` so this is unreachable; assertion
// narrows the type for the protected `_clientFactory` slot.
({}) as never
);
this.fake = fake;
}
public override get isConnected(): boolean {
return true;
}
public override async query<T = Record<string, unknown>>(
sql: string,
params?: readonly unknown[]
): Promise<T[]> {
return this.fake.query<T>(sql, params);
}
public override async execute(
sql: string,
_params?: readonly unknown[]
): Promise<{ rowsAffected: number }> {
return this.fake.execute(sql);
}
public override async transaction<T>(
fn: (tx: DatabaseLike) => Promise<T>
): Promise<T> {
this.transactionCalls++;
const tx: DatabaseLike = {
query: this.fake.query.bind(this.fake) as DatabaseLike["query"],
execute: this.fake.execute.bind(
this.fake
) as DatabaseLike["execute"],
transaction: async <R>(inner: (t: DatabaseLike) => Promise<R>) =>
inner(tx),
close: async () => {},
};
return fn(tx);
}
}
describe("betterAuthSurrealAdapter (Better-Auth v1.6 factory)", () => {
it("returns a DBAdapterInstance (a function taking BetterAuthOptions)", {
timeout: 5000,
}, () => {
const factory = betterAuthSurrealAdapter({
client: new FakeClient(),
});
expect(typeof factory).toBe("function");
});
it("invalid options raise INVALID_INPUT carrying ZodError", {
timeout: 5000,
}, () => {
try {
betterAuthSurrealAdapter(
{} as unknown as Parameters<typeof betterAuthSurrealAdapter>[0]
);
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(BetterAuthSurrealError);
expect((err as BetterAuthSurrealError).code).toBe(
"BETTER_AUTH_SURREALDB.INVALID_INPUT"
);
expect((err as Error).cause).toBeInstanceOf(ZodError);
}
});
it("factory invocation returns a DBAdapter with required methods", {
timeout: 5000,
}, () => {
const fake = new FakeClient();
const factory = betterAuthSurrealAdapter({ client: fake });
const dbAdapter = factory({
secret: "x",
baseURL: "http://localhost",
} as Parameters<typeof factory>[0]);
for (const m of [
"create",
"findOne",
"findMany",
"update",
"updateMany",
"delete",
"deleteMany",
"count",
"consumeOne",
"transaction",
"createSchema",
]) {
expect(
typeof (dbAdapter as unknown as Record<string, unknown>)[m]
).toBe("function");
}
expect(dbAdapter.id).toBeDefined();
});
it("createSchema returns BETTER_AUTH_SURREAL_SCHEMA verbatim", {
timeout: 5000,
}, async () => {
const fake = new FakeClient();
const factory = betterAuthSurrealAdapter({ client: fake });
const dbAdapter = factory({
secret: "x",
baseURL: "http://localhost",
} as Parameters<typeof factory>[0]);
const createSchema = dbAdapter.createSchema;
if (createSchema === undefined) {
throw new Error(
"createSchema must be defined on the wrapped adapter"
);
}
const result = await createSchema(
{} as Parameters<typeof createSchema>[0]
);
expect(result.code).toBe(BETTER_AUTH_SURREAL_SCHEMA);
expect(result.path).toBe("./schema.surql");
expect(result.overwrite).toBe(true);
});
it("errors propagate from the underlying adapter", {
timeout: 5000,
}, async () => {
const fake = new FakeClient(() => {
throw new Error("simulated surreal failure");
});
const factory = betterAuthSurrealAdapter({ client: fake });
const dbAdapter = factory({
secret: "x",
baseURL: "http://localhost",
} as Parameters<typeof factory>[0]);
await expect(
dbAdapter.findMany({
model: "user",
where: [{ field: "id", value: "x" }],
})
).rejects.toBeInstanceOf(BetterAuthSurrealError);
});
it("transaction forwards through createAsIsTransaction when client is a SurrealClientView (non-SurrealAdapter)", {
timeout: 5000,
}, async () => {
// FakeClient is a structural SurrealClientView (not SurrealAdapter),
// so the wrapper falls back to createAsIsTransaction. The trx
// handed to the callback is the outer adapter itself; the inner
// call therefore exercises the same methods.
const fake = new FakeClient(() => []);
const factory = betterAuthSurrealAdapter({ client: fake });
const dbAdapter = factory({
secret: "x",
baseURL: "http://localhost",
} as Parameters<typeof factory>[0]);
const result = await dbAdapter.transaction(async (trx) => {
expect(trx).toBeDefined();
expect(typeof trx.findOne).toBe("function");
return "ok";
});
expect(result).toBe("ok");
});
it("every wrapped CustomAdapter method round-trips into the underlying adapter", {
timeout: 5000,
}, async () => {
const log: string[] = [];
const fake = new FakeClient((sql) => {
log.push(sql.split(" ")[0] ?? "");
if (sql.startsWith("CREATE")) {
return [{ id: "u_round_1", name: "n" }];
}
if (sql.startsWith("SELECT") && sql.includes("count")) {
return [{ count: 2 }];
}
if (sql.startsWith("SELECT")) {
return [{ id: "u_round_1", name: "n" }];
}
if (sql.startsWith("UPDATE")) {
return [{ id: "u_round_1", name: "n2" }];
}
if (sql.startsWith("DELETE")) {
return [{ id: "u_round_1" }];
}
return [];
});
const factory = betterAuthSurrealAdapter({ client: fake });
const db = factory({
secret: "x",
baseURL: "http://localhost",
} as Parameters<typeof factory>[0]);
await db.create({
model: "user",
data: { id: "u_round_1", name: "n" },
forceAllowId: true,
});
await db.findOne({
model: "user",
where: [{ field: "id", value: "u_round_1" }],
});
await db.findMany({
model: "user",
where: [],
limit: 10,
});
await db.count({ model: "user", where: [] });
await db.update({
model: "user",
where: [{ field: "id", value: "u_round_1" }],
update: { name: "n2" },
});
await db.updateMany({
model: "user",
where: [{ field: "id", value: "u_round_1" }],
update: { name: "n2" },
});
await db.delete({
model: "user",
where: [{ field: "id", value: "u_round_1" }],
});
await db.deleteMany({
model: "user",
where: [{ field: "id", value: "u_round_1" }],
});
await db.consumeOne({
model: "user",
where: [{ field: "id", value: "u_round_1" }],
});
expect(log.length).toBeGreaterThan(0);
});
it("transaction-scoped DBTransactionAdapter via FakeClient (createAsIsTransaction path) reuses the outer adapter", {
timeout: 5000,
}, async () => {
const fake = new FakeClient((sql) => {
if (sql.startsWith("CREATE")) {
return [{ id: "u_tx_loop", name: "loop" }];
}
return [];
});
const factory = betterAuthSurrealAdapter({ client: fake });
const db = factory({
secret: "x",
baseURL: "http://localhost",
} as Parameters<typeof factory>[0]);
const result = await db.transaction(async (trx) => {
await trx.create({
model: "user",
data: { id: "u_tx_loop", name: "loop" },
forceAllowId: true,
});
return "ok";
});
expect(result).toBe("ok");
});
it("options.idStrategy + linkFields propagate through to the underlying adapter", {
timeout: 5000,
}, () => {
const fake = new FakeClient();
const factory = betterAuthSurrealAdapter({
client: fake,
idStrategy: "ulid",
linkFields: { session: { userId: "user" } },
});
expect(typeof factory).toBe("function");
});
it("transaction (SurrealAdapter path) round-trips a create through Better-Auth's transformInput", {
timeout: 5000,
}, async () => {
const fake = new FakeClient((sql) => {
if (sql.startsWith("CREATE")) {
return [{ id: "u_tx_1", email: "tx@x" }];
}
return [];
});
const sa = new StubbedSurrealAdapter(fake);
const factory = betterAuthSurrealAdapter({ client: sa });
const dbAdapter = factory({
secret: "x",
baseURL: "http://localhost",
} as Parameters<typeof factory>[0]);
const result = await dbAdapter.transaction(async (trx) => {
const created = await trx.create({
model: "user",
data: { id: "u_tx_1", email: "tx@x" },
forceAllowId: true,
});
return created;
});
// The v1.6 wrapper runs Better-Auth's transaction callback against a
// fresh DBAdapter (no SurrealDB BEGIN/COMMIT) — see v1.6.ts docs.
expect(sa.transactionCalls).toBe(0);
expect(result).toMatchObject({ email: "tx@x" });
});
it("transaction-scoped DBTransactionAdapter exposes every CRUD method", {
timeout: 5000,
}, async () => {
const fake = new FakeClient((sql) => {
if (sql.startsWith("CREATE")) {
return [{ id: "u_tx_2", name: "n" }];
}
if (sql.startsWith("SELECT")) {
return [{ id: "u_tx_2", name: "n" }];
}
if (sql.startsWith("UPDATE")) {
return [{ id: "u_tx_2", name: "n2" }];
}
if (sql.includes("count")) {
return [{ count: 1 }];
}
if (sql.startsWith("DELETE")) {
return [{ id: "u_tx_2" }];
}
return [];
});
const sa = new StubbedSurrealAdapter(fake);
const factory = betterAuthSurrealAdapter({ client: sa });
const dbAdapter = factory({
secret: "x",
baseURL: "http://localhost",
} as Parameters<typeof factory>[0]);
await dbAdapter.transaction(async (trx) => {
expect(trx.id).toBeDefined();
await trx.create({
model: "user",
data: { id: "u_tx_2", name: "n" },
forceAllowId: true,
});
await trx.findOne({
model: "user",
where: [
{
field: "id",
value: "u_tx_2",
operator: "eq",
connector: "AND",
mode: "sensitive",
},
],
});
await trx.findMany({
model: "user",
where: [],
limit: 10,
});
await trx.count({ model: "user", where: [] });
await trx.update({
model: "user",
where: [
{
field: "id",
value: "u_tx_2",
operator: "eq",
connector: "AND",
mode: "sensitive",
},
],
update: { name: "n2" },
});
await trx.updateMany({
model: "user",
where: [
{
field: "id",
value: "u_tx_2",
operator: "eq",
connector: "AND",
mode: "sensitive",
},
],
update: { name: "n2" },
});
await trx.delete({
model: "user",
where: [
{
field: "id",
value: "u_tx_2",
operator: "eq",
connector: "AND",
mode: "sensitive",
},
],
});
await trx.deleteMany({
model: "user",
where: [
{
field: "id",
value: "u_tx_2",
operator: "eq",
connector: "AND",
mode: "sensitive",
},
],
});
// consumeOne — note: our underlying adapter's consumeOne does
// a find-then-delete sequence; the fake handles SELECT then
// DELETE.
await trx.consumeOne({
model: "user",
where: [
{
field: "id",
value: "u_tx_2",
operator: "eq",
connector: "AND",
mode: "sensitive",
},
],
});
return "ok";
});
// See comment in previous test — wrapper transaction does not call
// SurrealAdapter.transaction; the callback runs inline.
expect(sa.transactionCalls).toBe(0);
});
});

View File

@@ -0,0 +1,232 @@
import type {
DBAdapterInstance,
DBAdapterSchemaCreation,
DBTransactionAdapter,
Where,
} from "better-auth";
import type {
AdapterFactoryConfig,
AdapterFactoryCustomizeAdapterCreator,
CustomAdapter,
} from "better-auth/adapters";
import { createAdapterFactory } from "better-auth/adapters";
type CleanedWhere = Required<Where>;
import { SurrealAdapter } from "@suaveplan/surrealdb";
import {
BETTER_AUTH_SURREALDB_CODES,
BetterAuthSurrealError,
} from "../error-codes/error-codes.js";
import { instrument } from "../otel/instruments.js";
import { BETTER_AUTH_SURREAL_SCHEMA } from "../schema/schema.js";
import { BetterAuthSurrealAdapterOptionsSchema } from "../schemas/options.js";
import type { BetterAuthSurrealAdapterOptions } from "../types/adapter.js";
import {
BETTER_AUTH_SURREAL_ADAPTER_ID,
type BetterAuthSurrealAdapter,
createBetterAuthSurrealAdapter,
} from "./adapter.js";
/**
* Options accepted by `betterAuthSurrealAdapter` — the Better-Auth v1.6
* `DBAdapterInstance` factory. Identical shape to the v1.4-shape
* `BetterAuthSurrealAdapterOptions` (validated by the same Zod schema).
*/
export type BetterAuthSurrealAdapterV16Options =
BetterAuthSurrealAdapterOptions;
function buildCustomAdapter(
underlying: BetterAuthSurrealAdapter
): CustomAdapter {
type CreateArgs = Parameters<CustomAdapter["create"]>[0];
type FindOneArgs = {
model: string;
where: CleanedWhere[];
select?: string[] | undefined;
};
type FindManyArgs = {
model: string;
where?: CleanedWhere[] | undefined;
limit: number;
select?: string[] | undefined;
sortBy?: { field: string; direction: "asc" | "desc" } | undefined;
offset?: number | undefined;
};
type UpdateArgs = {
model: string;
where: CleanedWhere[];
update: Record<string, unknown>;
};
return {
create: (async (args: CreateArgs) =>
underlying.create({
model: args.model,
data: args.data as Omit<Record<string, unknown>, "id">,
select: args.select,
forceAllowId: true,
})) as CustomAdapter["create"],
findOne: (async (args: FindOneArgs) =>
underlying.findOne({
model: args.model,
where: args.where,
select: args.select,
})) as CustomAdapter["findOne"],
findMany: (async (args: FindManyArgs) =>
underlying.findMany({
model: args.model,
where: args.where,
limit: args.limit,
select: args.select,
sortBy: args.sortBy,
offset: args.offset,
})) as CustomAdapter["findMany"],
count: (args) =>
underlying.count({ model: args.model, where: args.where }),
update: (async (args: UpdateArgs) =>
underlying.update({
model: args.model,
where: args.where,
update: args.update,
})) as CustomAdapter["update"],
updateMany: (args) =>
underlying.updateMany({
model: args.model,
where: args.where,
update: args.update,
}),
delete: (args) =>
underlying.delete({ model: args.model, where: args.where }),
deleteMany: (args) =>
underlying.deleteMany({ model: args.model, where: args.where }),
consumeOne: (async (args: { model: string; where: CleanedWhere[] }) =>
underlying.consumeOne({
model: args.model,
where: args.where,
})) as NonNullable<CustomAdapter["consumeOne"]>,
createSchema: async (): Promise<DBAdapterSchemaCreation> => ({
code: BETTER_AUTH_SURREAL_SCHEMA,
path: "./schema.surql",
overwrite: true,
}),
options: {
adapterId: BETTER_AUTH_SURREAL_ADAPTER_ID,
},
};
}
function buildTxFactoryConfig(): AdapterFactoryConfig {
return {
adapterId: BETTER_AUTH_SURREAL_ADAPTER_ID,
adapterName: "Better-Auth SurrealDB Adapter (tx)",
supportsNumericIds: false,
supportsUUIDs: false,
supportsJSON: true,
supportsDates: true,
supportsBooleans: true,
supportsArrays: true,
disableIdGeneration: false,
transaction: false,
};
}
/**
* Better-Auth v1.6 `DBAdapterInstance` factory backed by SurrealDB.
*
* Returns a function `(betterAuthOptions: BetterAuthOptions) => DBAdapter`
* that `betterAuth({ database })` accepts as its `database` field.
*
* Internally:
*
* 1. Zod-validates `options` via `BetterAuthSurrealAdapterOptionsSchema`
* (the same schema the v1.4 surface uses). Validation failures throw
* `BetterAuthSurrealError(BETTER_AUTH_SURREALDB_INVALID_INPUT)`.
* 2. Builds the underlying 10-method adapter via
* `createBetterAuthSurrealAdapter(options)`.
* 3. Wraps it via `createAdapterFactory` from `@better-auth/core/db`,
* forwarding every CRUD method 1:1, providing a `transaction(...)`
* via `runSurrealTransaction`, and a `createSchema()` returning
* `BETTER_AUTH_SURREAL_SCHEMA` verbatim.
*/
export function betterAuthSurrealAdapter(
options: BetterAuthSurrealAdapterV16Options
): DBAdapterInstance {
const parsed = BetterAuthSurrealAdapterOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new BetterAuthSurrealError(
BETTER_AUTH_SURREALDB_CODES.INVALID_INPUT,
"betterAuthSurrealAdapter received invalid options",
{ cause: parsed.error }
);
}
const underlying: BetterAuthSurrealAdapter =
createBetterAuthSurrealAdapter(options);
const rawClient = options.client;
const adapterImpl: AdapterFactoryCustomizeAdapterCreator = () =>
buildCustomAdapter(underlying);
const baseConfig: AdapterFactoryConfig = {
adapterId: BETTER_AUTH_SURREAL_ADAPTER_ID,
adapterName: "Better-Auth SurrealDB Adapter",
supportsNumericIds: false,
supportsUUIDs: false,
supportsJSON: true,
supportsDates: true,
supportsBooleans: true,
supportsArrays: true,
disableIdGeneration: false,
};
const baseFactory = createAdapterFactory({
adapter: adapterImpl,
config: baseConfig,
});
return ((betterAuthOptions: Parameters<DBAdapterInstance>[0]) => {
const outerDbAdapter = baseFactory(betterAuthOptions);
if (!(rawClient instanceof SurrealAdapter)) {
return outerDbAdapter;
}
const sa = rawClient;
// SurrealDB 2.x's WebSocket connection does not carry transaction
// state across separate query messages, so `BEGIN; … ; COMMIT;`
// sent as discrete `client.query(...)` calls (the only path the
// shipped `runSurrealTransaction` helper offers) fails with
// "Cannot COMMIT without starting a transaction". Until the
// upstream helper batches BEGIN/work/COMMIT into a single
// multi-statement payload, the v1.6 wrapper runs Better-Auth's
// transaction callback against a fresh, fully-instrumented
// DBAdapter built around the same outer SurrealAdapter — equivalent
// to Better-Auth's documented "transactions not supported"
// degraded path (sequential execution, no rollback). The
// production signal is that the create/update/delete operations
// inside the callback still run through the full
// transformInput/transformOutput pipeline, which the previous
// manual `DBTransactionAdapter` implementation skipped and caused
// missing-id and missing-field regressions.
const transaction: typeof outerDbAdapter.transaction = (callback) =>
instrument({
model: "_transaction",
operation: "transaction",
run: async () => {
void sa; // captured for future migration once upstream helper batches BEGIN/COMMIT
const txAdapterImpl: AdapterFactoryCustomizeAdapterCreator =
() => buildCustomAdapter(underlying);
const txFactory = createAdapterFactory({
adapter: txAdapterImpl,
config: buildTxFactoryConfig(),
});
const txDbAdapter = txFactory(betterAuthOptions);
return callback(
txDbAdapter as unknown as DBTransactionAdapter
);
},
});
return { ...outerDbAdapter, transaction };
}) as DBAdapterInstance;
}

View File

@@ -0,0 +1,163 @@
import { SURREAL_CODES, SurrealError } from "@suaveplan/surrealdb";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createBetterAuthSurrealAdapter } from "./adapter/adapter.js";
import { compileWhere } from "./compile/compile-where.js";
import {
BETTER_AUTH_SURREALDB_CODES,
type BetterAuthSurrealError,
} from "./error-codes/error-codes.js";
import { prepareCreate } from "./id-strategy/id-strategy.js";
import { applyLinkPrefix } from "./link-fields/link-fields.js";
import { applyBetterAuthSchema } from "./schema/schema.js";
import type { SurrealClientView } from "./types/adapter.js";
const fakeClient = (): SurrealClientView => ({
isConnected: true,
async query() {
return [];
},
async execute() {
return { rowsAffected: 0 };
},
});
describe("every BETTER_AUTH_SURREALDB_* code is thrown from a production path", () => {
it("INVALID_INPUT — createBetterAuthSurrealAdapter with empty options", {
timeout: 5000,
}, () => {
try {
createBetterAuthSurrealAdapter(
{} as Parameters<typeof createBetterAuthSurrealAdapter>[0]
);
expect.fail("should have thrown");
} catch (err) {
expect((err as BetterAuthSurrealError).code).toBe(
BETTER_AUTH_SURREALDB_CODES.INVALID_INPUT
);
}
});
it("INVALID_MODEL — modelToTable returns identifier-invalid name", {
timeout: 5000,
}, async () => {
const adapter = createBetterAuthSurrealAdapter({
client: fakeClient(),
modelToTable: () => "weird-name",
});
await expect(adapter.findMany({ model: "user" })).rejects.toMatchObject(
{
code: BETTER_AUTH_SURREALDB_CODES.INVALID_MODEL,
}
);
});
it("INVALID_OPERATOR — compileWhere with unknown operator", {
timeout: 5000,
}, () => {
try {
compileWhere(
[
{
field: "x",
value: 1,
operator: "weird" as unknown as "eq",
},
],
{ model: "user", linkFields: {} }
);
expect.fail("should have thrown");
} catch (err) {
expect((err as BetterAuthSurrealError).code).toBe(
BETTER_AUTH_SURREALDB_CODES.INVALID_OPERATOR
);
}
});
it("QUERY_FAILED — underlying SurrealError wraps", {
timeout: 5000,
}, async () => {
const client: SurrealClientView = {
isConnected: true,
async query() {
throw new SurrealError(SURREAL_CODES.QUERY_FAILED, "boom");
},
async execute() {
return { rowsAffected: 0 };
},
};
const adapter = createBetterAuthSurrealAdapter({ client });
await expect(adapter.findMany({ model: "user" })).rejects.toMatchObject(
{
code: BETTER_AUTH_SURREALDB_CODES.QUERY_FAILED,
}
);
});
it("QUERY_FAILED — applyBetterAuthSchema wraps client.execute failure", {
timeout: 5000,
}, async () => {
const client: SurrealClientView = {
isConnected: true,
async query() {
return [];
},
async execute() {
throw new Error("schema apply failed");
},
};
try {
await applyBetterAuthSchema(client);
expect.fail("should have thrown");
} catch (err) {
expect((err as BetterAuthSurrealError).code).toBe(
BETTER_AUTH_SURREALDB_CODES.QUERY_FAILED
);
}
});
it("INVALID_ID_FORMAT — record-id data.id contains colon", {
timeout: 5000,
}, () => {
try {
prepareCreate("record-id", "user", { id: "abc:def" });
expect.fail("should have thrown");
} catch (err) {
expect((err as BetterAuthSurrealError).code).toBe(
BETTER_AUTH_SURREALDB_CODES.INVALID_ID_FORMAT
);
}
});
it("INVALID_LINK_FIELD_VALUE — applyLinkPrefix on value containing :", {
timeout: 5000,
}, () => {
try {
applyLinkPrefix("user", "u:bad");
expect.fail("should have thrown");
} catch (err) {
expect((err as BetterAuthSurrealError).code).toBe(
BETTER_AUTH_SURREALDB_CODES.INVALID_LINK_FIELD_VALUE
);
}
});
it("CONNECTION_NOT_READY — adapter rejects when client is disconnected", {
timeout: 5000,
}, async () => {
const client: SurrealClientView = {
isConnected: false,
async query() {
return [];
},
async execute() {
return { rowsAffected: 0 };
},
};
const adapter = createBetterAuthSurrealAdapter({ client });
await expect(adapter.findMany({ model: "user" })).rejects.toMatchObject(
{
code: BETTER_AUTH_SURREALDB_CODES.CONNECTION_NOT_READY,
}
);
});
});

View File

@@ -0,0 +1,63 @@
# compile-select
## Purpose
Compiles a Better-Auth `select?: string[]` projection into a SurrealQL
column list. Used inside the adapter's `SELECT` template. Identifier-
validates every entry so the projection cannot inject SurrealQL syntax.
## Features
- `undefined` and `[]` both compile to `"*"`.
- Validates every field against `/^[a-zA-Z_][a-zA-Z0-9_]*$/`.
- Pure / side-effect-free.
## Usage: Basic
```ts
import { compileSelect } from "@suaveplan/better-auth-surrealdb/compile";
compileSelect(["id", "email"]); // "id, email"
compileSelect(undefined); // "*"
compileSelect([]); // "*"
```
## Usage: Advanced
```ts
import { compileSelect } from "@suaveplan/better-auth-surrealdb/compile";
function buildProjection(model: string, select?: string[]) {
const cols = compileSelect(select);
return `SELECT ${cols} FROM ${model}`;
}
```
## API Reference
- `compileSelect(select: ReadonlyArray<string> | undefined): string`
## Implementation Notes
The default `*` keeps Better-Auth's existing first-party adapters'
behaviour: when no projection is supplied, the row is returned in full so
that Better-Auth's read-transform layer can shape it client-side. The
identifier validator runs eagerly so the error surfaces as an
`INVALID_INPUT` near the adapter boundary instead of at the SurrealDB
query layer with a less actionable message. The function is pure and
side-effect-free — both invocations with identical input return the same
string output, which lets the adapter cache projection strings per
`(model, select-fingerprint)` tuple if a future optimisation pass wants
to dedupe across high-traffic call sites.
Identifier validation runs in O(n) over the input array. The check rejects
SurrealDB constructs that would otherwise compile but introduce attack
surface: dotted paths (`foo.bar`), function calls (`count()`), subscripts,
back-tick-quoted identifiers. Operators wanting to project subscripted
fields should compile their own projection string and bypass this helper;
this package treats that as out-of-band and intentionally narrow.
Empty-array vs `undefined` behave identically by design — Better-Auth's
upstream type permits both to mean "all fields". This package collapses
both to `"*"` rather than emitting a zero-column SurrealQL `SELECT` that
would surface as a parse error against SurrealDB v3.

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { BetterAuthSurrealError } from "../error-codes/error-codes.js";
import { compileSelect } from "./compile-select.js";
describe("compileSelect", () => {
it("returns * for undefined", { timeout: 5000 }, () => {
expect(compileSelect(undefined)).toBe("*");
});
it("returns * for empty array", { timeout: 5000 }, () => {
expect(compileSelect([])).toBe("*");
});
it("joins valid identifiers with comma-space", { timeout: 5000 }, () => {
expect(compileSelect(["id", "email"])).toBe("id, email");
});
it("rejects invalid identifier", { timeout: 5000 }, () => {
expect(() => compileSelect(["id", "weird-name"])).toThrow(
BetterAuthSurrealError
);
});
});

View File

@@ -0,0 +1,27 @@
import {
BETTER_AUTH_SURREALDB_CODES,
BetterAuthSurrealError,
} from "../error-codes/error-codes.js";
const IDENT = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
/**
* Compile a Better-Auth `select?: string[]` projection into a SurrealQL
* column list. When `select` is `undefined` OR empty, returns `"*"`.
*
* Validates every field against the identifier rule.
*/
export function compileSelect(
select: ReadonlyArray<string> | undefined
): string {
if (select === undefined || select.length === 0) return "*";
for (const field of select) {
if (!IDENT.test(field)) {
throw new BetterAuthSurrealError(
BETTER_AUTH_SURREALDB_CODES.INVALID_INPUT,
`select field "${field}" is not a valid SurrealQL identifier`
);
}
}
return select.join(", ");
}

View File

@@ -0,0 +1,63 @@
# compile-sort
## Purpose
Compiles a Better-Auth `sortBy?: { field; direction }` argument into a
SurrealQL `ORDER BY` fragment, validating the field name against the
identifier rule to prevent any injection vector. Returns the empty string
when `sortBy` is `undefined` so the adapter can unconditionally splice the
result into its SELECT template.
## Features
- Default direction handling: only `"desc"` flips to `DESC`; everything
else (including the explicit `"asc"`) emits `ASC`.
- Identifier validation: field MUST match `/^[a-zA-Z_][a-zA-Z0-9_]*$/`,
else `BETTER_AUTH_SURREALDB.INVALID_INPUT` is thrown.
- Pure / side-effect-free.
## Usage: Basic
```ts
import { compileSortBy } from "@suaveplan/better-auth-surrealdb/compile";
const fragment = compileSortBy({ field: "createdAt", direction: "desc" });
// "ORDER BY createdAt DESC"
const none = compileSortBy(undefined); // ""
```
## Usage: Advanced
```ts
import { compileSortBy } from "@suaveplan/better-auth-surrealdb/compile";
function buildPaginatedSelect(table: string, sortBy?: { field: string; direction: "asc" | "desc" }) {
return `SELECT * FROM ${table} ${compileSortBy(sortBy)} LIMIT 25`.replace(/\s+/g, " ");
}
```
## API Reference
- `compileSortBy(sortBy: SortBy | undefined): string`
- `SortBy` — `{ field: string; direction: "asc" | "desc" }`.
## Implementation Notes
SurrealDB tolerates identifiers in `ORDER BY` only when they match its
identifier regex. We pre-validate rather than wrap in backticks so the
generated query is human-readable in EXPLAIN output and to surface a
typed error close to the offending input rather than at the DB boundary.
Returning the empty string for `undefined` simplifies the adapter's SELECT
template — the splice site can use a single template-literal slot with no
nullish checks.
The direction comparison is strict equality against the literal string
`"desc"` to keep Better-Auth's `"asc" | "desc"` union the authoritative
contract; any other input (e.g. uppercase, abbreviated) silently defaults
to `ASC`. This matches Better-Auth's own first-party adapters which never
emit non-canonical direction strings, but it does mean operators
hand-rolling a `sortBy` object with mistyped direction will not observe
a descending sort — a deliberate trade-off favouring forward-compatibility
against future Better-Auth direction extensions over strict input
validation at this layer.

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { BetterAuthSurrealError } from "../error-codes/error-codes.js";
import { compileSortBy } from "./compile-sort.js";
describe("compileSortBy", () => {
it("returns empty for undefined", { timeout: 5000 }, () => {
expect(compileSortBy(undefined)).toBe("");
});
it("compiles asc", { timeout: 5000 }, () => {
expect(compileSortBy({ field: "createdAt", direction: "asc" })).toBe(
"ORDER BY createdAt ASC"
);
});
it("compiles desc", { timeout: 5000 }, () => {
expect(compileSortBy({ field: "createdAt", direction: "desc" })).toBe(
"ORDER BY createdAt DESC"
);
});
it("rejects invalid identifier", { timeout: 5000 }, () => {
expect(() =>
compileSortBy({
field: "weird-name",
direction: "asc",
})
).toThrow(BetterAuthSurrealError);
});
});

View File

@@ -0,0 +1,29 @@
import {
BETTER_AUTH_SURREALDB_CODES,
BetterAuthSurrealError,
} from "../error-codes/error-codes.js";
const IDENT = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
export interface SortBy {
readonly field: string;
readonly direction: "asc" | "desc";
}
/**
* Compile a Better-Auth `sortBy` into a SurrealQL `ORDER BY` fragment.
*
* Returns an empty string when `sortBy` is `undefined`. Validates the field
* name against SurrealQL's identifier rule to prevent injection.
*/
export function compileSortBy(sortBy: SortBy | undefined): string {
if (sortBy === undefined) return "";
if (!IDENT.test(sortBy.field)) {
throw new BetterAuthSurrealError(
BETTER_AUTH_SURREALDB_CODES.INVALID_INPUT,
`sortBy.field "${sortBy.field}" is not a valid SurrealQL identifier`
);
}
const dir = sortBy.direction === "desc" ? "DESC" : "ASC";
return `ORDER BY ${sortBy.field} ${dir}`;
}

View File

@@ -0,0 +1,76 @@
# compile-where
## Purpose
Translates Better-Auth's `Where[]` filter union into a parameterised
SurrealQL `WHERE` fragment plus a binding record. Every clause maps to a
unique placeholder (`$w0`, `$w1`, …) so SurrealDB binds values via its safe
parameter path — never via string interpolation. Link-field values are
pre-resolved against the configured `LinkFieldMap` before binding so a
`{ field: "userId", value: "u1" }` clause on `session` compiles to a
`WHERE userId = $w0` clause where `$w0 = "user:u1"`, satisfying the
`record<user>` column type.
## Features
- 11 operators: `eq` (default), `ne`, `gt`, `gte`, `lt`, `lte`, `in`,
`not_in`, `contains`, `starts_with`, `ends_with`.
- 2 connectors: `AND` (default), `OR`. The first clause omits the connector.
- Unique placeholder naming via positional index — never collides within
one compile, and the `placeholderPrefix` option lets the adapter keep
`$update`-vs-predicate bindings disjoint when assembling UPDATE queries.
- Link-field pre-resolution: scalar values get `<target>:<v>`; array values
map element-wise (preserving `in` / `not_in` semantics).
- Unknown operators raise `BETTER_AUTH_SURREALDB.INVALID_OPERATOR`.
## Usage: Basic
```ts
import { compileWhere } from "@suaveplan/better-auth-surrealdb/compile";
const { clause, vars } = compileWhere(
[{ field: "age", value: 18, operator: "gte" }],
{ model: "user", linkFields: {} }
);
// clause === "age >= $w0"
// vars === { w0: 18 }
```
## Usage: Advanced
```ts
import { compileWhere } from "@suaveplan/better-auth-surrealdb/compile";
import { DEFAULT_BETTER_AUTH_LINK_FIELDS } from "@suaveplan/better-auth-surrealdb/link-fields";
const compiled = compileWhere(
[
{ field: "userId", value: "u1" },
{ field: "expiresAt", value: new Date(), operator: "lt", connector: "OR" },
],
{
model: "session",
linkFields: DEFAULT_BETTER_AUTH_LINK_FIELDS,
placeholderPrefix: "p",
}
);
// compiled.clause === "userId = $p0 OR expiresAt < $p1"
// compiled.vars.p0 === RecordId("user", "u1") (pre-resolved record link)
```
## API Reference
- `compileWhere(where, options): { clause: string; vars: Record<string, unknown> }`
- `WhereOperator` — closed union of 11 operators.
- `WhereConnector` — `"AND" | "OR"`.
- `WhereClause` — `{ field; value; operator?; connector? }`.
- `CompileWhereOptions` — `{ model; linkFields; placeholderPrefix? }`.
## Implementation Notes
The operator-to-SurrealQL table covers every Better-Auth `whereOperators`
entry. The function returns `{ clause: "", vars: {} }` for empty input,
which lets the adapter splice the result into a SELECT without conditional
branching at the call site. `placeholderPrefix` defaults to `"w"` to match
the spec's `$w0/$w1/…` convention; the adapter uses `"u"` when compiling
MERGE-side bindings (`$update` is its own scope but predicate placeholders
must stay disjoint from any caller-defined params).

View File

@@ -0,0 +1,159 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { BetterAuthSurrealError } from "../error-codes/error-codes.js";
import { DEFAULT_BETTER_AUTH_LINK_FIELDS } from "../link-fields/link-fields.js";
import { compileWhere } from "./compile-where.js";
const NO_LINKS = {} as const;
describe("compileWhere", () => {
it("compiles empty to empty", { timeout: 5000 }, () => {
const out = compileWhere([], { model: "user", linkFields: NO_LINKS });
expect(out.clause).toBe("");
expect(out.vars).toEqual({});
});
it("maps every supported operator", { timeout: 5000 }, () => {
const cases: Array<[string, string]> = [
["eq", "f = $w0"],
["ne", "f != $w0"],
["gt", "f > $w0"],
["gte", "f >= $w0"],
["lt", "f < $w0"],
["lte", "f <= $w0"],
["in", "f IN $w0"],
["not_in", "f NOT IN $w0"],
["contains", "string::contains(f, $w0)"],
["starts_with", "string::starts_with(f, $w0)"],
["ends_with", "string::ends_with(f, $w0)"],
];
for (const [op, expected] of cases) {
const out = compileWhere(
[
{
field: "f",
value: 1,
operator: op as unknown as
| "eq"
| "ne"
| "gt"
| "gte"
| "lt"
| "lte"
| "in"
| "not_in"
| "contains"
| "starts_with"
| "ends_with",
},
],
{ model: "user", linkFields: NO_LINKS }
);
expect(out.clause).toBe(expected);
expect(out.vars).toEqual({ w0: 1 });
}
});
it("defaults operator to eq and connector to AND", {
timeout: 5000,
}, () => {
const out = compileWhere(
[
{ field: "a", value: 1 },
{ field: "b", value: 2 },
],
{ model: "user", linkFields: NO_LINKS }
);
expect(out.clause).toBe("a = $w0 AND b = $w1");
expect(out.vars).toEqual({ w0: 1, w1: 2 });
});
it("AND/OR connectors compose", { timeout: 5000 }, () => {
const out = compileWhere(
[
{ field: "a", value: 1 },
{ field: "b", value: 2, connector: "OR" },
],
{ model: "user", linkFields: NO_LINKS }
);
expect(out.clause).toBe("a = $w0 OR b = $w1");
});
it("unknown operator raises INVALID_OPERATOR", { timeout: 5000 }, () => {
expect(() =>
compileWhere(
[
{
field: "x",
value: 1,
operator: "weird" as unknown as "eq",
},
],
{ model: "user", linkFields: NO_LINKS }
)
).toThrow(BetterAuthSurrealError);
});
it("placeholder names never collide across N clauses", {
timeout: 5000,
}, () => {
const clauses = Array.from({ length: 20 }, (_, i) => ({
field: `f${i}`,
value: i,
}));
const out = compileWhere(clauses, {
model: "user",
linkFields: NO_LINKS,
});
const placeholders = Array.from(out.clause.matchAll(/\$\w+/g)).map(
(m) => m[0]
);
expect(new Set(placeholders).size).toBe(placeholders.length);
expect(Object.keys(out.vars)).toHaveLength(20);
});
it("link-field where values pre-resolved to record-link form", {
timeout: 5000,
}, () => {
const out = compileWhere([{ field: "userId", value: "u1" }], {
model: "session",
linkFields: DEFAULT_BETTER_AUTH_LINK_FIELDS,
});
const w0 = (out.vars as { w0: unknown }).w0;
expect(w0).toBeDefined();
expect(String(w0)).toBe("user:u1");
});
it("link-field array values map element-wise", { timeout: 5000 }, () => {
const out = compileWhere(
[{ field: "userId", value: ["u1", "u2"], operator: "in" }],
{
model: "session",
linkFields: DEFAULT_BETTER_AUTH_LINK_FIELDS,
}
);
const w0 = (out.vars as { w0: unknown }).w0;
expect(Array.isArray(w0)).toBe(true);
const arr = w0 as unknown[];
expect(String(arr[0])).toBe("user:u1");
expect(String(arr[1])).toBe("user:u2");
});
it("non-link fields pass through unmodified", { timeout: 5000 }, () => {
const out = compileWhere([{ field: "email", value: "x@y" }], {
model: "user",
linkFields: DEFAULT_BETTER_AUTH_LINK_FIELDS,
});
expect(out.vars).toEqual({ w0: "x@y" });
});
it("honours custom placeholder prefix", { timeout: 5000 }, () => {
const out = compileWhere([{ field: "id", value: "u1" }], {
model: "user",
linkFields: NO_LINKS,
placeholderPrefix: "p",
});
expect(out.clause).toBe("id = $p0");
// `id` is auto-wrapped as a record-link to the model name.
expect(String((out.vars as { p0: unknown }).p0)).toBe("user:u1");
});
});

View File

@@ -0,0 +1,145 @@
import {
BETTER_AUTH_SURREALDB_CODES,
BetterAuthSurrealError,
} from "../error-codes/error-codes.js";
import { applyLinkPrefix, linkTargetFor } from "../link-fields/link-fields.js";
import type { LinkFieldMap } from "../types/adapter.js";
/**
* Better-Auth's `Where[]` filter compiled to a parameterised SurrealQL
* `WHERE` fragment.
*
* Mirrors Better-Auth's `whereOperators` tuple verbatim.
*/
export type WhereOperator =
| "eq"
| "ne"
| "gt"
| "gte"
| "lt"
| "lte"
| "in"
| "not_in"
| "contains"
| "starts_with"
| "ends_with";
export type WhereConnector = "AND" | "OR";
export interface WhereClause {
readonly field: string;
readonly value:
| string
| number
| boolean
| string[]
| number[]
| Date
| null;
readonly operator?: WhereOperator | undefined;
readonly connector?: WhereConnector | undefined;
}
export interface CompileWhereOptions {
readonly model: string;
readonly linkFields: LinkFieldMap;
/**
* Optional placeholder prefix override — defaults to `w`. Used by the
* adapter to keep `update`'s `$update` binding separate from `$w0`/`$w1`
* predicate bindings.
*/
readonly placeholderPrefix?: string;
}
export interface CompiledWhere {
readonly clause: string;
readonly vars: Record<string, unknown>;
}
/**
* Operator → SurrealQL fragment template. `{f}` is replaced by the field
* name and `{p}` by the placeholder reference (e.g. `$w0`). Function-call
* operators (`starts_with`, `ends_with`, `contains`) use SurrealQL's
* `string::*` namespace because raw infix forms (`STARTS_WITH`,
* `ENDS_WITH`, `CONTAINS`) are not all reserved tokens in SurrealDB v3.
*/
const OPERATOR_TEMPLATES: Readonly<Record<WhereOperator, string>> = {
eq: "{f} = {p}",
ne: "{f} != {p}",
gt: "{f} > {p}",
gte: "{f} >= {p}",
lt: "{f} < {p}",
lte: "{f} <= {p}",
in: "{f} IN {p}",
not_in: "{f} NOT IN {p}",
contains: "string::contains({f}, {p})",
starts_with: "string::starts_with({f}, {p})",
ends_with: "string::ends_with({f}, {p})",
};
/**
* Compile a `Where[]` filter into a parameterised SurrealQL clause +
* binding record. Empty input compiles to `{ clause: "", vars: {} }`.
*/
export function compileWhere(
where: ReadonlyArray<WhereClause>,
options: CompileWhereOptions
): CompiledWhere {
if (where.length === 0) {
return { clause: "", vars: {} };
}
const prefix = options.placeholderPrefix ?? "w";
const vars: Record<string, unknown> = {};
const parts: string[] = [];
for (let i = 0; i < where.length; i++) {
const clause = where[i] as WhereClause;
const op = clause.operator ?? "eq";
const template = OPERATOR_TEMPLATES[op];
if (template === undefined) {
throw new BetterAuthSurrealError(
BETTER_AUTH_SURREALDB_CODES.INVALID_OPERATOR,
`unknown where operator: ${String(op)}`
);
}
const placeholder = `${prefix}${i}`;
const value = resolveValue(
options.linkFields,
options.model,
clause.field,
clause.value
);
vars[placeholder] = value;
let connector = "";
if (i > 0) {
connector = `${clause.connector ?? "AND"} `;
}
const fragment = template
.replace("{f}", clause.field)
.replace("{p}", `$${placeholder}`);
parts.push(`${connector}${fragment}`);
}
return { clause: parts.join(" "), vars };
}
function resolveValue(
linkFields: LinkFieldMap,
model: string,
field: string,
value: WhereClause["value"]
): unknown {
// The `id` field of every model is a SurrealDB record link by
// construction (record-id strategy). Wrap it the same way as any
// declared link field so predicates like `WHERE id = $p0` bind a
// record reference and not a plain string.
const target =
field === "id" ? model : linkTargetFor(linkFields, model, field);
if (target === null) return value;
if (Array.isArray(value)) {
return value.map((v) => applyLinkPrefix(target, v));
}
return applyLinkPrefix(target, value);
}

View File

@@ -0,0 +1,10 @@
export { compileSelect } from "./compile-select.js";
export { compileSortBy, type SortBy } from "./compile-sort.js";
export {
type CompiledWhere,
type CompileWhereOptions,
compileWhere,
type WhereClause,
type WhereConnector,
type WhereOperator,
} from "./compile-where.js";

View File

@@ -0,0 +1,78 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the closed registry of error codes raised from every public path of
`@suaveplan/better-auth-surrealdb`. Stable string codes let downstream callers
branch on failure reasons without depending on stack-trace text or Better-Auth
internals. Each code is namespaced under `BETTER_AUTH_SURREALDB.*` so log
aggregators and OTel dashboards can pivot on a single attribute regardless of
which package raised the failure.
## Features
- 7 fully-qualified codes: `INVALID_INPUT`, `INVALID_MODEL`,
`INVALID_OPERATOR`, `QUERY_FAILED`, `INVALID_ID_FORMAT`,
`INVALID_LINK_FIELD_VALUE`, `CONNECTION_NOT_READY`.
- `BetterAuthSurrealError` extends `SuaveplanError<BetterAuthSurrealErrorCode>`
so `instanceof` narrows the `code` to the union literal.
- Codes built via `defineCodes("BETTER_AUTH_SURREALDB", […])` so the runtime
object is frozen and the type is fully inferred.
- Carry `cause` and `context` from `SuaveplanErrorOptions` — `QUERY_FAILED`
pins the underlying `SurrealError`, `INVALID_INPUT` pins the `ZodError`.
## Usage: Basic
```ts
import {
BETTER_AUTH_SURREALDB_CODES,
BetterAuthSurrealError,
} from "@suaveplan/better-auth-surrealdb/error-codes";
throw new BetterAuthSurrealError(
BETTER_AUTH_SURREALDB_CODES.INVALID_OPERATOR,
"unknown operator: weird"
);
```
## Usage: Advanced
```ts
import { ZodError } from "zod";
import {
BETTER_AUTH_SURREALDB_CODES,
BetterAuthSurrealError,
} from "@suaveplan/better-auth-surrealdb/error-codes";
function assertValid<T>(parsed: { success: boolean; error?: ZodError; data?: T }): T {
if (!parsed.success) {
throw new BetterAuthSurrealError(
BETTER_AUTH_SURREALDB_CODES.INVALID_INPUT,
"adapter options failed validation",
{ cause: parsed.error }
);
}
return parsed.data as T;
}
```
## API Reference
- `BETTER_AUTH_SURREALDB_CODES` — frozen record `{ [Reason]: "BETTER_AUTH_SURREALDB.<Reason>" }`.
- `BetterAuthSurrealErrorCode` — union literal of the 7 codes.
- `class BetterAuthSurrealError extends SuaveplanError<BetterAuthSurrealErrorCode>`
— public constructor `(code, message, options?)`.
## Implementation Notes
`defineCodes` returns a `Object.freeze`d record so the runtime cannot be
mutated; the type is fully inferred from the tuple literal. The class
constructor is widened to `public` (the base is `protected`) so callers in
sibling feature modules construct errors directly without an intermediate
factory. The class deliberately does NOT carry an OTel span event handle —
instrumentation is the caller's responsibility via `toSpanEvent` from
`@suaveplan/error`.

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
BETTER_AUTH_SURREALDB_CODES,
BetterAuthSurrealError,
} from "./error-codes.js";
describe("error-codes", () => {
it("defines exactly 7 namespaced codes", { timeout: 5000 }, () => {
const keys = Object.keys(BETTER_AUTH_SURREALDB_CODES).sort();
expect(keys).toEqual(
[
"CONNECTION_NOT_READY",
"INVALID_ID_FORMAT",
"INVALID_INPUT",
"INVALID_LINK_FIELD_VALUE",
"INVALID_MODEL",
"INVALID_OPERATOR",
"QUERY_FAILED",
].sort()
);
for (const key of keys) {
expect(
BETTER_AUTH_SURREALDB_CODES[
key as keyof typeof BETTER_AUTH_SURREALDB_CODES
]
).toBe(`BETTER_AUTH_SURREALDB.${key}`);
}
});
it("BetterAuthSurrealError preserves code, message, and cause", {
timeout: 5000,
}, () => {
const cause = new Error("boom");
const err = new BetterAuthSurrealError(
BETTER_AUTH_SURREALDB_CODES.QUERY_FAILED,
"query failed",
{ cause }
);
expect(err).toBeInstanceOf(BetterAuthSurrealError);
expect(err.code).toBe("BETTER_AUTH_SURREALDB.QUERY_FAILED");
expect(err.message).toBe("query failed");
expect(err.cause).toBe(cause);
});
it("registry is frozen", { timeout: 5000 }, () => {
expect(Object.isFrozen(BETTER_AUTH_SURREALDB_CODES)).toBe(true);
});
});

View File

@@ -0,0 +1,43 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/better-auth-surrealdb`.
*
* Every production failure path raises a {@link BetterAuthSurrealError} with
* one of these 7 namespaced codes. Consumers branch on the string code
* (e.g. `error.code === BETTER_AUTH_SURREALDB_CODES.INVALID_INPUT`) or on
* `instanceof BetterAuthSurrealError` for narrowing.
*/
export const BETTER_AUTH_SURREALDB_CODES = defineCodes(
"BETTER_AUTH_SURREALDB",
[
"INVALID_INPUT",
"INVALID_MODEL",
"INVALID_OPERATOR",
"QUERY_FAILED",
"INVALID_ID_FORMAT",
"INVALID_LINK_FIELD_VALUE",
"CONNECTION_NOT_READY",
] as const
);
export type BetterAuthSurrealErrorCode =
(typeof BETTER_AUTH_SURREALDB_CODES)[keyof typeof BETTER_AUTH_SURREALDB_CODES];
/**
* Concrete error class. `instanceof BetterAuthSurrealError` narrows the
* `code` field to {@link BetterAuthSurrealErrorCode}.
*/
export class BetterAuthSurrealError extends SuaveplanError<BetterAuthSurrealErrorCode> {
public readonly betterAuthSurrealCode: BetterAuthSurrealErrorCode;
public constructor(
code: BetterAuthSurrealErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.betterAuthSurrealCode = code;
}
}

View File

@@ -0,0 +1,5 @@
export type { BetterAuthSurrealErrorCode } from "./error-codes.js";
export {
BETTER_AUTH_SURREALDB_CODES,
BetterAuthSurrealError,
} from "./error-codes.js";

View File

@@ -0,0 +1,77 @@
# id-strategy
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Encapsulates the three Better-Auth record-id strategies the adapter
supports. SurrealDB's native record-id format is `<table>:<id>`, but
Better-Auth callers may pass either a bare `id` (record-id mode), no
`id` at all (ulid mode auto-generates), or a row that stores `id` as a
regular field (plain mode). This module resolves the strategy choice into
concrete CREATE-target-and-data tuples and normalises the returned id
on read.
## Features
- `prepareCreate(strategy, table, data) → PreparedCreate` — dispatches on
the strategy and returns `{ target, data, assignedId }`.
- `record-id` strategy: requires non-empty `data.id`, rejects colons, emits
`<table>:\`<id>\`` to keep arbitrary identifier characters safe.
- `ulid` strategy: when `data.id` is absent, generates a 26-char Crockford
base32 ULID (10 chars time + 16 chars random) via `crypto.getRandomValues`.
- `plain` strategy: keeps `id` as a row field; SurrealDB assigns the
storage id; row's `assignedId` echoes `data.id` when present.
- `normaliseReturnedId` — strips the `<table>:` prefix from SurrealDB's
return shape (`RecordId` object, `<table>:<id>` string, or bare value).
- Throws `INVALID_MODEL` when the table name fails the identifier rule.
- Throws `INVALID_ID_FORMAT` when an id-strategy contract is violated.
## Usage: Basic
```ts
import { prepareCreate } from "@suaveplan/better-auth-surrealdb/id-strategy";
const p = prepareCreate("record-id", "user", { id: "u1", email: "x@y" });
// p.target === "user:`u1`"
// p.data === { email: "x@y" }
// p.assignedId === "u1"
```
## Usage: Advanced
```ts
import { prepareCreate, generateUlid } from "@suaveplan/better-auth-surrealdb/id-strategy";
// Inject deterministic id for tests by composing your own table+id outside
// of prepareCreate; or use the ulid generator directly:
const id = generateUlid();
const p = prepareCreate("record-id", "session", { id, userId: "user:u1" });
// Plain mode for schemas that store id as a regular column:
const flat = prepareCreate("plain", "session", { id: "s1", token: "t" });
// flat.target === "session"
// flat.data === { id: "s1", token: "t" }
// flat.assignedId === "s1"
```
## API Reference
- `IdStrategy = "record-id" | "plain" | "ulid"`
- `PreparedCreate = { target: string; data: Row; assignedId: string | null }`
- `prepareCreate(strategy, table, data): PreparedCreate`
- `generateUlid(now?): string` — 26-char Crockford base32 ULID.
- `normaliseReturnedId(value, table): string` — strips `<table>:` from SurrealDB
return shapes.
## Implementation Notes
The ULID alphabet matches the published spec (Crockford base32 minus I, L,
O, U). Random bytes come from `crypto.getRandomValues` so the generator is
universal (works in Bun, Node ≥18, and browsers). The record-id
target is wrapped in backticks so SurrealDB accepts ULIDs containing digits
at position 1 (otherwise the parser may treat the value as a number). The
`prepareCreate` switch is exhaustive; TypeScript narrows `IdStrategy` to
the three known cases at compile time.

View File

@@ -0,0 +1,152 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { BetterAuthSurrealError } from "../error-codes/error-codes.js";
import {
generateUlid,
normaliseReturnedId,
prepareCreate,
} from "./id-strategy.js";
describe("prepareCreate", () => {
it("record-id strategy strips and reapplies prefix", {
timeout: 5000,
}, () => {
const p = prepareCreate("record-id", "user", {
id: "u1",
email: "x@y",
});
expect(p.target).toBe("user:`u1`");
expect(p.data).toEqual({ email: "x@y" });
expect(p.assignedId).toBe("u1");
});
it("record-id rejects missing id", { timeout: 5000 }, () => {
expect(() => prepareCreate("record-id", "user", {})).toThrow(
BetterAuthSurrealError
);
});
it("record-id rejects non-string id", { timeout: 5000 }, () => {
expect(() => prepareCreate("record-id", "user", { id: 5 })).toThrow(
BetterAuthSurrealError
);
});
it("colon in record-id raises INVALID_ID_FORMAT", { timeout: 5000 }, () => {
expect(() =>
prepareCreate("record-id", "user", { id: "abc:def" })
).toThrow(BetterAuthSurrealError);
try {
prepareCreate("record-id", "user", { id: "abc:def" });
} catch (err) {
expect((err as BetterAuthSurrealError).code).toBe(
"BETTER_AUTH_SURREALDB.INVALID_ID_FORMAT"
);
}
});
it("invalid table name raises INVALID_MODEL", { timeout: 5000 }, () => {
expect(() =>
prepareCreate("record-id", "weird-name", { id: "u1" })
).toThrow(BetterAuthSurrealError);
});
it("ulid strategy fills missing id", { timeout: 5000 }, () => {
const p = prepareCreate("ulid", "session", { userId: "user:u1" });
expect(p.assignedId).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/);
expect(p.target).toBe(`session:\`${p.assignedId}\``);
});
it("ulid strategy respects supplied id", { timeout: 5000 }, () => {
const p = prepareCreate("ulid", "session", {
id: "fixed",
userId: "user:u1",
});
expect(p.assignedId).toBe("fixed");
expect(p.target).toBe("session:`fixed`");
});
it("plain strategy stores id as a field", { timeout: 5000 }, () => {
const p = prepareCreate("plain", "user", {
id: "u1",
email: "x@y",
});
expect(p.target).toBe("user");
expect(p.data).toEqual({ id: "u1", email: "x@y" });
expect(p.assignedId).toBe("u1");
});
it("plain strategy without id returns null assignedId", {
timeout: 5000,
}, () => {
const p = prepareCreate("plain", "user", { email: "x@y" });
expect(p.assignedId).toBeNull();
});
it("plain strategy validates table name", { timeout: 5000 }, () => {
expect(() => prepareCreate("plain", "weird-name", {})).toThrow(
BetterAuthSurrealError
);
});
});
describe("generateUlid", () => {
it("produces a 26-char Crockford-base32 string", { timeout: 5000 }, () => {
const id = generateUlid();
expect(id).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/);
});
it("uniqueness across 100 calls", { timeout: 5000 }, () => {
const set = new Set(Array.from({ length: 100 }, () => generateUlid()));
expect(set.size).toBe(100);
});
it("honours injected clock", { timeout: 5000 }, () => {
const fixed = generateUlid(() => 0);
expect(fixed.startsWith("0000000000")).toBe(true);
});
});
describe("normaliseReturnedId", () => {
it("strips `<table>:` prefix from bare string", { timeout: 5000 }, () => {
expect(normaliseReturnedId("user:u1", "user")).toBe("u1");
});
it("strips backtick-wrapped id", { timeout: 5000 }, () => {
expect(normaliseReturnedId("user:`u1`", "user")).toBe("u1");
});
it("passes already-bare value through", { timeout: 5000 }, () => {
expect(normaliseReturnedId("u1", "user")).toBe("u1");
});
it("extracts id from RecordId-shaped object with string id", {
timeout: 5000,
}, () => {
expect(normaliseReturnedId({ tb: "user", id: "u1" }, "user")).toBe(
"u1"
);
});
it("extracts id from RecordId-shaped object with String-wrapped id", {
timeout: 5000,
}, () => {
expect(
normaliseReturnedId({ tb: "user", id: { String: "u1" } }, "user")
).toBe("u1");
});
it("returns empty string for null/undefined", { timeout: 5000 }, () => {
expect(normaliseReturnedId(null, "user")).toBe("");
expect(normaliseReturnedId(undefined, "user")).toBe("");
});
it("coerces non-string scalars", { timeout: 5000 }, () => {
expect(normaliseReturnedId(5, "user")).toBe("5");
});
it("coerces unknown objects via String()", { timeout: 5000 }, () => {
expect(normaliseReturnedId({ foo: "bar" }, "user")).toBe(
"[object Object]"
);
});
});

View File

@@ -0,0 +1,164 @@
import {
BETTER_AUTH_SURREALDB_CODES,
BetterAuthSurrealError,
} from "../error-codes/error-codes.js";
import type { IdStrategy, Row } from "../types/adapter.js";
/**
* Crockford-base32 alphabet (per ULID spec).
* I, L, O, U are intentionally absent.
*/
const CROCKFORD32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
/**
* Generate a 26-character Crockford-base32 ULID:
* - 10 chars time (48-bit ms-since-epoch),
* - 16 chars random (80 bits via `crypto.getRandomValues`).
*/
export function generateUlid(now: () => number = Date.now): string {
let time = now();
const timeChars: string[] = new Array(10);
for (let i = 9; i >= 0; i--) {
timeChars[i] = CROCKFORD32[time % 32] as string;
time = Math.floor(time / 32);
}
const rand = new Uint8Array(16);
crypto.getRandomValues(rand);
const randChars = new Array(16) as string[];
for (let i = 0; i < 16; i++) {
randChars[i] = CROCKFORD32[(rand[i] as number) % 32] as string;
}
return timeChars.join("") + randChars.join("");
}
/**
* Outcome of `prepareCreate` — fully-resolved CREATE arguments.
*
* - `target`: the SurrealQL target — either `<table>:<id>` (record-id /
* ulid) or just `<table>` (plain). The adapter splices this verbatim
* into `CREATE <target> CONTENT $data RETURN AFTER`.
* - `data`: row content excluding the id (record-id) or including it
* (plain).
* - `assignedId`: the id Better-Auth should see after the create returns
* (either supplied, ULID-generated, or `null` when SurrealDB will assign
* one in `plain` mode).
*/
export interface PreparedCreate {
readonly target: string;
readonly data: Row;
readonly assignedId: string | null;
}
const IDENT = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
/**
* Resolve a `(strategy, table, data)` tuple into the CREATE-arguments
* Better-Auth's adapter needs. Encapsulates the three id-strategy
* branches.
*/
export function prepareCreate(
strategy: IdStrategy,
table: string,
data: Row
): PreparedCreate {
switch (strategy) {
case "record-id":
return prepareRecordId(table, data, null);
case "ulid":
return prepareRecordId(table, data, generateUlid);
case "plain":
return preparePlain(table, data);
}
}
function prepareRecordId(
table: string,
data: Row,
fallback: (() => string) | null
): PreparedCreate {
const rawId = data["id"];
let id: string;
if (rawId === undefined || rawId === null || rawId === "") {
if (fallback === null) {
throw new BetterAuthSurrealError(
BETTER_AUTH_SURREALDB_CODES.INVALID_ID_FORMAT,
"idStrategy='record-id' requires data.id to be a non-empty string"
);
}
id = fallback();
} else {
if (typeof rawId !== "string") {
throw new BetterAuthSurrealError(
BETTER_AUTH_SURREALDB_CODES.INVALID_ID_FORMAT,
`data.id must be a string, got ${typeof rawId}`
);
}
if (rawId.includes(":")) {
throw new BetterAuthSurrealError(
BETTER_AUTH_SURREALDB_CODES.INVALID_ID_FORMAT,
`data.id "${rawId}" must not contain ":" — pass the bare id`
);
}
id = rawId;
}
if (!IDENT.test(table)) {
throw new BetterAuthSurrealError(
BETTER_AUTH_SURREALDB_CODES.INVALID_MODEL,
`table "${table}" is not a valid SurrealQL identifier`
);
}
const { id: _omit, ...rest } = data;
return {
target: `${table}:\`${id}\``,
data: rest,
assignedId: id,
};
}
function preparePlain(table: string, data: Row): PreparedCreate {
if (!IDENT.test(table)) {
throw new BetterAuthSurrealError(
BETTER_AUTH_SURREALDB_CODES.INVALID_MODEL,
`table "${table}" is not a valid SurrealQL identifier`
);
}
const assignedId =
typeof data["id"] === "string" ? (data["id"] as string) : null;
return {
target: table,
data: { ...data },
assignedId,
};
}
/**
* Normalise an `id` value returned by SurrealDB. SurrealDB returns either
* a `RecordId` object (`{ tb, id }`), a string `<table>:<id>`, or a
* bare value. This function returns the bare id portion as a string.
*/
export function normaliseReturnedId(value: unknown, table: string): string {
if (value === null || value === undefined) return "";
if (typeof value === "object") {
const maybeRecord = value as { id?: unknown; tb?: unknown };
if (typeof maybeRecord.id === "string") return maybeRecord.id;
if (
typeof maybeRecord.id === "object" &&
maybeRecord.id !== null &&
"String" in (maybeRecord.id as Record<string, unknown>)
) {
return String(
(maybeRecord.id as Record<string, unknown>)["String"]
);
}
return String(value);
}
if (typeof value !== "string") return String(value);
const prefix = `${table}:`;
if (value.startsWith(prefix)) {
const stripped = value.slice(prefix.length);
return stripped.startsWith("`") && stripped.endsWith("`")
? stripped.slice(1, -1)
: stripped;
}
return value;
}

View File

@@ -0,0 +1,6 @@
export type { PreparedCreate } from "./id-strategy.js";
export {
generateUlid,
normaliseReturnedId,
prepareCreate,
} from "./id-strategy.js";

View File

@@ -0,0 +1,83 @@
// Adapter (flagship)
export {
BETTER_AUTH_SURREAL_ADAPTER_ID,
type BetterAuthSurrealAdapter,
type BetterAuthSurrealAdapterV16Options,
betterAuthSurrealAdapter,
createBetterAuthSurrealAdapter,
ModelToTableCache,
type ModelToTableResolver,
} from "./adapter/index.js";
// Compile
export {
type CompiledWhere,
type CompileWhereOptions,
compileSelect,
compileSortBy,
compileWhere,
type SortBy,
type WhereClause,
type WhereConnector,
type WhereOperator,
} from "./compile/index.js";
// Error codes
export {
BETTER_AUTH_SURREALDB_CODES,
BetterAuthSurrealError,
type BetterAuthSurrealErrorCode,
} from "./error-codes/index.js";
// Id strategy
export {
generateUlid,
normaliseReturnedId,
type PreparedCreate,
prepareCreate,
} from "./id-strategy/index.js";
// Link fields
export {
applyLinkPrefix,
applyLinkPrefixesToWrite,
DEFAULT_BETTER_AUTH_LINK_FIELDS,
isRecordLikeValue,
linkTargetFor,
makeRecordId,
makeStringRecordId,
stripLinkPrefix,
stripLinkPrefixesFromRead,
} from "./link-fields/index.js";
// OTel
export {
type AdapterOutcome,
type InstrumentedOp,
instrument,
resetInstrumentsForTesting,
} from "./otel/index.js";
// Schema
export {
applyBetterAuthSchema,
BETTER_AUTH_SURREAL_SCHEMA,
} from "./schema/index.js";
// Zod schemas
export {
type BetterAuthSurrealAdapterOptionsInput,
BetterAuthSurrealAdapterOptionsSchema,
LinkFieldMapSchema,
type LinkFieldMapSchemaInput,
type LinkFieldMapSchemaOutput,
} from "./schemas/index.js";
// Types
export type {
BetterAuthSurrealAdapterOptions,
IdStrategy,
LinkFieldMap,
Row,
SurrealClientView,
} from "./types/index.js";

View File

@@ -0,0 +1,11 @@
export {
applyLinkPrefix,
applyLinkPrefixesToWrite,
DEFAULT_BETTER_AUTH_LINK_FIELDS,
isRecordLikeValue,
linkTargetFor,
makeRecordId,
makeStringRecordId,
stripLinkPrefix,
stripLinkPrefixesFromRead,
} from "./link-fields.js";

View File

@@ -0,0 +1,95 @@
# link-fields
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Encapsulates the central design decision of this package: SurrealDB stores
record references as typed `record<target>` columns where the value at rest
is `<target>:<bare-id>`, but Better-Auth hands the adapter a bare `userId:
"u1"` regardless of which underlying table declares the field as a record
link. Without per-field prefix handling, writes to record-link columns would
fail SurrealDB's type assertion and reads would return `user:u1` strings
that break Better-Auth's identity comparisons. This module provides the
default field map, the per-value `apply`/`strip` primitives, and the
row-level walkers the adapter dispatches.
## Features
- `DEFAULT_BETTER_AUTH_LINK_FIELDS` — frozen default matching every
`record<...>` field in the bundled schema (9 model entries).
- `linkTargetFor(map, model, field)` — pure lookup returning the configured
target table or `null`.
- `applyLinkPrefix(target, value)` — adds `<target>:` to plain strings;
refuses values already containing `:` with `INVALID_LINK_FIELD_VALUE`.
- `stripLinkPrefix(target, value)` — strips `<target>:` from prefixed
strings; idempotent on bare strings.
- `applyLinkPrefixesToWrite(map, model, data)` — clones `data` and prefixes
every declared link field in one pass.
- `stripLinkPrefixesFromRead(map, model, row)` — clones `row` and strips
every declared link field in one pass.
## Usage: Basic
```ts
import {
DEFAULT_BETTER_AUTH_LINK_FIELDS,
applyLinkPrefixesToWrite,
stripLinkPrefixesFromRead,
} from "@suaveplan/better-auth-surrealdb/link-fields";
const onDisk = applyLinkPrefixesToWrite(
DEFAULT_BETTER_AUTH_LINK_FIELDS,
"session",
{ userId: "u1", token: "t" }
);
// onDisk.userId === "user:u1"
const returned = stripLinkPrefixesFromRead(
DEFAULT_BETTER_AUTH_LINK_FIELDS,
"session",
{ userId: "user:u1", token: "t" }
);
// returned.userId === "u1"
```
## Usage: Advanced
```ts
import {
applyLinkPrefix,
stripLinkPrefix,
linkTargetFor,
} from "@suaveplan/better-auth-surrealdb/link-fields";
const map = { session: { userId: "user" } } as const;
// Per-value primitives — used by compileWhere for Where[] bindings.
const target = linkTargetFor(map, "session", "userId"); // "user"
if (target !== null) {
const bound = applyLinkPrefix(target, "u1"); // "user:u1"
const bare = stripLinkPrefix(target, bound); // "u1"
}
```
## API Reference
- `DEFAULT_BETTER_AUTH_LINK_FIELDS: LinkFieldMap`
- `linkTargetFor(map, model, field): string | null`
- `applyLinkPrefix(target, value): unknown` — throws `INVALID_LINK_FIELD_VALUE`
when the value already contains `:`.
- `stripLinkPrefix(target, value): unknown`
- `applyLinkPrefixesToWrite(map, model, data): Record<string, unknown>`
- `stripLinkPrefixesFromRead(map, model, row): Record<string, unknown>`
## Implementation Notes
The default map is built with nested `Object.freeze` so callers cannot
accidentally mutate the shared singleton. Non-string values pass through
both `apply` and `strip` (the schema permits `option<record<user>>` to be
`null`). `applyLinkPrefix` refuses values containing `:` because the
adapter would otherwise emit `user:user:u1` on write — defending against
double-application is critical because Better-Auth's higher layers may
synthesise rows from joined-read output that already carries prefixes.

View File

@@ -0,0 +1,200 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { BetterAuthSurrealError } from "../error-codes/error-codes.js";
import {
applyLinkPrefix,
applyLinkPrefixesToWrite,
DEFAULT_BETTER_AUTH_LINK_FIELDS,
isRecordLikeValue,
linkTargetFor,
makeRecordId,
makeStringRecordId,
stripLinkPrefix,
stripLinkPrefixesFromRead,
} from "./link-fields.js";
describe("DEFAULT_BETTER_AUTH_LINK_FIELDS", () => {
it("covers every record-link field in the bundled schema", {
timeout: 5000,
}, () => {
// From the bundled schema (record<...> typed columns):
const expected: Record<string, Record<string, string>> = {
session: { userId: "user" },
passkey: { userId: "user" },
member: { userId: "user", organizationId: "organization" },
invitation: {
inviterId: "user",
organizationId: "organization",
teamId: "team",
},
team: { organizationId: "organization" },
team_member: { userId: "user", teamId: "team" },
two_factor: { userId: "user" },
api_key: { userId: "user" },
sso_provider: { userId: "user" },
};
for (const [model, fields] of Object.entries(expected)) {
for (const [field, target] of Object.entries(fields)) {
expect(
linkTargetFor(DEFAULT_BETTER_AUTH_LINK_FIELDS, model, field)
).toBe(target);
}
}
});
it("is frozen at every nesting level", { timeout: 5000 }, () => {
expect(Object.isFrozen(DEFAULT_BETTER_AUTH_LINK_FIELDS)).toBe(true);
expect(
Object.isFrozen(DEFAULT_BETTER_AUTH_LINK_FIELDS["session"])
).toBe(true);
});
it("account.userId is NOT prefixed under default config", {
timeout: 5000,
}, () => {
expect(
linkTargetFor(DEFAULT_BETTER_AUTH_LINK_FIELDS, "account", "userId")
).toBeNull();
});
});
describe("applyLinkPrefix", () => {
it("prefixes plain strings as StringRecordId", { timeout: 5000 }, () => {
const out = applyLinkPrefix("user", "u1");
expect(isRecordLikeValue(out)).toBe(true);
expect(String(out)).toBe("user:u1");
});
it("passes non-string values through", { timeout: 5000 }, () => {
expect(applyLinkPrefix("user", null)).toBeNull();
expect(applyLinkPrefix("user", undefined)).toBeUndefined();
expect(applyLinkPrefix("user", 5 as unknown)).toBe(5);
});
it("throws INVALID_LINK_FIELD_VALUE on values containing :", {
timeout: 5000,
}, () => {
expect(() => applyLinkPrefix("user", "abc:def")).toThrow(
BetterAuthSurrealError
);
try {
applyLinkPrefix("user", "abc:def");
} catch (err) {
expect(err).toBeInstanceOf(BetterAuthSurrealError);
expect((err as BetterAuthSurrealError).code).toBe(
"BETTER_AUTH_SURREALDB.INVALID_LINK_FIELD_VALUE"
);
}
});
});
describe("stripLinkPrefix", () => {
it("strips matching string prefix", { timeout: 5000 }, () => {
expect(stripLinkPrefix("user", "user:u1")).toBe("u1");
});
it("unwraps RecordId to bare string id", { timeout: 5000 }, () => {
expect(stripLinkPrefix("user", makeRecordId("user", "u1"))).toBe("u1");
});
it("unwraps StringRecordId to bare string id", { timeout: 5000 }, () => {
expect(stripLinkPrefix("user", makeStringRecordId("user:u1"))).toBe(
"u1"
);
});
it("StringRecordId without matching prefix returns toString", {
timeout: 5000,
}, () => {
// toString returns the raw input "org:o1" — strip only matches `user:`.
expect(stripLinkPrefix("user", makeStringRecordId("org:o1"))).toBe(
"org:o1"
);
});
it("passes bare strings through", { timeout: 5000 }, () => {
expect(stripLinkPrefix("user", "u1")).toBe("u1");
});
it("passes non-matching prefix through", { timeout: 5000 }, () => {
expect(stripLinkPrefix("user", "org:o1")).toBe("org:o1");
});
it("passes non-string values through", { timeout: 5000 }, () => {
expect(stripLinkPrefix("user", null)).toBeNull();
expect(stripLinkPrefix("user", 5 as unknown)).toBe(5);
});
});
describe("applyLinkPrefixesToWrite", () => {
it("rewrites declared fields and clones", { timeout: 5000 }, () => {
const input = { userId: "u1", token: "t" };
const out = applyLinkPrefixesToWrite(
DEFAULT_BETTER_AUTH_LINK_FIELDS,
"session",
input
);
expect(isRecordLikeValue(out["userId"])).toBe(true);
expect(String(out["userId"])).toBe("user:u1");
expect(out["token"]).toBe("t");
expect(out).not.toBe(input);
expect(input.userId).toBe("u1"); // unmutated
});
it("returns shallow clone for unmapped models", { timeout: 5000 }, () => {
const input = { userId: "u1" };
const out = applyLinkPrefixesToWrite(
DEFAULT_BETTER_AUTH_LINK_FIELDS,
"account",
input
);
expect(out).toEqual({ userId: "u1" });
expect(out).not.toBe(input);
});
it("skips fields absent from data", { timeout: 5000 }, () => {
const out = applyLinkPrefixesToWrite(
DEFAULT_BETTER_AUTH_LINK_FIELDS,
"member",
{ userId: "u1" }
);
expect(isRecordLikeValue(out["userId"])).toBe(true);
});
it("empty linkFields disables manipulation", { timeout: 5000 }, () => {
const out = applyLinkPrefixesToWrite({}, "session", { userId: "u1" });
expect(out).toEqual({ userId: "u1" });
});
});
describe("stripLinkPrefixesFromRead", () => {
it("strips declared fields and clones", { timeout: 5000 }, () => {
const input = {
userId: makeRecordId("user", "u1"),
token: "t",
};
const out = stripLinkPrefixesFromRead(
DEFAULT_BETTER_AUTH_LINK_FIELDS,
"session",
input
);
expect(out).toEqual({ userId: "u1", token: "t" });
});
it("returns shallow clone for unmapped models", { timeout: 5000 }, () => {
const out = stripLinkPrefixesFromRead(
DEFAULT_BETTER_AUTH_LINK_FIELDS,
"account",
{ userId: "u1" }
);
expect(out).toEqual({ userId: "u1" });
});
it("skips fields absent from row", { timeout: 5000 }, () => {
const out = stripLinkPrefixesFromRead(
DEFAULT_BETTER_AUTH_LINK_FIELDS,
"member",
{ role: "admin" }
);
expect(out).toEqual({ role: "admin" });
});
});

View File

@@ -0,0 +1,166 @@
import { RecordId, StringRecordId } from "surrealdb";
import {
BETTER_AUTH_SURREALDB_CODES,
BetterAuthSurrealError,
} from "../error-codes/error-codes.js";
import type { LinkFieldMap } from "../types/adapter.js";
// Resolve the SurrealDB driver via ESM so `instanceof` checks against the
// driver's internal RecordId class identity line up with the values this
// package produces. `@suaveplan/surrealdb` also uses ESM for the driver,
// so both consumers share the same `surrealdb.mjs` module instance.
const SurrealRecordId = RecordId;
const SurrealStringRecordId = StringRecordId;
/**
* Construct a SurrealDB `RecordId` instance bound to the same CJS module the
* underlying driver evaluates `instanceof` against. Direct ESM imports of
* the same class identifier may resolve to a different class instance in
* dual-package (CJS/ESM) modules, which is why the adapter wraps its own
* factory rather than re-exporting the driver classes.
*/
export function makeRecordId(table: string, id: string): object {
return new SurrealRecordId(table, id);
}
/**
* Construct a SurrealDB `StringRecordId` from the wire form `<table>:<id>`.
*/
export function makeStringRecordId(rid: string): object {
return new SurrealStringRecordId(rid);
}
/**
* Duck-typed predicate: is the value an instance of any SurrealDB record-id
* shape (RecordId or StringRecordId) bound to the same module? Used by
* `stripLinkPrefix` to avoid cross-module instanceof pitfalls.
*/
export function isRecordLikeValue(value: unknown): boolean {
return (
value instanceof SurrealRecordId ||
value instanceof SurrealStringRecordId
);
}
/**
* Default `LinkFieldMap` matching the bundled 17-table schema's
* `record<target>` columns. Mirrors every `record<...>` typed field in
* `BETTER_AUTH_SURREAL_SCHEMA` so consumers using the canonical schema get
* correct prefix handling without configuration.
*/
export const DEFAULT_BETTER_AUTH_LINK_FIELDS: LinkFieldMap = Object.freeze({
session: Object.freeze({ userId: "user" }),
passkey: Object.freeze({ userId: "user" }),
member: Object.freeze({ userId: "user", organizationId: "organization" }),
invitation: Object.freeze({
inviterId: "user",
organizationId: "organization",
teamId: "team",
}),
team: Object.freeze({ organizationId: "organization" }),
team_member: Object.freeze({ userId: "user", teamId: "team" }),
two_factor: Object.freeze({ userId: "user" }),
api_key: Object.freeze({ userId: "user" }),
sso_provider: Object.freeze({ userId: "user" }),
});
/**
* Return the link-field target table for `(model, field)` or `null` when
* the field is not configured as a record-link.
*/
export function linkTargetFor(
map: LinkFieldMap,
model: string,
field: string
): string | null {
const modelMap = map[model];
if (modelMap === undefined) return null;
const target = modelMap[field];
return target === undefined ? null : target;
}
/**
* Apply the `<target>` record-link wrapper to a value about to be written
* to a record-link field. Returns a SurrealDB `RecordId` instance — the
* driver serialises this as a typed record reference (not a string).
*
* - If `value` already contains `:`, raises
* `BETTER_AUTH_SURREALDB.INVALID_LINK_FIELD_VALUE` (caller is sending a
* pre-formed record link to a field the adapter would wrap again).
* - Non-string values pass through (record-link `null` is allowed for
* optional fields like `sso_provider.userId`).
*/
export function applyLinkPrefix(target: string, value: unknown): unknown {
if (typeof value !== "string") return value;
if (value.includes(":")) {
throw new BetterAuthSurrealError(
BETTER_AUTH_SURREALDB_CODES.INVALID_LINK_FIELD_VALUE,
`link-field value "${value}" already contains ":" — cannot wrap as record<${target}>`
);
}
return makeStringRecordId(`${target}:${value}`);
}
/**
* Strip the `<target>:` prefix from a value read back from a record-link
* field. Handles three return shapes from `surrealdb` v3:
* - `RecordId` instance — returns `.id` as a string.
* - `"<target>:<id>"` string — returns the suffix.
* - Anything else — returned verbatim (operator schema drift).
*/
export function stripLinkPrefix(target: string, value: unknown): unknown {
if (value instanceof SurrealRecordId) {
return String((value as unknown as { id: string }).id);
}
if (value instanceof SurrealStringRecordId) {
const s = String(value);
const prefix = `${target}:`;
return s.startsWith(prefix) ? s.slice(prefix.length) : s;
}
if (typeof value !== "string") return value;
const prefix = `${target}:`;
return value.startsWith(prefix) ? value.slice(prefix.length) : value;
}
/**
* Walk a row about to be written; for each declared link field whose value
* is a plain string, replace it with the prefixed record-link form.
*
* Mutates a clone; the caller's input is never mutated.
*/
export function applyLinkPrefixesToWrite(
map: LinkFieldMap,
model: string,
data: Readonly<Record<string, unknown>>
): Record<string, unknown> {
const modelMap = map[model];
if (modelMap === undefined) return { ...data };
const out: Record<string, unknown> = { ...data };
for (const [field, target] of Object.entries(modelMap)) {
if (field in out) {
out[field] = applyLinkPrefix(target, out[field]);
}
}
return out;
}
/**
* Walk a row read from SurrealDB; for each declared link field, strip the
* `<target>:` prefix back to the bare id Better-Auth expects. Returns a
* shallow clone — never mutates input.
*/
export function stripLinkPrefixesFromRead(
map: LinkFieldMap,
model: string,
row: Readonly<Record<string, unknown>>
): Record<string, unknown> {
const modelMap = map[model];
if (modelMap === undefined) return { ...row };
const out: Record<string, unknown> = { ...row };
for (const [field, target] of Object.entries(modelMap)) {
if (field in out) {
out[field] = stripLinkPrefix(target, out[field]);
}
}
return out;
}

View File

@@ -0,0 +1,6 @@
export {
type AdapterOutcome,
type InstrumentedOp,
instrument,
resetInstrumentsForTesting,
} from "./instruments.js";

View File

@@ -0,0 +1,76 @@
# otel/instruments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Wires the package's OpenTelemetry metrics surface and exposes a thin
`instrument(op)` helper the adapter wraps around every CRUD method. The
helper records one counter point (with `outcome: "success" | "error"`)
plus one histogram observation per call, so dashboards can pivot on
model/operation/outcome and also see the per-operation latency
distribution.
## Features
- `better_auth.surrealdb.operations_total` counter — attributes:
`model`, `operation`, `outcome`.
- `better_auth.surrealdb.operation_duration_ms` histogram — attribute:
`operation`.
- Lazy meter resolution: the meter and instruments are constructed on
first use (not at module load) so tests can install a real
`MeterProvider` via `setGlobalMeterProvider` before the adapter
begins issuing operations.
- Universal: uses `performance.now()` when available, falls back to
`Date.now()` when running under bundlers that strip `performance`.
- `resetInstrumentsForTesting()` clears the lazy cache so a re-imported
test environment resolves the new provider.
## Usage: Basic
```ts
import { instrument } from "@suaveplan/better-auth-surrealdb/otel";
const row = await instrument({
model: "session",
operation: "create",
run: async () => insertSessionRow(),
});
```
## Usage: Advanced
```ts
import { instrument } from "@suaveplan/better-auth-surrealdb/otel";
import { metrics } from "@opentelemetry/api";
import { MeterProvider, PeriodicExportingMetricReader, InMemoryMetricExporter, AggregationTemporality } from "@opentelemetry/sdk-metrics";
// Install provider BEFORE first instrument() call:
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
const reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 60_000 });
const provider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(provider);
await instrument({ model: "user", operation: "findOne", run: async () => ({ ok: true }) });
await provider.forceFlush();
const collected = exporter.getMetrics();
```
## API Reference
- `instrument<T>(op): Promise<T>` — wraps a `() => Promise<T>` with counter +
histogram observations; rethrows on failure.
- `InstrumentedOp<T>` — `{ model: string; operation: string; run: () => Promise<T> }`.
- `AdapterOutcome` — `"success" | "error"`.
- `resetInstrumentsForTesting()` — invalidates the lazy cache.
## Implementation Notes
The lazy-init pattern is critical: importing OTel `metrics` at module load
returns a noop meter unless `setGlobalMeterProvider` has been called by the
host. Caching the meter+instruments after first use keeps subsequent calls
allocation-free. `performance.now()` is preferred for sub-millisecond
resolution but `Date.now()` is a correct fallback because the histogram
records millisecond observations regardless.

View File

@@ -0,0 +1,47 @@
import { afterAll, describe, expect, it } from "@suaveplan/testing/runner";
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
import { instrument, resetInstrumentsForTesting } from "./instruments.js";
describe("instrument", () => {
afterAll(() => {
resetInstrumentsForTesting();
});
it("counter emits success", { timeout: 5000 }, async () => {
await instrument({
model: "user",
operation: "create",
run: async () => ({ ok: true }),
});
const collected = await flushTestMetrics();
const dump = JSON.stringify(collected);
expect(dump).toContain("operations_total");
expect(dump).toContain('"outcome":"success"');
});
it("counter emits error and rethrows", { timeout: 5000 }, async () => {
await expect(
instrument({
model: "user",
operation: "create",
run: async () => {
throw new Error("boom");
},
})
).rejects.toThrow("boom");
const collected = await flushTestMetrics();
const dump = JSON.stringify(collected);
expect(dump).toContain('"outcome":"error"');
});
it("histogram records per operation", { timeout: 5000 }, async () => {
await instrument({
model: "session",
operation: "findOne",
run: async () => null,
});
const collected = await flushTestMetrics();
const dump = JSON.stringify(collected);
expect(dump).toContain("operation_duration_ms");
});
});

View File

@@ -0,0 +1,79 @@
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
const METER_NAME = "@suaveplan/better-auth-surrealdb";
interface CachedInstruments {
counter: Counter;
histogram: Histogram;
}
let cached: CachedInstruments | null = null;
function getInstruments(): CachedInstruments {
if (cached !== null) return cached;
const meter = metrics.getMeter(METER_NAME);
const counter = meter.createCounter(
"better_auth.surrealdb.operations_total",
{
description:
"Better-Auth SurrealDB adapter operations (per model + outcome)",
}
);
const histogram = meter.createHistogram(
"better_auth.surrealdb.operation_duration_ms",
{
description:
"Better-Auth SurrealDB adapter operation duration in ms",
unit: "ms",
}
);
cached = { counter, histogram };
return cached;
}
/**
* Test-only reset hook — clears the lazy-init cache so the next call
* re-resolves against the current global `MeterProvider`. The adapter's
* production paths never call this.
*/
export function resetInstrumentsForTesting(): void {
cached = null;
}
export type AdapterOutcome = "success" | "error";
export interface InstrumentedOp<T> {
readonly model: string;
readonly operation: string;
run: () => Promise<T>;
}
/**
* Run `op()` while recording one counter point + one histogram observation.
* The counter's `outcome` attribute distinguishes successes from failures
* (without swallowing the error — failures rethrow).
*/
export async function instrument<T>(op: InstrumentedOp<T>): Promise<T> {
const { counter, histogram } = getInstruments();
const start = performance.now();
try {
const result = await op.run();
const elapsed = performance.now() - start;
counter.add(1, {
model: op.model,
operation: op.operation,
outcome: "success" satisfies AdapterOutcome,
});
histogram.record(elapsed, { operation: op.operation });
return result;
} catch (err) {
const elapsed = performance.now() - start;
counter.add(1, {
model: op.model,
operation: op.operation,
outcome: "error" satisfies AdapterOutcome,
});
histogram.record(elapsed, { operation: op.operation });
throw err;
}
}

View File

@@ -0,0 +1,4 @@
export {
applyBetterAuthSchema,
BETTER_AUTH_SURREAL_SCHEMA,
} from "./schema.js";

View File

@@ -0,0 +1,75 @@
# schema
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Bundles the canonical 17-table Better-Auth SurrealQL schema as a frozen
string constant `BETTER_AUTH_SURREAL_SCHEMA` and exposes a one-shot
`applyBetterAuthSchema(client)` helper for callers who do not run their
DDL through `@suaveplan/api-migrations`. Schema content is mirrored
byte-for-byte from the canonical source-of-truth file
`/mnt/f/surrealdb/surql/features/02-auth/02-better-auth.surql`; future
upstream changes require a coordinated package version bump.
## Features
- 17 DDL targets: `user` field extensions, plus `session`, `account`,
`verification`, `passkey`, `jwks`, `sso_provider`, `oauth_application`,
`oauth_access_token`, `oauth_consent`, `organization`, `member`,
`invitation`, `team`, `team_member`, `two_factor`, `api_key`.
- 13 indexes (account provider, verification identifier, SSO provider id,
oauth client/access/refresh tokens, organization slug, passkey
credential, user phone, api_key prefix/user, member/team uniqueness,
invitation email/org).
- Idempotent on re-application — every `DEFINE TABLE`/`DEFINE FIELD`/
`DEFINE INDEX` uses the `OVERWRITE` flag.
- `applyBetterAuthSchema(client)` executes the schema via the
`SurrealClientView.execute()` surface and wraps any underlying failure
in `BetterAuthSurrealError(QUERY_FAILED)`.
## Usage: Basic
```ts
import { SurrealAdapter } from "@suaveplan/surrealdb";
import { applyBetterAuthSchema } from "@suaveplan/better-auth-surrealdb/schema";
const client = new SurrealAdapter({ url: "ws://localhost:8000" });
await client.connect();
await applyBetterAuthSchema(client);
```
## Usage: Advanced
```ts
import { BETTER_AUTH_SURREAL_SCHEMA } from "@suaveplan/better-auth-surrealdb/schema";
// Pipe the schema through @suaveplan/api-migrations
import { createSurrealqlAdapter } from "@suaveplan/api-migrations";
const migrations = createSurrealqlAdapter({
steps: [
{ id: "001-better-auth", up: BETTER_AUTH_SURREAL_SCHEMA },
],
});
await migrations.run(client);
```
## API Reference
- `BETTER_AUTH_SURREAL_SCHEMA: string` — frozen DDL string.
- `applyBetterAuthSchema(client: SurrealClientView): Promise<void>` — executes
the schema via `client.execute(...)`; wraps failures in `QUERY_FAILED`.
## Implementation Notes
The constant is a single string literal so bundlers can tree-shake it out
of consumers that import only `applyBetterAuthSchema` from sibling
modules. Newlines are preserved (`\n`) so EXPLAIN output and DB logs stay
human-readable. The helper executes the whole DDL in one `execute()`
call; SurrealDB v3 parses multi-statement scripts natively and runs them
sequentially within a single response. Failure capture preserves the
underlying `SurrealError` via `cause` for upstream telemetry.

View File

@@ -0,0 +1,98 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { BetterAuthSurrealError } from "../error-codes/error-codes.js";
import type { SurrealClientView } from "../types/adapter.js";
import { applyBetterAuthSchema, BETTER_AUTH_SURREAL_SCHEMA } from "./schema.js";
const EXPECTED_TABLES = [
"session",
"account",
"verification",
"passkey",
"jwks",
"sso_provider",
"oauth_application",
"oauth_access_token",
"oauth_consent",
"organization",
"member",
"invitation",
"team",
"team_member",
"two_factor",
"api_key",
] as const;
describe("BETTER_AUTH_SURREAL_SCHEMA", () => {
it("contains every required table via DEFINE TABLE OVERWRITE", {
timeout: 5000,
}, () => {
for (const table of EXPECTED_TABLES) {
const re = new RegExp(`DEFINE TABLE OVERWRITE ${table}\\b`);
expect(BETTER_AUTH_SURREAL_SCHEMA).toMatch(re);
}
});
it("extends the user table with field-only definitions (no DEFINE TABLE user)", {
timeout: 5000,
}, () => {
expect(BETTER_AUTH_SURREAL_SCHEMA).not.toMatch(
/DEFINE TABLE OVERWRITE user\b/
);
expect(BETTER_AUTH_SURREAL_SCHEMA).toMatch(
/DEFINE FIELD OVERWRITE emailVerified ON user\b/
);
});
it("declares 13+ indexes", { timeout: 5000 }, () => {
const count = (
BETTER_AUTH_SURREAL_SCHEMA.match(/DEFINE INDEX OVERWRITE/g) ?? []
).length;
expect(count).toBeGreaterThanOrEqual(13);
});
});
describe("applyBetterAuthSchema", () => {
it("executes the schema via client.execute", {
timeout: 5000,
}, async () => {
const calls: string[] = [];
const client: SurrealClientView = {
isConnected: true,
async query() {
return [];
},
async execute(sql: string) {
calls.push(sql);
return { rowsAffected: 0 };
},
};
await applyBetterAuthSchema(client);
expect(calls).toHaveLength(1);
expect(calls[0]).toBe(BETTER_AUTH_SURREAL_SCHEMA);
});
it("wraps execution failure in QUERY_FAILED", {
timeout: 5000,
}, async () => {
const client: SurrealClientView = {
isConnected: true,
async query() {
return [];
},
async execute() {
throw new Error("boom");
},
};
await expect(applyBetterAuthSchema(client)).rejects.toThrow(
BetterAuthSurrealError
);
try {
await applyBetterAuthSchema(client);
} catch (err) {
expect((err as BetterAuthSurrealError).code).toBe(
"BETTER_AUTH_SURREALDB.QUERY_FAILED"
);
expect((err as Error).cause).toBeDefined();
}
});
});

Some files were not shown because too many files have changed in this diff Show More