Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,106 @@
# services/auth-service
## Purpose
The `auth-service` module owns the `AuthService` interface, its
constructor (`createAuthService`), and the side-effect module
augmentation that adds the `authService` slot to
`@suaveplan/api-plugin/service-registry`'s `ServiceRegistry`. Together
they let sibling plugins resolve the current principal and the
underlying Better-Auth instance with full type safety — no casts at the
call site.
## Features
- `AuthService` interface with three methods:
`getCurrentPrincipal(c)`, `getBetterAuthInstance()`, and
`getSession(headers)`.
- `createAuthService` constructor wired to a Better-Auth instance and
consumed exclusively by `createAuthPlugin`'s `onInit`.
- Side-effect module augmentation in `./registry-augmentation.ts` so
importing anything from `@suaveplan/api-plugin-auth` makes
`ctx.getService("authService")` type-narrow to `AuthService |
undefined`.
## Usage: Basic
```ts
import { createAuthService } from "@suaveplan/api-plugin-auth/services/auth-service";
const service = createAuthService({ auth });
const principal = service.getCurrentPrincipal(c);
```
## Usage: Advanced
From a sibling plugin's `onInit`, resolve the service and use it to
authenticate a stand-alone request (e.g. a queued job that carries a
serialised cookie jar):
```ts
import { definePlugin } from "@suaveplan/api-plugin";
import type { Principal } from "@suaveplan/api-plugin-auth/principal/principal";
export const vaultPlugin = definePlugin({
name: "vault",
version: "0.1.0",
dependencies: ["auth"],
async onInit(ctx) {
const authService = ctx.getService("authService");
if (!authService) {
throw new Error("authService not registered — is the auth plugin enabled?");
}
const headers = new Headers(savedCookieJar);
const result = await authService.getSession(headers);
if (result === null) return;
// result.user / result.session are typed via BetterAuthSessionResult
},
});
```
## API Reference
### `interface AuthService`
- `getCurrentPrincipal(c: Context): Principal | null` — reads
`c.get("user")`; returns `null` for anonymous requests.
- `getBetterAuthInstance(): Auth` — returns the Better-Auth instance
constructed by `createAuthPlugin`.
- `getSession(headers: Headers): Promise<BetterAuthSessionResult>` —
resolves a Better-Auth session from arbitrary request headers, useful
outside the request scope.
### `type BetterAuthSessionResult`
`{ user: Record<string, unknown>; session: Record<string, unknown> } |
null`.
### `interface CreateAuthServiceOptions`
`{ auth: Auth }`.
### `function createAuthService(options): AuthService`
Pure constructor — wraps the Better-Auth instance with the three-method
service shape.
### Module augmentation
`./registry-augmentation.ts` declares
`declare module "@suaveplan/api-plugin/service-registry" { interface
ServiceRegistry { authService: AuthService } }`. Side-effect import.
## Implementation Notes
- The service holds a reference to the Better-Auth instance — call
sites that need the raw handler (e.g. the Hono catch-all route
installed by `createAuthPlugin`) reach for
`getBetterAuthInstance().handler` rather than re-constructing the
Better-Auth client.
- `BetterAuthSessionResult` is intentionally loose because Better-Auth
plugins (organisations, passkeys, twoFactor) extend the `session` row
with additional fields. The auth plugin's own `Principal` mapping
decides which of those fields surface to consumers.
- Session resolution is asynchronous; downstream plugins that want a
synchronous principal-read use `getCurrentPrincipal(c)` from inside a
Hono handler instead.

View File

@@ -0,0 +1,15 @@
import type { ServiceRegistry } from "@suaveplan/api-plugin/service-registry";
import { describe, expectTypeOf, it } from "@suaveplan/testing/runner";
import type { AuthService } from "./auth-service.js";
// Pull in the registry-augmentation as a side effect so the slot is
// declared at type-check time.
import "./registry-augmentation.js";
describe("ServiceRegistry slot conformance", () => {
it("ServiceRegistry slot typed as AuthService", { timeout: 5000 }, () => {
expectTypeOf<
ServiceRegistry["authService"]
>().toEqualTypeOf<AuthService>();
});
});

View File

@@ -0,0 +1,79 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Auth } from "better-auth";
import { Hono } from "hono";
import type { Principal } from "../principal/principal.js";
import { createAuthService } from "./auth-service.js";
function makeAuthStub(sessionReturn: unknown = null): Auth {
const stub = {
api: {
getSession: async () => sessionReturn,
},
} as unknown as Auth;
return stub;
}
describe("createAuthService", () => {
it("getCurrentPrincipal returns c.get('user')", {
timeout: 5000,
}, async () => {
const principal: Principal = {
id: "u1",
emailVerified: true,
roles: ["admin"],
};
const auth = makeAuthStub();
const service = createAuthService({ auth });
const app = new Hono();
app.get("/whoami", (c) => {
c.set("user", principal);
const p = service.getCurrentPrincipal(c);
return c.json({ p });
});
const res = await app.request("/whoami");
const body = (await res.json()) as { p: Principal | null };
expect(body.p?.id).toBe("u1");
});
it("getCurrentPrincipal returns null when c.get returns undefined", {
timeout: 5000,
}, async () => {
const auth = makeAuthStub();
const service = createAuthService({ auth });
const app = new Hono();
app.get("/anon", (c) => {
const p = service.getCurrentPrincipal(c);
return c.json({ isNull: p === null });
});
const res = await app.request("/anon");
const body = (await res.json()) as { isNull: boolean };
expect(body.isNull).toBe(true);
});
it("getBetterAuthInstance returns the supplied auth", {
timeout: 5000,
}, () => {
const auth = makeAuthStub();
const service = createAuthService({ auth });
expect(service.getBetterAuthInstance()).toBe(auth);
});
it("getSession proxies to Better-Auth", { timeout: 5000 }, async () => {
const expected = {
user: { id: "u1" },
session: { id: "s1" },
};
const auth = makeAuthStub(expected);
const service = createAuthService({ auth });
const result = await service.getSession(new Headers());
expect(result).toBe(expected);
});
it("getSession resolves to null when Better-Auth returns null", {
timeout: 5000,
}, async () => {
const auth = makeAuthStub(null);
const service = createAuthService({ auth });
expect(await service.getSession(new Headers())).toBeNull();
});
});

View File

@@ -0,0 +1,76 @@
import type { Auth } from "better-auth";
import type { Context } from "hono";
import type { Principal } from "../principal/principal.js";
/**
* Loose-typed Better-Auth session shape returned by `auth.api.getSession`.
* Kept loose so plugin-extended sessions (passkeys, organisations,
* twoFactor) flow through without forcing this package to track every
* Better-Auth plugin's type extensions.
*/
export type BetterAuthSessionResult = {
readonly user: Record<string, unknown>;
readonly session: Record<string, unknown>;
} | null;
/**
* Service slot registered by `createAuthPlugin` in the host's
* `ServiceRegistry`. Downstream plugins (api-plugin-user,
* api-plugin-vault) call `ctx.getService("authService")` to read the
* current principal, the Better-Auth instance, or to manually resolve
* a session from arbitrary headers.
*/
export interface AuthService {
/**
* Returns the typed `Principal` attached to the current request
* context, or `null` for anonymous requests. The session middleware
* is responsible for populating `c.get("user")`.
*/
getCurrentPrincipal(c: Context): Principal | null;
/**
* Returns the underlying Better-Auth instance — useful for plugins
* that need to call Better-Auth API endpoints directly (e.g.
* organisation membership listings).
*/
getBetterAuthInstance(): Auth;
/**
* Resolves a session from arbitrary request headers. Plugins
* authenticating a stand-alone request (cron-triggered work, raw
* WebSocket upgrades) use this to get the same session shape the
* middleware would have produced.
*/
getSession(headers: Headers): Promise<BetterAuthSessionResult>;
}
/**
* Inputs required to build the `AuthService` impl. Kept explicit so
* the factory is testable without the full plugin lifecycle.
*/
export interface CreateAuthServiceOptions {
readonly auth: Auth;
}
/**
* Construct the canonical `AuthService` implementation backed by the
* supplied Better-Auth instance.
*/
export function createAuthService(
options: CreateAuthServiceOptions
): AuthService {
const { auth } = options;
return {
getCurrentPrincipal(c) {
const value = c.get("user");
return value ?? null;
},
getBetterAuthInstance() {
return auth;
},
async getSession(headers) {
const result = (await auth.api.getSession({
headers,
})) as BetterAuthSessionResult;
return result;
},
};
}

View File

@@ -0,0 +1,8 @@
import "./registry-augmentation.js";
export {
type AuthService,
type BetterAuthSessionResult,
type CreateAuthServiceOptions,
createAuthService,
} from "./auth-service.js";

View File

@@ -0,0 +1,84 @@
# services/registry-augmentation
## Purpose
Module augmentation that wires the `authService` slot into
`@suaveplan/api-plugin/service-registry`'s `ServiceRegistry`
interface. Once this file is imported (the package root barrel does so
as a side effect), every consumer's `ctx.getService("authService")`
returns `AuthService | undefined` with no cast.
## Features
- Side-effect import — pure type-system declaration, no runtime cost.
- Adds the `authService: AuthService` slot to `ServiceRegistry`.
- Re-exported transitively from `./index.ts` so the augmentation
loads with the first auth-plugin import.
## Usage: Basic
```ts
import "@suaveplan/api-plugin-auth/services/auth-service";
const svc = ctx.getService("authService");
```
## Usage: Advanced
From inside a sibling plugin's `onInit`, resolve the service with
typed inference. The augmentation makes the slot visible everywhere
`@suaveplan/api-plugin`'s `ServiceRegistry` is referenced, including
in fully-typed `ctx.getService(...)` calls inside third-party plugins:
```ts
import { definePlugin } from "@suaveplan/api-plugin";
export const myPlugin = definePlugin({
name: "my-plugin",
version: "0.1.0",
dependencies: ["auth"],
async onInit(ctx) {
const authService = ctx.getService("authService");
if (!authService) {
ctx.logger.warn("auth plugin disabled — running unauthenticated");
return;
}
const auth = authService.getBetterAuthInstance();
// Use the typed Better-Auth instance here.
},
});
```
## API Reference
### Module augmentation
```ts
declare module "@suaveplan/api-plugin/service-registry" {
interface ServiceRegistry {
authService: AuthService;
}
}
```
There are no runtime exports — the file is imported solely for the
declaration.
## Implementation Notes
- The slot is `AuthService` (no `| undefined`); the wrapper in
`ServiceRegistry` adds the `undefined` automatically via
`ctx.getService`'s return type signature.
- This pattern matches `@suaveplan/api`'s own registry-augmentation
module, which adds `app`, `openAPIRegistry`, `migrationRunner`,
`db`, `logger`, `eventBus`, `clock`. Loading both modules is
cumulative — TypeScript merges the declarations.
- Sibling plugins (user, vault) MUST NOT re-declare `authService`;
they declare their own slots (`userService`, `vaultService`).
- The augmentation also re-declares the host slots (`app`,
`openAPIRegistry`, `migrationRunner`, `db`, `logger`, `eventBus`,
`clock`) so the auth plugin's `onInit(ctx)` can resolve them with
full typing even when consumers run isolated unit tests of the
plugin without importing `@suaveplan/api`. TypeScript merges the
duplicate property declarations into a single combined interface,
so this is safe alongside the canonical augmentation shipped by
`@suaveplan/api`'s own `registry-augmentation` module.

View File

@@ -0,0 +1,39 @@
/**
* ServiceRegistry module augmentation — declares the `authService`
* slot on `@suaveplan/api-plugin/service-registry`'s `ServiceRegistry`
* interface so downstream plugins typed against the augmented registry
* see `ctx.getService("authService")` resolve to `AuthService |
* 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-auth` gets typed `authService` access.
*
* The host-side slots (`app`, `openAPIRegistry`, `migrationRunner`,
* `db`, `logger`, `eventBus`, `clock`) are owned by `@suaveplan/api`'s
* own registry-augmentation module. We re-declare them here as a
* cumulative augmentation so the auth plugin's `onInit(ctx)` can
* resolve them with full typing even when the consumer has not
* explicitly imported `@suaveplan/api` (e.g. during isolated
* unit-tests of the plugin). TypeScript merges duplicate declarations,
* so this is safe alongside `@suaveplan/api`'s declarations.
*/
import type { OpenAPIHono } from "@hono/zod-openapi";
import type { MigrationRunner } from "@suaveplan/api-migrations";
import type { OpenAPIRegistry } from "@suaveplan/api-openapi";
import type { SurrealAdapter } from "@suaveplan/surrealdb";
import type { Clock, EventBusLike, LoggerLike } from "@suaveplan/types";
import type { AuthService } from "./auth-service.js";
declare module "@suaveplan/api-plugin/service-registry" {
interface ServiceRegistry {
authService: AuthService;
app: OpenAPIHono;
openAPIRegistry: OpenAPIRegistry;
migrationRunner?: MigrationRunner;
db?: SurrealAdapter;
logger: LoggerLike;
eventBus: EventBusLike;
clock: Clock;
}
}