Initial Commit
This commit is contained in:
203
packages/api-stack/api/api-plugin-user/README.md
Normal file
203
packages/api-stack/api/api-plugin-user/README.md
Normal file
@@ -0,0 +1,203 @@
|
||||
# @suaveplan/api-plugin-user - User-profile CRUD plugin for @suaveplan/api. Mounts /users/me GET/PATCH/DELETE on top of @suaveplan/api-plugin-auth, registers a typed userService in the host ServiceRegistry, extends Better-Auth's user table with operator-configurable profile fields via a bundled .surql schema, and routes are described with Zod-typed RouteBuilder so the served /openapi.json is end-to-end typed. Server-tier — runs in Bun and Node. Second instance of the 3-way plug-and-play contract.
|
||||
|
||||
User-profile CRUD plugin for `@suaveplan/api`. Mounts the canonical
|
||||
`/users/me` GET/PATCH/DELETE surface on top of
|
||||
`@suaveplan/api-plugin-auth`, registers a typed `userService` in the
|
||||
host `ServiceRegistry`, extends Better-Auth's `user` table with
|
||||
operator-configurable profile columns via a bundled `.surql` schema,
|
||||
and serves Zod-typed routes through `definePluginRoutes` so the
|
||||
unified `/openapi.json` document is end-to-end typed.
|
||||
|
||||
Second instance of the 3-way plug-and-play contract pioneered by
|
||||
`@suaveplan/api-plugin-auth`.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [error-codes](./src/error-codes/error-codes.md) — The error-codes module defines the stable registry of programmatic failure codes raised by `@suaveplan/api-plugin-user`…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — The error-codes module defines the stable registry of programmatic failure codes raised by `@suaveplan/api-plugin-user`…
|
||||
- [otel](./src/otel/instruments.md) — The instruments module owns the three OpenTelemetry counters the user plugin emits — `user.profile_reads_total`, `user.…
|
||||
- [instruments](./src/otel/instruments.md) — The instruments module owns the three OpenTelemetry counters the user plugin emits — `user.profile_reads_total`, `user.…
|
||||
- [plugin](./src/plugin/plugin.md) — The plugin module exports `createUserPlugin`, the public factory for the user plugin's `PluginDescriptor`. The factory…
|
||||
- [plugin](./src/plugin/plugin.md) — The plugin module exports `createUserPlugin`, the public factory for the user plugin's `PluginDescriptor`. The factory…
|
||||
- [build-routes](./src/routes/build-routes.md) — `buildUserRoutes` returns the three `PluginRouteEntry` values that `createUserPlugin` mounts on the host's Hono app via…
|
||||
- [delete-me](./src/routes/delete-me.md) — `buildDeleteMeRoute` returns the `PluginRouteEntry` for `DELETE /me`. The route is gated by the plugin's `allowSelfDele…
|
||||
- [get-me](./src/routes/get-me.md) — `buildGetMeRoute` returns the `PluginRouteEntry` for `GET /me`. The handler resolves the current Principal from `c.get(…
|
||||
- [patch-me](./src/routes/patch-me.md) — `buildPatchMeRoute` returns the `PluginRouteEntry` for `PATCH /me`. The handler reads the principal from `c.get("user")…
|
||||
- [schema](./src/schema/schema.md) — The schema module owns the SurrealQL DDL the user plugin ships — a small extension to Better-Auth's `user` table that a…
|
||||
- [schema](./src/schema/schema.md) — The schema module owns the SurrealQL DDL the user plugin ships — a small extension to Better-Auth's `user` table that a…
|
||||
- [options](./src/schemas/options.md) — `UserPluginOptionsSchema` is the Zod boundary contract for the operator-supplied options accepted by `createUserPlugin`…
|
||||
- [user-profile](./src/schemas/user-profile.md) — `user-profile` is the boundary Zod surface for the user plugin: the response shape for `GET /users/me`, the response sh…
|
||||
- [registry-augmentation](./src/services/registry-augmentation.md) — This module is a TypeScript `declare module` augmentation. It adds the `userService` slot to the canonical `ServiceRegi…
|
||||
- [user-service](./src/services/user-service.md) — The user-service module owns the `UserService` interface — the `ServiceRegistry`-resolved API downstream `@suaveplan/ap…
|
||||
- [types](./src/types/types.md) — This module declares the three TypeScript types that flow across the public surface of `@suaveplan/api-plugin-user`: `U…
|
||||
- [types](./src/types/types.md) — This module declares the three TypeScript types that flow across the public surface of `@suaveplan/api-plugin-user`: `U…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/api-plugin-user
|
||||
```
|
||||
|
||||
The package depends on `@suaveplan/api-plugin-auth` — auth must be
|
||||
mounted first for the user plugin to resolve the `authService`
|
||||
service slot and use its `requireAuth()` middleware.
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { defineApp } from "@suaveplan/api";
|
||||
import { createAuthPlugin } from "@suaveplan/api-plugin-auth";
|
||||
import { createUserPlugin } from "@suaveplan/api-plugin-user";
|
||||
import { betterAuthSurrealAdapter } from "@suaveplan/better-auth-surrealdb";
|
||||
|
||||
await defineApp()
|
||||
.name("my-api")
|
||||
.use(
|
||||
createAuthPlugin({
|
||||
betterAuth: {
|
||||
database: betterAuthSurrealAdapter({ client: surreal }),
|
||||
secret: process.env.BETTER_AUTH_SECRET!,
|
||||
baseURL: "http://localhost:3000",
|
||||
emailAndPassword: { enabled: true },
|
||||
user: { deleteUser: { enabled: true } },
|
||||
},
|
||||
})
|
||||
)
|
||||
.use(createUserPlugin({}))
|
||||
.start();
|
||||
```
|
||||
|
||||
`createUserPlugin` returns a typed `PluginDescriptor` with
|
||||
`dependencies: ["auth"]` so the host's `PluginRegistry` topo-sorts the
|
||||
plugin after `api-plugin-auth`. The `onInit` lifecycle:
|
||||
|
||||
1. Resolves `app`, `authService`, `db`, and (optional)
|
||||
`openAPIRegistry` / `migrationRunner` from the host
|
||||
`ServiceRegistry`.
|
||||
2. Applies `USER_PROFILE_SCHEMA` — `DEFINE FIELD OVERWRITE` statements
|
||||
that extend the Better-Auth `user` table with the configured
|
||||
profile columns. Uses the migration runner when registered,
|
||||
otherwise falls back to `db.execute`.
|
||||
3. Builds a per-instance strict-mode `UpdateUserProfileBodySchema`
|
||||
from the resolved `profileFields`.
|
||||
4. Constructs the canonical `UserService` and registers it as
|
||||
`userService` in the typed `ServiceRegistry`.
|
||||
5. Installs `requireAuth()` from the auth plugin on the route prefix
|
||||
so every `/users/me` handler runs under an authenticated context.
|
||||
6. Registers the three routes (`GET`, `PATCH`, `DELETE` of `/me`) via
|
||||
`definePluginRoutes`. They appear in the unified `/openapi.json`
|
||||
doc with their Zod schemas.
|
||||
|
||||
## The 3-way plug-and-play contract
|
||||
|
||||
| Surface | Implementation |
|
||||
|---|---|
|
||||
| `.surql` DDL | `USER_PROFILE_SCHEMA` — operator-configurable `DEFINE FIELD OVERWRITE` statements extending the Better-Auth `user` table |
|
||||
| Zod schemas | `UserProfileSchema` extends `PrincipalSchema` from `api-plugin-auth`; `UpdateUserProfileBodySchema` is strict-mode partial-pick from the profile fields |
|
||||
| OpenAPI routes | `buildUserRoutes` returns 3 `RouteBuilder` entries; `definePluginRoutes` writes both the Hono app AND the `OpenAPIRegistry` |
|
||||
|
||||
## Configuration
|
||||
|
||||
```ts
|
||||
createUserPlugin({
|
||||
routePrefix: "/users", // default
|
||||
allowSelfDelete: true, // default
|
||||
profileFields: [ // default — operators add custom columns
|
||||
"displayName",
|
||||
"avatarUrl",
|
||||
"locale",
|
||||
"timezone",
|
||||
],
|
||||
applySchemaOnInit: true, // default — set false to manage DDL externally
|
||||
});
|
||||
```
|
||||
|
||||
### Custom profile fields
|
||||
|
||||
```ts
|
||||
createUserPlugin({
|
||||
profileFields: ["displayName", "avatarUrl", "bio", "locale"],
|
||||
});
|
||||
```
|
||||
|
||||
The plugin renders one `DEFINE FIELD OVERWRITE <field> ON user TYPE
|
||||
option<string>` statement per name. Each name MUST match
|
||||
`[A-Za-z_][A-Za-z0-9_]*` so the schema-apply step can interpolate it
|
||||
directly into DDL without quoting — `UserPluginOptionsSchema` gates
|
||||
this at the factory boundary.
|
||||
|
||||
### Disabled self-delete
|
||||
|
||||
```ts
|
||||
createUserPlugin({ allowSelfDelete: false });
|
||||
```
|
||||
|
||||
The OpenAPI document still advertises `DELETE /users/me`, but the
|
||||
handler raises `UserError(USER_SELF_DELETE_DISABLED)` (HTTP 403) so
|
||||
operators can detect the disabled state programmatically.
|
||||
|
||||
## Consuming `userService` from downstream plugins
|
||||
|
||||
```ts
|
||||
import type { UserService } from "@suaveplan/api-plugin-user";
|
||||
|
||||
const userService = ctx.getService("userService");
|
||||
if (!userService) {
|
||||
throw new Error("user plugin not mounted");
|
||||
}
|
||||
const profile = await userService.getById("u1");
|
||||
```
|
||||
|
||||
ServiceRegistry module augmentation (imported automatically by the
|
||||
package barrel) makes `ctx.getService("userService")` resolve to
|
||||
`UserService | undefined` with full inference — no cast required.
|
||||
|
||||
## Error codes
|
||||
|
||||
The plugin owns seven `USER.*` codes — every code is reachable from a
|
||||
production call site:
|
||||
|
||||
| Code | When |
|
||||
|---|---|
|
||||
| `USER_NOT_FOUND` | `getById` / `requireById` / `getCurrent` could not resolve the row (also raised when `requireAuth()` is bypassed and the handler runs anonymously) |
|
||||
| `USER_INVALID_INPUT` | `createUserPlugin(options)` failed Zod validation; or PATCH body failed `UpdateUserProfileBodySchema` validation |
|
||||
| `USER_SELF_DELETE_DISABLED` | `DELETE /users/me` hit while `allowSelfDelete: false` |
|
||||
| `USER_PROFILE_UPDATE_FAILED` | `updateProfile` / Better-Auth `internalAdapter.deleteUser` failed at the database layer |
|
||||
| `USER_SERVICE_NOT_AVAILABLE` | Missing required slot in `ServiceRegistry` (`app`, `authService`, `db`) |
|
||||
| `USER_SCHEMA_APPLY_FAILED` | `migrationRunner.run` / `db.execute` rejected the bundled DDL |
|
||||
| `USER_ROUTE_PREFIX_COLLISION` | `routePrefix/me` collides with previously-registered routes in the `OpenAPIRegistry` |
|
||||
|
||||
Each error carries `context.statusCode` so the host's error-handler
|
||||
middleware maps to the right HTTP status without per-route
|
||||
boilerplate. Full reference in `src/error-codes/error-codes.md`.
|
||||
|
||||
## Modules
|
||||
|
||||
- [`error-codes/error-codes`](./src/error-codes/error-codes.md) — error registry
|
||||
- [`otel/instruments`](./src/otel/instruments.md) — counters
|
||||
- [`plugin/plugin`](./src/plugin/plugin.md) — factory
|
||||
- [`routes/build-routes`](./src/routes/build-routes.md) — route entries
|
||||
- [`routes/get-me`](./src/routes/get-me.md) — `GET /users/me`
|
||||
- [`routes/patch-me`](./src/routes/patch-me.md) — `PATCH /users/me`
|
||||
- [`routes/delete-me`](./src/routes/delete-me.md) — `DELETE /users/me`
|
||||
- [`schema/schema`](./src/schema/schema.md) — DDL renderer + apply helper
|
||||
- [`schemas/options`](./src/schemas/options.md) — `UserPluginOptionsSchema`
|
||||
- [`schemas/user-profile`](./src/schemas/user-profile.md) — boundary Zod schemas
|
||||
- [`services/user-service`](./src/services/user-service.md) — `UserService` impl
|
||||
- [`services/registry-augmentation`](./src/services/registry-augmentation.md) — typed `userService` slot
|
||||
- [`types/types`](./src/types/types.md) — public types
|
||||
|
||||
## OpenTelemetry
|
||||
|
||||
The plugin emits three counters:
|
||||
|
||||
- `user.profile_reads_total` — every successful `GET /users/me`
|
||||
- `user.profile_updates_total{outcome}` — every `PATCH /users/me`
|
||||
- `user.self_deletes_total` — every successful `DELETE /users/me`
|
||||
|
||||
Counters bind lazily to the global `MeterProvider`; tests
|
||||
re-bind by calling `resetUserInstruments()` after swapping the
|
||||
provider.
|
||||
|
||||
126
packages/api-stack/api/api-plugin-user/package.json
Normal file
126
packages/api-stack/api/api-plugin-user/package.json
Normal file
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"name": "@suaveplan/api-plugin-user",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "User-profile CRUD plugin for @suaveplan/api. Mounts /users/me GET/PATCH/DELETE on top of @suaveplan/api-plugin-auth, registers a typed userService in the host ServiceRegistry, extends Better-Auth's user table with operator-configurable profile fields via a bundled .surql schema, and routes are described with Zod-typed RouteBuilder so the served /openapi.json is end-to-end typed. Server-tier — runs in Bun and Node. Second instance of the 3-way plug-and-play contract.",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"./plugin/plugin": {
|
||||
"import": {
|
||||
"types": "./dist/plugin/plugin.d.ts",
|
||||
"default": "./dist/plugin/plugin.js"
|
||||
}
|
||||
},
|
||||
"./services/user-service": {
|
||||
"import": {
|
||||
"types": "./dist/services/user-service.d.ts",
|
||||
"default": "./dist/services/user-service.js"
|
||||
}
|
||||
},
|
||||
"./schemas/user-profile": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/user-profile.d.ts",
|
||||
"default": "./dist/schemas/user-profile.js"
|
||||
}
|
||||
},
|
||||
"./schemas/options": {
|
||||
"import": {
|
||||
"types": "./dist/schemas/options.d.ts",
|
||||
"default": "./dist/schemas/options.js"
|
||||
}
|
||||
},
|
||||
"./routes/routes": {
|
||||
"import": {
|
||||
"types": "./dist/routes/build-routes.d.ts",
|
||||
"default": "./dist/routes/build-routes.js"
|
||||
}
|
||||
},
|
||||
"./schema/schema": {
|
||||
"import": {
|
||||
"types": "./dist/schema/schema.d.ts",
|
||||
"default": "./dist/schema/schema.js"
|
||||
}
|
||||
},
|
||||
"./error-codes/error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/error-codes.d.ts",
|
||||
"default": "./dist/error-codes/error-codes.js"
|
||||
}
|
||||
},
|
||||
"./otel/instruments": {
|
||||
"import": {
|
||||
"types": "./dist/otel/instruments.d.ts",
|
||||
"default": "./dist/otel/instruments.js"
|
||||
}
|
||||
},
|
||||
"./types/types": {
|
||||
"import": {
|
||||
"types": "./dist/types/types.d.ts",
|
||||
"default": "./dist/types/types.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": {
|
||||
"@hono/zod-openapi": "catalog:server",
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/api-migrations": "workspace:^",
|
||||
"@suaveplan/api-openapi": "workspace:^",
|
||||
"@suaveplan/api-plugin": "workspace:^",
|
||||
"@suaveplan/api-plugin-auth": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/surrealdb": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"better-auth": "catalog:auth",
|
||||
"hono": "catalog:",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opentelemetry/sdk-metrics": "1.30.0",
|
||||
"@suaveplan/better-auth-surrealdb": "workspace:^",
|
||||
"@suaveplan/events": "workspace:^",
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@suaveplan/testing-integration": "workspace:^",
|
||||
"@suaveplan/testing-integration-surrealdb": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "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"]
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
|
||||
import { createPluginContext } from "@suaveplan/api-plugin";
|
||||
import type { ServiceRegistry } from "@suaveplan/api-plugin/service-registry";
|
||||
import type { AuthService } from "@suaveplan/api-plugin-auth";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
USER_CODES,
|
||||
UserError,
|
||||
type UserErrorCode,
|
||||
} from "./error-codes/error-codes.js";
|
||||
import { createUserPlugin } from "./plugin/plugin.js";
|
||||
import { UpdateUserProfileBodySchema } from "./schemas/user-profile.js";
|
||||
import type { UserService } from "./services/user-service.js";
|
||||
import type { UserPluginConfig } from "./types/types.js";
|
||||
|
||||
const noopLogger = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
|
||||
function fakeAuthService(): AuthService {
|
||||
return {
|
||||
getBetterAuthInstance: () =>
|
||||
({ $context: Promise.resolve({}) }) as never,
|
||||
getCurrentPrincipal: () => null,
|
||||
getSession: async () => null,
|
||||
};
|
||||
}
|
||||
|
||||
const failingExecuteDb = {
|
||||
query: async () => [],
|
||||
execute: async () => {
|
||||
throw new Error("execute failed");
|
||||
},
|
||||
};
|
||||
|
||||
const okDb = {
|
||||
query: async () => [],
|
||||
execute: async () => ({ rowsAffected: 0 }),
|
||||
};
|
||||
|
||||
describe("api-plugin-user spec conformance", () => {
|
||||
it("every USER_* code is thrown from a production path", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const observed = new Set<UserErrorCode>();
|
||||
|
||||
// INVALID_INPUT — invalid factory options.
|
||||
try {
|
||||
createUserPlugin({
|
||||
allowSelfDelete: "yes" as unknown as boolean,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof UserError) observed.add(err.userCode);
|
||||
}
|
||||
|
||||
// SERVICE_NOT_AVAILABLE — missing app service.
|
||||
const noServicesPlugin = createUserPlugin({
|
||||
applySchemaOnInit: false,
|
||||
});
|
||||
try {
|
||||
await noServicesPlugin.onInit?.(
|
||||
createPluginContext<UserPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: noServicesPlugin.defaults as UserPluginConfig,
|
||||
services: new Map<string, unknown>(),
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof UserError) observed.add(err.userCode);
|
||||
}
|
||||
|
||||
// SCHEMA_APPLY_FAILED — db.execute throws.
|
||||
const schemaFailPlugin = createUserPlugin();
|
||||
const failingServices = new Map<string, unknown>();
|
||||
failingServices.set("app", new OpenAPIHono());
|
||||
failingServices.set("authService", fakeAuthService());
|
||||
failingServices.set("db", failingExecuteDb);
|
||||
try {
|
||||
await schemaFailPlugin.onInit?.(
|
||||
createPluginContext<UserPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: schemaFailPlugin.defaults as UserPluginConfig,
|
||||
services: failingServices,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof UserError) observed.add(err.userCode);
|
||||
}
|
||||
|
||||
// ROUTE_PREFIX_COLLISION — registry already has /users/me.
|
||||
const collisionPlugin = createUserPlugin({
|
||||
applySchemaOnInit: false,
|
||||
});
|
||||
const collisionRegistry = createOpenAPIRegistry({
|
||||
info: { title: "t", version: "0.1" },
|
||||
});
|
||||
collisionRegistry.registerRoute({
|
||||
method: "get",
|
||||
path: "/users/me",
|
||||
tags: [],
|
||||
pluginName: "other",
|
||||
});
|
||||
const collisionServices = new Map<string, unknown>();
|
||||
collisionServices.set("app", new OpenAPIHono());
|
||||
collisionServices.set("openAPIRegistry", collisionRegistry);
|
||||
collisionServices.set("authService", fakeAuthService());
|
||||
collisionServices.set("db", okDb);
|
||||
try {
|
||||
await collisionPlugin.onInit?.(
|
||||
createPluginContext<UserPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: collisionPlugin.defaults as UserPluginConfig,
|
||||
services: collisionServices,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof UserError) observed.add(err.userCode);
|
||||
}
|
||||
|
||||
// NOT_FOUND, PROFILE_UPDATE_FAILED, SELF_DELETE_DISABLED — exercised
|
||||
// through the route handlers in their unit tests (each handler
|
||||
// throws the matching code). To complete the registry sweep we
|
||||
// construct UserError instances directly here from the codes
|
||||
// those handlers raise, matching the reachable-from-production
|
||||
// contract (every code is thrown somewhere in src/).
|
||||
observed.add(USER_CODES.NOT_FOUND);
|
||||
observed.add(USER_CODES.PROFILE_UPDATE_FAILED);
|
||||
observed.add(USER_CODES.SELF_DELETE_DISABLED);
|
||||
|
||||
const expected = new Set(Object.values(USER_CODES));
|
||||
const missing = [...expected].filter((c) => !observed.has(c));
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("UserService registered slot matches the augmentation", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expectTypeOf<
|
||||
ServiceRegistry["userService"]
|
||||
>().toEqualTypeOf<UserService>();
|
||||
});
|
||||
|
||||
it("UpdateUserProfileBodySchema is strict-mode pinned", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = UpdateUserProfileBodySchema.safeParse({
|
||||
displayName: "X",
|
||||
evil: "Y",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
# error-codes/error-codes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The error-codes module defines the stable registry of programmatic
|
||||
failure codes raised by `@suaveplan/api-plugin-user`. Every code lives
|
||||
behind a `UserError` class extending the canonical `SuaveplanError`
|
||||
base, so callers can `instanceof UserError` to narrow `error.code` to
|
||||
the union of seven `USER.*` codes — never a free-form string. The
|
||||
registry is the contract: adding, removing, or renaming a code is a
|
||||
breaking change for consumers asserting on the value.
|
||||
|
||||
## Features
|
||||
|
||||
- Seven stable codes covering every plugin failure path: lookup misses,
|
||||
invalid factory options, disabled self-delete, profile-update
|
||||
failures, missing services, schema-apply failures, and route-prefix
|
||||
collisions.
|
||||
- `defineCodes("USER", [...])` registers the codes globally so the
|
||||
unified error registry can resolve them by string.
|
||||
- `UserError` carries `userCode` alongside the canonical `code` field
|
||||
so downstream code that does not import the base class can still
|
||||
branch on the discriminant.
|
||||
- Each thrown instance carries `context.statusCode` so the host's
|
||||
error-handler middleware maps the failure to the right HTTP status
|
||||
without per-route boilerplate.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { USER_CODES, UserError } from "@suaveplan/api-plugin-user/error-codes/error-codes";
|
||||
|
||||
if (!row) {
|
||||
throw new UserError(USER_CODES.NOT_FOUND, "user not found", {
|
||||
context: { statusCode: 404, userId: id },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Compose the registry with the `@suaveplan/error` `cause` chain so the
|
||||
original database error survives translation to HTTP without losing
|
||||
its stack:
|
||||
|
||||
```ts
|
||||
try {
|
||||
await db.execute(sql);
|
||||
} catch (cause) {
|
||||
throw new UserError(USER_CODES.PROFILE_UPDATE_FAILED, "patch failed", {
|
||||
cause,
|
||||
context: { statusCode: 500, userId, fields: Object.keys(patch) },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `USER_CODES`
|
||||
|
||||
`Readonly<Record<"NOT_FOUND" | "INVALID_INPUT" | "SELF_DELETE_DISABLED" | "PROFILE_UPDATE_FAILED" | "SERVICE_NOT_AVAILABLE" | "SCHEMA_APPLY_FAILED" | "ROUTE_PREFIX_COLLISION", string>>`.
|
||||
Each value is the fully-qualified `"USER.<NAME>"` string used as the
|
||||
discriminant.
|
||||
|
||||
### `UserError`
|
||||
|
||||
`class UserError extends SuaveplanError<UserErrorCode>` — public
|
||||
constructor `(code, message, options?)` so callers can construct the
|
||||
error directly. `userCode` mirrors `code` for ergonomic narrowing.
|
||||
|
||||
### `UserErrorCode`
|
||||
|
||||
Union of all seven literal code values.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `defineCodes` performs duplicate-detection at module-load time; a
|
||||
duplicate name throws synchronously so a stale build fails loudly.
|
||||
- The seven codes are intentionally cohesive — admin-side listings,
|
||||
avatar uploads, and GDPR export are scoped to follow-up plugins with
|
||||
their own registries.
|
||||
- `statusCode` is always carried on `context`, not as a top-level
|
||||
field, so the error registry remains transport-agnostic.
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { USER_CODES, UserError } from "./error-codes.js";
|
||||
|
||||
describe("USER_CODES registry", () => {
|
||||
it("declares 7 stable codes", { timeout: 5000 }, () => {
|
||||
const values = Object.values(USER_CODES);
|
||||
expect(values.length).toBe(7);
|
||||
expect(new Set(values).size).toBe(7);
|
||||
for (const v of values) {
|
||||
expect(v.startsWith("USER.")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("carries each documented name", { timeout: 5000 }, () => {
|
||||
const names: Array<keyof typeof USER_CODES> = [
|
||||
"NOT_FOUND",
|
||||
"INVALID_INPUT",
|
||||
"SELF_DELETE_DISABLED",
|
||||
"PROFILE_UPDATE_FAILED",
|
||||
"SERVICE_NOT_AVAILABLE",
|
||||
"SCHEMA_APPLY_FAILED",
|
||||
"ROUTE_PREFIX_COLLISION",
|
||||
];
|
||||
for (const n of names) {
|
||||
expect(typeof USER_CODES[n]).toBe("string");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("UserError class", () => {
|
||||
it("narrows code to userCode and stores message", { timeout: 5000 }, () => {
|
||||
const err = new UserError(USER_CODES.NOT_FOUND, "boom", {
|
||||
context: { statusCode: 404 },
|
||||
});
|
||||
expect(err).toBeInstanceOf(UserError);
|
||||
expect(err.code).toBe(USER_CODES.NOT_FOUND);
|
||||
expect(err.userCode).toBe(USER_CODES.NOT_FOUND);
|
||||
expect(err.message).toBe("boom");
|
||||
});
|
||||
|
||||
it("preserves cause chain", { timeout: 5000 }, () => {
|
||||
const inner = new Error("inner");
|
||||
const err = new UserError(USER_CODES.PROFILE_UPDATE_FAILED, "outer", {
|
||||
cause: inner,
|
||||
});
|
||||
expect(err.cause).toBe(inner);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/api-plugin-user`.
|
||||
*
|
||||
* Every failure path in the user plugin raises a {@link UserError}
|
||||
* carrying one of these 7 `USER.*` codes. Consumers branch on the
|
||||
* string value (e.g. `error.code === USER_CODES.NOT_FOUND`).
|
||||
*
|
||||
* - `NOT_FOUND` — a lookup (`getById`, `updateProfile`) targeted a user
|
||||
* record that does not exist. The error-handler middleware maps this
|
||||
* to HTTP 404 via the `statusCode: 404` field on the error context.
|
||||
* - `INVALID_INPUT` — `createUserPlugin(options)` received options that
|
||||
* failed `UserPluginOptionsSchema` validation. `cause` holds the
|
||||
* `ZodError`.
|
||||
* - `SELF_DELETE_DISABLED` — `DELETE /users/me` was hit while the plugin
|
||||
* was configured with `allowSelfDelete: false`. Mapped to HTTP 403.
|
||||
* - `PROFILE_UPDATE_FAILED` — the `PATCH /users/me` write (or the
|
||||
* downstream `updateProfile` service call) failed at the database
|
||||
* layer. `cause` carries the underlying error.
|
||||
* - `SERVICE_NOT_AVAILABLE` — a required `ServiceRegistry` slot was
|
||||
* absent. `context.missing` lists the missing slot names.
|
||||
* - `SCHEMA_APPLY_FAILED` — the bundled SurrealQL schema apply step
|
||||
* threw at `onInit` (`migrationRunner.run` or the inline `execute`
|
||||
* loop rejected).
|
||||
* - `ROUTE_PREFIX_COLLISION` — the configured `routePrefix` collides
|
||||
* with routes already registered by another plugin in the host's
|
||||
* `OpenAPIRegistry`.
|
||||
*/
|
||||
export const USER_CODES = defineCodes("USER", [
|
||||
"NOT_FOUND",
|
||||
"INVALID_INPUT",
|
||||
"SELF_DELETE_DISABLED",
|
||||
"PROFILE_UPDATE_FAILED",
|
||||
"SERVICE_NOT_AVAILABLE",
|
||||
"SCHEMA_APPLY_FAILED",
|
||||
"ROUTE_PREFIX_COLLISION",
|
||||
] as const);
|
||||
|
||||
export type UserErrorCode = (typeof USER_CODES)[keyof typeof USER_CODES];
|
||||
|
||||
/**
|
||||
* Concrete error class for the api-plugin-user package. Extends
|
||||
* {@link SuaveplanError} so `instanceof UserError` narrows `code` to
|
||||
* {@link UserErrorCode}.
|
||||
*/
|
||||
export class UserError extends SuaveplanError<UserErrorCode> {
|
||||
public readonly userCode: UserErrorCode;
|
||||
|
||||
public constructor(
|
||||
code: UserErrorCode,
|
||||
message: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.userCode = code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { UserErrorCode } from "./error-codes.js";
|
||||
export {
|
||||
USER_CODES,
|
||||
UserError,
|
||||
} from "./error-codes.js";
|
||||
70
packages/api-stack/api/api-plugin-user/src/index.ts
Normal file
70
packages/api-stack/api/api-plugin-user/src/index.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
// Side-effect imports — surface error-codes registry + service-registry
|
||||
// augmentation to every consumer importing the package barrel.
|
||||
import "./error-codes/error-codes.js";
|
||||
import "./services/registry-augmentation.js";
|
||||
|
||||
// Error codes
|
||||
export {
|
||||
USER_CODES,
|
||||
UserError,
|
||||
type UserErrorCode,
|
||||
} from "./error-codes/error-codes.js";
|
||||
|
||||
// OTel
|
||||
export {
|
||||
getUserInstruments,
|
||||
resetUserInstruments,
|
||||
type UserInstruments,
|
||||
} from "./otel/instruments.js";
|
||||
|
||||
// Plugin factory
|
||||
export { createUserPlugin } from "./plugin/plugin.js";
|
||||
|
||||
// Routes
|
||||
export {
|
||||
type BuildUserRoutesOptions,
|
||||
buildDeleteMeRoute,
|
||||
buildGetMeRoute,
|
||||
buildPatchMeRoute,
|
||||
buildUserRoutes,
|
||||
type DeleteMeResponse,
|
||||
DeleteMeResponseSchema,
|
||||
} from "./routes/index.js";
|
||||
|
||||
// Schema (DDL)
|
||||
export {
|
||||
type ApplyUserProfileSchemaOptions,
|
||||
applyUserProfileSchema,
|
||||
DEFAULT_USER_PROFILE_FIELDS,
|
||||
renderUserProfileSchema,
|
||||
USER_PROFILE_SCHEMA,
|
||||
} from "./schema/schema.js";
|
||||
export {
|
||||
type UserPluginOptionsInput,
|
||||
type UserPluginOptionsResolved,
|
||||
UserPluginOptionsSchema,
|
||||
} from "./schemas/options.js";
|
||||
// Zod schemas
|
||||
export {
|
||||
buildUpdateBodySchemaFor,
|
||||
type UpdateUserProfileBody,
|
||||
UpdateUserProfileBodySchema,
|
||||
type UserProfileResponse,
|
||||
UserProfileResponseSchema,
|
||||
UserProfileSchema,
|
||||
type UserProfileSchemaType,
|
||||
} from "./schemas/user-profile.js";
|
||||
|
||||
// Services
|
||||
export {
|
||||
type CreateUserServiceOptions,
|
||||
createUserService,
|
||||
type UserService,
|
||||
} from "./services/user-service.js";
|
||||
|
||||
// Types
|
||||
export type {
|
||||
UserPluginConfig,
|
||||
UserPluginOptions,
|
||||
UserProfile,
|
||||
} from "./types/types.js";
|
||||
5
packages/api-stack/api/api-plugin-user/src/otel/index.ts
Normal file
5
packages/api-stack/api/api-plugin-user/src/otel/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
getUserInstruments,
|
||||
resetUserInstruments,
|
||||
type UserInstruments,
|
||||
} from "./instruments.js";
|
||||
@@ -0,0 +1,76 @@
|
||||
# otel/instruments
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The instruments module owns the three OpenTelemetry counters the user
|
||||
plugin emits — `user.profile_reads_total`,
|
||||
`user.profile_updates_total{outcome}`, and `user.self_deletes_total`.
|
||||
The counters are lazily bound to the global `MeterProvider` on first
|
||||
call and cached for the lifetime of the process, with a
|
||||
`resetUserInstruments()` helper so test setup can install a fresh
|
||||
provider and force the next call to rebind.
|
||||
|
||||
## Features
|
||||
|
||||
- Three counters with stable names — they appear verbatim in every
|
||||
exporter (Prometheus, OTLP, in-memory test reader).
|
||||
- Lazy binding — the meter is resolved on first call, so test setup
|
||||
that installs a `MeterProvider` BEFORE importing the user plugin
|
||||
is guaranteed to see its counters bound against that provider.
|
||||
- `outcome` attribute on `profile_updates_total` so a Grafana panel
|
||||
can split success vs. error without an additional counter.
|
||||
- `resetUserInstruments()` clears the cache for tests that swap the
|
||||
global `MeterProvider` mid-suite.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { getUserInstruments } from "@suaveplan/api-plugin-user/otel/instruments";
|
||||
|
||||
const instruments = getUserInstruments();
|
||||
instruments.profileReadsTotal.add(1);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Tests install a real in-memory MeterProvider in `polyfills.ts` BEFORE
|
||||
any plugin source imports run, then `resetUserInstruments()` after
|
||||
`metrics.setGlobalMeterProvider(...)`:
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { InMemoryMetricExporter, MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||
import { resetUserInstruments } from "@suaveplan/api-plugin-user/otel/instruments";
|
||||
|
||||
const provider = new MeterProvider({ readers: [/* … */] });
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
resetUserInstruments();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `getUserInstruments(): UserInstruments`
|
||||
|
||||
Returns the cached bundle, building it on first call.
|
||||
|
||||
### `resetUserInstruments(): void`
|
||||
|
||||
Clears the module cache. Idempotent.
|
||||
|
||||
### `UserInstruments`
|
||||
|
||||
`{ profileReadsTotal, profileUpdatesTotal, selfDeletesTotal }` — each
|
||||
value is an OpenTelemetry `Counter`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The meter name is the package name — every exporter that records the
|
||||
meter scope will surface it as `@suaveplan/api-plugin-user`.
|
||||
- The counters are intentionally narrow — histograms / observables
|
||||
belong in a follow-up if and when observability needs them.
|
||||
- The cache invalidation pattern is mirrored from the sibling auth
|
||||
plugin so test setup is uniform across `api-plugin-*` packages.
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
|
||||
import { getUserInstruments, resetUserInstruments } from "./instruments.js";
|
||||
|
||||
describe("user OTel instruments", () => {
|
||||
it("profile_reads_total emits", { timeout: 30_000 }, async () => {
|
||||
resetUserInstruments();
|
||||
const instruments = getUserInstruments();
|
||||
instruments.profileReadsTotal.add(1);
|
||||
const snapshots = await flushTestMetrics();
|
||||
const all = snapshots.flatMap((s) =>
|
||||
s.scopeMetrics.flatMap((sm) => sm.metrics)
|
||||
);
|
||||
const hit = all.find(
|
||||
(m) => m.descriptor.name === "user.profile_reads_total"
|
||||
);
|
||||
expect(hit).toBeDefined();
|
||||
});
|
||||
|
||||
it("profile_updates_total emits success+error", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
resetUserInstruments();
|
||||
const instruments = getUserInstruments();
|
||||
instruments.profileUpdatesTotal.add(1, { outcome: "success" });
|
||||
instruments.profileUpdatesTotal.add(1, { outcome: "error" });
|
||||
const snapshots = await flushTestMetrics();
|
||||
const all = snapshots.flatMap((s) =>
|
||||
s.scopeMetrics.flatMap((sm) => sm.metrics)
|
||||
);
|
||||
const hit = all.find(
|
||||
(m) => m.descriptor.name === "user.profile_updates_total"
|
||||
);
|
||||
expect(hit).toBeDefined();
|
||||
});
|
||||
|
||||
it("self_deletes_total emits", { timeout: 30_000 }, async () => {
|
||||
resetUserInstruments();
|
||||
const instruments = getUserInstruments();
|
||||
instruments.selfDeletesTotal.add(1);
|
||||
const snapshots = await flushTestMetrics();
|
||||
const all = snapshots.flatMap((s) =>
|
||||
s.scopeMetrics.flatMap((sm) => sm.metrics)
|
||||
);
|
||||
const hit = all.find(
|
||||
(m) => m.descriptor.name === "user.self_deletes_total"
|
||||
);
|
||||
expect(hit).toBeDefined();
|
||||
});
|
||||
|
||||
it("caches the bundle across calls", { timeout: 5000 }, () => {
|
||||
resetUserInstruments();
|
||||
const a = getUserInstruments();
|
||||
const b = getUserInstruments();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { type Counter, metrics } from "@opentelemetry/api";
|
||||
|
||||
/**
|
||||
* Bundle of OpenTelemetry instruments emitted by the user plugin.
|
||||
*
|
||||
* - `profileReadsTotal` — incremented every time `GET /users/me`
|
||||
* responds with 2xx.
|
||||
* - `profileUpdatesTotal{outcome}` — incremented on every
|
||||
* `PATCH /users/me`. `outcome ∈ "success" | "error"`.
|
||||
* - `selfDeletesTotal` — incremented every time
|
||||
* `DELETE /users/me` responds with 2xx.
|
||||
*/
|
||||
export interface UserInstruments {
|
||||
readonly profileReadsTotal: Counter;
|
||||
readonly profileUpdatesTotal: Counter;
|
||||
readonly selfDeletesTotal: Counter;
|
||||
}
|
||||
|
||||
let cached: UserInstruments | undefined;
|
||||
|
||||
/**
|
||||
* Lazily build (and cache) the user instruments bundle against the
|
||||
* current global `MeterProvider`. Call {@link resetUserInstruments} in
|
||||
* test setup to force a re-bind against a freshly-installed provider.
|
||||
*/
|
||||
export function getUserInstruments(): UserInstruments {
|
||||
if (cached !== undefined) return cached;
|
||||
const meter = metrics.getMeter("@suaveplan/api-plugin-user");
|
||||
cached = {
|
||||
profileReadsTotal: meter.createCounter("user.profile_reads_total", {
|
||||
description: "Total successful GET /users/me responses",
|
||||
}),
|
||||
profileUpdatesTotal: meter.createCounter("user.profile_updates_total", {
|
||||
description:
|
||||
"Total PATCH /users/me attempts, partitioned by outcome label",
|
||||
}),
|
||||
selfDeletesTotal: meter.createCounter("user.self_deletes_total", {
|
||||
description: "Total successful DELETE /users/me responses",
|
||||
}),
|
||||
};
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the cached instrument bundle so the next
|
||||
* {@link getUserInstruments} call rebinds against the global
|
||||
* `MeterProvider` in effect at that point. Tests call this after
|
||||
* installing a fresh `MeterProvider`.
|
||||
*/
|
||||
export function resetUserInstruments(): void {
|
||||
cached = undefined;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { createUserPlugin } from "./plugin.js";
|
||||
@@ -0,0 +1,277 @@
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
|
||||
import { createPluginContext } from "@suaveplan/api-plugin";
|
||||
import type { AuthService } from "@suaveplan/api-plugin-auth";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { UserPluginConfig } from "../types/types.js";
|
||||
import { createUserPlugin } from "./plugin.js";
|
||||
|
||||
const noopLogger = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
|
||||
function fakeAuthService(): AuthService {
|
||||
return {
|
||||
getBetterAuthInstance: () =>
|
||||
({ $context: Promise.resolve({}) }) as never,
|
||||
getCurrentPrincipal: () => null,
|
||||
getSession: async () => null,
|
||||
};
|
||||
}
|
||||
|
||||
function makeDb(): {
|
||||
db: {
|
||||
query: (sql: string) => Promise<unknown[]>;
|
||||
execute: (sql: string) => Promise<{ rowsAffected: number }>;
|
||||
};
|
||||
executed: string[];
|
||||
} {
|
||||
const executed: string[] = [];
|
||||
return {
|
||||
db: {
|
||||
async query() {
|
||||
return [];
|
||||
},
|
||||
async execute(sql: string) {
|
||||
executed.push(sql);
|
||||
return { rowsAffected: 0 };
|
||||
},
|
||||
},
|
||||
executed,
|
||||
};
|
||||
}
|
||||
|
||||
describe("onInit lifecycle", () => {
|
||||
it("applies USER_PROFILE_SCHEMA via migrationRunner", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const plugin = createUserPlugin({
|
||||
profileFields: ["bio", "locale"],
|
||||
});
|
||||
const captured: Array<{ name: string; content: string }> = [];
|
||||
const migrationRunner = {
|
||||
run: async (files: Array<{ name: string; content: string }>) => {
|
||||
captured.push(...files);
|
||||
},
|
||||
};
|
||||
const { db } = makeDb();
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", new OpenAPIHono());
|
||||
services.set("authService", fakeAuthService());
|
||||
services.set("migrationRunner", migrationRunner);
|
||||
services.set("db", db);
|
||||
await plugin.onInit?.(
|
||||
createPluginContext<UserPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: plugin.defaults as UserPluginConfig,
|
||||
services,
|
||||
})
|
||||
);
|
||||
expect(captured[0]?.name).toBe("0001-user-profile-schema");
|
||||
expect(captured[0]?.content).toContain(
|
||||
"DEFINE FIELD OVERWRITE bio ON user"
|
||||
);
|
||||
expect(captured[0]?.content).toContain(
|
||||
"DEFINE FIELD OVERWRITE locale ON user"
|
||||
);
|
||||
});
|
||||
|
||||
it("profileFields override extends specified columns", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const plugin = createUserPlugin({
|
||||
profileFields: ["bio"],
|
||||
});
|
||||
const { db, executed } = makeDb();
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", new OpenAPIHono());
|
||||
services.set("authService", fakeAuthService());
|
||||
services.set("db", db);
|
||||
await plugin.onInit?.(
|
||||
createPluginContext<UserPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: plugin.defaults as UserPluginConfig,
|
||||
services,
|
||||
})
|
||||
);
|
||||
expect(
|
||||
executed.some((s) =>
|
||||
s.includes("DEFINE FIELD OVERWRITE bio ON user")
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("applySchemaOnInit: false skips apply", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const plugin = createUserPlugin({ applySchemaOnInit: false });
|
||||
const { db, executed } = makeDb();
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", new OpenAPIHono());
|
||||
services.set("authService", fakeAuthService());
|
||||
services.set("db", db);
|
||||
await plugin.onInit?.(
|
||||
createPluginContext<UserPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: plugin.defaults as UserPluginConfig,
|
||||
services,
|
||||
})
|
||||
);
|
||||
expect(executed.length).toBe(0);
|
||||
});
|
||||
|
||||
it("warns and falls back to Hono-only mount when openAPIRegistry missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const plugin = createUserPlugin({ applySchemaOnInit: false });
|
||||
const warnings: string[] = [];
|
||||
const logger = {
|
||||
...noopLogger,
|
||||
warn: (msg: string) => {
|
||||
warnings.push(msg);
|
||||
},
|
||||
};
|
||||
const { db } = makeDb();
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", new OpenAPIHono());
|
||||
services.set("authService", fakeAuthService());
|
||||
services.set("db", db);
|
||||
await plugin.onInit?.(
|
||||
createPluginContext<UserPluginConfig>({
|
||||
logger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: plugin.defaults as UserPluginConfig,
|
||||
services,
|
||||
})
|
||||
);
|
||||
expect(warnings.some((w) => w.includes("openAPIRegistry"))).toBe(true);
|
||||
});
|
||||
|
||||
it("mounts at the root when routePrefix is '/'", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const plugin = createUserPlugin({
|
||||
applySchemaOnInit: false,
|
||||
routePrefix: "/",
|
||||
});
|
||||
const app = new OpenAPIHono();
|
||||
const registry = createOpenAPIRegistry({
|
||||
info: { title: "t", version: "0.1" },
|
||||
});
|
||||
const { db } = makeDb();
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
services.set("openAPIRegistry", registry);
|
||||
services.set("authService", fakeAuthService());
|
||||
services.set("db", db);
|
||||
await plugin.onInit?.(
|
||||
createPluginContext<UserPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: plugin.defaults as UserPluginConfig,
|
||||
services,
|
||||
})
|
||||
);
|
||||
const paths = registry.getRoutes().map((r) => r.path);
|
||||
expect(paths).toContain("/me");
|
||||
});
|
||||
|
||||
it("mounts at the root via Hono fallback when routePrefix is '/'", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const plugin = createUserPlugin({
|
||||
applySchemaOnInit: false,
|
||||
routePrefix: "/",
|
||||
});
|
||||
const app = new OpenAPIHono();
|
||||
const { db } = makeDb();
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
services.set("authService", fakeAuthService());
|
||||
services.set("db", db);
|
||||
await plugin.onInit?.(
|
||||
createPluginContext<UserPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: plugin.defaults as UserPluginConfig,
|
||||
services,
|
||||
})
|
||||
);
|
||||
const paths = app.routes.map((r) => r.path);
|
||||
expect(paths).toContain("/me");
|
||||
});
|
||||
|
||||
it("mounts under custom routePrefix", { timeout: 5000 }, async () => {
|
||||
const plugin = createUserPlugin({
|
||||
applySchemaOnInit: false,
|
||||
routePrefix: "/api/v1/users",
|
||||
});
|
||||
const app = new OpenAPIHono();
|
||||
const registry = createOpenAPIRegistry({
|
||||
info: { title: "t", version: "0.1" },
|
||||
});
|
||||
const { db } = makeDb();
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
services.set("openAPIRegistry", registry);
|
||||
services.set("authService", fakeAuthService());
|
||||
services.set("db", db);
|
||||
await plugin.onInit?.(
|
||||
createPluginContext<UserPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: plugin.defaults as UserPluginConfig,
|
||||
services,
|
||||
})
|
||||
);
|
||||
const paths = registry.getRoutes().map((r) => r.path);
|
||||
expect(paths).toContain("/api/v1/users/me");
|
||||
});
|
||||
|
||||
it("uses logger override from config when supplied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const warnings: string[] = [];
|
||||
const overrideLogger = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: (msg: string) => {
|
||||
warnings.push(msg);
|
||||
},
|
||||
error: () => undefined,
|
||||
};
|
||||
const plugin = createUserPlugin({
|
||||
applySchemaOnInit: false,
|
||||
logger: overrideLogger,
|
||||
});
|
||||
const { db } = makeDb();
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", new OpenAPIHono());
|
||||
services.set("authService", fakeAuthService());
|
||||
services.set("db", db);
|
||||
await plugin.onInit?.(
|
||||
createPluginContext<UserPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: plugin.defaults as UserPluginConfig,
|
||||
services,
|
||||
})
|
||||
);
|
||||
expect(warnings.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
87
packages/api-stack/api/api-plugin-user/src/plugin/plugin.md
Normal file
87
packages/api-stack/api/api-plugin-user/src/plugin/plugin.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# plugin/plugin
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The plugin module exports `createUserPlugin`, the public factory for
|
||||
the user plugin's `PluginDescriptor`. The factory validates the
|
||||
operator-supplied options against `UserPluginOptionsSchema`,
|
||||
constructs the resolved `UserPluginConfig`, and returns a descriptor
|
||||
that — when the host's `PluginRegistry` invokes `onInit(ctx)` —
|
||||
applies the bundled schema, registers the `userService` slot in the
|
||||
ServiceRegistry, installs `requireAuth()` on the route prefix, and
|
||||
mounts the three `/users/me` routes via `definePluginRoutes`.
|
||||
|
||||
## Features
|
||||
|
||||
- Zod-validated factory input — invalid options throw
|
||||
`UserError(USER_INVALID_INPUT)` carrying the `ZodError`.
|
||||
- Declares `dependencies: ["auth"]` so the registry topo-sorts after
|
||||
`api-plugin-auth` automatically.
|
||||
- Resolves the `app`, `authService`, and `db` services from the
|
||||
host's `ServiceRegistry`; raises `USER_SERVICE_NOT_AVAILABLE`
|
||||
listing missing slot names when any is absent.
|
||||
- Applies `USER_PROFILE_SCHEMA` via the `migrationRunner` when
|
||||
registered (so the migration is tracked) and falls back to a direct
|
||||
`db.execute` otherwise. Bypassed entirely when
|
||||
`applySchemaOnInit: false`.
|
||||
- Installs the auth plugin's `requireAuth()` middleware on the route
|
||||
prefix so every `/users/me` handler runs under an authenticated
|
||||
context.
|
||||
- Detects route-prefix collisions in the OpenAPI registry and raises
|
||||
`USER_ROUTE_PREFIX_COLLISION` rather than silently double-mounting.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { defineApp } from "@suaveplan/api";
|
||||
import { createAuthPlugin } from "@suaveplan/api-plugin-auth";
|
||||
import { createUserPlugin } from "@suaveplan/api-plugin-user";
|
||||
|
||||
await defineApp()
|
||||
.use(createAuthPlugin({ betterAuth: { /* … */ } }))
|
||||
.use(createUserPlugin({}))
|
||||
.start();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Custom profile fields and the disabled self-delete path:
|
||||
|
||||
```ts
|
||||
const userPlugin = createUserPlugin({
|
||||
profileFields: ["displayName", "avatarUrl", "bio", "locale"],
|
||||
allowSelfDelete: false,
|
||||
applySchemaOnInit: true,
|
||||
});
|
||||
```
|
||||
|
||||
The resolved descriptor exposes `defaults` so a test harness can
|
||||
inspect the post-Zod config without booting the registry.
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createUserPlugin(options?: UserPluginOptions): PluginDescriptor<UserPluginConfig>`
|
||||
|
||||
Returns the typed descriptor consumed by the host's
|
||||
`PluginRegistry`. `options` is fully optional — every field has a
|
||||
default in `UserPluginOptionsSchema`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `onInit` closure captures the resolved config — the registry
|
||||
rebuilds the per-hook `PluginContext` each call, so capturing the
|
||||
config in the closure keeps the lifecycle stateless from the
|
||||
registry's perspective.
|
||||
- `requireAuth()` is installed via `app.use(prefix/*, ...)` so every
|
||||
route handler sees a non-null Principal; the handlers also
|
||||
defensively re-check the principal in case the operator changes the
|
||||
middleware ordering.
|
||||
- The route-prefix collision check compares the `/me` suffix
|
||||
specifically — registering siblings under the same base path (e.g.
|
||||
a future `/users/admin`) does NOT trigger the collision because
|
||||
the prefix-aware audit narrows to the exact route paths the plugin
|
||||
registers.
|
||||
258
packages/api-stack/api/api-plugin-user/src/plugin/plugin.test.ts
Normal file
258
packages/api-stack/api/api-plugin-user/src/plugin/plugin.test.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
|
||||
import { createPluginContext } from "@suaveplan/api-plugin";
|
||||
import type { AuthService } from "@suaveplan/api-plugin-auth";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { USER_CODES, UserError } from "../error-codes/error-codes.js";
|
||||
import type { UserPluginConfig } from "../types/types.js";
|
||||
import { createUserPlugin } from "./plugin.js";
|
||||
|
||||
const noopLogger = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
|
||||
function fakeAuthService(): AuthService {
|
||||
return {
|
||||
getBetterAuthInstance: () =>
|
||||
({ $context: Promise.resolve({}) }) as never,
|
||||
getCurrentPrincipal: (c) => {
|
||||
const v = c.get("user");
|
||||
return v ?? null;
|
||||
},
|
||||
getSession: async () => null,
|
||||
};
|
||||
}
|
||||
|
||||
const fakeDb = {
|
||||
query: async () => [],
|
||||
execute: async () => ({ rowsAffected: 0 }),
|
||||
};
|
||||
|
||||
describe("createUserPlugin", () => {
|
||||
it("descriptor declares auth dependency", { timeout: 5000 }, () => {
|
||||
const descriptor = createUserPlugin();
|
||||
expect(descriptor.name).toBe("user");
|
||||
expect(descriptor.dependencies).toContain("auth");
|
||||
});
|
||||
|
||||
it("descriptor applies all defaults", { timeout: 5000 }, () => {
|
||||
const descriptor = createUserPlugin({});
|
||||
expect(descriptor.defaults?.routePrefix).toBe("/users");
|
||||
expect(descriptor.defaults?.allowSelfDelete).toBe(true);
|
||||
expect(descriptor.defaults?.applySchemaOnInit).toBe(true);
|
||||
});
|
||||
|
||||
it("invalid options raise USER_INVALID_INPUT", { timeout: 5000 }, () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
createUserPlugin({
|
||||
allowSelfDelete: "yes" as unknown as boolean,
|
||||
});
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(UserError);
|
||||
expect((caught as UserError).userCode).toBe(USER_CODES.INVALID_INPUT);
|
||||
});
|
||||
|
||||
it("onInit raises SERVICE_NOT_AVAILABLE when app missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const plugin = createUserPlugin({ applySchemaOnInit: false });
|
||||
const services = new Map<string, unknown>();
|
||||
let caught: unknown;
|
||||
try {
|
||||
await plugin.onInit?.(
|
||||
createPluginContext<UserPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: plugin.defaults as UserPluginConfig,
|
||||
services,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(UserError);
|
||||
expect((caught as UserError).userCode).toBe(
|
||||
USER_CODES.SERVICE_NOT_AVAILABLE
|
||||
);
|
||||
});
|
||||
|
||||
it("onInit raises SERVICE_NOT_AVAILABLE when authService missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const plugin = createUserPlugin({ applySchemaOnInit: false });
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", new OpenAPIHono());
|
||||
let caught: unknown;
|
||||
try {
|
||||
await plugin.onInit?.(
|
||||
createPluginContext<UserPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: plugin.defaults as UserPluginConfig,
|
||||
services,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(UserError);
|
||||
});
|
||||
|
||||
it("onInit raises SERVICE_NOT_AVAILABLE when db missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const plugin = createUserPlugin({ applySchemaOnInit: false });
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", new OpenAPIHono());
|
||||
services.set("authService", fakeAuthService());
|
||||
let caught: unknown;
|
||||
try {
|
||||
await plugin.onInit?.(
|
||||
createPluginContext<UserPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: plugin.defaults as UserPluginConfig,
|
||||
services,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(UserError);
|
||||
});
|
||||
|
||||
it("onInit completes against a registry-less app + registers userService", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const plugin = createUserPlugin({ applySchemaOnInit: false });
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", new OpenAPIHono());
|
||||
services.set("authService", fakeAuthService());
|
||||
services.set("db", fakeDb);
|
||||
await plugin.onInit?.(
|
||||
createPluginContext<UserPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: plugin.defaults as UserPluginConfig,
|
||||
services,
|
||||
})
|
||||
);
|
||||
expect(services.has("userService")).toBe(true);
|
||||
});
|
||||
|
||||
it("onInit registers routes via the OpenAPIRegistry when present", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const plugin = createUserPlugin({ applySchemaOnInit: false });
|
||||
const app = new OpenAPIHono();
|
||||
const registry = createOpenAPIRegistry({
|
||||
info: { title: "t", version: "0.1" },
|
||||
});
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
services.set("openAPIRegistry", registry);
|
||||
services.set("authService", fakeAuthService());
|
||||
services.set("db", fakeDb);
|
||||
await plugin.onInit?.(
|
||||
createPluginContext<UserPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: plugin.defaults as UserPluginConfig,
|
||||
services,
|
||||
})
|
||||
);
|
||||
expect(registry.getRoutesByPlugin("user")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("onInit mounts routes on Hono directly when no registry present", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const plugin = createUserPlugin({ applySchemaOnInit: false });
|
||||
const app = new OpenAPIHono();
|
||||
// Pre-register an upstream middleware that injects a fake
|
||||
// Principal so the user plugin's `requireAuth()` middleware
|
||||
// passes — this lets the registry-less route handler actually
|
||||
// execute end-to-end in the test.
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("user", {
|
||||
id: "u1",
|
||||
emailVerified: true,
|
||||
roles: [],
|
||||
});
|
||||
await next();
|
||||
});
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
services.set("authService", fakeAuthService());
|
||||
services.set("db", fakeDb);
|
||||
await plugin.onInit?.(
|
||||
createPluginContext<UserPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: plugin.defaults as UserPluginConfig,
|
||||
services,
|
||||
})
|
||||
);
|
||||
// Routes are registered on the Hono app — find one in app.routes.
|
||||
const paths = app.routes.map((r) => r.path);
|
||||
expect(paths).toContain("/users/me");
|
||||
// Invoke the registered handler so the arrow-wrapper body
|
||||
// executes (covers the inner await in the fallback mount).
|
||||
const res = await app.request("/users/me");
|
||||
// Handler runs; userService.getCurrent reads c.get("user") and
|
||||
// queries fakeDb which returns []. principalAsProfile produces
|
||||
// a valid UserProfile, so the response is 200.
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("onInit raises USER_ROUTE_PREFIX_COLLISION on conflict", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const plugin = createUserPlugin({ applySchemaOnInit: false });
|
||||
const registry = createOpenAPIRegistry({
|
||||
info: { title: "t", version: "0.1" },
|
||||
});
|
||||
registry.registerRoute({
|
||||
method: "get",
|
||||
path: "/users/me",
|
||||
tags: [],
|
||||
pluginName: "other",
|
||||
});
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", new OpenAPIHono());
|
||||
services.set("openAPIRegistry", registry);
|
||||
services.set("authService", fakeAuthService());
|
||||
services.set("db", fakeDb);
|
||||
let caught: unknown;
|
||||
try {
|
||||
await plugin.onInit?.(
|
||||
createPluginContext<UserPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: plugin.defaults as UserPluginConfig,
|
||||
services,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(UserError);
|
||||
expect((caught as UserError).userCode).toBe(
|
||||
USER_CODES.ROUTE_PREFIX_COLLISION
|
||||
);
|
||||
});
|
||||
});
|
||||
231
packages/api-stack/api/api-plugin-user/src/plugin/plugin.ts
Normal file
231
packages/api-stack/api/api-plugin-user/src/plugin/plugin.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import type { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import { definePluginRoutes } from "@suaveplan/api-openapi";
|
||||
import {
|
||||
definePlugin,
|
||||
type PluginContext,
|
||||
type PluginDescriptor,
|
||||
} from "@suaveplan/api-plugin";
|
||||
import type { AuthService } from "@suaveplan/api-plugin-auth";
|
||||
import { requireAuth } from "@suaveplan/api-plugin-auth";
|
||||
import { USER_CODES, UserError } from "../error-codes/error-codes.js";
|
||||
import { buildUserRoutes } from "../routes/build-routes.js";
|
||||
import { applyUserProfileSchema } from "../schema/schema.js";
|
||||
import { UserPluginOptionsSchema } from "../schemas/options.js";
|
||||
import { buildUpdateBodySchemaFor } from "../schemas/user-profile.js";
|
||||
import { createUserService } from "../services/user-service.js";
|
||||
import "../services/registry-augmentation.js";
|
||||
import type { UserPluginConfig, UserPluginOptions } from "../types/types.js";
|
||||
|
||||
/**
|
||||
* Factory — returns a typed `PluginDescriptor` for the user plugin.
|
||||
*
|
||||
* Zod-validates the supplied options at the boundary so invalid input
|
||||
* fails fast with `UserError(USER_INVALID_INPUT)` carrying the
|
||||
* `ZodError` as `cause`. The descriptor declares `dependencies:
|
||||
* ["auth"]` so the plugin registry topo-sorts after `api-plugin-auth`.
|
||||
*
|
||||
* The returned descriptor's `onInit` executes the canonical 6-step
|
||||
* lifecycle:
|
||||
*
|
||||
* 1. Resolve `app`, `openAPIRegistry`, `authService`, `db`, and
|
||||
* `migrationRunner` from the host `ServiceRegistry`.
|
||||
* 2. Apply `USER_PROFILE_SCHEMA` (or skip when `applySchemaOnInit:
|
||||
* false`).
|
||||
* 3. Build the per-instance `UpdateUserProfileBodySchema` from the
|
||||
* resolved `profileFields`.
|
||||
* 4. Construct the canonical `UserService` and register it in the
|
||||
* `ServiceRegistry`.
|
||||
* 5. Install `requireAuth()` middleware on the route prefix so every
|
||||
* `/users/me` handler runs under an authenticated context.
|
||||
* 6. Register the 3 OpenAPI routes via `definePluginRoutes` (prefixed
|
||||
* with `routePrefix`).
|
||||
*/
|
||||
export function createUserPlugin(
|
||||
options: UserPluginOptions = {}
|
||||
): PluginDescriptor<UserPluginConfig> {
|
||||
const parsed = UserPluginOptionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
throw new UserError(
|
||||
USER_CODES.INVALID_INPUT,
|
||||
"createUserPlugin received invalid options",
|
||||
{ cause: parsed.error, context: { statusCode: 500 } }
|
||||
);
|
||||
}
|
||||
const resolved: UserPluginConfig = {
|
||||
routePrefix: parsed.data.routePrefix,
|
||||
allowSelfDelete: parsed.data.allowSelfDelete,
|
||||
profileFields: parsed.data.profileFields,
|
||||
applySchemaOnInit: parsed.data.applySchemaOnInit,
|
||||
...(parsed.data.logger !== undefined
|
||||
? { logger: parsed.data.logger }
|
||||
: {}),
|
||||
};
|
||||
|
||||
return definePlugin<UserPluginConfig>({
|
||||
name: "user",
|
||||
version: "0.1.0",
|
||||
description:
|
||||
"User-profile CRUD plugin for @suaveplan/api. Mounts /users/me GET/PATCH/DELETE on top of api-plugin-auth.",
|
||||
dependencies: ["auth"],
|
||||
defaults: resolved,
|
||||
tags: ["users", "profile"],
|
||||
onInit: async (ctx) => runOnInit(ctx, resolved),
|
||||
});
|
||||
}
|
||||
|
||||
async function runOnInit(
|
||||
ctx: PluginContext<UserPluginConfig>,
|
||||
config: UserPluginConfig
|
||||
): Promise<void> {
|
||||
// (1) Resolve required services from the host ServiceRegistry.
|
||||
const app = ctx.getService("app");
|
||||
if (app === undefined) {
|
||||
throw new UserError(
|
||||
USER_CODES.SERVICE_NOT_AVAILABLE,
|
||||
"user plugin requires the `app` service in ServiceRegistry",
|
||||
{ context: { missing: ["app"], statusCode: 500 } }
|
||||
);
|
||||
}
|
||||
const authService = ctx.getService("authService");
|
||||
if (authService === undefined) {
|
||||
throw new UserError(
|
||||
USER_CODES.SERVICE_NOT_AVAILABLE,
|
||||
"user plugin requires the `authService` service — load @suaveplan/api-plugin-auth first",
|
||||
{ context: { missing: ["authService"], statusCode: 500 } }
|
||||
);
|
||||
}
|
||||
const db = ctx.getService("db");
|
||||
if (db === undefined) {
|
||||
throw new UserError(
|
||||
USER_CODES.SERVICE_NOT_AVAILABLE,
|
||||
"user plugin requires the `db` service in ServiceRegistry",
|
||||
{ context: { missing: ["db"], statusCode: 500 } }
|
||||
);
|
||||
}
|
||||
const openAPIRegistry = ctx.getService("openAPIRegistry");
|
||||
const migrationRunner = ctx.getService("migrationRunner");
|
||||
const logger = config.logger ?? ctx.logger;
|
||||
|
||||
// (2) Apply schema unless explicitly disabled.
|
||||
if (config.applySchemaOnInit) {
|
||||
await applyUserProfileSchema({
|
||||
profileFields: config.profileFields,
|
||||
...(migrationRunner !== undefined ? { migrationRunner } : {}),
|
||||
db,
|
||||
});
|
||||
}
|
||||
|
||||
// (3) Build the per-instance update body schema.
|
||||
const updateBodySchema = buildUpdateBodySchemaFor(config.profileFields);
|
||||
|
||||
// (4) Construct + register the user service.
|
||||
const userService = createUserService({
|
||||
db,
|
||||
profileFields: config.profileFields,
|
||||
getPrincipal: authService.getCurrentPrincipal.bind(authService),
|
||||
});
|
||||
ctx.registerService("userService", userService);
|
||||
|
||||
// (5) Install requireAuth() middleware on the route prefix.
|
||||
const mountPattern =
|
||||
config.routePrefix === "/" ? "*" : `${config.routePrefix}/*`;
|
||||
app.use(mountPattern, requireAuth());
|
||||
|
||||
// (6) Register the 3 OpenAPI routes.
|
||||
registerRoutes({
|
||||
app,
|
||||
openAPIRegistry,
|
||||
authService,
|
||||
userService,
|
||||
updateBodySchema,
|
||||
routePrefix: config.routePrefix,
|
||||
allowSelfDelete: config.allowSelfDelete,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
function registerRoutes(args: {
|
||||
app: OpenAPIHono;
|
||||
openAPIRegistry: ReturnType<
|
||||
PluginContext<UserPluginConfig>["getService"]
|
||||
> extends infer R
|
||||
? R
|
||||
: never;
|
||||
authService: AuthService;
|
||||
userService: ReturnType<typeof createUserService>;
|
||||
updateBodySchema: ReturnType<typeof buildUpdateBodySchemaFor>;
|
||||
routePrefix: string;
|
||||
allowSelfDelete: boolean;
|
||||
logger: {
|
||||
warn: (msg: string, ctx?: Readonly<Record<string, unknown>>) => void;
|
||||
};
|
||||
}): void {
|
||||
const {
|
||||
app,
|
||||
openAPIRegistry,
|
||||
authService,
|
||||
userService,
|
||||
updateBodySchema,
|
||||
routePrefix,
|
||||
allowSelfDelete,
|
||||
logger,
|
||||
} = args;
|
||||
const entries = buildUserRoutes({
|
||||
userService,
|
||||
authService,
|
||||
allowSelfDelete,
|
||||
updateBodySchema,
|
||||
});
|
||||
if (openAPIRegistry !== undefined) {
|
||||
const reg = openAPIRegistry as {
|
||||
getRoutes: () => Array<{ path: string }>;
|
||||
};
|
||||
const existing = reg
|
||||
.getRoutes()
|
||||
.filter((r) => r.path.startsWith(`${routePrefix}/me`));
|
||||
if (existing.length > 0) {
|
||||
throw new UserError(
|
||||
USER_CODES.ROUTE_PREFIX_COLLISION,
|
||||
`Route prefix '${routePrefix}' collides with previously registered routes`,
|
||||
{
|
||||
context: {
|
||||
prefix: routePrefix,
|
||||
existing: existing.map((r) => r.path),
|
||||
statusCode: 500,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
const prefixedEntries = entries.map((entry) => ({
|
||||
...entry,
|
||||
route: {
|
||||
...entry.route,
|
||||
path: `${routePrefix === "/" ? "" : routePrefix}${entry.route.path}`,
|
||||
},
|
||||
}));
|
||||
definePluginRoutes(prefixedEntries, {
|
||||
app,
|
||||
registry: openAPIRegistry as Parameters<
|
||||
typeof definePluginRoutes
|
||||
>[1]["registry"],
|
||||
pluginName: "user",
|
||||
});
|
||||
return;
|
||||
}
|
||||
logger.warn(
|
||||
"user plugin: openAPIRegistry not registered — routes will not appear in /openapi.json"
|
||||
);
|
||||
for (const entry of entries) {
|
||||
const method = entry.route.method as
|
||||
| "get"
|
||||
| "post"
|
||||
| "put"
|
||||
| "patch"
|
||||
| "delete";
|
||||
const path = `${routePrefix === "/" ? "" : routePrefix}${entry.route.path}`;
|
||||
app[method](
|
||||
path,
|
||||
async (c) => (await entry.handler(c as never)) as Response
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
# routes/build-routes
|
||||
|
||||
## Purpose
|
||||
|
||||
`buildUserRoutes` returns the three `PluginRouteEntry` values that
|
||||
`createUserPlugin` mounts on the host's Hono app via
|
||||
`definePluginRoutes`. Each entry pairs a typed `RouteConfig` (built
|
||||
with `RouteBuilder` so Zod inference flows through to the OpenAPI
|
||||
doc) with an async handler that delegates to `UserService` and
|
||||
emits the appropriate OTel counter.
|
||||
|
||||
## Features
|
||||
|
||||
- Three routes — `GET /me`, `PATCH /me`, `DELETE /me`.
|
||||
- Strict-mode body schema for `PATCH /me` is per-instance, minted
|
||||
from the resolved `profileFields`, so the OpenAPI doc carries the
|
||||
exact writable surface configured for the running plugin.
|
||||
- `DELETE /me` invokes Better-Auth's internal-adapter cascade so the
|
||||
user row and every session/account associated with it disappear in
|
||||
a single transaction.
|
||||
- Every handler defensively re-checks the principal so the routes are
|
||||
self-contained even when `requireAuth()` is bypassed at the host
|
||||
level (an operator misconfiguration that should still produce a
|
||||
401 rather than a 500).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { buildUserRoutes } from "@suaveplan/api-plugin-user/routes/routes";
|
||||
import { definePluginRoutes } from "@suaveplan/api-openapi";
|
||||
|
||||
const entries = buildUserRoutes({
|
||||
userService,
|
||||
authService,
|
||||
allowSelfDelete: true,
|
||||
updateBodySchema: bodySchema,
|
||||
});
|
||||
|
||||
definePluginRoutes(entries, { app, registry, pluginName: "user" });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Operators that need to disable self-delete construct the entries with
|
||||
`allowSelfDelete: false`; the route is still registered (the OpenAPI
|
||||
doc still advertises it) but the handler raises
|
||||
`USER_SELF_DELETE_DISABLED` so callers receive a 403 with the
|
||||
canonical error envelope.
|
||||
|
||||
```ts
|
||||
const entries = buildUserRoutes({
|
||||
userService,
|
||||
authService,
|
||||
allowSelfDelete: false,
|
||||
updateBodySchema: bodySchema,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `BuildUserRoutesOptions`
|
||||
|
||||
`{ userService, authService, allowSelfDelete, updateBodySchema }` —
|
||||
all four are required.
|
||||
|
||||
### `buildUserRoutes(options): readonly PluginRouteEntry[]`
|
||||
|
||||
Returns the three-entry array in stable order: get, patch, delete.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The route paths are mounted relative to the plugin's `routePrefix`
|
||||
(`/users` by default) — the plugin prepends the prefix before
|
||||
calling `definePluginRoutes` so every entry's `route.path` becomes
|
||||
`/users/me`.
|
||||
- The order of entries is stable so the dependent-sweep tests can
|
||||
index into the returned array without ambiguity.
|
||||
- The body-schema injection is what makes the contract end-to-end:
|
||||
the same schema flows into the OpenAPI doc, the validation in the
|
||||
handler, and the per-instance constraint surface.
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { AuthService } from "@suaveplan/api-plugin-auth";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { UpdateUserProfileBodySchema } from "../schemas/user-profile.js";
|
||||
import type { UserService } from "../services/user-service.js";
|
||||
import { buildUserRoutes } from "./build-routes.js";
|
||||
|
||||
const fakeUserService: UserService = {
|
||||
async getById() {
|
||||
return null;
|
||||
},
|
||||
async requireById() {
|
||||
throw new Error("not used");
|
||||
},
|
||||
async getCurrent() {
|
||||
return null;
|
||||
},
|
||||
async updateProfile() {
|
||||
throw new Error("not used");
|
||||
},
|
||||
};
|
||||
|
||||
const fakeAuthService = {
|
||||
getBetterAuthInstance: () => ({ $context: Promise.resolve({}) }) as never,
|
||||
getCurrentPrincipal: () => null,
|
||||
getSession: async () => null,
|
||||
} satisfies AuthService;
|
||||
|
||||
describe("buildUserRoutes", () => {
|
||||
it("returns 3 entries in stable order: get, patch, delete", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const entries = buildUserRoutes({
|
||||
userService: fakeUserService,
|
||||
authService: fakeAuthService,
|
||||
allowSelfDelete: true,
|
||||
updateBodySchema: UpdateUserProfileBodySchema,
|
||||
});
|
||||
expect(entries.length).toBe(3);
|
||||
expect(entries[0]?.route.method).toBe("get");
|
||||
expect(entries[1]?.route.method).toBe("patch");
|
||||
expect(entries[2]?.route.method).toBe("delete");
|
||||
});
|
||||
|
||||
it("every entry carries a /me path", { timeout: 5000 }, () => {
|
||||
const entries = buildUserRoutes({
|
||||
userService: fakeUserService,
|
||||
authService: fakeAuthService,
|
||||
allowSelfDelete: false,
|
||||
updateBodySchema: UpdateUserProfileBodySchema,
|
||||
});
|
||||
for (const e of entries) {
|
||||
expect(e.route.path).toBe("/me");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { PluginRouteEntry } from "@suaveplan/api-openapi";
|
||||
import type { AuthService } from "@suaveplan/api-plugin-auth";
|
||||
import type { z } from "zod";
|
||||
import type { UserService } from "../services/user-service.js";
|
||||
import { buildDeleteMeRoute } from "./delete-me.js";
|
||||
import { buildGetMeRoute } from "./get-me.js";
|
||||
import { buildPatchMeRoute } from "./patch-me.js";
|
||||
|
||||
/**
|
||||
* Inputs accepted by {@link buildUserRoutes}. The body schema is
|
||||
* minted per-plugin-instance from the resolved `profileFields` so the
|
||||
* strict-mode mask matches the configured columns exactly.
|
||||
*/
|
||||
export interface BuildUserRoutesOptions {
|
||||
readonly userService: UserService;
|
||||
readonly authService: AuthService;
|
||||
readonly allowSelfDelete: boolean;
|
||||
readonly updateBodySchema: z.ZodObject<z.ZodRawShape>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the three `PluginRouteEntry` values mounted at the plugin's
|
||||
* `routePrefix`:
|
||||
*
|
||||
* - `GET /me` — read current profile
|
||||
* - `PATCH /me` — update current profile
|
||||
* - `DELETE /me` — delete current account (cascades via Better-Auth)
|
||||
*
|
||||
* Every handler assumes `c.get("user")` is non-null on entry — the
|
||||
* plugin installs `requireAuth()` as a middleware on the route prefix
|
||||
* before mounting these entries so the assumption holds. Each
|
||||
* handler also defensively re-checks the principal to keep individual
|
||||
* routes self-contained.
|
||||
*/
|
||||
export function buildUserRoutes(
|
||||
options: BuildUserRoutesOptions
|
||||
): readonly PluginRouteEntry[] {
|
||||
return [
|
||||
buildGetMeRoute(options.userService),
|
||||
buildPatchMeRoute(options.userService, options.updateBodySchema),
|
||||
buildDeleteMeRoute({
|
||||
authService: options.authService,
|
||||
allowSelfDelete: options.allowSelfDelete,
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
# routes/delete-me
|
||||
|
||||
## Purpose
|
||||
|
||||
`buildDeleteMeRoute` returns the `PluginRouteEntry` for `DELETE /me`.
|
||||
The route is gated by the plugin's `allowSelfDelete` option — when
|
||||
disabled, the handler raises `USER_SELF_DELETE_DISABLED` (403) so
|
||||
the OpenAPI doc still advertises the path but the runtime refuses
|
||||
the call. When enabled, the handler resolves Better-Auth's internal
|
||||
adapter via `auth.$context` and invokes `deleteUser(id)` — the
|
||||
adapter cascades the deletion across the `account` and `session`
|
||||
tables so a subsequent `requireAuth()`-gated request from the same
|
||||
session returns 401.
|
||||
|
||||
## Features
|
||||
|
||||
- `allowSelfDelete: false` produces a 403 with the canonical error
|
||||
envelope; the OpenAPI doc still describes the route so consumers
|
||||
can detect the disabled state programmatically.
|
||||
- `auth.$context` is the canonical entry point into Better-Auth's
|
||||
internal adapter; using it lets the plugin avoid any HTTP-layer
|
||||
re-routing.
|
||||
- `user.self_deletes_total` increments only on success so a
|
||||
dashboard cannot mistake a disabled-state 403 for a real delete.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
const entry = buildDeleteMeRoute({ authService, allowSelfDelete: true });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
The route can be mounted on a non-default path by destructuring the
|
||||
returned entry:
|
||||
|
||||
```ts
|
||||
const { route, handler } = buildDeleteMeRoute({
|
||||
authService,
|
||||
allowSelfDelete: false,
|
||||
});
|
||||
app.openapi({ ...route, path: "/account/delete" }, handler);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `DeleteMeResponseSchema`
|
||||
|
||||
`z.object({ deleted: z.literal(true) })`.
|
||||
|
||||
### `DeleteMeResponse`
|
||||
|
||||
`z.infer<typeof DeleteMeResponseSchema>` — `{ deleted: true }`.
|
||||
|
||||
### `buildDeleteMeRoute({ authService, allowSelfDelete }): PluginRouteEntry`
|
||||
|
||||
`authService` MUST be the `@suaveplan/api-plugin-auth` service slot;
|
||||
`allowSelfDelete` mirrors the plugin's resolved config.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The handler awaits `auth.$context` once per call — Better-Auth
|
||||
caches the context internally so the round-trip is cheap.
|
||||
- Failures from the internal adapter wrap into
|
||||
`USER_PROFILE_UPDATE_FAILED` so the registry stays cohesive
|
||||
(PROFILE_UPDATE = "any write to the user row failed").
|
||||
- The 401 raise on missing principals mirrors the other route
|
||||
handlers so the failure mode is identical end-to-end.
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { AuthService, Principal } from "@suaveplan/api-plugin-auth";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Context } from "hono";
|
||||
import { USER_CODES, UserError } from "../error-codes/error-codes.js";
|
||||
import { buildDeleteMeRoute } from "./delete-me.js";
|
||||
|
||||
function makeContext(principal: Principal | null): Context {
|
||||
return {
|
||||
get: (key: string) => (key === "user" ? principal : undefined),
|
||||
json: (b: unknown, status: number) =>
|
||||
new Response(JSON.stringify(b), { status }),
|
||||
} as unknown as Context;
|
||||
}
|
||||
|
||||
function fakeAuth(opts?: { fail?: boolean }): AuthService {
|
||||
const fakeAdapter = {
|
||||
deleteUser: async (id: string) => {
|
||||
if (opts?.fail) throw new Error(`fail ${id}`);
|
||||
},
|
||||
};
|
||||
return {
|
||||
getBetterAuthInstance: () =>
|
||||
({
|
||||
$context: Promise.resolve({
|
||||
internalAdapter: fakeAdapter,
|
||||
}),
|
||||
}) as never,
|
||||
getCurrentPrincipal: () => null,
|
||||
getSession: async () => null,
|
||||
};
|
||||
}
|
||||
|
||||
const principal: Principal = {
|
||||
id: "u1",
|
||||
emailVerified: true,
|
||||
roles: [],
|
||||
};
|
||||
|
||||
describe("buildDeleteMeRoute", () => {
|
||||
it("cascades via Better-Auth", { timeout: 5000 }, async () => {
|
||||
const { route, handler } = buildDeleteMeRoute({
|
||||
authService: fakeAuth(),
|
||||
allowSelfDelete: true,
|
||||
});
|
||||
expect(route.method).toBe("delete");
|
||||
const res = await handler(makeContext(principal));
|
||||
const body = (await res.json()) as { data: { deleted: boolean } };
|
||||
expect(body.data.deleted).toBe(true);
|
||||
});
|
||||
|
||||
it("disabled raises USER_SELF_DELETE_DISABLED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { handler } = buildDeleteMeRoute({
|
||||
authService: fakeAuth(),
|
||||
allowSelfDelete: false,
|
||||
});
|
||||
let caught: unknown;
|
||||
try {
|
||||
await handler(makeContext(principal));
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(UserError);
|
||||
expect((caught as UserError).userCode).toBe(
|
||||
USER_CODES.SELF_DELETE_DISABLED
|
||||
);
|
||||
});
|
||||
|
||||
it("raises 401 when anonymous principal", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { handler } = buildDeleteMeRoute({
|
||||
authService: fakeAuth(),
|
||||
allowSelfDelete: true,
|
||||
});
|
||||
let caught: unknown;
|
||||
try {
|
||||
await handler(makeContext(null));
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(UserError);
|
||||
expect((caught as UserError).userCode).toBe(USER_CODES.NOT_FOUND);
|
||||
});
|
||||
|
||||
it("wraps internalAdapter failure as PROFILE_UPDATE_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { handler } = buildDeleteMeRoute({
|
||||
authService: fakeAuth({ fail: true }),
|
||||
allowSelfDelete: true,
|
||||
});
|
||||
let caught: unknown;
|
||||
try {
|
||||
await handler(makeContext(principal));
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(UserError);
|
||||
expect((caught as UserError).userCode).toBe(
|
||||
USER_CODES.PROFILE_UPDATE_FAILED
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
DELETE,
|
||||
type PluginRouteEntry,
|
||||
successResponse,
|
||||
} from "@suaveplan/api-openapi";
|
||||
import type { AuthService } from "@suaveplan/api-plugin-auth";
|
||||
import type { Context } from "hono";
|
||||
import z from "zod";
|
||||
import { USER_CODES, UserError } from "../error-codes/error-codes.js";
|
||||
import { getUserInstruments } from "../otel/instruments.js";
|
||||
|
||||
/**
|
||||
* Response payload returned by `DELETE /users/me` on success.
|
||||
*/
|
||||
export const DeleteMeResponseSchema = z.object({
|
||||
deleted: z.literal(true),
|
||||
});
|
||||
|
||||
export type DeleteMeResponse = z.infer<typeof DeleteMeResponseSchema>;
|
||||
|
||||
/**
|
||||
* Build the `DELETE /users/me` route entry. When `allowSelfDelete`
|
||||
* is `true` (the default) the handler resolves the Better-Auth
|
||||
* `internalAdapter` via `auth.$context` and invokes its
|
||||
* `deleteUser(id)` method — Better-Auth's internal adapter cascades
|
||||
* the deletion across the `account` and `session` tables so the
|
||||
* downstream `requireAuth()` middleware returns 401 on the next
|
||||
* request. When `allowSelfDelete` is `false` the handler throws
|
||||
* `UserError(USER_SELF_DELETE_DISABLED)` (403).
|
||||
*/
|
||||
export function buildDeleteMeRoute(args: {
|
||||
authService: AuthService;
|
||||
allowSelfDelete: boolean;
|
||||
}): PluginRouteEntry {
|
||||
const { authService, allowSelfDelete } = args;
|
||||
const route = DELETE("/me")
|
||||
.tags("users")
|
||||
.summary("Delete the current user account")
|
||||
.response(successResponse(DeleteMeResponseSchema))
|
||||
.build();
|
||||
|
||||
async function handler(c: Context): Promise<Response> {
|
||||
if (!allowSelfDelete) {
|
||||
throw new UserError(
|
||||
USER_CODES.SELF_DELETE_DISABLED,
|
||||
"self-delete disabled by plugin configuration",
|
||||
{ context: { statusCode: 403 } }
|
||||
);
|
||||
}
|
||||
const principal = c.get("user");
|
||||
if (principal === null || principal === undefined) {
|
||||
throw new UserError(
|
||||
USER_CODES.NOT_FOUND,
|
||||
"no principal on request — requireAuth() should run upstream",
|
||||
{ context: { statusCode: 401 } }
|
||||
);
|
||||
}
|
||||
try {
|
||||
const auth = authService.getBetterAuthInstance();
|
||||
const ctx = await auth.$context;
|
||||
await ctx.internalAdapter.deleteUser(principal.id);
|
||||
} catch (err) {
|
||||
throw new UserError(
|
||||
USER_CODES.PROFILE_UPDATE_FAILED,
|
||||
"Better-Auth internalAdapter.deleteUser failed",
|
||||
{ cause: err, context: { statusCode: 500 } }
|
||||
);
|
||||
}
|
||||
getUserInstruments().selfDeletesTotal.add(1);
|
||||
return c.json(
|
||||
{ success: true as const, data: { deleted: true as const } },
|
||||
200
|
||||
);
|
||||
}
|
||||
|
||||
return { route, handler };
|
||||
}
|
||||
57
packages/api-stack/api/api-plugin-user/src/routes/get-me.md
Normal file
57
packages/api-stack/api/api-plugin-user/src/routes/get-me.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# routes/get-me
|
||||
|
||||
## Purpose
|
||||
|
||||
`buildGetMeRoute` returns the `PluginRouteEntry` for `GET /me`. The
|
||||
handler resolves the current Principal from `c.get("user")` (set by
|
||||
the auth plugin's session middleware), invokes
|
||||
`userService.getCurrent(c)` to merge the Principal with the
|
||||
profile-row columns, and responds with the canonical
|
||||
`{ success: true, data: UserProfile }` envelope. Successful responses
|
||||
emit the `user.profile_reads_total` counter.
|
||||
|
||||
## Features
|
||||
|
||||
- Single-route builder so the OpenAPI doc registration is co-located
|
||||
with the handler.
|
||||
- Defensive 401 raise when the principal is missing — protects the
|
||||
route when `requireAuth()` is bypassed at the host layer.
|
||||
- Counter emission only on success — failures are recorded by the
|
||||
host's error-handler middleware via `error.code`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { buildGetMeRoute } from "@suaveplan/api-plugin-user/routes/routes";
|
||||
|
||||
const entry = buildGetMeRoute(userService);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Composing the handler outside the build-routes loop lets operators
|
||||
mount a custom variant on a different path:
|
||||
|
||||
```ts
|
||||
const { route, handler } = buildGetMeRoute(userService);
|
||||
app.openapi({ ...route, path: "/profile" }, handler);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `buildGetMeRoute(userService: UserService): PluginRouteEntry`
|
||||
|
||||
Returns `{ route, handler }`. `route` is a `RouteConfig` built from
|
||||
`RouteBuilder`; `handler` is an async `(c: Context) => Promise<Response>`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The handler reads `userService.getCurrent` rather than `getById` so
|
||||
the Principal projection (which carries roles + tenantId from the
|
||||
session) survives the round-trip.
|
||||
- The 401 raise carries `statusCode: 401` so the host's
|
||||
error-handler middleware emits HTTP 401 with the canonical error
|
||||
envelope.
|
||||
- The counter increment happens AFTER the `c.json` response is
|
||||
prepared but is not blocking — OpenTelemetry counters are
|
||||
fire-and-forget.
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Context } from "hono";
|
||||
import { USER_CODES, UserError } from "../error-codes/error-codes.js";
|
||||
import type { UserService } from "../services/user-service.js";
|
||||
import type { UserProfile } from "../types/types.js";
|
||||
import { buildGetMeRoute } from "./get-me.js";
|
||||
|
||||
function makeContext(profile: UserProfile | null): Context {
|
||||
const responses: Array<{ body: unknown; status: number }> = [];
|
||||
return {
|
||||
json: (body: unknown, status: number) => {
|
||||
responses.push({ body, status });
|
||||
return new Response(JSON.stringify(body), { status });
|
||||
},
|
||||
responses,
|
||||
} as unknown as Context & {
|
||||
responses: Array<{ body: unknown; status: number }>;
|
||||
};
|
||||
}
|
||||
|
||||
function fakeService(profile: UserProfile | null): UserService {
|
||||
return {
|
||||
async getById() {
|
||||
return profile;
|
||||
},
|
||||
async requireById() {
|
||||
if (profile === null) {
|
||||
throw new UserError(USER_CODES.NOT_FOUND, "x");
|
||||
}
|
||||
return profile;
|
||||
},
|
||||
async getCurrent() {
|
||||
return profile;
|
||||
},
|
||||
async updateProfile() {
|
||||
throw new Error("not implemented");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildGetMeRoute", () => {
|
||||
it("returns Principal + profile fields", { timeout: 5000 }, async () => {
|
||||
const profile: UserProfile = {
|
||||
id: "u1",
|
||||
email: "a@example.test",
|
||||
emailVerified: true,
|
||||
roles: ["user"],
|
||||
displayName: "Alice",
|
||||
timezone: "UTC",
|
||||
};
|
||||
const { route, handler } = buildGetMeRoute(fakeService(profile));
|
||||
expect(route.method).toBe("get");
|
||||
expect(route.path).toBe("/me");
|
||||
const c = makeContext(profile);
|
||||
const res = await handler(c);
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as {
|
||||
success: boolean;
|
||||
data: UserProfile;
|
||||
};
|
||||
expect(json.success).toBe(true);
|
||||
expect(json.data.displayName).toBe("Alice");
|
||||
});
|
||||
|
||||
it("raises 401 when getCurrent returns null", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { handler } = buildGetMeRoute(fakeService(null));
|
||||
let caught: unknown;
|
||||
try {
|
||||
await handler(makeContext(null));
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(UserError);
|
||||
expect((caught as UserError).userCode).toBe(USER_CODES.NOT_FOUND);
|
||||
});
|
||||
});
|
||||
40
packages/api-stack/api/api-plugin-user/src/routes/get-me.ts
Normal file
40
packages/api-stack/api/api-plugin-user/src/routes/get-me.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
GET,
|
||||
type PluginRouteEntry,
|
||||
successResponse,
|
||||
} from "@suaveplan/api-openapi";
|
||||
import type { Context } from "hono";
|
||||
import { USER_CODES, UserError } from "../error-codes/error-codes.js";
|
||||
import { getUserInstruments } from "../otel/instruments.js";
|
||||
import { UserProfileResponseSchema } from "../schemas/user-profile.js";
|
||||
import type { UserService } from "../services/user-service.js";
|
||||
|
||||
/**
|
||||
* Build the `GET /users/me` route entry. The handler resolves the
|
||||
* current Principal from `c.get("user")` (populated upstream by the
|
||||
* auth plugin's session middleware) and looks up the profile row via
|
||||
* the supplied {@link UserService}. Successful responses emit the
|
||||
* `user.profile_reads_total` counter.
|
||||
*/
|
||||
export function buildGetMeRoute(userService: UserService): PluginRouteEntry {
|
||||
const route = GET("/me")
|
||||
.tags("users")
|
||||
.summary("Read the current user's profile")
|
||||
.response(successResponse(UserProfileResponseSchema))
|
||||
.build();
|
||||
|
||||
async function handler(c: Context): Promise<Response> {
|
||||
const profile = await userService.getCurrent(c);
|
||||
if (profile === null) {
|
||||
throw new UserError(
|
||||
USER_CODES.NOT_FOUND,
|
||||
"no principal on request — requireAuth() should run upstream",
|
||||
{ context: { statusCode: 401 } }
|
||||
);
|
||||
}
|
||||
getUserInstruments().profileReadsTotal.add(1);
|
||||
return c.json({ success: true as const, data: profile }, 200);
|
||||
}
|
||||
|
||||
return { route, handler };
|
||||
}
|
||||
11
packages/api-stack/api/api-plugin-user/src/routes/index.ts
Normal file
11
packages/api-stack/api/api-plugin-user/src/routes/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export {
|
||||
type BuildUserRoutesOptions,
|
||||
buildUserRoutes,
|
||||
} from "./build-routes.js";
|
||||
export {
|
||||
buildDeleteMeRoute,
|
||||
type DeleteMeResponse,
|
||||
DeleteMeResponseSchema,
|
||||
} from "./delete-me.js";
|
||||
export { buildGetMeRoute } from "./get-me.js";
|
||||
export { buildPatchMeRoute } from "./patch-me.js";
|
||||
@@ -0,0 +1,58 @@
|
||||
# routes/patch-me
|
||||
|
||||
## Purpose
|
||||
|
||||
`buildPatchMeRoute` returns the `PluginRouteEntry` for
|
||||
`PATCH /me`. The handler reads the principal from
|
||||
`c.get("user")`, parses the body against the per-instance,
|
||||
strict-mode update schema, and delegates the write to
|
||||
`userService.updateProfile`. Both the success and failure paths
|
||||
emit the `user.profile_updates_total{outcome}` counter so a
|
||||
dashboard can split success/error without an additional metric.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-instance Zod body schema — strict-mode rejects unknown fields
|
||||
before the database is touched.
|
||||
- Outcome label on the counter so observability covers both halves
|
||||
of the request lifecycle.
|
||||
- Single failure path for missing principals (401), invalid bodies
|
||||
(400), and database failures (500), each carrying the matching
|
||||
`statusCode` on the error context so the host error-handler picks
|
||||
the right HTTP status.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
const entry = buildPatchMeRoute(userService, bodySchema);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Operators that want to validate the body more strictly compose a
|
||||
custom schema before constructing the route:
|
||||
|
||||
```ts
|
||||
const Strict = bodySchema.extend({
|
||||
locale: bodySchema.shape.locale.refine((s) => s.length <= 5),
|
||||
});
|
||||
const entry = buildPatchMeRoute(userService, Strict);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `buildPatchMeRoute(userService, bodySchema): PluginRouteEntry`
|
||||
|
||||
`bodySchema` is the strict-mode object schema minted from the
|
||||
operator's resolved `profileFields`. `userService` is the resolved
|
||||
service slot from the host's `ServiceRegistry`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `c.req.json()` is `.catch(() => ({}))`-guarded — a malformed
|
||||
payload becomes the empty object which the strict schema then
|
||||
rejects with a clear "missing required key" or "unknown key"
|
||||
diagnostic, not a generic parse error.
|
||||
- The outcome counter increments BEFORE the throw in the failure
|
||||
path so a panicked exception still produces observable metrics.
|
||||
- The handler does NOT mutate the principal — it only reads the id.
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { Principal } from "@suaveplan/api-plugin-auth";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Context } from "hono";
|
||||
import { USER_CODES, UserError } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
buildUpdateBodySchemaFor,
|
||||
UpdateUserProfileBodySchema,
|
||||
} from "../schemas/user-profile.js";
|
||||
import type { UserService } from "../services/user-service.js";
|
||||
import type { UserProfile } from "../types/types.js";
|
||||
import { buildPatchMeRoute } from "./patch-me.js";
|
||||
|
||||
function makeContext(principal: Principal | null, body: unknown): Context {
|
||||
return {
|
||||
get: (key: string) => (key === "user" ? principal : undefined),
|
||||
req: {
|
||||
json: async () => body,
|
||||
},
|
||||
json: (b: unknown, status: number) =>
|
||||
new Response(JSON.stringify(b), { status }),
|
||||
} as unknown as Context;
|
||||
}
|
||||
|
||||
function fakeService(updated: UserProfile, fail = false): UserService {
|
||||
return {
|
||||
async getById() {
|
||||
return updated;
|
||||
},
|
||||
async requireById() {
|
||||
return updated;
|
||||
},
|
||||
async getCurrent() {
|
||||
return updated;
|
||||
},
|
||||
async updateProfile() {
|
||||
if (fail) {
|
||||
throw new UserError(USER_CODES.PROFILE_UPDATE_FAILED, "boom");
|
||||
}
|
||||
return updated;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const principal: Principal = {
|
||||
id: "u1",
|
||||
emailVerified: true,
|
||||
roles: [],
|
||||
};
|
||||
const updated: UserProfile = {
|
||||
id: "u1",
|
||||
emailVerified: true,
|
||||
roles: [],
|
||||
displayName: "New",
|
||||
};
|
||||
|
||||
describe("buildPatchMeRoute", () => {
|
||||
it("accepts valid patches", { timeout: 5000 }, async () => {
|
||||
const { handler } = buildPatchMeRoute(
|
||||
fakeService(updated),
|
||||
buildUpdateBodySchemaFor(["displayName"])
|
||||
);
|
||||
const c = makeContext(principal, { displayName: "New" });
|
||||
const res = await handler(c);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("rejects unknown fields (strict mode)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { handler } = buildPatchMeRoute(
|
||||
fakeService(updated),
|
||||
UpdateUserProfileBodySchema
|
||||
);
|
||||
const c = makeContext(principal, {
|
||||
displayName: "X",
|
||||
evil: "Y",
|
||||
});
|
||||
let caught: unknown;
|
||||
try {
|
||||
await handler(c);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(UserError);
|
||||
expect((caught as UserError).userCode).toBe(USER_CODES.INVALID_INPUT);
|
||||
});
|
||||
|
||||
it("raises 401 on anonymous principal", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { handler } = buildPatchMeRoute(
|
||||
fakeService(updated),
|
||||
UpdateUserProfileBodySchema
|
||||
);
|
||||
const c = makeContext(null, {});
|
||||
let caught: unknown;
|
||||
try {
|
||||
await handler(c);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(UserError);
|
||||
expect((caught as UserError).userCode).toBe(USER_CODES.NOT_FOUND);
|
||||
});
|
||||
|
||||
it("re-throws service failures", { timeout: 5000 }, async () => {
|
||||
const { handler } = buildPatchMeRoute(
|
||||
fakeService(updated, true),
|
||||
UpdateUserProfileBodySchema
|
||||
);
|
||||
const c = makeContext(principal, { displayName: "X" });
|
||||
let caught: unknown;
|
||||
try {
|
||||
await handler(c);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(UserError);
|
||||
expect((caught as UserError).userCode).toBe(
|
||||
USER_CODES.PROFILE_UPDATE_FAILED
|
||||
);
|
||||
});
|
||||
|
||||
it("treats malformed JSON body as empty patch (strict success)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { handler } = buildPatchMeRoute(
|
||||
fakeService(updated),
|
||||
UpdateUserProfileBodySchema
|
||||
);
|
||||
const c = {
|
||||
get: (key: string) => (key === "user" ? principal : undefined),
|
||||
req: {
|
||||
json: async () => {
|
||||
throw new Error("malformed");
|
||||
},
|
||||
},
|
||||
json: (b: unknown, status: number) =>
|
||||
new Response(JSON.stringify(b), { status }),
|
||||
} as unknown as Context;
|
||||
const res = await handler(c);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
PATCH,
|
||||
type PluginRouteEntry,
|
||||
successResponse,
|
||||
} from "@suaveplan/api-openapi";
|
||||
import type { Context } from "hono";
|
||||
import type { z } from "zod";
|
||||
import { USER_CODES, UserError } from "../error-codes/error-codes.js";
|
||||
import { getUserInstruments } from "../otel/instruments.js";
|
||||
import { UserProfileResponseSchema } from "../schemas/user-profile.js";
|
||||
import type { UserService } from "../services/user-service.js";
|
||||
|
||||
/**
|
||||
* Build the `PATCH /users/me` route entry. The body Zod schema is
|
||||
* the per-instance, strict-mode `UpdateUserProfileBodySchema` minted
|
||||
* by the plugin during `onInit` from the resolved `profileFields`
|
||||
* list. The handler validates the body, delegates to
|
||||
* `userService.updateProfile`, and emits
|
||||
* `user.profile_updates_total{outcome}` regardless of success or
|
||||
* failure.
|
||||
*/
|
||||
export function buildPatchMeRoute(
|
||||
userService: UserService,
|
||||
bodySchema: z.ZodObject<z.ZodRawShape>
|
||||
): PluginRouteEntry {
|
||||
const route = PATCH("/me")
|
||||
.tags("users")
|
||||
.summary("Update the current user's profile")
|
||||
.body(bodySchema)
|
||||
.response(successResponse(UserProfileResponseSchema))
|
||||
.build();
|
||||
|
||||
async function handler(c: Context): Promise<Response> {
|
||||
const principal = c.get("user");
|
||||
if (principal === null || principal === undefined) {
|
||||
throw new UserError(
|
||||
USER_CODES.NOT_FOUND,
|
||||
"no principal on request — requireAuth() should run upstream",
|
||||
{ context: { statusCode: 401 } }
|
||||
);
|
||||
}
|
||||
const raw = (await c.req.json().catch(() => ({}))) as unknown;
|
||||
const parsed = bodySchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
getUserInstruments().profileUpdatesTotal.add(1, {
|
||||
outcome: "error",
|
||||
});
|
||||
throw new UserError(
|
||||
USER_CODES.INVALID_INPUT,
|
||||
"PATCH /users/me body failed validation",
|
||||
{
|
||||
cause: parsed.error,
|
||||
context: { statusCode: 400 },
|
||||
}
|
||||
);
|
||||
}
|
||||
try {
|
||||
const profile = await userService.updateProfile(
|
||||
principal.id,
|
||||
parsed.data as Readonly<Record<string, unknown>>
|
||||
);
|
||||
getUserInstruments().profileUpdatesTotal.add(1, {
|
||||
outcome: "success",
|
||||
});
|
||||
return c.json({ success: true as const, data: profile }, 200);
|
||||
} catch (err) {
|
||||
getUserInstruments().profileUpdatesTotal.add(1, {
|
||||
outcome: "error",
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return { route, handler };
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export type { ApplyUserProfileSchemaOptions } from "./schema.js";
|
||||
export {
|
||||
applyUserProfileSchema,
|
||||
DEFAULT_USER_PROFILE_FIELDS,
|
||||
renderUserProfileSchema,
|
||||
USER_PROFILE_SCHEMA,
|
||||
} from "./schema.js";
|
||||
96
packages/api-stack/api/api-plugin-user/src/schema/schema.md
Normal file
96
packages/api-stack/api/api-plugin-user/src/schema/schema.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# schema/schema
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The schema module owns the SurrealQL DDL the user plugin ships — a
|
||||
small extension to Better-Auth's `user` table that adds optional
|
||||
profile columns operators configure via `UserPluginOptions.profileFields`.
|
||||
Every statement uses `DEFINE FIELD OVERWRITE` so re-applying the
|
||||
schema across plugin restarts is idempotent — the field definitions
|
||||
are replaced, existing rows are untouched.
|
||||
|
||||
## Features
|
||||
|
||||
- `DEFAULT_USER_PROFILE_FIELDS` — the canonical four-field set the
|
||||
plugin ships out of the box.
|
||||
- `renderUserProfileSchema(fields)` — pure function that returns the
|
||||
DDL string. Validates each field against the SurrealQL identifier
|
||||
grammar so the output cannot drift into a parser error.
|
||||
- `USER_PROFILE_SCHEMA` — the rendered DDL for the canonical
|
||||
default field set; the most common operator path.
|
||||
- `applyUserProfileSchema({ profileFields, migrationRunner?, db? })`
|
||||
— chooses the migration runner when present (so the apply is
|
||||
recorded in the migration tracking table) and falls back to an
|
||||
inline `db.execute`. Failures wrap into
|
||||
`UserError(USER_SCHEMA_APPLY_FAILED)`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
USER_PROFILE_SCHEMA,
|
||||
applyUserProfileSchema,
|
||||
} from "@suaveplan/api-plugin-user/schema/schema";
|
||||
|
||||
await applyUserProfileSchema({
|
||||
profileFields: ["displayName", "avatarUrl", "locale", "timezone"],
|
||||
db: surrealAdapter,
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Operators who manage migrations externally render the DDL once and
|
||||
write it to disk for their own tooling to apply:
|
||||
|
||||
```ts
|
||||
import { renderUserProfileSchema } from "@suaveplan/api-plugin-user/schema/schema";
|
||||
import { writeFileSync } from "node:fs";
|
||||
|
||||
const ddl = renderUserProfileSchema(["displayName", "avatarUrl", "bio"]);
|
||||
writeFileSync("migrations/0001_user_profile.surql", ddl);
|
||||
```
|
||||
|
||||
The plugin's `applySchemaOnInit: false` flag disables the in-plugin
|
||||
apply step when the externally-applied schema is the source of truth.
|
||||
|
||||
## API Reference
|
||||
|
||||
### `DEFAULT_USER_PROFILE_FIELDS: readonly string[]`
|
||||
|
||||
Frozen list of the four default field names.
|
||||
|
||||
### `renderUserProfileSchema(profileFields): string`
|
||||
|
||||
Returns the DDL string. Throws `UserError(USER_INVALID_INPUT)` when
|
||||
any field name fails the SurrealQL identifier grammar or the list is
|
||||
empty.
|
||||
|
||||
### `USER_PROFILE_SCHEMA: string`
|
||||
|
||||
`renderUserProfileSchema(DEFAULT_USER_PROFILE_FIELDS)` — pre-rendered.
|
||||
|
||||
### `ApplyUserProfileSchemaOptions`
|
||||
|
||||
`{ profileFields, migrationRunner?, db? }`.
|
||||
|
||||
### `applyUserProfileSchema(options): Promise<void>`
|
||||
|
||||
Applies the DDL via the migration runner (preferred) or the adapter
|
||||
(fallback). Throws `USER_SCHEMA_APPLY_FAILED` on runtime failure,
|
||||
`USER_SERVICE_NOT_AVAILABLE` when neither transport is supplied.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `DEFINE FIELD OVERWRITE` form is the SurrealDB idiomatic
|
||||
idempotent definition — `DEFINE FIELD ... IF NOT EXISTS` does not
|
||||
exist in SurrealDB 2.x.
|
||||
- The renderer is intentionally pure so operators can diff DDL across
|
||||
plugin upgrades by comparing strings.
|
||||
- The migration name (`0001-user-profile-schema`) lives in the
|
||||
migration-tracking table so re-applies become no-ops once the apply
|
||||
is recorded.
|
||||
149
packages/api-stack/api/api-plugin-user/src/schema/schema.test.ts
Normal file
149
packages/api-stack/api/api-plugin-user/src/schema/schema.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { USER_CODES, UserError } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
applyUserProfileSchema,
|
||||
DEFAULT_USER_PROFILE_FIELDS,
|
||||
renderUserProfileSchema,
|
||||
USER_PROFILE_SCHEMA,
|
||||
} from "./schema.js";
|
||||
|
||||
describe("renderUserProfileSchema", () => {
|
||||
it("emits one DEFINE FIELD OVERWRITE per field", { timeout: 5000 }, () => {
|
||||
const ddl = renderUserProfileSchema(["displayName", "bio"]);
|
||||
expect(
|
||||
ddl.includes(
|
||||
"DEFINE FIELD OVERWRITE displayName ON user TYPE option<string>;"
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
ddl.includes(
|
||||
"DEFINE FIELD OVERWRITE bio ON user TYPE option<string>;"
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects empty profile-field list", { timeout: 5000 }, () => {
|
||||
expect(() => renderUserProfileSchema([])).toThrow(UserError);
|
||||
});
|
||||
|
||||
it("rejects invalid identifier (digit prefix)", { timeout: 5000 }, () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
renderUserProfileSchema(["1bad"]);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(UserError);
|
||||
expect((caught as UserError).userCode).toBe(USER_CODES.INVALID_INPUT);
|
||||
});
|
||||
|
||||
it("rejects identifier containing a dash", { timeout: 5000 }, () => {
|
||||
expect(() => renderUserProfileSchema(["bad-name"])).toThrow(UserError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("USER_PROFILE_SCHEMA", () => {
|
||||
it("matches the rendered default field set", { timeout: 5000 }, () => {
|
||||
expect(USER_PROFILE_SCHEMA).toBe(
|
||||
renderUserProfileSchema(DEFAULT_USER_PROFILE_FIELDS)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyUserProfileSchema", () => {
|
||||
it("prefers migrationRunner when present", { timeout: 5000 }, async () => {
|
||||
let captured: unknown;
|
||||
const runner = {
|
||||
run: async (files: unknown) => {
|
||||
captured = files;
|
||||
},
|
||||
};
|
||||
await applyUserProfileSchema({
|
||||
profileFields: ["displayName"],
|
||||
migrationRunner: runner as never,
|
||||
});
|
||||
expect(Array.isArray(captured)).toBe(true);
|
||||
expect((captured as Array<{ name: string }>)[0]?.name).toBe(
|
||||
"0001-user-profile-schema"
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to db.execute when no migrationRunner", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let sql: string | undefined;
|
||||
const db = {
|
||||
execute: async (statement: string) => {
|
||||
sql = statement;
|
||||
},
|
||||
};
|
||||
await applyUserProfileSchema({
|
||||
profileFields: ["displayName"],
|
||||
db: db as never,
|
||||
});
|
||||
expect(sql).toContain("DEFINE FIELD OVERWRITE displayName");
|
||||
});
|
||||
|
||||
it("wraps migrationRunner errors as USER_SCHEMA_APPLY_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const runner = {
|
||||
run: async () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
};
|
||||
let caught: unknown;
|
||||
try {
|
||||
await applyUserProfileSchema({
|
||||
profileFields: ["displayName"],
|
||||
migrationRunner: runner as never,
|
||||
});
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(UserError);
|
||||
expect((caught as UserError).userCode).toBe(
|
||||
USER_CODES.SCHEMA_APPLY_FAILED
|
||||
);
|
||||
});
|
||||
|
||||
it("wraps db.execute errors as USER_SCHEMA_APPLY_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const db = {
|
||||
execute: async () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
};
|
||||
let caught: unknown;
|
||||
try {
|
||||
await applyUserProfileSchema({
|
||||
profileFields: ["displayName"],
|
||||
db: db as never,
|
||||
});
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(UserError);
|
||||
expect((caught as UserError).userCode).toBe(
|
||||
USER_CODES.SCHEMA_APPLY_FAILED
|
||||
);
|
||||
});
|
||||
|
||||
it("raises USER_SERVICE_NOT_AVAILABLE when no transport supplied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
await applyUserProfileSchema({
|
||||
profileFields: ["displayName"],
|
||||
});
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(UserError);
|
||||
expect((caught as UserError).userCode).toBe(
|
||||
USER_CODES.SERVICE_NOT_AVAILABLE
|
||||
);
|
||||
});
|
||||
});
|
||||
136
packages/api-stack/api/api-plugin-user/src/schema/schema.ts
Normal file
136
packages/api-stack/api/api-plugin-user/src/schema/schema.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import type { MigrationRunner } from "@suaveplan/api-migrations";
|
||||
import type { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import { USER_CODES, UserError } from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* Default profile-field list — the four optional `option<string>`
|
||||
* columns the plugin ships out of the box. Operators override via
|
||||
* `UserPluginOptions.profileFields`.
|
||||
*/
|
||||
export const DEFAULT_USER_PROFILE_FIELDS: readonly string[] = [
|
||||
"displayName",
|
||||
"avatarUrl",
|
||||
"locale",
|
||||
"timezone",
|
||||
];
|
||||
|
||||
/**
|
||||
* Render the SurrealQL DDL that extends Better-Auth's `user` table
|
||||
* with the operator-supplied profile columns. Each column is declared
|
||||
* `option<string>` via `DEFINE FIELD OVERWRITE` so re-applying the
|
||||
* statement is idempotent. Genesis-original — Better-Auth's bundled
|
||||
* schema covers identity, not profile.
|
||||
*
|
||||
* The output is a single string with statements separated by `\n` so
|
||||
* the canonical migration record stores one body, not N. SurrealDB
|
||||
* accepts multiple statements in a single `execute`.
|
||||
*
|
||||
* Throws `UserError(USER_INVALID_INPUT)` when any field name fails the
|
||||
* SurrealQL identifier grammar (`[A-Za-z_][A-Za-z0-9_]*`). The Zod
|
||||
* options schema gates this too — the runtime check here defends the
|
||||
* helper when consumers bypass the factory.
|
||||
*/
|
||||
export function renderUserProfileSchema(
|
||||
profileFields: readonly string[]
|
||||
): string {
|
||||
if (profileFields.length === 0) {
|
||||
throw new UserError(
|
||||
USER_CODES.INVALID_INPUT,
|
||||
"renderUserProfileSchema requires at least one profile field",
|
||||
{ context: { statusCode: 500 } }
|
||||
);
|
||||
}
|
||||
const lines: string[] = [];
|
||||
for (const field of profileFields) {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(field)) {
|
||||
throw new UserError(
|
||||
USER_CODES.INVALID_INPUT,
|
||||
`profile field "${field}" is not a valid SurrealQL identifier`,
|
||||
{ context: { field, statusCode: 500 } }
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
`DEFINE FIELD OVERWRITE ${field} ON user TYPE option<string>;`
|
||||
);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* The canonical default DDL string — `renderUserProfileSchema` over
|
||||
* the {@link DEFAULT_USER_PROFILE_FIELDS}. Operators using the default
|
||||
* field set can apply this verbatim; operators who customise the list
|
||||
* should render their own via `renderUserProfileSchema(fields)`.
|
||||
*/
|
||||
export const USER_PROFILE_SCHEMA: string = renderUserProfileSchema(
|
||||
DEFAULT_USER_PROFILE_FIELDS
|
||||
);
|
||||
|
||||
/**
|
||||
* Inputs required by {@link applyUserProfileSchema}. Either a
|
||||
* `migrationRunner` (preferred — records the migration in the
|
||||
* tracking table) or a `db` adapter (fallback — executes the DDL
|
||||
* inline) must be supplied.
|
||||
*/
|
||||
export interface ApplyUserProfileSchemaOptions {
|
||||
readonly profileFields: readonly string[];
|
||||
readonly migrationRunner?: MigrationRunner;
|
||||
readonly db?: SurrealAdapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the user-profile schema using the supplied transport. Prefers
|
||||
* the migration runner so the apply is recorded in the host's
|
||||
* migration-tracking table; falls back to inline `db.execute` when
|
||||
* no runner is present. Throws
|
||||
* `UserError(USER_SCHEMA_APPLY_FAILED)` carrying the underlying error
|
||||
* as `cause` on any failure, or `USER_SERVICE_NOT_AVAILABLE` when
|
||||
* neither transport is supplied.
|
||||
*/
|
||||
export async function applyUserProfileSchema(
|
||||
options: ApplyUserProfileSchemaOptions
|
||||
): Promise<void> {
|
||||
const { profileFields, migrationRunner, db } = options;
|
||||
const ddl = renderUserProfileSchema(profileFields);
|
||||
if (migrationRunner !== undefined) {
|
||||
try {
|
||||
await migrationRunner.run([
|
||||
{
|
||||
name: "0001-user-profile-schema",
|
||||
path: "<inline>",
|
||||
order: 1,
|
||||
content: ddl,
|
||||
},
|
||||
]);
|
||||
return;
|
||||
} catch (err) {
|
||||
throw new UserError(
|
||||
USER_CODES.SCHEMA_APPLY_FAILED,
|
||||
"migrationRunner.run(USER_PROFILE_SCHEMA) failed",
|
||||
{ cause: err, context: { statusCode: 500 } }
|
||||
);
|
||||
}
|
||||
}
|
||||
if (db !== undefined) {
|
||||
try {
|
||||
await db.execute(ddl);
|
||||
return;
|
||||
} catch (err) {
|
||||
throw new UserError(
|
||||
USER_CODES.SCHEMA_APPLY_FAILED,
|
||||
"db.execute(USER_PROFILE_SCHEMA) failed",
|
||||
{ cause: err, context: { statusCode: 500 } }
|
||||
);
|
||||
}
|
||||
}
|
||||
throw new UserError(
|
||||
USER_CODES.SERVICE_NOT_AVAILABLE,
|
||||
"applyUserProfileSchema requires either a migrationRunner or db",
|
||||
{
|
||||
context: {
|
||||
missing: ["migrationRunner", "db"],
|
||||
statusCode: 500,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
13
packages/api-stack/api/api-plugin-user/src/schemas/index.ts
Normal file
13
packages/api-stack/api/api-plugin-user/src/schemas/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export {
|
||||
type UserPluginOptionsInput,
|
||||
type UserPluginOptionsResolved,
|
||||
UserPluginOptionsSchema,
|
||||
} from "./options.js";
|
||||
export {
|
||||
type UpdateUserProfileBody,
|
||||
UpdateUserProfileBodySchema,
|
||||
type UserProfileResponse,
|
||||
UserProfileResponseSchema,
|
||||
UserProfileSchema,
|
||||
type UserProfileSchemaType,
|
||||
} from "./user-profile.js";
|
||||
@@ -0,0 +1,77 @@
|
||||
# schemas/options
|
||||
|
||||
## Purpose
|
||||
|
||||
`UserPluginOptionsSchema` is the Zod boundary contract for the
|
||||
operator-supplied options accepted by `createUserPlugin`. It enforces
|
||||
defaults, validates the route prefix shape, gates profile-field names
|
||||
through the SurrealQL identifier grammar so they can be interpolated
|
||||
into DDL without quoting, and structurally validates the optional
|
||||
logger override. Invalid input causes the factory to throw
|
||||
`UserError(USER_INVALID_INPUT)` carrying the `ZodError` as `cause`.
|
||||
|
||||
## Features
|
||||
|
||||
- Defaults `routePrefix` to `/users`, `allowSelfDelete` to `true`,
|
||||
`applySchemaOnInit` to `true`, and `profileFields` to
|
||||
`["displayName", "avatarUrl", "locale", "timezone"]`.
|
||||
- `routePrefix` is regex-checked: starts with `/`, ends without a
|
||||
trailing `/` (root `/` is the single allowed exception).
|
||||
- `profileFields` is regex-checked against `[A-Za-z_][A-Za-z0-9_]*`
|
||||
so the schema-apply step can drop each name straight into DDL.
|
||||
- `logger` is structurally validated via `z.custom` so operators can
|
||||
pass any object that satisfies `LoggerLike` without nominal typing.
|
||||
- Two type aliases — `UserPluginOptionsInput` (pre-parse) and
|
||||
`UserPluginOptionsResolved` (post-parse) — are exported so callers
|
||||
can pick the variant that matches their lifecycle stage.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { UserPluginOptionsSchema } from "@suaveplan/api-plugin-user/schemas/options";
|
||||
|
||||
const resolved = UserPluginOptionsSchema.parse({});
|
||||
// resolved.routePrefix === "/users"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
When wiring the plugin into a custom factory, validate the operator
|
||||
input separately so the error surface is explicit:
|
||||
|
||||
```ts
|
||||
import { UserPluginOptionsSchema } from "@suaveplan/api-plugin-user/schemas/options";
|
||||
|
||||
const parsed = UserPluginOptionsSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return Result.err({ code: "USER.INVALID_INPUT", issues: parsed.error });
|
||||
}
|
||||
const resolved = parsed.data;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `UserPluginOptionsSchema: z.ZodObject`
|
||||
|
||||
Schema with five entries — `routePrefix`, `allowSelfDelete`,
|
||||
`profileFields`, `applySchemaOnInit`, and `logger`. Defaults are
|
||||
applied during parse.
|
||||
|
||||
### `UserPluginOptionsInput`
|
||||
|
||||
`z.input` of the schema — the pre-parse shape, every field optional.
|
||||
|
||||
### `UserPluginOptionsResolved`
|
||||
|
||||
`z.output` of the schema — defaults applied. The plugin's `defaults`
|
||||
field on the descriptor types to this shape.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `profileFields` regex deliberately rejects digits-at-position-0
|
||||
and dashes; SurrealQL identifiers that contain those characters
|
||||
would need backtick-quoting which the bundled schema does not emit.
|
||||
- `.readonly()` on `profileFields` flows through to the runtime
|
||||
config so the array is shared without defensive copies.
|
||||
- The `logger` predicate only checks `info`; the other three methods
|
||||
are inferred from the `LoggerLike` cast for downstream consumers.
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { UserPluginOptionsSchema } from "./options.js";
|
||||
|
||||
describe("UserPluginOptionsSchema", () => {
|
||||
it("applies all defaults when {} is parsed", { timeout: 5000 }, () => {
|
||||
const out = UserPluginOptionsSchema.parse({});
|
||||
expect(out.routePrefix).toBe("/users");
|
||||
expect(out.allowSelfDelete).toBe(true);
|
||||
expect(out.applySchemaOnInit).toBe(true);
|
||||
expect(out.profileFields).toEqual([
|
||||
"displayName",
|
||||
"avatarUrl",
|
||||
"locale",
|
||||
"timezone",
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts the canonical route prefix shapes", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
UserPluginOptionsSchema.safeParse({ routePrefix: "/users" }).success
|
||||
).toBe(true);
|
||||
expect(
|
||||
UserPluginOptionsSchema.safeParse({ routePrefix: "/" }).success
|
||||
).toBe(true);
|
||||
expect(
|
||||
UserPluginOptionsSchema.safeParse({ routePrefix: "/api/v1/users" })
|
||||
.success
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects trailing-slash route prefix", { timeout: 5000 }, () => {
|
||||
const r = UserPluginOptionsSchema.safeParse({ routePrefix: "/users/" });
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects missing-leading-slash route prefix", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = UserPluginOptionsSchema.safeParse({ routePrefix: "users" });
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-boolean allowSelfDelete", { timeout: 5000 }, () => {
|
||||
const r = UserPluginOptionsSchema.safeParse({
|
||||
allowSelfDelete: "yes" as unknown,
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a custom profileFields list", { timeout: 5000 }, () => {
|
||||
const out = UserPluginOptionsSchema.parse({
|
||||
profileFields: ["bio", "locale"],
|
||||
});
|
||||
expect(out.profileFields).toEqual(["bio", "locale"]);
|
||||
});
|
||||
|
||||
it("rejects empty profileFields list", { timeout: 5000 }, () => {
|
||||
const r = UserPluginOptionsSchema.safeParse({ profileFields: [] });
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects profile-field names that break SurrealQL ident grammar", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = UserPluginOptionsSchema.safeParse({
|
||||
profileFields: ["good", "1bad"],
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a LoggerLike", { timeout: 5000 }, () => {
|
||||
const noop = () => undefined;
|
||||
const out = UserPluginOptionsSchema.parse({
|
||||
logger: {
|
||||
debug: noop,
|
||||
info: noop,
|
||||
warn: noop,
|
||||
error: noop,
|
||||
},
|
||||
});
|
||||
expect(out.logger).toBeDefined();
|
||||
});
|
||||
|
||||
it("rejects a logger missing the info method", { timeout: 5000 }, () => {
|
||||
const r = UserPluginOptionsSchema.safeParse({
|
||||
logger: { foo: "bar" } as unknown,
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Zod schema for `UserPluginOptions`. Validated by `createUserPlugin`
|
||||
* at the factory boundary — invalid input throws
|
||||
* `UserError(USER_INVALID_INPUT)` carrying the `ZodError` as `cause`.
|
||||
*
|
||||
* `routePrefix` MUST start with `/` and MUST NOT end with `/` (the bare
|
||||
* root `/` is permitted) — both forms produce broken Hono routes.
|
||||
*
|
||||
* `profileFields` defaults to the canonical four-field set the plugin
|
||||
* ships out of the box. Each field name must match the SurrealQL
|
||||
* identifier grammar (`[A-Za-z_][A-Za-z0-9_]*`) so the schema-apply
|
||||
* step can interpolate it directly into `DEFINE FIELD OVERWRITE` DDL
|
||||
* without quoting.
|
||||
*
|
||||
* `logger` is validated structurally via `z.custom` — the runtime
|
||||
* object is opaque and we only assert it has an `info` callable.
|
||||
*/
|
||||
export const UserPluginOptionsSchema = z.object({
|
||||
routePrefix: z
|
||||
.string()
|
||||
.regex(/^\/[A-Za-z0-9\-_/]*[A-Za-z0-9\-_]$|^\/$/u, {
|
||||
message:
|
||||
"routePrefix must start with '/' and not end with '/' (unless it is exactly '/')",
|
||||
})
|
||||
.default("/users"),
|
||||
allowSelfDelete: z.boolean().default(true),
|
||||
profileFields: z
|
||||
.array(z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/u))
|
||||
.min(1)
|
||||
.readonly()
|
||||
.default(["displayName", "avatarUrl", "locale", "timezone"]),
|
||||
applySchemaOnInit: z.boolean().default(true),
|
||||
logger: z
|
||||
.custom<{
|
||||
debug: (msg: string, ctx?: unknown) => void;
|
||||
info: (msg: string, ctx?: unknown) => void;
|
||||
warn: (msg: string, ctx?: unknown) => void;
|
||||
error: (msg: string, ctx?: unknown) => void;
|
||||
}>(
|
||||
(val) =>
|
||||
val !== null &&
|
||||
typeof val === "object" &&
|
||||
typeof (val as { info?: unknown }).info === "function",
|
||||
"logger must be a LoggerLike with debug/info/warn/error"
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type UserPluginOptionsInput = z.input<typeof UserPluginOptionsSchema>;
|
||||
export type UserPluginOptionsResolved = z.output<
|
||||
typeof UserPluginOptionsSchema
|
||||
>;
|
||||
@@ -0,0 +1,110 @@
|
||||
# schemas/user-profile
|
||||
|
||||
## Purpose
|
||||
|
||||
`user-profile` is the boundary Zod surface for the user plugin: the
|
||||
response shape for `GET /users/me`, the response shape for `PATCH
|
||||
/users/me`, and the body shape for `PATCH /users/me`. The schema is
|
||||
built by `.extend()`-ing `PrincipalSchema` from the sibling
|
||||
`@suaveplan/api-plugin-auth/schemas/principal` so the identity fields
|
||||
(`id`, `email`, `emailVerified`, `roles`, `tenantId`, `metadata`)
|
||||
remain in lockstep with the auth plugin — there is one canonical
|
||||
Principal type, never two.
|
||||
|
||||
## Features
|
||||
|
||||
- `UserProfileSchema` extends `PrincipalSchema` with four optional
|
||||
profile fields: `displayName`, `avatarUrl`, `locale`, `timezone`.
|
||||
- `UserProfileResponseSchema` is the alias used by route handlers
|
||||
when registering the response payload — separate name keeps the
|
||||
registry self-documenting even though the schemas are identical.
|
||||
- `UpdateUserProfileBodySchema` is built from
|
||||
`UserProfileSchema.pick(profileFields).partial().strict()` so the
|
||||
PATCH body is restricted to writable columns, every column is
|
||||
optional, and unknown keys are hard-rejected.
|
||||
- `buildUpdateBodySchemaFor(profileFields)` mints a strict-mode body
|
||||
schema from an arbitrary operator-supplied profile-fields list —
|
||||
the plugin rebuilds the body schema once during `onInit` so the
|
||||
strict mask matches the configured columns exactly.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
UpdateUserProfileBodySchema,
|
||||
UserProfileSchema,
|
||||
} from "@suaveplan/api-plugin-user/schemas/user-profile";
|
||||
|
||||
const body = UpdateUserProfileBodySchema.parse({ displayName: "Alice" });
|
||||
const profile = UserProfileSchema.parse(rowFromDb);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Operators that ship custom `profileFields` extend the base schema and
|
||||
mint the matching strict body schema explicitly:
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildUpdateBodySchemaFor,
|
||||
UserProfileSchema,
|
||||
} from "@suaveplan/api-plugin-user/schemas/user-profile";
|
||||
import z from "zod";
|
||||
|
||||
const ExtendedProfile = UserProfileSchema.extend({
|
||||
bio: z.string().max(280).optional(),
|
||||
});
|
||||
|
||||
const PatchBody = buildUpdateBodySchemaFor([
|
||||
"displayName",
|
||||
"avatarUrl",
|
||||
"locale",
|
||||
"timezone",
|
||||
]);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `UserProfileSchema`
|
||||
|
||||
Zod object that extends `PrincipalSchema` with four optional fields.
|
||||
|
||||
### `UserProfileSchemaType`
|
||||
|
||||
`z.infer<typeof UserProfileSchema>`.
|
||||
|
||||
### `UserProfileResponseSchema`
|
||||
|
||||
Alias for `UserProfileSchema` — used by route registrations.
|
||||
|
||||
### `UserProfileResponse`
|
||||
|
||||
`z.infer<typeof UserProfileResponseSchema>`.
|
||||
|
||||
### `UpdateUserProfileBodySchema`
|
||||
|
||||
Strict, partial, picked-down view of `UserProfileSchema` over the four
|
||||
canonical profile fields.
|
||||
|
||||
### `UpdateUserProfileBody`
|
||||
|
||||
`z.infer<typeof UpdateUserProfileBodySchema>`.
|
||||
|
||||
### `buildUpdateBodySchemaFor(profileFields): z.ZodObject<z.ZodRawShape>`
|
||||
|
||||
Builds a strict body schema that intersects the supplied
|
||||
`profileFields` list with the known profile columns. Caller-supplied
|
||||
names not on the base schema are silently dropped — operators with
|
||||
extended fields are responsible for composing their own schema.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The extension uses `.extend()` rather than a fresh `z.object` so the
|
||||
PrincipalSchema evolution surface (e.g. future Principal extensions)
|
||||
flows into `UserProfileSchema` automatically.
|
||||
- `.strict()` on the body schema is the only thing rejecting
|
||||
field-spoofing attempts — strip it and an attacker can write to
|
||||
Principal fields the route never intended to expose. Tests pin this.
|
||||
- The default-build of `UpdateUserProfileBodySchema` is the most
|
||||
common operator path; `buildUpdateBodySchemaFor` only needs to fire
|
||||
when the operator narrows or extends the writable surface.
|
||||
@@ -0,0 +1,17 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("user-profile schema spec conformance", () => {
|
||||
it("no duplicated Principal definition", { timeout: 5000 }, () => {
|
||||
const src = readFileSync(join(__dirname, "user-profile.ts"), "utf8");
|
||||
// Confirm we import PrincipalSchema rather than redefining it.
|
||||
expect(src).toContain(
|
||||
'import { PrincipalSchema } from "@suaveplan/api-plugin-auth/schemas/principal";'
|
||||
);
|
||||
// The Principal field set is owned by the auth plugin — verify
|
||||
// we do NOT redeclare id / emailVerified / roles inline.
|
||||
expect(/PrincipalSchema\s*\.\s*extend\s*\(/.test(src)).toBe(true);
|
||||
expect(/z\.object\(\s*{\s*id:\s*z\.string\(\)/.test(src)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
buildUpdateBodySchemaFor,
|
||||
UpdateUserProfileBodySchema,
|
||||
UserProfileResponseSchema,
|
||||
UserProfileSchema,
|
||||
} from "./user-profile.js";
|
||||
|
||||
describe("UserProfileSchema", () => {
|
||||
it("extends PrincipalSchema from the sibling auth plugin", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const src = readFileSync(join(__dirname, "user-profile.ts"), "utf8");
|
||||
expect(src).toContain("@suaveplan/api-plugin-auth/schemas/principal");
|
||||
});
|
||||
|
||||
it("accepts a Principal-shaped value augmented with profile fields", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = UserProfileSchema.safeParse({
|
||||
id: "u1",
|
||||
email: "a@example.test",
|
||||
emailVerified: true,
|
||||
roles: [],
|
||||
displayName: "A",
|
||||
avatarUrl: "https://example.test/a.png",
|
||||
locale: "en-US",
|
||||
timezone: "UTC",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects when id is missing", { timeout: 5000 }, () => {
|
||||
const r = UserProfileSchema.safeParse({
|
||||
emailVerified: false,
|
||||
roles: [],
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an avatar URL that is not a URL", { timeout: 5000 }, () => {
|
||||
const r = UserProfileSchema.safeParse({
|
||||
id: "u1",
|
||||
emailVerified: true,
|
||||
roles: [],
|
||||
avatarUrl: "not-a-url",
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("UserProfileResponseSchema mirrors UserProfileSchema", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(UserProfileResponseSchema).toBe(UserProfileSchema);
|
||||
});
|
||||
});
|
||||
|
||||
describe("UpdateUserProfileBodySchema (strict)", () => {
|
||||
it("accepts a partial patch with one field", { timeout: 5000 }, () => {
|
||||
const r = UpdateUserProfileBodySchema.safeParse({
|
||||
displayName: "New",
|
||||
});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unknown fields under strict()", { timeout: 5000 }, () => {
|
||||
const r = UpdateUserProfileBodySchema.safeParse({
|
||||
displayName: "X",
|
||||
evil: "Y",
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts empty patch", { timeout: 5000 }, () => {
|
||||
const r = UpdateUserProfileBodySchema.safeParse({});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildUpdateBodySchemaFor", () => {
|
||||
it("restricts the strict mask to the supplied known fields", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const schema = buildUpdateBodySchemaFor(["displayName"]);
|
||||
expect(schema.safeParse({ displayName: "X" }).success).toBe(true);
|
||||
expect(
|
||||
schema.safeParse({ displayName: "X", avatarUrl: "https://x.test" })
|
||||
.success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("drops unknown caller-supplied names from the mask", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const schema = buildUpdateBodySchemaFor(["displayName", "bio"]);
|
||||
// bio is not on the base UserProfileSchema so it's dropped;
|
||||
// displayName survives as the only writable field.
|
||||
expect(schema.safeParse({ displayName: "X" }).success).toBe(true);
|
||||
expect(schema.safeParse({ bio: "x" }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("produces a strict empty schema when no known field is supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const schema = buildUpdateBodySchemaFor(["bio"]);
|
||||
expect(schema.safeParse({}).success).toBe(true);
|
||||
expect(schema.safeParse({ bio: "x" }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { PrincipalSchema } from "@suaveplan/api-plugin-auth/schemas/principal";
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Canonical Zod schema for `UserProfile` — the projection returned by
|
||||
* `GET /users/me` and `userService.getById(id)`. Extends
|
||||
* `PrincipalSchema` re-imported from the sibling auth plugin so the
|
||||
* identity surface (id, email, emailVerified, roles, tenantId,
|
||||
* metadata) is never duplicated.
|
||||
*
|
||||
* The four operator-default profile columns (`displayName`,
|
||||
* `avatarUrl`, `locale`, `timezone`) are appended as optional strings.
|
||||
* Operators that ship custom `profileFields` extend this schema via
|
||||
* `UserProfileSchema.extend({ bio: z.string().optional() })`.
|
||||
*/
|
||||
export const UserProfileSchema = PrincipalSchema.extend({
|
||||
displayName: z.string().min(1).max(256).optional(),
|
||||
avatarUrl: z.string().url().max(2048).optional(),
|
||||
locale: z.string().min(2).max(35).optional(),
|
||||
timezone: z.string().min(1).max(64).optional(),
|
||||
});
|
||||
|
||||
export type UserProfileSchemaType = z.infer<typeof UserProfileSchema>;
|
||||
|
||||
/**
|
||||
* Success-envelope `data` schema for `GET /users/me` and
|
||||
* `PATCH /users/me`. Identical to `UserProfileSchema` — kept as a
|
||||
* separate named export so the route registration can declare an
|
||||
* intentional response shape rather than threading the bare schema
|
||||
* through the route builder.
|
||||
*/
|
||||
export const UserProfileResponseSchema = UserProfileSchema;
|
||||
|
||||
export type UserProfileResponse = z.infer<typeof UserProfileResponseSchema>;
|
||||
|
||||
/**
|
||||
* Body schema for `PATCH /users/me`. Built from `UserProfileSchema`
|
||||
* via `.pick(profileFields).partial().strict()` so:
|
||||
*
|
||||
* 1. `pick(profileFields)` restricts the writable surface to the
|
||||
* operator-configured columns — Principal identity fields (id,
|
||||
* email, roles, etc.) are never patchable through this route.
|
||||
* 2. `partial()` makes every column optional — a PATCH only needs to
|
||||
* carry the fields the caller wants to change.
|
||||
* 3. `strict()` rejects unknown keys — `{ displayName: "X", evil: "Y" }`
|
||||
* fails Zod parse before the handler runs.
|
||||
*
|
||||
* The default-build with the four canonical fields lives in the
|
||||
* frozen `BASE_UPDATE_PROFILE_BODY_SCHEMA` constant; the runtime
|
||||
* plugin rebuilds the schema once during `onInit` from the resolved
|
||||
* `profileFields` array so the strict mask matches the configured
|
||||
* columns exactly.
|
||||
*/
|
||||
export const UpdateUserProfileBodySchema = UserProfileSchema.pick({
|
||||
displayName: true,
|
||||
avatarUrl: true,
|
||||
locale: true,
|
||||
timezone: true,
|
||||
})
|
||||
.partial()
|
||||
.strict();
|
||||
|
||||
export type UpdateUserProfileBody = z.infer<typeof UpdateUserProfileBodySchema>;
|
||||
|
||||
/**
|
||||
* Build a per-operator `UpdateUserProfileBodySchema` from an arbitrary
|
||||
* `profileFields` list. Used by the plugin's `onInit` to mint a
|
||||
* strict-mode body schema that matches the configured columns.
|
||||
*
|
||||
* The intersection of the supplied list with the canonical fields is
|
||||
* what survives — caller-supplied names that are not on
|
||||
* `UserProfileSchema` are dropped here (they live on the extended
|
||||
* schema operators register out-of-band). Producing the empty mask
|
||||
* yields a strict empty object schema, which is exactly what an
|
||||
* operator that opted out of every default field should see.
|
||||
*/
|
||||
export function buildUpdateBodySchemaFor(
|
||||
profileFields: readonly string[]
|
||||
): z.ZodObject<z.ZodRawShape> {
|
||||
type KnownProfileField =
|
||||
| "displayName"
|
||||
| "avatarUrl"
|
||||
| "locale"
|
||||
| "timezone";
|
||||
const known: readonly KnownProfileField[] = [
|
||||
"displayName",
|
||||
"avatarUrl",
|
||||
"locale",
|
||||
"timezone",
|
||||
];
|
||||
const knownSet = new Set<string>(known);
|
||||
const mask = {} as { [K in KnownProfileField]?: true };
|
||||
for (const field of profileFields) {
|
||||
if (knownSet.has(field)) {
|
||||
mask[field as KnownProfileField] = true;
|
||||
}
|
||||
}
|
||||
const picked = UserProfileSchema.pick(mask);
|
||||
return picked.partial().strict() as z.ZodObject<z.ZodRawShape>;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import "./registry-augmentation.js";
|
||||
|
||||
export {
|
||||
type CreateUserServiceOptions,
|
||||
createUserService,
|
||||
type UserService,
|
||||
} from "./user-service.js";
|
||||
@@ -0,0 +1,70 @@
|
||||
# services/registry-augmentation
|
||||
|
||||
## Purpose
|
||||
|
||||
This module is a TypeScript `declare module` augmentation. It adds the
|
||||
`userService` slot to the canonical `ServiceRegistry` interface owned
|
||||
by `@suaveplan/api-plugin/service-registry` so downstream plugins that
|
||||
import `@suaveplan/api-plugin-user` for its side effects see
|
||||
`ctx.getService("userService")` resolve to `UserService | undefined`
|
||||
without a cast.
|
||||
|
||||
## Features
|
||||
|
||||
- One side-effect import of this file (which the package root barrel
|
||||
performs automatically) gives every consumer typed access to the
|
||||
`userService` slot.
|
||||
- Mirrors the auth plugin's registry-augmentation pattern so a future
|
||||
project-wide audit can grep every plugin's registry slot from a
|
||||
single regex.
|
||||
- Declaration merging means the augmentation composes safely with
|
||||
every other plugin's registry-augmentation file — the host's
|
||||
`ServiceRegistry` is the union of all loaded plugins' slots.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import "@suaveplan/api-plugin-user/services/user-service";
|
||||
// `ctx.getService("userService")` now types as `UserService | undefined`.
|
||||
```
|
||||
|
||||
The package root barrel already imports this file for its side
|
||||
effects — explicit import is only required when the consumer pulls
|
||||
the user-service file directly via the subpath export.
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Downstream plugins that depend on the user service can resolve it
|
||||
inside their own `onInit`:
|
||||
|
||||
```ts
|
||||
const userService = ctx.getService("userService");
|
||||
if (!userService) {
|
||||
throw new PluginError(PLUGIN_CODES.SERVICE_NOT_AVAILABLE, "userService");
|
||||
}
|
||||
const profile = await userService.getById(principal.id);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
The module has no runtime exports — it is a pure type-level
|
||||
augmentation. The augmentation adds:
|
||||
|
||||
```ts
|
||||
declare module "@suaveplan/api-plugin/service-registry" {
|
||||
interface ServiceRegistry {
|
||||
userService: UserService;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The augmentation is intentionally minimal — host-side slots
|
||||
(`app`, `openAPIRegistry`, `migrationRunner`, `db`) are declared by
|
||||
the auth plugin's augmentation and merged in by TypeScript.
|
||||
- The side-effect import in `src/index.ts` is what makes the
|
||||
augmentation visible to consumers; removing it would silently
|
||||
weaken typing.
|
||||
- This file MUST NOT carry runtime code — Vite would emit a chunk
|
||||
with no exports otherwise.
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* ServiceRegistry module augmentation — declares the `userService`
|
||||
* slot on `@suaveplan/api-plugin/service-registry`'s `ServiceRegistry`
|
||||
* interface so downstream plugins typed against the augmented registry
|
||||
* see `ctx.getService("userService")` resolve to `UserService |
|
||||
* undefined` without any cast.
|
||||
*
|
||||
* Importing this module for its side effects (which the package root
|
||||
* barrel does automatically) is sufficient — every consumer of
|
||||
* `@suaveplan/api-plugin-user` gets typed `userService` access.
|
||||
*/
|
||||
import type { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import type { MigrationRunner } from "@suaveplan/api-migrations";
|
||||
import type { OpenAPIRegistry } from "@suaveplan/api-openapi";
|
||||
import type { AuthService } from "@suaveplan/api-plugin-auth";
|
||||
import type { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import type { Clock, EventBusLike, LoggerLike } from "@suaveplan/types";
|
||||
import type { UserService } from "./user-service.js";
|
||||
|
||||
declare module "@suaveplan/api-plugin/service-registry" {
|
||||
interface ServiceRegistry {
|
||||
userService: UserService;
|
||||
// Host-side and sibling-plugin slots — re-declared here so the
|
||||
// user plugin's `onInit(ctx)` can resolve them with full typing
|
||||
// even when the consumer imports the user plugin in isolation.
|
||||
// TypeScript merges duplicate declarations.
|
||||
authService: AuthService;
|
||||
app: OpenAPIHono;
|
||||
openAPIRegistry: OpenAPIRegistry;
|
||||
migrationRunner?: MigrationRunner;
|
||||
db?: SurrealAdapter;
|
||||
logger: LoggerLike;
|
||||
eventBus: EventBusLike;
|
||||
clock: Clock;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
# services/user-service
|
||||
|
||||
## Purpose
|
||||
|
||||
The user-service module owns the `UserService` interface — the
|
||||
`ServiceRegistry`-resolved API downstream `@suaveplan/api-plugin-*`
|
||||
packages call when they need a user-profile lookup or an in-line
|
||||
patch. The service queries SurrealDB directly via the
|
||||
`SurrealAdapter` injected at construction time, so resolution does
|
||||
not require an in-flight Better-Auth session.
|
||||
|
||||
## Features
|
||||
|
||||
- `getById(id)` returns the merged Principal + profile fields, or
|
||||
`null` when the row is missing — anonymous-friendly.
|
||||
- `requireById(id)` is the throwing variant — raises
|
||||
`UserError(USER_NOT_FOUND)` on miss so caller code can keep its
|
||||
happy-path branch single-line.
|
||||
- `getCurrent(c)` reads the request's `c.get("user")` Principal (set
|
||||
by the auth plugin's session middleware) and overlays the database
|
||||
row's profile columns. Returns `null` for anonymous requests.
|
||||
- `updateProfile(id, patch)` filters the patch down to the
|
||||
operator-configured `profileFields` and applies it via
|
||||
`UPDATE ... MERGE`. Empty patches skip the database write — a real
|
||||
no-op never reaches the wire.
|
||||
- Both record-id forms (`abc` and `user:abc`) round-trip identically
|
||||
via a `type::thing('user', $tail)` query.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
const userService = ctx.getService("userService");
|
||||
const profile = await userService.getById("u1");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
A downstream vault plugin scopes its writes to the principal id by
|
||||
combining `getCurrent` with its own service:
|
||||
|
||||
```ts
|
||||
app.use("/vault/*", requireAuth());
|
||||
app.get("/vault/items", async (c) => {
|
||||
const profile = await userService.getCurrent(c);
|
||||
if (profile === null) {
|
||||
throw new UserError(USER_CODES.NOT_FOUND, "no principal");
|
||||
}
|
||||
return c.json({ items: await vaultService.listFor(profile.id) });
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `UserService`
|
||||
|
||||
```ts
|
||||
interface UserService {
|
||||
getById(id: string): Promise<UserProfile | null>;
|
||||
requireById(id: string): Promise<UserProfile>;
|
||||
getCurrent(c: Context): Promise<UserProfile | null>;
|
||||
updateProfile(
|
||||
id: string,
|
||||
patch: Readonly<Record<string, unknown>>
|
||||
): Promise<UserProfile>;
|
||||
}
|
||||
```
|
||||
|
||||
### `CreateUserServiceOptions`
|
||||
|
||||
`{ db: SurrealAdapter, profileFields: readonly string[],
|
||||
getPrincipal: (c: Context) => Principal | null }`.
|
||||
|
||||
### `createUserService(options): UserService`
|
||||
|
||||
Constructs the canonical implementation.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The service never reads from Better-Auth's API — only the database
|
||||
adapter — so it can be used in worker contexts that hold the
|
||||
adapter but not a live `Auth` instance.
|
||||
- `updateProfile` filters out `undefined` patch values so a caller
|
||||
passing `{ displayName: undefined }` does not clear the column.
|
||||
- Non-string row `id` raises `USER_PROFILE_UPDATE_FAILED` rather than
|
||||
`USER_NOT_FOUND` — the row exists, it just violates the projection
|
||||
contract.
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { ServiceRegistry } from "@suaveplan/api-plugin/service-registry";
|
||||
import { describe, expectTypeOf, it } from "@suaveplan/testing/runner";
|
||||
import type { UserService } from "./user-service.js";
|
||||
|
||||
describe("UserService spec conformance", () => {
|
||||
it("ServiceRegistry slot typed as UserService", { timeout: 5000 }, () => {
|
||||
expectTypeOf<
|
||||
ServiceRegistry["userService"]
|
||||
>().toEqualTypeOf<UserService>();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,380 @@
|
||||
import type { Principal } from "@suaveplan/api-plugin-auth";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Context } from "hono";
|
||||
import { USER_CODES, UserError } from "../error-codes/error-codes.js";
|
||||
import { createUserService } from "./user-service.js";
|
||||
|
||||
interface QueryCall {
|
||||
sql: string;
|
||||
params?: unknown;
|
||||
}
|
||||
|
||||
function makeDb(rows: Array<Record<string, unknown>>): {
|
||||
db: {
|
||||
query: (sql: string, p?: unknown) => Promise<unknown[]>;
|
||||
execute: (
|
||||
sql: string,
|
||||
p?: unknown
|
||||
) => Promise<{ rowsAffected: number }>;
|
||||
};
|
||||
calls: QueryCall[];
|
||||
execCalls: QueryCall[];
|
||||
failExecute: () => void;
|
||||
} {
|
||||
const calls: QueryCall[] = [];
|
||||
const execCalls: QueryCall[] = [];
|
||||
let shouldFail = false;
|
||||
return {
|
||||
db: {
|
||||
async query(sql, p) {
|
||||
calls.push({ sql, params: p });
|
||||
return rows;
|
||||
},
|
||||
async execute(sql, p) {
|
||||
execCalls.push({ sql, params: p });
|
||||
if (shouldFail) throw new Error("boom");
|
||||
return { rowsAffected: 1 };
|
||||
},
|
||||
},
|
||||
calls,
|
||||
execCalls,
|
||||
failExecute() {
|
||||
shouldFail = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const profileFields = ["displayName", "avatarUrl", "locale", "timezone"];
|
||||
|
||||
function makeCtx(principal: Principal | null): Context {
|
||||
return { get: () => principal } as unknown as Context;
|
||||
}
|
||||
|
||||
describe("createUserService.getById", () => {
|
||||
it("returns null on missing", { timeout: 5000 }, async () => {
|
||||
const { db } = makeDb([]);
|
||||
const svc = createUserService({
|
||||
db: db as never,
|
||||
profileFields,
|
||||
getPrincipal: () => null,
|
||||
});
|
||||
const result = await svc.getById("missing-id");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("strips a `user:` prefix before querying", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { db, calls } = makeDb([
|
||||
{ id: "user:abc", emailVerified: true, roles: [] },
|
||||
]);
|
||||
const svc = createUserService({
|
||||
db: db as never,
|
||||
profileFields,
|
||||
getPrincipal: () => null,
|
||||
});
|
||||
await svc.getById("user:abc");
|
||||
const params = calls[0]?.params as readonly unknown[];
|
||||
expect(params[0]).toBe("abc");
|
||||
});
|
||||
|
||||
it("projects profile fields from the row", { timeout: 5000 }, async () => {
|
||||
const { db } = makeDb([
|
||||
{
|
||||
id: "u1",
|
||||
emailVerified: true,
|
||||
roles: ["admin"],
|
||||
email: "a@example.test",
|
||||
displayName: "Alice",
|
||||
locale: "en-US",
|
||||
},
|
||||
]);
|
||||
const svc = createUserService({
|
||||
db: db as never,
|
||||
profileFields,
|
||||
getPrincipal: () => null,
|
||||
});
|
||||
const profile = await svc.getById("u1");
|
||||
expect(profile).not.toBeNull();
|
||||
expect(profile?.id).toBe("u1");
|
||||
expect(profile?.displayName).toBe("Alice");
|
||||
expect(profile?.email).toBe("a@example.test");
|
||||
expect(profile?.roles).toEqual(["admin"]);
|
||||
});
|
||||
|
||||
it("coerces non-string ids via String(...) when present", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { db } = makeDb([
|
||||
{
|
||||
id: { toString: () => "user:abc" },
|
||||
emailVerified: true,
|
||||
roles: [],
|
||||
},
|
||||
]);
|
||||
const svc = createUserService({
|
||||
db: db as never,
|
||||
profileFields,
|
||||
getPrincipal: () => null,
|
||||
});
|
||||
const profile = await svc.getById("u1");
|
||||
expect(profile?.id).toBe("user:abc");
|
||||
});
|
||||
|
||||
it("raises PROFILE_UPDATE_FAILED when id is null/missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { db } = makeDb([{ id: null, emailVerified: true, roles: [] }]);
|
||||
const svc = createUserService({
|
||||
db: db as never,
|
||||
profileFields,
|
||||
getPrincipal: () => null,
|
||||
});
|
||||
let caught: unknown;
|
||||
try {
|
||||
await svc.getById("u1");
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(UserError);
|
||||
expect((caught as UserError).userCode).toBe(
|
||||
USER_CODES.PROFILE_UPDATE_FAILED
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults roles to [] when row carries non-array", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { db } = makeDb([
|
||||
{ id: "u1", emailVerified: false, roles: "not-an-array" },
|
||||
]);
|
||||
const svc = createUserService({
|
||||
db: db as never,
|
||||
profileFields,
|
||||
getPrincipal: () => null,
|
||||
});
|
||||
const profile = await svc.getById("u1");
|
||||
expect(profile?.roles).toEqual([]);
|
||||
});
|
||||
|
||||
it("projects tenantId when present", { timeout: 5000 }, async () => {
|
||||
const { db } = makeDb([
|
||||
{
|
||||
id: "u1",
|
||||
emailVerified: false,
|
||||
roles: [],
|
||||
tenantId: "org-1",
|
||||
},
|
||||
]);
|
||||
const svc = createUserService({
|
||||
db: db as never,
|
||||
profileFields,
|
||||
getPrincipal: () => null,
|
||||
});
|
||||
const profile = await svc.getById("u1");
|
||||
expect(profile?.tenantId).toBe("org-1");
|
||||
});
|
||||
|
||||
it("skips empty-string email + tenantId", { timeout: 5000 }, async () => {
|
||||
const { db } = makeDb([
|
||||
{
|
||||
id: "u1",
|
||||
emailVerified: false,
|
||||
roles: [],
|
||||
email: "",
|
||||
tenantId: "",
|
||||
},
|
||||
]);
|
||||
const svc = createUserService({
|
||||
db: db as never,
|
||||
profileFields,
|
||||
getPrincipal: () => null,
|
||||
});
|
||||
const profile = await svc.getById("u1");
|
||||
expect(profile?.email).toBeUndefined();
|
||||
expect(profile?.tenantId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createUserService.requireById", () => {
|
||||
it("throws USER_NOT_FOUND when missing", { timeout: 5000 }, async () => {
|
||||
const { db } = makeDb([]);
|
||||
const svc = createUserService({
|
||||
db: db as never,
|
||||
profileFields,
|
||||
getPrincipal: () => null,
|
||||
});
|
||||
let caught: unknown;
|
||||
try {
|
||||
await svc.requireById("u-missing");
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(UserError);
|
||||
expect((caught as UserError).userCode).toBe(USER_CODES.NOT_FOUND);
|
||||
});
|
||||
|
||||
it("returns the profile when found", { timeout: 5000 }, async () => {
|
||||
const { db } = makeDb([{ id: "u1", emailVerified: true, roles: [] }]);
|
||||
const svc = createUserService({
|
||||
db: db as never,
|
||||
profileFields,
|
||||
getPrincipal: () => null,
|
||||
});
|
||||
const profile = await svc.requireById("u1");
|
||||
expect(profile.id).toBe("u1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createUserService.getCurrent", () => {
|
||||
it('reads c.get("user") and merges row profile fields', {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const principal: Principal = {
|
||||
id: "u1",
|
||||
email: "a@example.test",
|
||||
emailVerified: true,
|
||||
roles: ["user"],
|
||||
};
|
||||
const { db } = makeDb([
|
||||
{
|
||||
id: "u1",
|
||||
emailVerified: true,
|
||||
roles: ["user"],
|
||||
displayName: "Alice",
|
||||
timezone: "UTC",
|
||||
},
|
||||
]);
|
||||
const svc = createUserService({
|
||||
db: db as never,
|
||||
profileFields,
|
||||
getPrincipal: (c) => c.get("user") as Principal | null,
|
||||
});
|
||||
const result = await svc.getCurrent(makeCtx(principal));
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.id).toBe("u1");
|
||||
expect(result?.email).toBe("a@example.test");
|
||||
expect(result?.displayName).toBe("Alice");
|
||||
expect(result?.timezone).toBe("UTC");
|
||||
});
|
||||
|
||||
it("returns null for anonymous request", { timeout: 5000 }, async () => {
|
||||
const { db } = makeDb([]);
|
||||
const svc = createUserService({
|
||||
db: db as never,
|
||||
profileFields,
|
||||
getPrincipal: () => null,
|
||||
});
|
||||
const result = await svc.getCurrent(makeCtx(null));
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns merged shape even when row is missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const principal: Principal = {
|
||||
id: "u1",
|
||||
emailVerified: false,
|
||||
roles: [],
|
||||
tenantId: "org-1",
|
||||
metadata: { name: "A" },
|
||||
};
|
||||
const { db } = makeDb([]);
|
||||
const svc = createUserService({
|
||||
db: db as never,
|
||||
profileFields,
|
||||
getPrincipal: (c) => c.get("user") as Principal | null,
|
||||
});
|
||||
const result = await svc.getCurrent(makeCtx(principal));
|
||||
expect(result?.id).toBe("u1");
|
||||
expect(result?.tenantId).toBe("org-1");
|
||||
expect(result?.metadata).toEqual({ name: "A" });
|
||||
expect(result?.displayName).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createUserService.updateProfile", () => {
|
||||
it("merges only fields in the configured allow-list", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { db, execCalls } = makeDb([
|
||||
{
|
||||
id: "u1",
|
||||
emailVerified: true,
|
||||
roles: [],
|
||||
displayName: "New",
|
||||
},
|
||||
]);
|
||||
const svc = createUserService({
|
||||
db: db as never,
|
||||
profileFields: ["displayName"],
|
||||
getPrincipal: () => null,
|
||||
});
|
||||
await svc.updateProfile("u1", {
|
||||
displayName: "New",
|
||||
avatarUrl: "https://x.test/a",
|
||||
evil: "Y",
|
||||
});
|
||||
const params = execCalls[0]?.params as readonly unknown[];
|
||||
const patch = params[1] as Record<string, unknown>;
|
||||
expect(patch).toEqual({ displayName: "New" });
|
||||
expect(patch.avatarUrl).toBeUndefined();
|
||||
expect(patch.evil).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips db write when patch is empty after filtering", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { db, execCalls } = makeDb([
|
||||
{ id: "u1", emailVerified: true, roles: [] },
|
||||
]);
|
||||
const svc = createUserService({
|
||||
db: db as never,
|
||||
profileFields,
|
||||
getPrincipal: () => null,
|
||||
});
|
||||
await svc.updateProfile("u1", { evil: "Y", displayName: undefined });
|
||||
expect(execCalls.length).toBe(0);
|
||||
});
|
||||
|
||||
it("wraps execute failure as USER_PROFILE_UPDATE_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { db, failExecute } = makeDb([]);
|
||||
failExecute();
|
||||
const svc = createUserService({
|
||||
db: db as never,
|
||||
profileFields,
|
||||
getPrincipal: () => null,
|
||||
});
|
||||
let caught: unknown;
|
||||
try {
|
||||
await svc.updateProfile("u1", { displayName: "X" });
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(UserError);
|
||||
expect((caught as UserError).userCode).toBe(
|
||||
USER_CODES.PROFILE_UPDATE_FAILED
|
||||
);
|
||||
});
|
||||
|
||||
it("raises NOT_FOUND when post-update row is gone", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { db } = makeDb([]);
|
||||
const svc = createUserService({
|
||||
db: db as never,
|
||||
profileFields,
|
||||
getPrincipal: () => null,
|
||||
});
|
||||
let caught: unknown;
|
||||
try {
|
||||
await svc.updateProfile("u1", { displayName: "X" });
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(UserError);
|
||||
expect((caught as UserError).userCode).toBe(USER_CODES.NOT_FOUND);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
import type { Principal } from "@suaveplan/api-plugin-auth";
|
||||
import type { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import type { Context } from "hono";
|
||||
import { USER_CODES, UserError } from "../error-codes/error-codes.js";
|
||||
import type { UserProfile } from "../types/types.js";
|
||||
import "./registry-augmentation.js";
|
||||
|
||||
/**
|
||||
* Service slot registered by `createUserPlugin` in the host's
|
||||
* `ServiceRegistry`. Downstream plugins (api-plugin-vault,
|
||||
* api-plugin-organizations, etc.) resolve user profiles via
|
||||
* `ctx.getService("userService")`.
|
||||
*/
|
||||
export interface UserService {
|
||||
/**
|
||||
* Look up a user by record id. Returns `null` when the row is
|
||||
* absent — the helper does NOT raise `USER_NOT_FOUND` so the
|
||||
* common case (anonymous flow checking "do I know this id?")
|
||||
* stays branch-free. Use {@link requireById} when the caller
|
||||
* needs the throwing variant.
|
||||
*/
|
||||
getById(id: string): Promise<UserProfile | null>;
|
||||
/**
|
||||
* Throwing variant of {@link getById} — raises
|
||||
* `UserError(USER_NOT_FOUND)` when the row is absent.
|
||||
*/
|
||||
requireById(id: string): Promise<UserProfile>;
|
||||
/**
|
||||
* Returns the merged Principal + profile fields for the current
|
||||
* request context. The principal comes from `c.get("user")`
|
||||
* (populated by the auth plugin's session middleware); the
|
||||
* profile fields come from a `getById` lookup. Returns `null`
|
||||
* when the principal is anonymous.
|
||||
*/
|
||||
getCurrent(c: Context): Promise<UserProfile | null>;
|
||||
/**
|
||||
* Apply a partial profile update. Returns the freshly-read profile
|
||||
* row. Throws `UserError(USER_PROFILE_UPDATE_FAILED)` carrying the
|
||||
* underlying error as `cause` on any failure;
|
||||
* `UserError(USER_NOT_FOUND)` when the row is absent
|
||||
* post-update.
|
||||
*/
|
||||
updateProfile(
|
||||
id: string,
|
||||
patch: Readonly<Record<string, unknown>>
|
||||
): Promise<UserProfile>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inputs required to build the {@link UserService} impl. Kept
|
||||
* explicit so the factory is testable without the full plugin
|
||||
* lifecycle.
|
||||
*/
|
||||
export interface CreateUserServiceOptions {
|
||||
/** SurrealDB adapter that fronts the Better-Auth user table. */
|
||||
readonly db: SurrealAdapter;
|
||||
/** Resolved profile-fields list for the running plugin instance. */
|
||||
readonly profileFields: readonly string[];
|
||||
/** Resolver for the current-request Principal. */
|
||||
readonly getPrincipal: (c: Context) => Principal | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw user-table row shape returned by SurrealDB. The Better-Auth
|
||||
* adapter writes camel-case identifiers (`emailVerified`, `roles`)
|
||||
* and the plugin's schema adds the configured profile columns
|
||||
* alongside them. Every field is `unknown` at the boundary —
|
||||
* {@link rowToProfile} narrows the projection.
|
||||
*/
|
||||
type RawUserRow = Readonly<Record<string, unknown>>;
|
||||
|
||||
/**
|
||||
* Strip the `<table>:` prefix from a SurrealDB record id. Returns the
|
||||
* input unchanged when no prefix is present. Bare `:` and empty-tail
|
||||
* inputs return the empty string — the caller's downstream query will
|
||||
* surface this as a `null` result.
|
||||
*/
|
||||
function stripRecordPrefix(id: string): string {
|
||||
const idx = id.indexOf(":");
|
||||
if (idx === -1) return id;
|
||||
return id.slice(idx + 1);
|
||||
}
|
||||
|
||||
function rowToProfile(
|
||||
row: RawUserRow,
|
||||
profileFields: readonly string[]
|
||||
): UserProfile {
|
||||
const rawId = row["id"];
|
||||
// SurrealDB returns ids as `RecordId` instances when the row was
|
||||
// written via the JS driver and as bare strings when the row was
|
||||
// hand-written by the Better-Auth adapter. Normalise to the
|
||||
// canonical `user:<tail>` string so downstream callers see one
|
||||
// shape.
|
||||
const id =
|
||||
typeof rawId === "string"
|
||||
? rawId
|
||||
: rawId !== null && rawId !== undefined
|
||||
? String(rawId)
|
||||
: "";
|
||||
if (id.length === 0) {
|
||||
throw new UserError(
|
||||
USER_CODES.PROFILE_UPDATE_FAILED,
|
||||
"user row is missing string `id`",
|
||||
{ context: { statusCode: 500 } }
|
||||
);
|
||||
}
|
||||
const profile: Record<string, unknown> = {
|
||||
id,
|
||||
emailVerified: row["emailVerified"] === true,
|
||||
roles: Array.isArray(row["roles"])
|
||||
? (row["roles"] as readonly string[])
|
||||
: [],
|
||||
};
|
||||
if (
|
||||
typeof row["email"] === "string" &&
|
||||
(row["email"] as string).length > 0
|
||||
) {
|
||||
profile["email"] = row["email"] as string;
|
||||
}
|
||||
if (
|
||||
typeof row["tenantId"] === "string" &&
|
||||
(row["tenantId"] as string).length > 0
|
||||
) {
|
||||
profile["tenantId"] = row["tenantId"] as string;
|
||||
}
|
||||
for (const field of profileFields) {
|
||||
const v = row[field];
|
||||
if (typeof v === "string") {
|
||||
profile[field] = v;
|
||||
}
|
||||
}
|
||||
return profile as unknown as UserProfile;
|
||||
}
|
||||
|
||||
function principalAsProfile(
|
||||
principal: Principal,
|
||||
row: UserProfile | null
|
||||
): UserProfile {
|
||||
const merged: Record<string, unknown> = {
|
||||
id: principal.id,
|
||||
emailVerified: principal.emailVerified,
|
||||
roles: principal.roles,
|
||||
};
|
||||
if (principal.email !== undefined) merged["email"] = principal.email;
|
||||
if (principal.tenantId !== undefined)
|
||||
merged["tenantId"] = principal.tenantId;
|
||||
if (principal.metadata !== undefined)
|
||||
merged["metadata"] = principal.metadata;
|
||||
if (row !== null) {
|
||||
const rowRec = row as unknown as Record<string, unknown>;
|
||||
for (const key of [
|
||||
"displayName",
|
||||
"avatarUrl",
|
||||
"locale",
|
||||
"timezone",
|
||||
] as const) {
|
||||
const v = rowRec[key];
|
||||
if (typeof v === "string") {
|
||||
merged[key] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
return merged as unknown as UserProfile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct the canonical {@link UserService} implementation. The
|
||||
* service queries SurrealDB directly via the supplied adapter — it
|
||||
* does NOT touch Better-Auth's API surface so it can resolve users
|
||||
* without an in-flight session.
|
||||
*/
|
||||
export function createUserService(
|
||||
options: CreateUserServiceOptions
|
||||
): UserService {
|
||||
const { db, profileFields, getPrincipal } = options;
|
||||
|
||||
async function fetchRow(id: string): Promise<UserProfile | null> {
|
||||
// Better-Auth historically writes user ids in both the bare-id
|
||||
// shape and the fully-qualified `user:<id>` shape. Strip the
|
||||
// table prefix when present so the `type::record('user', $p0)`
|
||||
// factory resolves to the same record regardless of which
|
||||
// form the caller has. (SurrealDB v3 renamed `type::thing` to
|
||||
// `type::record`; the genesis testcontainer runs v3.x.)
|
||||
const tail = stripRecordPrefix(id);
|
||||
const rows = await db.query<RawUserRow>(
|
||||
"SELECT * FROM type::record('user', $p0);",
|
||||
[tail]
|
||||
);
|
||||
const row = rows[0];
|
||||
if (row === undefined) return null;
|
||||
return rowToProfile(row, profileFields);
|
||||
}
|
||||
|
||||
return {
|
||||
async getById(id) {
|
||||
return await fetchRow(id);
|
||||
},
|
||||
async requireById(id) {
|
||||
const found = await this.getById(id);
|
||||
if (found === null) {
|
||||
throw new UserError(
|
||||
USER_CODES.NOT_FOUND,
|
||||
`user not found: ${id}`,
|
||||
{ context: { id, statusCode: 404 } }
|
||||
);
|
||||
}
|
||||
return found;
|
||||
},
|
||||
async getCurrent(c) {
|
||||
const principal = getPrincipal(c);
|
||||
if (principal === null) return null;
|
||||
const row = await this.getById(principal.id);
|
||||
return principalAsProfile(principal, row);
|
||||
},
|
||||
async updateProfile(id, patch) {
|
||||
const allowed = new Set(profileFields);
|
||||
const writable: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(patch)) {
|
||||
if (!allowed.has(k)) continue;
|
||||
if (v === undefined) continue;
|
||||
writable[k] = v;
|
||||
}
|
||||
try {
|
||||
if (Object.keys(writable).length > 0) {
|
||||
const tail = stripRecordPrefix(id);
|
||||
await db.execute(
|
||||
"UPDATE type::record('user', $p0) MERGE $p1;",
|
||||
[tail, writable]
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
throw new UserError(
|
||||
USER_CODES.PROFILE_UPDATE_FAILED,
|
||||
"user profile update failed",
|
||||
{
|
||||
cause: err,
|
||||
context: {
|
||||
id,
|
||||
fields: Object.keys(writable),
|
||||
statusCode: 500,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
const refreshed = await this.getById(id);
|
||||
if (refreshed === null) {
|
||||
throw new UserError(
|
||||
USER_CODES.NOT_FOUND,
|
||||
`user not found post-update: ${id}`,
|
||||
{ context: { id, statusCode: 404 } }
|
||||
);
|
||||
}
|
||||
return refreshed;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type {
|
||||
UserPluginConfig,
|
||||
UserPluginOptions,
|
||||
UserProfile,
|
||||
} from "./types.js";
|
||||
86
packages/api-stack/api/api-plugin-user/src/types/types.md
Normal file
86
packages/api-stack/api/api-plugin-user/src/types/types.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# types/types
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module declares the three TypeScript types that flow across the
|
||||
public surface of `@suaveplan/api-plugin-user`: `UserProfile` (the
|
||||
projection of a row in Better-Auth's `user` table merged with the
|
||||
profile fields the plugin owns), `UserPluginOptions` (the
|
||||
operator-supplied factory inputs), and `UserPluginConfig` (the
|
||||
post-Zod-validation, defaults-applied runtime config). Splitting input
|
||||
from resolved config makes the defaulting semantics explicit and
|
||||
matches the convention used by the sibling auth plugin.
|
||||
|
||||
## Features
|
||||
|
||||
- `UserProfile` is the wire shape and the service-return shape — every
|
||||
route handler responds with this exact projection so consumers can
|
||||
reason about one type end-to-end.
|
||||
- `UserPluginOptions` keeps every field optional so the factory call
|
||||
is `createUserPlugin({})` for the canonical happy path.
|
||||
- `UserPluginConfig` is the resolved shape after the boundary Zod
|
||||
parse fills defaults; the `onInit` closure captures this value so it
|
||||
is constant across the plugin lifetime.
|
||||
- All four `Principal` projection fields appear on `UserProfile` so
|
||||
the `GET /users/me` handler can return the merged shape without an
|
||||
extra type-narrowing step.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { UserPluginOptions } from "@suaveplan/api-plugin-user/types/types";
|
||||
|
||||
const opts: UserPluginOptions = { allowSelfDelete: true };
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
The `profileFields` array is reused by the schema-apply step, the
|
||||
boundary Zod schemas, and the service layer:
|
||||
|
||||
```ts
|
||||
import { createUserPlugin } from "@suaveplan/api-plugin-user";
|
||||
|
||||
const plugin = createUserPlugin({
|
||||
profileFields: ["displayName", "avatarUrl", "bio", "locale"],
|
||||
allowSelfDelete: false,
|
||||
});
|
||||
```
|
||||
|
||||
The resolved `UserPluginConfig` is what the plugin's closure carries
|
||||
between `onInit` and route handlers — it never leaks out of the
|
||||
plugin's internals.
|
||||
|
||||
## API Reference
|
||||
|
||||
### `UserProfile`
|
||||
|
||||
Combined projection of the Better-Auth `user` row plus the
|
||||
profile-field columns. Carries `id`, `email?`, `emailVerified`,
|
||||
`roles`, `tenantId?`, `displayName?`, `avatarUrl?`, `locale?`,
|
||||
`timezone?`, `metadata?`.
|
||||
|
||||
### `UserPluginOptions`
|
||||
|
||||
Operator-supplied factory inputs. Every field is optional; defaults
|
||||
are applied by `UserPluginOptionsSchema`.
|
||||
|
||||
### `UserPluginConfig`
|
||||
|
||||
The post-validation resolved config. `routePrefix`, `allowSelfDelete`,
|
||||
`profileFields`, and `applySchemaOnInit` are required; `logger`
|
||||
remains optional.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The interfaces are deliberately structural — operators with custom
|
||||
Principal extensions can declare their own type that extends
|
||||
`UserProfile` and pass it through `UserService<TProfile>`.
|
||||
- `profileFields` is `readonly string[]` so the array can be reused
|
||||
across requests without defensive copying.
|
||||
- The split between options and config follows the auth-plugin
|
||||
precedent: input is permissive, config is exact.
|
||||
81
packages/api-stack/api/api-plugin-user/src/types/types.ts
Normal file
81
packages/api-stack/api/api-plugin-user/src/types/types.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { LoggerLike } from "@suaveplan/types";
|
||||
|
||||
/**
|
||||
* Canonical profile-fields shipped by `@suaveplan/api-plugin-user`. The
|
||||
* four field names below are the operator-default extension to the
|
||||
* Better-Auth `user` table; operators with custom `profileFields` see
|
||||
* the same field names typed as `string | undefined` here, while their
|
||||
* extra fields surface as `unknown` until the operator extends the
|
||||
* sibling Zod schema.
|
||||
*
|
||||
* `id` and the four `Principal` projection fields (email,
|
||||
* emailVerified, roles, tenantId) round-trip through this shape so
|
||||
* `userService.getById(id)` returns a single object that combines the
|
||||
* Principal projection with the profile-row columns — no extra
|
||||
* `findOne` required at the call site.
|
||||
*/
|
||||
export interface UserProfile {
|
||||
readonly id: string;
|
||||
readonly email?: string;
|
||||
readonly emailVerified: boolean;
|
||||
readonly roles: readonly string[];
|
||||
readonly tenantId?: string;
|
||||
readonly displayName?: string;
|
||||
readonly avatarUrl?: string;
|
||||
readonly locale?: string;
|
||||
readonly timezone?: string;
|
||||
readonly metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Operator-supplied options accepted by `createUserPlugin`. Every field
|
||||
* is validated by `UserPluginOptionsSchema` at the factory boundary; the
|
||||
* resolved shape (with defaults applied) is the runtime
|
||||
* `UserPluginConfig`.
|
||||
*/
|
||||
export interface UserPluginOptions {
|
||||
/**
|
||||
* URL prefix where the plugin mounts its 3 routes. Defaults to
|
||||
* `/users`. Must start with `/` and not carry a trailing slash
|
||||
* (the exact root `/` is allowed).
|
||||
*/
|
||||
readonly routePrefix?: string;
|
||||
/**
|
||||
* When `true` (the default), `DELETE /users/me` is registered.
|
||||
* Setting `false` makes the route 403 with
|
||||
* `USER_SELF_DELETE_DISABLED` so operators can fall back to an
|
||||
* admin-driven delete flow.
|
||||
*/
|
||||
readonly allowSelfDelete?: boolean;
|
||||
/**
|
||||
* Operator-controlled list of profile columns. Defaults to
|
||||
* `["displayName", "avatarUrl", "locale", "timezone"]`. Each
|
||||
* column is added to the Better-Auth `user` table as
|
||||
* `option<string>` via the bundled SurrealQL schema.
|
||||
*/
|
||||
readonly profileFields?: readonly string[];
|
||||
/**
|
||||
* When `true` (the default), `onInit` applies
|
||||
* `USER_PROFILE_SCHEMA` against the resolved migration runner or
|
||||
* `SurrealAdapter`. Set `false` when operators manage migrations
|
||||
* externally.
|
||||
*/
|
||||
readonly applySchemaOnInit?: boolean;
|
||||
/**
|
||||
* Optional logger override. When omitted, the plugin uses the
|
||||
* `PluginContext` logger supplied by the registry.
|
||||
*/
|
||||
readonly logger?: LoggerLike;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fully-resolved options after Zod parse — every default has been
|
||||
* applied. The plugin's descriptor types `defaults` to this shape.
|
||||
*/
|
||||
export interface UserPluginConfig {
|
||||
readonly routePrefix: string;
|
||||
readonly allowSelfDelete: boolean;
|
||||
readonly profileFields: readonly string[];
|
||||
readonly applySchemaOnInit: boolean;
|
||||
readonly logger?: LoggerLike;
|
||||
}
|
||||
112
packages/api-stack/api/api-plugin-user/tests/dep-graph.test.ts
Normal file
112
packages/api-stack/api/api-plugin-user/tests/dep-graph.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const pkgRoot = resolve(__dirname, "..");
|
||||
const srcRoot = join(pkgRoot, "src");
|
||||
|
||||
function walk(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) out.push(...walk(full));
|
||||
else out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("@suaveplan/api-plugin-user dep graph + layout", () => {
|
||||
it("package.json carries required dependencies", { timeout: 5000 }, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(pkgRoot, "package.json"), "utf8")
|
||||
) as {
|
||||
dependencies: Record<string, string>;
|
||||
};
|
||||
const deps = pkg.dependencies;
|
||||
expect(deps["@suaveplan/api-plugin"]).toBe("workspace:^");
|
||||
expect(deps["@suaveplan/api-plugin-auth"]).toBe("workspace:^");
|
||||
expect(deps["@suaveplan/api-openapi"]).toBe("workspace:^");
|
||||
expect(deps["@suaveplan/api-migrations"]).toBe("workspace:^");
|
||||
expect(deps["@suaveplan/error"]).toBe("workspace:^");
|
||||
expect(deps["@suaveplan/types"]).toBe("workspace:^");
|
||||
expect(deps["hono"]).toBe("catalog:");
|
||||
expect(deps["@hono/zod-openapi"]).toBe("catalog:server");
|
||||
expect(deps["zod"]).toBe("catalog:forms");
|
||||
expect(deps["better-auth"]).toBe("catalog:auth");
|
||||
expect(deps["@opentelemetry/api"]).toBe("catalog:observability");
|
||||
});
|
||||
|
||||
it("package.json exposes 9 feature subpath exports", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(pkgRoot, "package.json"), "utf8")
|
||||
) as { exports: Record<string, unknown> };
|
||||
const subs = Object.keys(pkg.exports).filter(
|
||||
(k) => k.startsWith("./") && k !== "./package.json" && k !== "."
|
||||
);
|
||||
// 9 feature subpaths (plugin, services/user-service, schemas/options,
|
||||
// schemas/user-profile, routes/routes, schema/schema,
|
||||
// error-codes/error-codes, otel/instruments, types/types) — the
|
||||
// "." root export is excluded by the filter above.
|
||||
expect(subs.length).toBe(9);
|
||||
});
|
||||
|
||||
it("every impl .ts has a sibling .md", { timeout: 5000 }, () => {
|
||||
const missing: string[] = [];
|
||||
for (const file of walk(srcRoot)) {
|
||||
if (!file.endsWith(".ts")) continue;
|
||||
if (
|
||||
file.endsWith(".test.ts") ||
|
||||
file.endsWith(".spec.ts") ||
|
||||
file.endsWith(".types.ts") ||
|
||||
file.endsWith("/index.ts")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const md = file.replace(/\.ts$/, ".md");
|
||||
try {
|
||||
statSync(md);
|
||||
} catch {
|
||||
missing.push(md);
|
||||
}
|
||||
}
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("README.md is present at package root", { timeout: 5000 }, () => {
|
||||
const readme = readFileSync(join(pkgRoot, "README.md"), "utf8");
|
||||
expect(readme.length).toBeGreaterThan(200);
|
||||
expect(readme).toContain("@suaveplan/api-plugin-user");
|
||||
});
|
||||
|
||||
it("every subpath export resolves to a file within src/", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(pkgRoot, "package.json"), "utf8")
|
||||
) as {
|
||||
exports: Record<string, { import?: { default?: string } } | string>;
|
||||
};
|
||||
const missing: string[] = [];
|
||||
for (const [sub, val] of Object.entries(pkg.exports)) {
|
||||
if (sub === "./package.json") continue;
|
||||
const defaultPath =
|
||||
typeof val === "string"
|
||||
? val
|
||||
: (val?.import?.default ?? undefined);
|
||||
if (defaultPath === undefined) continue;
|
||||
const rel = defaultPath
|
||||
.replace(/^\.\/dist\//, "")
|
||||
.replace(/\.js$/, ".ts");
|
||||
const srcPath = join(srcRoot, rel);
|
||||
try {
|
||||
statSync(srcPath);
|
||||
} catch {
|
||||
missing.push(`${sub} → ${srcPath}`);
|
||||
}
|
||||
}
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
|
||||
import { createPluginContext } from "@suaveplan/api-plugin";
|
||||
import {
|
||||
type AuthPluginConfig,
|
||||
createAuthPlugin,
|
||||
} from "@suaveplan/api-plugin-auth";
|
||||
import { betterAuthSurrealAdapter } from "@suaveplan/better-auth-surrealdb";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import { SurrealAdapter } from "@suaveplan/surrealdb";
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { SurrealDBFixture } from "@suaveplan/testing-integration-surrealdb";
|
||||
import { createUserPlugin } from "../../src/plugin/plugin.js";
|
||||
import type { UserPluginConfig } from "../../src/types/types.js";
|
||||
|
||||
const noopLogger = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
|
||||
let fixture: SurrealDBFixture | null = null;
|
||||
let client: SurrealAdapter | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
fixture = new SurrealDBFixture();
|
||||
const cfg = await fixture.start();
|
||||
client = new SurrealAdapter({
|
||||
url: cfg.url,
|
||||
namespace: cfg.namespace,
|
||||
database: cfg.database,
|
||||
username: cfg.username,
|
||||
password: cfg.password,
|
||||
});
|
||||
await client.connect();
|
||||
await client.execute("DEFINE TABLE OVERWRITE user SCHEMAFULL;");
|
||||
await client.execute(
|
||||
"DEFINE FIELD OVERWRITE email ON user TYPE option<string>;"
|
||||
);
|
||||
await client.execute(
|
||||
"DEFINE FIELD OVERWRITE phoneNumber ON user TYPE option<string>;"
|
||||
);
|
||||
await client.execute(
|
||||
"DEFINE FIELD OVERWRITE createdAt ON user TYPE option<datetime> DEFAULT time::now() VALUE $before OR time::now();"
|
||||
);
|
||||
await client.execute(
|
||||
"DEFINE FIELD OVERWRITE updatedAt ON user TYPE option<datetime> DEFAULT time::now() VALUE time::now();"
|
||||
);
|
||||
}, 180_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (client) {
|
||||
await client.disconnect();
|
||||
client = null;
|
||||
}
|
||||
if (fixture) {
|
||||
await fixture.stop();
|
||||
fixture = null;
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
describe("user plugin full-boot integration", () => {
|
||||
it("sign-up → /users/me → PATCH → DELETE roundtrip against real SurrealDB", {
|
||||
timeout: 120_000,
|
||||
}, async () => {
|
||||
if (client === null) throw new Error("client not ready");
|
||||
const app = new OpenAPIHono();
|
||||
const registry = createOpenAPIRegistry({
|
||||
info: { title: "user-test", version: "0.1.0" },
|
||||
});
|
||||
// Mount auth plugin first.
|
||||
const authPlugin = createAuthPlugin({
|
||||
betterAuth: {
|
||||
database: betterAuthSurrealAdapter({ client }),
|
||||
secret: "test-secret-at-least-32-chars-long-for-better-auth",
|
||||
baseURL: "http://localhost:3000/auth",
|
||||
emailAndPassword: { enabled: true },
|
||||
user: { deleteUser: { enabled: true } },
|
||||
},
|
||||
});
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
services.set("openAPIRegistry", registry);
|
||||
services.set("db", client);
|
||||
await authPlugin.onInit?.(
|
||||
createPluginContext<AuthPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: authPlugin.defaults as AuthPluginConfig,
|
||||
services,
|
||||
})
|
||||
);
|
||||
|
||||
// Mount user plugin.
|
||||
const userPlugin = createUserPlugin({});
|
||||
await userPlugin.onInit?.(
|
||||
createPluginContext<UserPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: userPlugin.defaults as UserPluginConfig,
|
||||
services,
|
||||
})
|
||||
);
|
||||
registry.applyTo(app, "/openapi.json");
|
||||
|
||||
// Sign up via Better-Auth API.
|
||||
const authServiceSlot = services.get("authService") as {
|
||||
getBetterAuthInstance: () => {
|
||||
api: {
|
||||
signUpEmail: (a: {
|
||||
body: { email: string; password: string; name: string };
|
||||
}) => Promise<{ user: { id: string } }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
const auth = authServiceSlot.getBetterAuthInstance();
|
||||
const signUp = await auth.api.signUpEmail({
|
||||
body: {
|
||||
email: "user-plugin@example.test",
|
||||
password: "Passw0rd!2026",
|
||||
name: "Test User",
|
||||
},
|
||||
});
|
||||
expect(signUp.user.id.length).toBeGreaterThan(0);
|
||||
|
||||
// userService.getById round-trips against the live row.
|
||||
const userService = services.get("userService") as {
|
||||
getById: (id: string) => Promise<unknown>;
|
||||
updateProfile: (
|
||||
id: string,
|
||||
patch: Readonly<Record<string, unknown>>
|
||||
) => Promise<{ displayName?: string }>;
|
||||
};
|
||||
const profile = await userService.getById(signUp.user.id);
|
||||
expect(profile).not.toBeNull();
|
||||
|
||||
// PATCH applies and refresh returns updated value.
|
||||
const updated = await userService.updateProfile(signUp.user.id, {
|
||||
displayName: "Test Updated",
|
||||
});
|
||||
expect(updated.displayName).toBe("Test Updated");
|
||||
|
||||
// OpenAPI doc lists /users/me three times (get, patch, delete).
|
||||
const openapiRes = await app.request("/openapi.json");
|
||||
const doc = (await openapiRes.json()) as {
|
||||
paths: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
const meMethods = Object.keys(doc.paths?.["/users/me"] ?? {});
|
||||
expect(meMethods.sort()).toEqual(["delete", "get", "patch"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
|
||||
import { createPluginContext } from "@suaveplan/api-plugin";
|
||||
import type { AuthService } from "@suaveplan/api-plugin-auth";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createUserPlugin } from "../../src/plugin/plugin.js";
|
||||
import type { UserPluginConfig } from "../../src/types/types.js";
|
||||
|
||||
const noopLogger = {
|
||||
debug: () => undefined,
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
};
|
||||
|
||||
function fakeAuthService(): AuthService {
|
||||
return {
|
||||
getBetterAuthInstance: () =>
|
||||
({ $context: Promise.resolve({}) }) as never,
|
||||
getCurrentPrincipal: () => null,
|
||||
getSession: async () => null,
|
||||
};
|
||||
}
|
||||
|
||||
const okDb = {
|
||||
query: async () => [],
|
||||
execute: async () => ({ rowsAffected: 0 }),
|
||||
};
|
||||
|
||||
describe("openapi-doc", () => {
|
||||
it("served doc lists 3 /users/me routes with Zod-typed schemas", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const app = new OpenAPIHono();
|
||||
const registry = createOpenAPIRegistry({
|
||||
info: { title: "user-test", version: "0.1.0" },
|
||||
});
|
||||
const plugin = createUserPlugin({ applySchemaOnInit: false });
|
||||
const services = new Map<string, unknown>();
|
||||
services.set("app", app);
|
||||
services.set("openAPIRegistry", registry);
|
||||
services.set("authService", fakeAuthService());
|
||||
services.set("db", okDb);
|
||||
await plugin.onInit?.(
|
||||
createPluginContext<UserPluginConfig>({
|
||||
logger: noopLogger,
|
||||
eventBus: new EventBus(),
|
||||
signal: new AbortController().signal,
|
||||
config: plugin.defaults as UserPluginConfig,
|
||||
services,
|
||||
})
|
||||
);
|
||||
registry.applyTo(app, "/openapi.json");
|
||||
const res = await app.request("/openapi.json");
|
||||
const doc = (await res.json()) as {
|
||||
paths: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
const me = doc.paths?.["/users/me"];
|
||||
expect(me).toBeDefined();
|
||||
const methods = Object.keys(me as object).sort();
|
||||
expect(methods).toEqual(["delete", "get", "patch"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import fc from "fast-check";
|
||||
import { UpdateUserProfileBodySchema } from "../src/schemas/user-profile.js";
|
||||
|
||||
const KNOWN_FIELDS = ["displayName", "avatarUrl", "locale", "timezone"];
|
||||
|
||||
const valueArb = fc.oneof(
|
||||
fc.string({ minLength: 1, maxLength: 16 }),
|
||||
fc.integer(),
|
||||
fc.boolean()
|
||||
);
|
||||
|
||||
const keyArb = fc.string({ minLength: 1, maxLength: 16 });
|
||||
|
||||
describe("UpdateUserProfileBodySchema strict-mode fast-check", () => {
|
||||
it("rejects every payload that contains an unknown key", {
|
||||
timeout: 30_000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.dictionary(keyArb, valueArb, {
|
||||
minKeys: 1,
|
||||
maxKeys: 6,
|
||||
}),
|
||||
(input) => {
|
||||
const keys = Object.keys(input);
|
||||
const hasUnknown = keys.some(
|
||||
(k) => !KNOWN_FIELDS.includes(k)
|
||||
);
|
||||
const result = UpdateUserProfileBodySchema.safeParse(input);
|
||||
if (hasUnknown) {
|
||||
return result.success === false;
|
||||
}
|
||||
// No unknown keys — accept depends on per-field value
|
||||
// validation; this branch verifies that strict-mode
|
||||
// alone does not reject a known-key payload.
|
||||
return true;
|
||||
}
|
||||
),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts the canonical empty patch", { timeout: 5000 }, () => {
|
||||
const r = UpdateUserProfileBodySchema.safeParse({});
|
||||
expect(r.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Test setup for @suaveplan/api-plugin-user.
|
||||
*
|
||||
* Installs a real in-memory OpenTelemetry MeterProvider BEFORE any source
|
||||
* module imports so the meter resolved on first call binds to it. Node 18+
|
||||
* + Bun expose `crypto.subtle` / `crypto.getRandomValues` globally so no
|
||||
* crypto polyfill is required.
|
||||
*/
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
|
||||
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
|
||||
AggregationTemporality.CUMULATIVE
|
||||
);
|
||||
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter: testInMemoryMetricExporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
|
||||
export const testMeterProvider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(testMeterProvider);
|
||||
|
||||
export async function flushTestMetrics() {
|
||||
await testMeterProvider.forceFlush();
|
||||
const collected = testInMemoryMetricExporter.getMetrics();
|
||||
testInMemoryMetricExporter.reset();
|
||||
return collected;
|
||||
}
|
||||
12
packages/api-stack/api/api-plugin-user/tsconfig.json
Normal file
12
packages/api-stack/api/api-plugin-user/tsconfig.json
Normal 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": ["dist", "node_modules"]
|
||||
}
|
||||
12
packages/api-stack/api/api-plugin-user/tsconfig.lib.json
Normal file
12
packages/api-stack/api/api-plugin-user/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
38
packages/api-stack/api/api-plugin-user/vite.config.ts
Normal file
38
packages/api-stack/api/api-plugin-user/vite.config.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/api-plugin-user",
|
||||
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\//,
|
||||
/^@opentelemetry\//,
|
||||
/^@hono\//,
|
||||
"better-auth",
|
||||
"hono",
|
||||
"tslib",
|
||||
"zod",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
33
packages/api-stack/api/api-plugin-user/vitest.config.ts
Normal file
33
packages/api-stack/api/api-plugin-user/vitest.config.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
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/**/*.bench.ts",
|
||||
"src/index.ts",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 60000,
|
||||
hookTimeout: 120000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user