Initial Commit
This commit is contained in:
@@ -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.
|
||||
@@ -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>();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import "./registry-augmentation.js";
|
||||
|
||||
export {
|
||||
type AuthService,
|
||||
type BetterAuthSessionResult,
|
||||
type CreateAuthServiceOptions,
|
||||
createAuthService,
|
||||
} from "./auth-service.js";
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user