feat: add functional authentication
This commit is contained in:
@@ -1,5 +0,0 @@
|
||||
import { create } from "./routes/create.ts";
|
||||
|
||||
export const routes = {
|
||||
create,
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
import { route } from "@spec/relay";
|
||||
import { NameSchema } from "@spec/shared";
|
||||
import z from "zod";
|
||||
|
||||
export const create = route.post("/api/v1/accounts").body(z.object({ name: NameSchema }));
|
||||
@@ -1,8 +0,0 @@
|
||||
import { authenticate } from "./routes/authenticate.ts";
|
||||
|
||||
export * from "./errors.ts";
|
||||
export * from "./strategies.ts";
|
||||
|
||||
export const routes = {
|
||||
authenticate,
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
import { route } from "@spec/relay";
|
||||
|
||||
import { AuthenticationStrategyPayloadError } from "../errors.ts";
|
||||
import { StrategyPayloadSchema } from "../strategies.ts";
|
||||
|
||||
export const authenticate = route
|
||||
.post("/api/v1/authenticate")
|
||||
.body(StrategyPayloadSchema)
|
||||
.errors([AuthenticationStrategyPayloadError]);
|
||||
@@ -1,4 +1,6 @@
|
||||
import z, { ZodType } from "zod";
|
||||
/* eslint-disable @typescript-eslint/no-empty-object-type */
|
||||
|
||||
import z, { ZodObject, ZodType } from "zod";
|
||||
|
||||
import type { RelayAdapter, RelayInput, RelayResponse } from "./adapter.ts";
|
||||
import { Route, type Routes } from "./route.ts";
|
||||
@@ -45,7 +47,7 @@ function getNestedRoute<TRoutes extends Routes>(config: Config, routes: TRoutes)
|
||||
}
|
||||
|
||||
function getRouteFn(route: Route, { adapter }: Config) {
|
||||
return async (options: any) => {
|
||||
return async (options: any = {}) => {
|
||||
const input: RelayInput = {
|
||||
method: route.state.method,
|
||||
endpoint: route.state.path,
|
||||
@@ -146,34 +148,45 @@ type RelayRequest = {
|
||||
|
||||
type RelayRoutes<TRoutes extends Routes> = {
|
||||
[TKey in keyof TRoutes]: TRoutes[TKey] extends Route
|
||||
? ((
|
||||
payload: OmitNever<{
|
||||
params: TRoutes[TKey]["$params"];
|
||||
query: TRoutes[TKey]["$query"];
|
||||
body: TRoutes[TKey]["$body"];
|
||||
headers?: Headers;
|
||||
}>,
|
||||
) => Promise<RelayResponse<RelayRouteResponse<TRoutes[TKey]>, RelayRouteErrors<TRoutes[TKey]>>>) & {
|
||||
$params: TRoutes[TKey]["$params"];
|
||||
$query: TRoutes[TKey]["$query"];
|
||||
$body: TRoutes[TKey]["$body"];
|
||||
$response: TRoutes[TKey]["$response"];
|
||||
}
|
||||
? HasPayload<TRoutes[TKey]> extends true
|
||||
? (
|
||||
payload: Prettify<
|
||||
(TRoutes[TKey]["state"]["params"] extends ZodObject ? { params: TRoutes[TKey]["$params"] } : {}) &
|
||||
(TRoutes[TKey]["state"]["query"] extends ZodObject ? { query: TRoutes[TKey]["$query"] } : {}) &
|
||||
(TRoutes[TKey]["state"]["body"] extends ZodType ? { body: TRoutes[TKey]["$body"] } : {}) & {
|
||||
headers?: HeadersInit;
|
||||
}
|
||||
>,
|
||||
) => RouteResponse<TRoutes[TKey]>
|
||||
: (payload?: { headers: HeadersInit }) => RouteResponse<TRoutes[TKey]>
|
||||
: TRoutes[TKey] extends Routes
|
||||
? RelayClient<TRoutes[TKey]>
|
||||
? RelayRoutes<TRoutes[TKey]>
|
||||
: never;
|
||||
};
|
||||
|
||||
type RelayRouteResponse<TRoute extends Route> = TRoute["state"]["output"] extends ZodType
|
||||
type HasPayload<TRoute extends Route> = TRoute["state"]["params"] extends ZodObject
|
||||
? true
|
||||
: TRoute["state"]["query"] extends ZodObject
|
||||
? true
|
||||
: TRoute["state"]["body"] extends ZodType
|
||||
? true
|
||||
: false;
|
||||
|
||||
type RouteResponse<TRoute extends Route> = Promise<RelayResponse<RouteOutput<TRoute>, RouteErrors<TRoute>>> & {
|
||||
$params: TRoute["$params"];
|
||||
$query: TRoute["$query"];
|
||||
$body: TRoute["$body"];
|
||||
$response: TRoute["$response"];
|
||||
};
|
||||
|
||||
type RouteOutput<TRoute extends Route> = TRoute["state"]["output"] extends ZodType
|
||||
? z.infer<TRoute["state"]["output"]>
|
||||
: null;
|
||||
|
||||
type RelayRouteErrors<TRoute extends Route> = InstanceType<TRoute["state"]["errors"][number]>;
|
||||
|
||||
type OmitNever<T> = {
|
||||
[K in keyof T as T[K] extends never ? never : K]: T[K];
|
||||
};
|
||||
type RouteErrors<TRoute extends Route> = InstanceType<TRoute["state"]["errors"][number]>;
|
||||
|
||||
type Config = {
|
||||
adapter: RelayAdapter;
|
||||
};
|
||||
|
||||
type Prettify<T> = { [K in keyof T]: T[K] } & {};
|
||||
|
||||
@@ -10,7 +10,7 @@ export class Route<const TState extends RouteState = RouteState> {
|
||||
declare readonly $params: TState["params"] extends ZodObject ? z.input<TState["params"]> : never;
|
||||
declare readonly $query: TState["query"] extends ZodObject ? z.input<TState["query"]> : never;
|
||||
declare readonly $body: TState["body"] extends ZodType ? z.input<TState["body"]> : never;
|
||||
declare readonly $response: TState["output"] extends ZodType ? z.output<TState["output"]> : never;
|
||||
declare readonly $response: TState["response"] extends ZodType ? z.output<TState["response"]> : never;
|
||||
|
||||
#matchFn?: MatchFunction<any>;
|
||||
|
||||
@@ -69,16 +69,6 @@ export class Route<const TState extends RouteState = RouteState> {
|
||||
return result.params as TParams;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the content the route expects, 'json' or 'form-data' which the client uses
|
||||
* to determine which adapter operation to execute on requests.
|
||||
*
|
||||
* @param content - Content expected during transfers.
|
||||
*/
|
||||
content<TContent extends RouteContent>(content: TContent): Route<Omit<TState, "content"> & { content: TContent }> {
|
||||
return new Route({ ...this.state, content });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the meta data for this route which can be used in e.g. OpenAPI generation
|
||||
*
|
||||
@@ -218,33 +208,6 @@ export class Route<const TState extends RouteState = RouteState> {
|
||||
return new Route({ ...this.state, body });
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape of the success response this route produces. This is used by the transform
|
||||
* tools to ensure the client receives parsed data.
|
||||
*
|
||||
* @param response - Response shape of the route.
|
||||
*
|
||||
* @examples
|
||||
*
|
||||
* ```ts
|
||||
* route
|
||||
* .post("/foo")
|
||||
* .response(
|
||||
* z.object({
|
||||
* bar: z.number()
|
||||
* })
|
||||
* )
|
||||
* .handle(async () => {
|
||||
* return {
|
||||
* bar: 1
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
response<TResponse extends ZodType>(output: TResponse): Route<Omit<TState, "output"> & { output: TResponse }> {
|
||||
return new Route({ ...this.state, output });
|
||||
}
|
||||
|
||||
/**
|
||||
* Instances of the possible error responses this route produces.
|
||||
*
|
||||
@@ -267,6 +230,33 @@ export class Route<const TState extends RouteState = RouteState> {
|
||||
return new Route({ ...this.state, errors });
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape of the success response this route produces. This is used by the transform
|
||||
* tools to ensure the client receives parsed data.
|
||||
*
|
||||
* @param response - Response shape of the route.
|
||||
*
|
||||
* @examples
|
||||
*
|
||||
* ```ts
|
||||
* route
|
||||
* .post("/foo")
|
||||
* .response(
|
||||
* z.object({
|
||||
* bar: z.number()
|
||||
* })
|
||||
* )
|
||||
* .handle(async () => {
|
||||
* return {
|
||||
* bar: 1
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
response<TResponse extends ZodType>(response: TResponse): Route<Omit<TState, "response"> & { response: TResponse }> {
|
||||
return new Route({ ...this.state, response });
|
||||
}
|
||||
|
||||
/**
|
||||
* Server handler callback method.
|
||||
*
|
||||
@@ -286,7 +276,7 @@ export class Route<const TState extends RouteState = RouteState> {
|
||||
* .handle(async ({ bar }, [ "string", number ]) => {});
|
||||
* ```
|
||||
*/
|
||||
handle<THandleFn extends HandleFn<ServerArgs<TState>, TState["output"]>>(
|
||||
handle<THandleFn extends HandleFn<ServerArgs<TState>, TState["response"]>>(
|
||||
handle: THandleFn,
|
||||
): Route<Omit<TState, "handle"> & { handle: THandleFn }> {
|
||||
return new Route({ ...this.state, handle });
|
||||
@@ -433,14 +423,13 @@ export type Routes = {
|
||||
type RouteState = {
|
||||
method: RouteMethod;
|
||||
path: string;
|
||||
content: RouteContent;
|
||||
meta?: RouteMeta;
|
||||
access?: RouteAccess;
|
||||
params?: ZodObject;
|
||||
query?: ZodObject;
|
||||
body?: ZodType;
|
||||
output?: ZodType;
|
||||
errors: ServerErrorClass[];
|
||||
response?: ZodType;
|
||||
handle?: HandleFn;
|
||||
hooks?: Hooks;
|
||||
};
|
||||
@@ -454,8 +443,6 @@ export type RouteMeta = {
|
||||
|
||||
export type RouteMethod = "POST" | "GET" | "PUT" | "PATCH" | "DELETE";
|
||||
|
||||
export type RouteContent = "json" | "form-data";
|
||||
|
||||
export type RouteAccess = "public" | "session" | (() => boolean)[];
|
||||
|
||||
export type AccessFn = (resource: string, action: string) => () => boolean;
|
||||
@@ -466,8 +453,8 @@ export interface ServerContext {}
|
||||
type HandleFn<TArgs extends Array<any> = any[], TResponse = any> = (
|
||||
...args: TArgs
|
||||
) => TResponse extends ZodType
|
||||
? Promise<z.infer<TResponse> | Response | ServerError | unknown>
|
||||
: Promise<Response | ServerError | unknown | void>;
|
||||
? Promise<z.infer<TResponse> | Response | ServerError>
|
||||
: Promise<Response | ServerError | void>;
|
||||
|
||||
type ServerArgs<TState extends RouteState> =
|
||||
HasInputArgs<TState> extends true
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { makeSchemaParser } from "@spec/shared";
|
||||
import z from "zod";
|
||||
|
||||
import { makeSchemaParser } from "../database.ts";
|
||||
|
||||
export const RoleSchema = z.object({
|
||||
id: z.uuid(),
|
||||
name: z.string(),
|
||||
@@ -10,3 +11,4 @@ export const RoleSchema = z.object({
|
||||
export const parseRole = makeSchemaParser(RoleSchema);
|
||||
|
||||
export type Role = z.infer<typeof RoleSchema>;
|
||||
export type RoleDocument = z.infer<typeof RoleSchema>;
|
||||
@@ -1,7 +1,10 @@
|
||||
import { AvatarSchema, ContactSchema, makeSchemaParser, NameSchema } from "@spec/shared";
|
||||
import { z } from "zod";
|
||||
|
||||
import { RoleSchema } from "../access/role.ts";
|
||||
import { AvatarSchema } from "../avatar.ts";
|
||||
import { ContactSchema } from "../contact.ts";
|
||||
import { makeSchemaParser } from "../database.ts";
|
||||
import { NameSchema } from "../name.ts";
|
||||
import { StrategySchema } from "./strategies.ts";
|
||||
|
||||
export const AccountSchema = z.object({
|
||||
@@ -15,9 +18,10 @@ export const AccountSchema = z.object({
|
||||
roles: z.array(RoleSchema).default([]),
|
||||
});
|
||||
|
||||
export const AccountDocumentSchema = AccountSchema.omit({ roles: true }).extend({ roles: z.string().array() });
|
||||
export const AccountDocumentSchema = AccountSchema.omit({ roles: true }).extend({ roles: z.array(z.string()) });
|
||||
|
||||
export const parseAccount = makeSchemaParser(AccountSchema);
|
||||
export const toAccountDocument = makeSchemaParser(AccountDocumentSchema);
|
||||
export const fromAccountDocument = makeSchemaParser(AccountSchema);
|
||||
|
||||
export type Account = z.infer<typeof AccountSchema>;
|
||||
export type AccountDocument = z.infer<typeof AccountDocumentSchema>;
|
||||
7
spec/schemas/account/errors.ts
Normal file
7
spec/schemas/account/errors.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { ConflictError } from "@spec/relay/mod.ts";
|
||||
|
||||
export class AccountEmailClaimedError extends ConflictError {
|
||||
constructor(email: string) {
|
||||
super(`Email '${email}' is already claimed by another account.`);
|
||||
}
|
||||
}
|
||||
20
spec/schemas/account/routes.ts
Normal file
20
spec/schemas/account/routes.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { route } from "@spec/relay";
|
||||
import z from "zod";
|
||||
|
||||
import { NameSchema } from "../name.ts";
|
||||
import { AccountEmailClaimedError } from "./errors.ts";
|
||||
|
||||
export const create = route
|
||||
.post("/api/v1/accounts")
|
||||
.body(
|
||||
z.object({
|
||||
name: NameSchema,
|
||||
email: z.email(),
|
||||
}),
|
||||
)
|
||||
.errors([AccountEmailClaimedError])
|
||||
.response(z.uuid());
|
||||
|
||||
export const routes = {
|
||||
create,
|
||||
};
|
||||
41
spec/schemas/auth/routes.ts
Normal file
41
spec/schemas/auth/routes.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { route, UnauthorizedError } from "@spec/relay";
|
||||
import z from "zod";
|
||||
|
||||
import { AccountSchema } from "../account/account.ts";
|
||||
|
||||
export * from "./errors.ts";
|
||||
export * from "./strategies.ts";
|
||||
|
||||
export const email = route.post("/api/v1/auth/email").body(
|
||||
z.object({
|
||||
base: z.url(),
|
||||
email: z.email(),
|
||||
}),
|
||||
);
|
||||
|
||||
export const password = route.post("/api/v1/auth/password").body(
|
||||
z.object({
|
||||
alias: z.string(),
|
||||
password: z.string(),
|
||||
}),
|
||||
);
|
||||
|
||||
export const code = route
|
||||
.get("/api/v1/auth/code/:accountId/code/:codeId/:value")
|
||||
.params({
|
||||
accountId: z.string(),
|
||||
codeId: z.string(),
|
||||
value: z.string(),
|
||||
})
|
||||
.query({
|
||||
next: z.string().optional(),
|
||||
});
|
||||
|
||||
export const session = route.get("/api/v1/auth/session").response(AccountSchema).errors([UnauthorizedError]);
|
||||
|
||||
export const routes = {
|
||||
email,
|
||||
password,
|
||||
code,
|
||||
session,
|
||||
};
|
||||
@@ -34,6 +34,11 @@ export const PasswordStrategySchema = z.object({
|
||||
password: z.string().describe("User's password"),
|
||||
});
|
||||
|
||||
export const StrategyPayloadSchema = z
|
||||
export const StrategySchema = z
|
||||
.union([PasskeyStrategySchema, EmailStrategySchema, PasswordStrategySchema])
|
||||
.describe("Union of all available authentication strategy schemas");
|
||||
|
||||
export type PasskeyStrategy = z.infer<typeof PasskeyStrategySchema>;
|
||||
export type EmailStrategy = z.infer<typeof EmailStrategySchema>;
|
||||
export type PasswordStrategy = z.infer<typeof PasswordStrategySchema>;
|
||||
export type Strategy = z.infer<typeof StrategySchema>;
|
||||
@@ -1,11 +1,10 @@
|
||||
{
|
||||
"name": "@spec/modules",
|
||||
"name": "@spec/schemas",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@spec/relay": "workspace:*",
|
||||
"@spec/shared": "workspace:*",
|
||||
"zod": "4"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export * from "./avatar.ts";
|
||||
export * from "./contact.ts";
|
||||
export * from "./database.ts";
|
||||
export * from "./email.ts";
|
||||
export * from "./name.ts";
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"name": "@spec/shared",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./mod.ts",
|
||||
"exports": {
|
||||
".": "./mod.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "4"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user