@vertz/server 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +386 -0
- package/dist/index.js +638 -0
- package/package.json +50 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import { AccumulateProvides, AppBuilder, AppConfig, BootInstruction, BootSequence, CorsConfig, Ctx, DeepReadonly, Deps, EnvConfig, ExtractMethods, HandlerCtx, HttpMethod, HttpStatusCode, Infer as Infer2, InferSchema, ListenOptions, MiddlewareDef, Module, ModuleBootInstruction, ModuleDef, NamedMiddlewareDef, NamedModule, NamedModuleDef, NamedRouterDef, NamedServiceDef, RawRequest, ResolveInjectMap, RouterDef, ServerAdapter, ServerHandle, ServiceBootInstruction, ServiceDef, ServiceFactory } from "@vertz/core";
|
|
2
|
+
import { BadRequestException, ConflictException, createEnv, createImmutableProxy, createMiddleware, createModule, createModuleDef, createServer, deepFreeze, ForbiddenException, InternalServerErrorException, makeImmutable, NotFoundException, ServiceUnavailableException, UnauthorizedException, ValidationException, VertzException, vertz } from "@vertz/core";
|
|
3
|
+
interface JsonbValidator<T> {
|
|
4
|
+
parse(value: unknown): T;
|
|
5
|
+
}
|
|
6
|
+
interface ColumnMetadata {
|
|
7
|
+
readonly sqlType: string;
|
|
8
|
+
readonly primary: boolean;
|
|
9
|
+
readonly unique: boolean;
|
|
10
|
+
readonly nullable: boolean;
|
|
11
|
+
readonly hasDefault: boolean;
|
|
12
|
+
readonly sensitive: boolean;
|
|
13
|
+
readonly hidden: boolean;
|
|
14
|
+
readonly isTenant: boolean;
|
|
15
|
+
readonly references: {
|
|
16
|
+
readonly table: string;
|
|
17
|
+
readonly column: string;
|
|
18
|
+
} | null;
|
|
19
|
+
readonly check: string | null;
|
|
20
|
+
readonly defaultValue?: unknown;
|
|
21
|
+
readonly format?: string;
|
|
22
|
+
readonly length?: number;
|
|
23
|
+
readonly precision?: number;
|
|
24
|
+
readonly scale?: number;
|
|
25
|
+
readonly enumName?: string;
|
|
26
|
+
readonly enumValues?: readonly string[];
|
|
27
|
+
readonly validator?: JsonbValidator<unknown>;
|
|
28
|
+
}
|
|
29
|
+
/** Phantom symbol to carry the TypeScript type without a runtime value. */
|
|
30
|
+
declare const PhantomType: unique symbol;
|
|
31
|
+
interface ColumnBuilder<
|
|
32
|
+
TType,
|
|
33
|
+
TMeta extends ColumnMetadata = ColumnMetadata
|
|
34
|
+
> {
|
|
35
|
+
/** Phantom field -- only exists at the type level for inference. Do not access at runtime. */
|
|
36
|
+
readonly [PhantomType]: TType;
|
|
37
|
+
readonly _meta: TMeta;
|
|
38
|
+
primary(): ColumnBuilder<TType, Omit<TMeta, "primary" | "hasDefault"> & {
|
|
39
|
+
readonly primary: true;
|
|
40
|
+
readonly hasDefault: true;
|
|
41
|
+
}>;
|
|
42
|
+
unique(): ColumnBuilder<TType, Omit<TMeta, "unique"> & {
|
|
43
|
+
readonly unique: true;
|
|
44
|
+
}>;
|
|
45
|
+
nullable(): ColumnBuilder<TType | null, Omit<TMeta, "nullable"> & {
|
|
46
|
+
readonly nullable: true;
|
|
47
|
+
}>;
|
|
48
|
+
default(value: TType | "now"): ColumnBuilder<TType, Omit<TMeta, "hasDefault"> & {
|
|
49
|
+
readonly hasDefault: true;
|
|
50
|
+
readonly defaultValue: TType | "now";
|
|
51
|
+
}>;
|
|
52
|
+
sensitive(): ColumnBuilder<TType, Omit<TMeta, "sensitive"> & {
|
|
53
|
+
readonly sensitive: true;
|
|
54
|
+
}>;
|
|
55
|
+
hidden(): ColumnBuilder<TType, Omit<TMeta, "hidden"> & {
|
|
56
|
+
readonly hidden: true;
|
|
57
|
+
}>;
|
|
58
|
+
check(sql: string): ColumnBuilder<TType, Omit<TMeta, "check"> & {
|
|
59
|
+
readonly check: string;
|
|
60
|
+
}>;
|
|
61
|
+
references(table: string, column?: string): ColumnBuilder<TType, Omit<TMeta, "references"> & {
|
|
62
|
+
readonly references: {
|
|
63
|
+
readonly table: string;
|
|
64
|
+
readonly column: string;
|
|
65
|
+
};
|
|
66
|
+
}>;
|
|
67
|
+
}
|
|
68
|
+
type InferColumnType<C> = C extends ColumnBuilder<infer T, ColumnMetadata> ? T : never;
|
|
69
|
+
interface ThroughDef<TJoin extends TableDef<ColumnRecord> = TableDef<ColumnRecord>> {
|
|
70
|
+
readonly table: () => TJoin;
|
|
71
|
+
readonly thisKey: string;
|
|
72
|
+
readonly thatKey: string;
|
|
73
|
+
}
|
|
74
|
+
interface RelationDef<
|
|
75
|
+
TTarget extends TableDef<ColumnRecord> = TableDef<ColumnRecord>,
|
|
76
|
+
TType extends "one" | "many" = "one" | "many"
|
|
77
|
+
> {
|
|
78
|
+
readonly _type: TType;
|
|
79
|
+
readonly _target: () => TTarget;
|
|
80
|
+
readonly _foreignKey: string | null;
|
|
81
|
+
readonly _through: ThroughDef | null;
|
|
82
|
+
}
|
|
83
|
+
interface IndexDef {
|
|
84
|
+
readonly columns: readonly string[];
|
|
85
|
+
}
|
|
86
|
+
/** A record of column builders -- the shape passed to d.table(). */
|
|
87
|
+
type ColumnRecord = Record<string, ColumnBuilder<unknown, ColumnMetadata>>;
|
|
88
|
+
/** Extract the TypeScript type from every column in a record. */
|
|
89
|
+
type InferColumns<T extends ColumnRecord> = { [K in keyof T] : InferColumnType<T[K]> };
|
|
90
|
+
/** Keys of columns where a given metadata flag is `true`. */
|
|
91
|
+
type ColumnKeysWhere<
|
|
92
|
+
T extends ColumnRecord,
|
|
93
|
+
Flag extends keyof ColumnMetadata
|
|
94
|
+
> = { [K in keyof T] : T[K] extends ColumnBuilder<unknown, infer M> ? M extends Record<Flag, true> ? K : never : never }[keyof T];
|
|
95
|
+
/** Keys of columns where a given metadata flag is NOT `true` (i.e., false). */
|
|
96
|
+
type ColumnKeysWhereNot<
|
|
97
|
+
T extends ColumnRecord,
|
|
98
|
+
Flag extends keyof ColumnMetadata
|
|
99
|
+
> = { [K in keyof T] : T[K] extends ColumnBuilder<unknown, infer M> ? M extends Record<Flag, true> ? never : K : never }[keyof T];
|
|
100
|
+
/**
|
|
101
|
+
* $infer -- default SELECT type.
|
|
102
|
+
* Excludes hidden columns. Includes everything else (including sensitive).
|
|
103
|
+
*/
|
|
104
|
+
type Infer<T extends ColumnRecord> = { [K in ColumnKeysWhereNot<T, "hidden">] : InferColumnType<T[K]> };
|
|
105
|
+
/**
|
|
106
|
+
* $infer_all -- all columns including hidden.
|
|
107
|
+
*/
|
|
108
|
+
type InferAll<T extends ColumnRecord> = InferColumns<T>;
|
|
109
|
+
/**
|
|
110
|
+
* $insert -- write type. ALL columns included (visibility is read-side only).
|
|
111
|
+
* Columns with hasDefault: true become optional.
|
|
112
|
+
*/
|
|
113
|
+
type Insert<T extends ColumnRecord> = { [K in ColumnKeysWhereNot<T, "hasDefault">] : InferColumnType<T[K]> } & { [K in ColumnKeysWhere<T, "hasDefault">]? : InferColumnType<T[K]> };
|
|
114
|
+
/**
|
|
115
|
+
* $update -- write type. ALL non-primary-key columns, all optional.
|
|
116
|
+
* Primary key excluded (you don't update a PK).
|
|
117
|
+
*/
|
|
118
|
+
type Update<T extends ColumnRecord> = { [K in ColumnKeysWhereNot<T, "primary">]? : InferColumnType<T[K]> };
|
|
119
|
+
/**
|
|
120
|
+
* $not_sensitive -- excludes columns marked .sensitive() OR .hidden().
|
|
121
|
+
* (hidden implies sensitive for read purposes)
|
|
122
|
+
*/
|
|
123
|
+
type NotSensitive<T extends ColumnRecord> = { [K in ColumnKeysWhereNot<T, "sensitive"> & ColumnKeysWhereNot<T, "hidden"> & keyof T] : InferColumnType<T[K]> };
|
|
124
|
+
/**
|
|
125
|
+
* $not_hidden -- excludes columns marked .hidden().
|
|
126
|
+
* Same as $infer (excludes hidden, keeps sensitive).
|
|
127
|
+
*/
|
|
128
|
+
type NotHidden<T extends ColumnRecord> = { [K in ColumnKeysWhereNot<T, "hidden">] : InferColumnType<T[K]> };
|
|
129
|
+
interface TableDef<TColumns extends ColumnRecord = ColumnRecord> {
|
|
130
|
+
readonly _name: string;
|
|
131
|
+
readonly _columns: TColumns;
|
|
132
|
+
readonly _indexes: readonly IndexDef[];
|
|
133
|
+
readonly _shared: boolean;
|
|
134
|
+
/** Default SELECT type -- excludes hidden columns. */
|
|
135
|
+
readonly $infer: Infer<TColumns>;
|
|
136
|
+
/** All columns including hidden. */
|
|
137
|
+
readonly $infer_all: InferAll<TColumns>;
|
|
138
|
+
/** Insert type -- defaulted columns optional. ALL columns included. */
|
|
139
|
+
readonly $insert: Insert<TColumns>;
|
|
140
|
+
/** Update type -- all non-PK columns optional. ALL columns included. */
|
|
141
|
+
readonly $update: Update<TColumns>;
|
|
142
|
+
/** Excludes sensitive and hidden columns. */
|
|
143
|
+
readonly $not_sensitive: NotSensitive<TColumns>;
|
|
144
|
+
/** Excludes hidden columns. */
|
|
145
|
+
readonly $not_hidden: NotHidden<TColumns>;
|
|
146
|
+
/** Mark this table as shared / cross-tenant. */
|
|
147
|
+
shared(): TableDef<TColumns>;
|
|
148
|
+
}
|
|
149
|
+
/** Relations record — maps relation names to RelationDef. */
|
|
150
|
+
type RelationsRecord = Record<string, RelationDef>;
|
|
151
|
+
/** A table entry in the database registry, pairing a table with its relations. */
|
|
152
|
+
interface TableEntry<
|
|
153
|
+
TTable extends TableDef<ColumnRecord> = TableDef<ColumnRecord>,
|
|
154
|
+
TRelations extends RelationsRecord = RelationsRecord
|
|
155
|
+
> {
|
|
156
|
+
readonly table: TTable;
|
|
157
|
+
readonly relations: TRelations;
|
|
158
|
+
}
|
|
159
|
+
type DomainType = "persisted" | "process" | "view" | "session";
|
|
160
|
+
interface DomainContext<TRow = any> {
|
|
161
|
+
user: {
|
|
162
|
+
id: string;
|
|
163
|
+
role: string;
|
|
164
|
+
[key: string]: unknown;
|
|
165
|
+
} | null;
|
|
166
|
+
tenant: {
|
|
167
|
+
id: string;
|
|
168
|
+
[key: string]: unknown;
|
|
169
|
+
} | null;
|
|
170
|
+
request: {
|
|
171
|
+
method: string;
|
|
172
|
+
path: string;
|
|
173
|
+
headers: Record<string, string>;
|
|
174
|
+
ip: string;
|
|
175
|
+
};
|
|
176
|
+
db: Record<string, any>;
|
|
177
|
+
services: Record<string, unknown>;
|
|
178
|
+
defaultHandler: (data: any) => Promise<TRow>;
|
|
179
|
+
}
|
|
180
|
+
type AccessRule<TRow> = (row: TRow, ctx: DomainContext<TRow>) => boolean;
|
|
181
|
+
interface AccessRules<TRow> {
|
|
182
|
+
read?: AccessRule<TRow>;
|
|
183
|
+
create?: AccessRule<Partial<TRow>>;
|
|
184
|
+
update?: AccessRule<TRow>;
|
|
185
|
+
delete?: AccessRule<TRow>;
|
|
186
|
+
}
|
|
187
|
+
type Result<
|
|
188
|
+
T,
|
|
189
|
+
E = any
|
|
190
|
+
> = {
|
|
191
|
+
ok: true;
|
|
192
|
+
data: T;
|
|
193
|
+
} | {
|
|
194
|
+
ok: false;
|
|
195
|
+
error: E;
|
|
196
|
+
};
|
|
197
|
+
interface DomainError {
|
|
198
|
+
type: string;
|
|
199
|
+
code: string;
|
|
200
|
+
message: string;
|
|
201
|
+
entity?: string;
|
|
202
|
+
field?: string;
|
|
203
|
+
}
|
|
204
|
+
interface DomainOptions<TEntry extends TableEntry<any, any>> {
|
|
205
|
+
type: DomainType;
|
|
206
|
+
table: TEntry;
|
|
207
|
+
fields?: any;
|
|
208
|
+
expose?: any;
|
|
209
|
+
access?: AccessRules<any>;
|
|
210
|
+
handlers?: any;
|
|
211
|
+
actions?: Record<string, any>;
|
|
212
|
+
}
|
|
213
|
+
interface DomainDefinition2<TEntry extends TableEntry<any, any> = TableEntry<any, any>> {
|
|
214
|
+
readonly name: string;
|
|
215
|
+
readonly type: DomainType;
|
|
216
|
+
readonly table: TEntry;
|
|
217
|
+
readonly exposedRelations: Record<string, any>;
|
|
218
|
+
readonly access: AccessRules<any>;
|
|
219
|
+
readonly handlers: any;
|
|
220
|
+
readonly actions: Record<string, any>;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* STUB: domain() function for TDD red phase
|
|
224
|
+
* This returns a properly shaped, frozen object that passes all structure tests.
|
|
225
|
+
* Business logic (CRUD generation, access enforcement, etc.) will be implemented next.
|
|
226
|
+
*/
|
|
227
|
+
declare function domain<TEntry extends TableEntry<any, any>>(name?: string, options?: DomainOptions<TEntry>): DomainDefinition2<TEntry>;
|
|
228
|
+
type SessionStrategy = "jwt" | "database" | "hybrid";
|
|
229
|
+
interface CookieConfig {
|
|
230
|
+
name?: string;
|
|
231
|
+
httpOnly?: boolean;
|
|
232
|
+
secure?: boolean;
|
|
233
|
+
sameSite?: "strict" | "lax" | "none";
|
|
234
|
+
path?: string;
|
|
235
|
+
maxAge?: number;
|
|
236
|
+
}
|
|
237
|
+
interface SessionConfig {
|
|
238
|
+
strategy: SessionStrategy;
|
|
239
|
+
ttl: string | number;
|
|
240
|
+
refreshable?: boolean;
|
|
241
|
+
cookie?: CookieConfig;
|
|
242
|
+
}
|
|
243
|
+
interface PasswordRequirements {
|
|
244
|
+
minLength?: number;
|
|
245
|
+
requireUppercase?: boolean;
|
|
246
|
+
requireNumbers?: boolean;
|
|
247
|
+
requireSymbols?: boolean;
|
|
248
|
+
}
|
|
249
|
+
interface EmailPasswordConfig {
|
|
250
|
+
enabled?: boolean;
|
|
251
|
+
password?: PasswordRequirements;
|
|
252
|
+
rateLimit?: RateLimitConfig;
|
|
253
|
+
}
|
|
254
|
+
interface RateLimitConfig {
|
|
255
|
+
window: string;
|
|
256
|
+
maxAttempts: number;
|
|
257
|
+
}
|
|
258
|
+
interface AuthConfig {
|
|
259
|
+
session: SessionConfig;
|
|
260
|
+
emailPassword?: EmailPasswordConfig;
|
|
261
|
+
jwtSecret?: string;
|
|
262
|
+
jwtAlgorithm?: "HS256" | "HS384" | "HS512" | "RS256";
|
|
263
|
+
/** Custom claims function for JWT payload */
|
|
264
|
+
claims?: (user: AuthUser) => Record<string, unknown>;
|
|
265
|
+
}
|
|
266
|
+
interface AuthUser {
|
|
267
|
+
id: string;
|
|
268
|
+
email: string;
|
|
269
|
+
role: string;
|
|
270
|
+
plan?: string;
|
|
271
|
+
createdAt: Date;
|
|
272
|
+
updatedAt: Date;
|
|
273
|
+
[key: string]: unknown;
|
|
274
|
+
}
|
|
275
|
+
interface SessionPayload {
|
|
276
|
+
sub: string;
|
|
277
|
+
email: string;
|
|
278
|
+
role: string;
|
|
279
|
+
iat: number;
|
|
280
|
+
exp: number;
|
|
281
|
+
claims?: Record<string, unknown>;
|
|
282
|
+
}
|
|
283
|
+
interface Session {
|
|
284
|
+
user: AuthUser;
|
|
285
|
+
expiresAt: Date;
|
|
286
|
+
payload: SessionPayload;
|
|
287
|
+
}
|
|
288
|
+
interface SignUpInput {
|
|
289
|
+
email: string;
|
|
290
|
+
password: string;
|
|
291
|
+
role?: string;
|
|
292
|
+
[key: string]: unknown;
|
|
293
|
+
}
|
|
294
|
+
interface SignInInput {
|
|
295
|
+
email: string;
|
|
296
|
+
password: string;
|
|
297
|
+
}
|
|
298
|
+
interface AuthApi {
|
|
299
|
+
signUp: (data: SignUpInput) => Promise<AuthResult<Session>>;
|
|
300
|
+
signIn: (data: SignInInput) => Promise<AuthResult<Session>>;
|
|
301
|
+
signOut: (ctx: AuthContext) => Promise<AuthResult<void>>;
|
|
302
|
+
getSession: (headers: Headers) => Promise<AuthResult<Session | null>>;
|
|
303
|
+
refreshSession: (ctx: AuthContext) => Promise<AuthResult<Session>>;
|
|
304
|
+
}
|
|
305
|
+
interface AuthInstance {
|
|
306
|
+
/** HTTP handler for auth routes */
|
|
307
|
+
handler: (request: Request) => Promise<Response>;
|
|
308
|
+
/** Server-side API */
|
|
309
|
+
api: AuthApi;
|
|
310
|
+
/** Session middleware that injects ctx.user */
|
|
311
|
+
middleware: () => any;
|
|
312
|
+
/** Initialize auth (create tables, etc.) */
|
|
313
|
+
initialize: () => Promise<void>;
|
|
314
|
+
}
|
|
315
|
+
interface AuthContext {
|
|
316
|
+
headers: Headers;
|
|
317
|
+
request: Request;
|
|
318
|
+
ip?: string;
|
|
319
|
+
}
|
|
320
|
+
type AuthResult<T> = {
|
|
321
|
+
ok: true;
|
|
322
|
+
data: T;
|
|
323
|
+
} | {
|
|
324
|
+
ok: false;
|
|
325
|
+
error: AuthError;
|
|
326
|
+
};
|
|
327
|
+
interface AuthError {
|
|
328
|
+
code: string;
|
|
329
|
+
message: string;
|
|
330
|
+
status: number;
|
|
331
|
+
}
|
|
332
|
+
interface RateLimitResult {
|
|
333
|
+
allowed: boolean;
|
|
334
|
+
remaining: number;
|
|
335
|
+
resetAt: Date;
|
|
336
|
+
}
|
|
337
|
+
type Entitlement = string;
|
|
338
|
+
interface RoleDefinition {
|
|
339
|
+
entitlements: Entitlement[];
|
|
340
|
+
}
|
|
341
|
+
interface EntitlementDefinition {
|
|
342
|
+
roles: string[];
|
|
343
|
+
/** Optional: Description for documentation */
|
|
344
|
+
description?: string;
|
|
345
|
+
}
|
|
346
|
+
interface AccessConfig {
|
|
347
|
+
roles: Record<string, RoleDefinition>;
|
|
348
|
+
entitlements: Record<string, EntitlementDefinition>;
|
|
349
|
+
}
|
|
350
|
+
interface AccessInstance {
|
|
351
|
+
/** Check if user has a specific entitlement */
|
|
352
|
+
can(entitlement: Entitlement, user: AuthUser | null): Promise<boolean>;
|
|
353
|
+
/** Check with resource context */
|
|
354
|
+
canWithResource(entitlement: Entitlement, resource: Resource, user: AuthUser | null): Promise<boolean>;
|
|
355
|
+
/** Throws if not authorized */
|
|
356
|
+
authorize(entitlement: Entitlement, user: AuthUser | null): Promise<void>;
|
|
357
|
+
/** Authorize with resource context */
|
|
358
|
+
authorizeWithResource(entitlement: Entitlement, resource: Resource, user: AuthUser | null): Promise<void>;
|
|
359
|
+
/** Check multiple entitlements at once */
|
|
360
|
+
canAll(checks: Array<{
|
|
361
|
+
entitlement: Entitlement;
|
|
362
|
+
resource?: Resource;
|
|
363
|
+
}>, user: AuthUser | null): Promise<Map<string, boolean>>;
|
|
364
|
+
/** Get all entitlements for a role */
|
|
365
|
+
getEntitlementsForRole(role: string): Entitlement[];
|
|
366
|
+
/** Middleware that adds ctx.can() and ctx.authorize() to context */
|
|
367
|
+
middleware: () => any;
|
|
368
|
+
}
|
|
369
|
+
interface Resource {
|
|
370
|
+
id: string;
|
|
371
|
+
type: string;
|
|
372
|
+
ownerId?: string;
|
|
373
|
+
[key: string]: unknown;
|
|
374
|
+
}
|
|
375
|
+
declare class AuthorizationError extends Error {
|
|
376
|
+
readonly entitlement: Entitlement;
|
|
377
|
+
readonly userId?: string | undefined;
|
|
378
|
+
constructor(message: string, entitlement: Entitlement, userId?: string | undefined);
|
|
379
|
+
}
|
|
380
|
+
declare function createAccess(config: AccessConfig): AccessInstance;
|
|
381
|
+
declare const defaultAccess: AccessInstance;
|
|
382
|
+
declare function hashPassword(password: string): Promise<string>;
|
|
383
|
+
declare function verifyPassword(password: string, hash: string): Promise<boolean>;
|
|
384
|
+
declare function validatePassword(password: string, requirements?: PasswordRequirements): AuthError | null;
|
|
385
|
+
declare function createAuth(config: AuthConfig): AuthInstance;
|
|
386
|
+
export { vertz, verifyPassword, validatePassword, makeImmutable, hashPassword, domain, defaultAccess, deepFreeze, createServer, createModuleDef, createModule, createMiddleware, createImmutableProxy, createEnv, createAuth, createAccess, VertzException, ValidationException, UnauthorizedException, SignUpInput, SignInInput, SessionStrategy, SessionPayload, SessionConfig, Session, ServiceUnavailableException, ServiceFactory, ServiceDef, ServiceBootInstruction, ServerHandle, ServerAdapter, RouterDef, Result, Resource, ResolveInjectMap, RawRequest, RateLimitResult, RateLimitConfig, PasswordRequirements, NotFoundException, NamedServiceDef, NamedRouterDef, NamedModuleDef, NamedModule, NamedMiddlewareDef, ModuleDef, ModuleBootInstruction, Module, MiddlewareDef, ListenOptions, InternalServerErrorException, InferSchema, Infer2 as Infer, HttpStatusCode, HttpMethod, HandlerCtx, ForbiddenException, ExtractMethods, EnvConfig, EntitlementDefinition, Entitlement, EmailPasswordConfig, DomainType, DomainOptions, DomainError, DomainDefinition2 as DomainDefinition, DomainContext, Deps, DeepReadonly, Ctx, CorsConfig, CookieConfig, ConflictException, BootSequence, BootInstruction, BadRequestException, AuthorizationError, AuthUser, AuthResult, AuthInstance, AuthError, AuthContext, AuthConfig, AuthApi, AppConfig, AppBuilder, AccumulateProvides, AccessRules, AccessRule, AccessInstance, AccessConfig };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import {
|
|
3
|
+
BadRequestException,
|
|
4
|
+
ConflictException,
|
|
5
|
+
createEnv,
|
|
6
|
+
createImmutableProxy,
|
|
7
|
+
createMiddleware,
|
|
8
|
+
createModule,
|
|
9
|
+
createModuleDef,
|
|
10
|
+
createServer,
|
|
11
|
+
deepFreeze,
|
|
12
|
+
ForbiddenException,
|
|
13
|
+
InternalServerErrorException,
|
|
14
|
+
makeImmutable,
|
|
15
|
+
NotFoundException,
|
|
16
|
+
ServiceUnavailableException,
|
|
17
|
+
UnauthorizedException,
|
|
18
|
+
ValidationException,
|
|
19
|
+
VertzException,
|
|
20
|
+
vertz
|
|
21
|
+
} from "@vertz/core";
|
|
22
|
+
|
|
23
|
+
// src/domain/domain.ts
|
|
24
|
+
function domain(name, options) {
|
|
25
|
+
if (!name || !options) {
|
|
26
|
+
return Object.freeze({
|
|
27
|
+
name: name || "",
|
|
28
|
+
type: "persisted",
|
|
29
|
+
table: null,
|
|
30
|
+
exposedRelations: {},
|
|
31
|
+
access: {},
|
|
32
|
+
handlers: {},
|
|
33
|
+
actions: {}
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
const def = {
|
|
37
|
+
name,
|
|
38
|
+
type: options.type,
|
|
39
|
+
table: options.table,
|
|
40
|
+
exposedRelations: options.expose || {},
|
|
41
|
+
access: options.access || {},
|
|
42
|
+
handlers: options.handlers || {},
|
|
43
|
+
actions: options.actions || {}
|
|
44
|
+
};
|
|
45
|
+
return Object.freeze(def);
|
|
46
|
+
}
|
|
47
|
+
// src/auth/index.ts
|
|
48
|
+
import * as jose from "jose";
|
|
49
|
+
import bcrypt from "bcryptjs";
|
|
50
|
+
|
|
51
|
+
// src/auth/access.ts
|
|
52
|
+
class AuthorizationError extends Error {
|
|
53
|
+
entitlement;
|
|
54
|
+
userId;
|
|
55
|
+
constructor(message, entitlement, userId) {
|
|
56
|
+
super(message);
|
|
57
|
+
this.entitlement = entitlement;
|
|
58
|
+
this.userId = userId;
|
|
59
|
+
this.name = "AuthorizationError";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function createAccess(config) {
|
|
63
|
+
const { roles, entitlements } = config;
|
|
64
|
+
const roleEntitlements = new Map;
|
|
65
|
+
for (const [roleName, roleDef] of Object.entries(roles)) {
|
|
66
|
+
roleEntitlements.set(roleName, new Set(roleDef.entitlements));
|
|
67
|
+
}
|
|
68
|
+
const entitlementRoles = new Map;
|
|
69
|
+
for (const [entName, entDef] of Object.entries(entitlements)) {
|
|
70
|
+
entitlementRoles.set(entName, new Set(entDef.roles));
|
|
71
|
+
}
|
|
72
|
+
function roleHasEntitlement(role, entitlement) {
|
|
73
|
+
const roleEnts = roleEntitlements.get(role);
|
|
74
|
+
if (!roleEnts)
|
|
75
|
+
return false;
|
|
76
|
+
if (roleEnts.has(entitlement))
|
|
77
|
+
return true;
|
|
78
|
+
const [resource, action] = entitlement.split(":");
|
|
79
|
+
if (action && resource !== "*") {
|
|
80
|
+
const wildcard = `${resource}:*`;
|
|
81
|
+
if (roleEnts.has(wildcard))
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
async function checkEntitlement(entitlement, user) {
|
|
87
|
+
if (!user)
|
|
88
|
+
return false;
|
|
89
|
+
const allowedRoles = entitlementRoles.get(entitlement);
|
|
90
|
+
if (!allowedRoles) {
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
return allowedRoles.has(user.role) || roleHasEntitlement(user.role, entitlement);
|
|
94
|
+
}
|
|
95
|
+
async function can(entitlement, user) {
|
|
96
|
+
return checkEntitlement(entitlement, user);
|
|
97
|
+
}
|
|
98
|
+
async function canWithResource(entitlement, _resource, user) {
|
|
99
|
+
const hasEntitlement = await checkEntitlement(entitlement, user);
|
|
100
|
+
if (!hasEntitlement)
|
|
101
|
+
return false;
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
async function authorize(entitlement, user) {
|
|
105
|
+
const allowed = await can(entitlement, user);
|
|
106
|
+
if (!allowed) {
|
|
107
|
+
throw new AuthorizationError(`Not authorized to perform this action: ${entitlement}`, entitlement, user?.id);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
async function authorizeWithResource(entitlement, resource, user) {
|
|
111
|
+
const allowed = await canWithResource(entitlement, resource, user);
|
|
112
|
+
if (!allowed) {
|
|
113
|
+
throw new AuthorizationError(`Not authorized to perform this action on this resource: ${entitlement}`, entitlement, user?.id);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
async function canAll(checks, user) {
|
|
117
|
+
const results = new Map;
|
|
118
|
+
for (const { entitlement, resource } of checks) {
|
|
119
|
+
const key = resource ? `${entitlement}:${resource.id}` : entitlement;
|
|
120
|
+
const allowed = resource ? await canWithResource(entitlement, resource, user) : await can(entitlement, user);
|
|
121
|
+
results.set(key, allowed);
|
|
122
|
+
}
|
|
123
|
+
return results;
|
|
124
|
+
}
|
|
125
|
+
function getEntitlementsForRole(role) {
|
|
126
|
+
const roleEnts = roleEntitlements.get(role);
|
|
127
|
+
return roleEnts ? Array.from(roleEnts) : [];
|
|
128
|
+
}
|
|
129
|
+
function createMiddleware() {
|
|
130
|
+
return async (ctx, next) => {
|
|
131
|
+
ctx.can = async (entitlement, resource) => {
|
|
132
|
+
if (resource) {
|
|
133
|
+
return canWithResource(entitlement, resource, ctx.user ?? null);
|
|
134
|
+
}
|
|
135
|
+
return can(entitlement, ctx.user ?? null);
|
|
136
|
+
};
|
|
137
|
+
ctx.authorize = async (entitlement, resource) => {
|
|
138
|
+
if (resource) {
|
|
139
|
+
return authorizeWithResource(entitlement, resource, ctx.user ?? null);
|
|
140
|
+
}
|
|
141
|
+
return authorize(entitlement, ctx.user ?? null);
|
|
142
|
+
};
|
|
143
|
+
await next();
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
can,
|
|
148
|
+
canWithResource,
|
|
149
|
+
authorize,
|
|
150
|
+
authorizeWithResource,
|
|
151
|
+
canAll,
|
|
152
|
+
getEntitlementsForRole,
|
|
153
|
+
middleware: createMiddleware
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
var defaultAccess = createAccess({
|
|
157
|
+
roles: {
|
|
158
|
+
user: { entitlements: ["read", "create"] },
|
|
159
|
+
editor: { entitlements: ["read", "create", "update"] },
|
|
160
|
+
admin: { entitlements: ["read", "create", "update", "delete"] }
|
|
161
|
+
},
|
|
162
|
+
entitlements: {
|
|
163
|
+
"user:read": { roles: ["user", "editor", "admin"] },
|
|
164
|
+
"user:create": { roles: ["user", "editor", "admin"] },
|
|
165
|
+
"user:update": { roles: ["editor", "admin"] },
|
|
166
|
+
"user:delete": { roles: ["admin"] },
|
|
167
|
+
read: { roles: ["user", "editor", "admin"] },
|
|
168
|
+
create: { roles: ["user", "editor", "admin"] },
|
|
169
|
+
update: { roles: ["editor", "admin"] },
|
|
170
|
+
delete: { roles: ["admin"] }
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// src/auth/index.ts
|
|
175
|
+
class RateLimiter {
|
|
176
|
+
store = new Map;
|
|
177
|
+
windowMs;
|
|
178
|
+
constructor(window) {
|
|
179
|
+
this.windowMs = this.parseDuration(window);
|
|
180
|
+
}
|
|
181
|
+
parseDuration(duration) {
|
|
182
|
+
const match = duration.match(/^(\d+)([smh])$/);
|
|
183
|
+
if (!match)
|
|
184
|
+
throw new Error(`Invalid duration: ${duration}`);
|
|
185
|
+
const value = parseInt(match[1], 10);
|
|
186
|
+
const unit = match[2];
|
|
187
|
+
const multipliers = { s: 1000, m: 60000, h: 3600000 };
|
|
188
|
+
return value * multipliers[unit];
|
|
189
|
+
}
|
|
190
|
+
check(key, maxAttempts) {
|
|
191
|
+
const now = new Date;
|
|
192
|
+
const entry = this.store.get(key);
|
|
193
|
+
if (!entry || entry.resetAt < now) {
|
|
194
|
+
const resetAt = new Date(now.getTime() + this.windowMs);
|
|
195
|
+
this.store.set(key, { count: 1, resetAt });
|
|
196
|
+
return { allowed: true, remaining: maxAttempts - 1, resetAt };
|
|
197
|
+
}
|
|
198
|
+
if (entry.count >= maxAttempts) {
|
|
199
|
+
return { allowed: false, remaining: 0, resetAt: entry.resetAt };
|
|
200
|
+
}
|
|
201
|
+
entry.count++;
|
|
202
|
+
return { allowed: true, remaining: maxAttempts - entry.count, resetAt: entry.resetAt };
|
|
203
|
+
}
|
|
204
|
+
cleanup() {
|
|
205
|
+
const now = new Date;
|
|
206
|
+
for (const [key, entry] of this.store) {
|
|
207
|
+
if (entry.resetAt < now) {
|
|
208
|
+
this.store.delete(key);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
var DEFAULT_PASSWORD_REQUIREMENTS = {
|
|
214
|
+
minLength: 8,
|
|
215
|
+
requireUppercase: false,
|
|
216
|
+
requireNumbers: false,
|
|
217
|
+
requireSymbols: false
|
|
218
|
+
};
|
|
219
|
+
var BCRYPT_ROUNDS = 12;
|
|
220
|
+
async function hashPassword(password) {
|
|
221
|
+
return bcrypt.hash(password, BCRYPT_ROUNDS);
|
|
222
|
+
}
|
|
223
|
+
async function verifyPassword(password, hash) {
|
|
224
|
+
return bcrypt.compare(password, hash);
|
|
225
|
+
}
|
|
226
|
+
function validatePassword(password, requirements) {
|
|
227
|
+
const req = { ...DEFAULT_PASSWORD_REQUIREMENTS, ...requirements };
|
|
228
|
+
if (password.length < (req.minLength ?? 8)) {
|
|
229
|
+
return {
|
|
230
|
+
code: "PASSWORD_TOO_SHORT",
|
|
231
|
+
message: `Password must be at least ${req.minLength} characters`,
|
|
232
|
+
status: 400
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
if (req.requireUppercase && !/[A-Z]/.test(password)) {
|
|
236
|
+
return {
|
|
237
|
+
code: "PASSWORD_NO_UPPERCASE",
|
|
238
|
+
message: "Password must contain at least one uppercase letter",
|
|
239
|
+
status: 400
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
if (req.requireNumbers && !/\d/.test(password)) {
|
|
243
|
+
return {
|
|
244
|
+
code: "PASSWORD_NO_NUMBER",
|
|
245
|
+
message: "Password must contain at least one number",
|
|
246
|
+
status: 400
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
if (req.requireSymbols && !/[!@#$%^&*(),.?":{}|<>]/.test(password)) {
|
|
250
|
+
return {
|
|
251
|
+
code: "PASSWORD_NO_SYMBOL",
|
|
252
|
+
message: "Password must contain at least one symbol",
|
|
253
|
+
status: 400
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
var DEFAULT_COOKIE_CONFIG = {
|
|
259
|
+
name: "vertz.sid",
|
|
260
|
+
httpOnly: true,
|
|
261
|
+
secure: true,
|
|
262
|
+
sameSite: "lax",
|
|
263
|
+
path: "/",
|
|
264
|
+
maxAge: 60 * 60 * 24 * 7
|
|
265
|
+
};
|
|
266
|
+
function parseDuration(duration) {
|
|
267
|
+
if (typeof duration === "number")
|
|
268
|
+
return duration;
|
|
269
|
+
const match = duration.match(/^(\d+)([smhd])$/);
|
|
270
|
+
if (!match)
|
|
271
|
+
throw new Error(`Invalid duration: ${duration}`);
|
|
272
|
+
const value = parseInt(match[1], 10);
|
|
273
|
+
const unit = match[2];
|
|
274
|
+
const multipliers = { s: 1, m: 60, h: 3600, d: 86400 };
|
|
275
|
+
return value * multipliers[unit] * 1000;
|
|
276
|
+
}
|
|
277
|
+
async function createJWT(user, secret, ttl, algorithm, customClaims) {
|
|
278
|
+
const claims = customClaims ? customClaims(user) : {};
|
|
279
|
+
const jwt = await new jose.SignJWT({
|
|
280
|
+
sub: user.id,
|
|
281
|
+
email: user.email,
|
|
282
|
+
role: user.role,
|
|
283
|
+
...claims
|
|
284
|
+
}).setProtectedHeader({ alg: algorithm }).setIssuedAt().setExpirationTime(Math.floor(ttl / 1000)).sign(new TextEncoder().encode(secret));
|
|
285
|
+
return jwt;
|
|
286
|
+
}
|
|
287
|
+
async function verifyJWT(token, secret, algorithm) {
|
|
288
|
+
try {
|
|
289
|
+
const { payload } = await jose.jwtVerify(token, new TextEncoder().encode(secret), { algorithms: [algorithm] });
|
|
290
|
+
return payload;
|
|
291
|
+
} catch {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
var users = new Map;
|
|
296
|
+
var sessions = new Map;
|
|
297
|
+
function createAuth(config) {
|
|
298
|
+
const {
|
|
299
|
+
session,
|
|
300
|
+
emailPassword,
|
|
301
|
+
jwtSecret: configJwtSecret,
|
|
302
|
+
jwtAlgorithm = "HS256",
|
|
303
|
+
claims
|
|
304
|
+
} = config;
|
|
305
|
+
const envJwtSecret = process.env.AUTH_JWT_SECRET;
|
|
306
|
+
let jwtSecret;
|
|
307
|
+
if (configJwtSecret) {
|
|
308
|
+
jwtSecret = configJwtSecret;
|
|
309
|
+
} else if (envJwtSecret) {
|
|
310
|
+
jwtSecret = envJwtSecret;
|
|
311
|
+
} else {
|
|
312
|
+
if (false) {} else {
|
|
313
|
+
console.warn("⚠️ Using insecure default JWT secret. Set AUTH_JWT_SECRET for production.");
|
|
314
|
+
jwtSecret = "dev-secret-change-in-production";
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
const cookieConfig = { ...DEFAULT_COOKIE_CONFIG, ...session.cookie };
|
|
318
|
+
const ttlMs = parseDuration(session.ttl);
|
|
319
|
+
const signInLimiter = new RateLimiter(emailPassword?.rateLimit?.window || "15m");
|
|
320
|
+
const signUpLimiter = new RateLimiter("1h");
|
|
321
|
+
const refreshLimiter = new RateLimiter("1m");
|
|
322
|
+
setInterval(() => {
|
|
323
|
+
signInLimiter.cleanup();
|
|
324
|
+
signUpLimiter.cleanup();
|
|
325
|
+
refreshLimiter.cleanup();
|
|
326
|
+
}, 60000);
|
|
327
|
+
function buildAuthUser(stored) {
|
|
328
|
+
return stored.user;
|
|
329
|
+
}
|
|
330
|
+
async function signUp(data) {
|
|
331
|
+
const { email, password, role = "user", ...additionalFields } = data;
|
|
332
|
+
if (!email || !email.includes("@")) {
|
|
333
|
+
return { ok: false, error: { code: "INVALID_EMAIL", message: "Invalid email format", status: 400 } };
|
|
334
|
+
}
|
|
335
|
+
const passwordError = validatePassword(password, emailPassword?.password);
|
|
336
|
+
if (passwordError) {
|
|
337
|
+
return { ok: false, error: passwordError };
|
|
338
|
+
}
|
|
339
|
+
if (users.has(email.toLowerCase())) {
|
|
340
|
+
return { ok: false, error: { code: "USER_EXISTS", message: "User already exists", status: 409 } };
|
|
341
|
+
}
|
|
342
|
+
const signUpRateLimit = signUpLimiter.check(`signup:${email.toLowerCase()}`, emailPassword?.rateLimit?.maxAttempts || 3);
|
|
343
|
+
if (!signUpRateLimit.allowed) {
|
|
344
|
+
return { ok: false, error: { code: "RATE_LIMITED", message: "Too many sign up attempts", status: 429 } };
|
|
345
|
+
}
|
|
346
|
+
const passwordHash = await hashPassword(password);
|
|
347
|
+
const now = new Date;
|
|
348
|
+
const user = {
|
|
349
|
+
id: crypto.randomUUID(),
|
|
350
|
+
email: email.toLowerCase(),
|
|
351
|
+
role,
|
|
352
|
+
createdAt: now,
|
|
353
|
+
updatedAt: now,
|
|
354
|
+
...additionalFields
|
|
355
|
+
};
|
|
356
|
+
users.set(email.toLowerCase(), { user, passwordHash });
|
|
357
|
+
const token = await createJWT(user, jwtSecret, ttlMs, jwtAlgorithm, claims);
|
|
358
|
+
const expiresAt = new Date(Date.now() + ttlMs);
|
|
359
|
+
const payload = {
|
|
360
|
+
sub: user.id,
|
|
361
|
+
email: user.email,
|
|
362
|
+
role: user.role,
|
|
363
|
+
iat: Math.floor(Date.now() / 1000),
|
|
364
|
+
exp: Math.floor(expiresAt.getTime() / 1000),
|
|
365
|
+
claims: claims ? claims(user) : undefined
|
|
366
|
+
};
|
|
367
|
+
sessions.set(token, { userId: user.id, expiresAt });
|
|
368
|
+
return {
|
|
369
|
+
ok: true,
|
|
370
|
+
data: { user, expiresAt, payload }
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
async function signIn(data) {
|
|
374
|
+
const { email, password } = data;
|
|
375
|
+
const stored = users.get(email.toLowerCase());
|
|
376
|
+
if (!stored) {
|
|
377
|
+
return { ok: false, error: { code: "INVALID_CREDENTIALS", message: "Invalid email or password", status: 401 } };
|
|
378
|
+
}
|
|
379
|
+
const signInRateLimit = signInLimiter.check(`signin:${email.toLowerCase()}`, emailPassword?.rateLimit?.maxAttempts || 5);
|
|
380
|
+
if (!signInRateLimit.allowed) {
|
|
381
|
+
return { ok: false, error: { code: "RATE_LIMITED", message: "Too many sign in attempts", status: 429 } };
|
|
382
|
+
}
|
|
383
|
+
const valid = await verifyPassword(password, stored.passwordHash);
|
|
384
|
+
if (!valid) {
|
|
385
|
+
return { ok: false, error: { code: "INVALID_CREDENTIALS", message: "Invalid email or password", status: 401 } };
|
|
386
|
+
}
|
|
387
|
+
const user = buildAuthUser(stored);
|
|
388
|
+
const token = await createJWT(user, jwtSecret, ttlMs, jwtAlgorithm, claims);
|
|
389
|
+
const expiresAt = new Date(Date.now() + ttlMs);
|
|
390
|
+
const payload = {
|
|
391
|
+
sub: user.id,
|
|
392
|
+
email: user.email,
|
|
393
|
+
role: user.role,
|
|
394
|
+
iat: Math.floor(Date.now() / 1000),
|
|
395
|
+
exp: Math.floor(expiresAt.getTime() / 1000),
|
|
396
|
+
claims: claims ? claims(user) : undefined
|
|
397
|
+
};
|
|
398
|
+
sessions.set(token, { userId: user.id, expiresAt });
|
|
399
|
+
return {
|
|
400
|
+
ok: true,
|
|
401
|
+
data: { user, expiresAt, payload }
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
async function signOut(ctx) {
|
|
405
|
+
const cookieName = cookieConfig.name || "vertz.sid";
|
|
406
|
+
const token = ctx.headers.get("cookie")?.split(";").find((c) => c.trim().startsWith(`${cookieName}=`))?.split("=")[1];
|
|
407
|
+
if (token) {
|
|
408
|
+
sessions.delete(token);
|
|
409
|
+
}
|
|
410
|
+
return { ok: true, data: undefined };
|
|
411
|
+
}
|
|
412
|
+
async function getSession(headers) {
|
|
413
|
+
const cookieName = cookieConfig.name || "vertz.sid";
|
|
414
|
+
const token = headers.get("cookie")?.split(";").find((c) => c.trim().startsWith(`${cookieName}=`))?.split("=")[1];
|
|
415
|
+
if (!token) {
|
|
416
|
+
return { ok: true, data: null };
|
|
417
|
+
}
|
|
418
|
+
const session2 = sessions.get(token);
|
|
419
|
+
if (!session2) {
|
|
420
|
+
return { ok: true, data: null };
|
|
421
|
+
}
|
|
422
|
+
if (session2.expiresAt < new Date) {
|
|
423
|
+
sessions.delete(token);
|
|
424
|
+
return { ok: true, data: null };
|
|
425
|
+
}
|
|
426
|
+
const payload = await verifyJWT(token, jwtSecret, jwtAlgorithm);
|
|
427
|
+
if (!payload) {
|
|
428
|
+
sessions.delete(token);
|
|
429
|
+
return { ok: true, data: null };
|
|
430
|
+
}
|
|
431
|
+
const stored = users.get(payload.email);
|
|
432
|
+
if (!stored) {
|
|
433
|
+
return { ok: true, data: null };
|
|
434
|
+
}
|
|
435
|
+
const user = buildAuthUser(stored);
|
|
436
|
+
const expiresAt = new Date(payload.exp * 1000);
|
|
437
|
+
return {
|
|
438
|
+
ok: true,
|
|
439
|
+
data: { user, expiresAt, payload }
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
async function refreshSession(ctx) {
|
|
443
|
+
const refreshRateLimit = refreshLimiter.check(`refresh:${ctx.headers.get("x-forwarded-ip") || "default"}`, 10);
|
|
444
|
+
if (!refreshRateLimit.allowed) {
|
|
445
|
+
return { ok: false, error: { code: "RATE_LIMITED", message: "Too many refresh attempts", status: 429 } };
|
|
446
|
+
}
|
|
447
|
+
const sessionResult = await getSession(ctx.headers);
|
|
448
|
+
if (!sessionResult.ok) {
|
|
449
|
+
return sessionResult;
|
|
450
|
+
}
|
|
451
|
+
if (!sessionResult.data) {
|
|
452
|
+
return { ok: false, error: { code: "NO_SESSION", message: "No active session", status: 401 } };
|
|
453
|
+
}
|
|
454
|
+
const user = sessionResult.data.user;
|
|
455
|
+
const token = await createJWT(user, jwtSecret, ttlMs, jwtAlgorithm, claims);
|
|
456
|
+
const expiresAt = new Date(Date.now() + ttlMs);
|
|
457
|
+
const payload = {
|
|
458
|
+
sub: user.id,
|
|
459
|
+
email: user.email,
|
|
460
|
+
role: user.role,
|
|
461
|
+
iat: Math.floor(Date.now() / 1000),
|
|
462
|
+
exp: Math.floor(expiresAt.getTime() / 1000),
|
|
463
|
+
claims: claims ? claims(user) : undefined
|
|
464
|
+
};
|
|
465
|
+
sessions.set(token, { userId: user.id, expiresAt });
|
|
466
|
+
return {
|
|
467
|
+
ok: true,
|
|
468
|
+
data: { user, expiresAt, payload }
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
async function handleAuthRequest(request) {
|
|
472
|
+
const url = new URL(request.url);
|
|
473
|
+
const path = url.pathname.replace("/api/auth", "") || "/";
|
|
474
|
+
const method = request.method;
|
|
475
|
+
if (["POST", "PUT", "DELETE", "PATCH"].includes(method)) {
|
|
476
|
+
const origin = request.headers.get("origin");
|
|
477
|
+
const referer = request.headers.get("referer");
|
|
478
|
+
if (!origin && !referer) {
|
|
479
|
+
if (false) {}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
try {
|
|
483
|
+
if (method === "POST" && path === "/signup") {
|
|
484
|
+
const body = await request.json();
|
|
485
|
+
const result = await signUp(body);
|
|
486
|
+
if (!result.ok) {
|
|
487
|
+
return new Response(JSON.stringify({ error: result.error }), {
|
|
488
|
+
status: result.error.status,
|
|
489
|
+
headers: { "Content-Type": "application/json" }
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
const cookieValue = await createJWT(result.data.user, jwtSecret, ttlMs, jwtAlgorithm, claims);
|
|
493
|
+
return new Response(JSON.stringify({ user: result.data.user }), {
|
|
494
|
+
status: 201,
|
|
495
|
+
headers: {
|
|
496
|
+
"Content-Type": "application/json",
|
|
497
|
+
"Set-Cookie": buildCookie(cookieValue)
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
if (method === "POST" && path === "/signin") {
|
|
502
|
+
const body = await request.json();
|
|
503
|
+
const result = await signIn(body);
|
|
504
|
+
if (!result.ok) {
|
|
505
|
+
return new Response(JSON.stringify({ error: result.error }), {
|
|
506
|
+
status: result.error.status,
|
|
507
|
+
headers: { "Content-Type": "application/json" }
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
const cookieValue = await createJWT(result.data.user, jwtSecret, ttlMs, jwtAlgorithm, claims);
|
|
511
|
+
return new Response(JSON.stringify({ user: result.data.user }), {
|
|
512
|
+
status: 200,
|
|
513
|
+
headers: {
|
|
514
|
+
"Content-Type": "application/json",
|
|
515
|
+
"Set-Cookie": buildCookie(cookieValue)
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
if (method === "POST" && path === "/signout") {
|
|
520
|
+
await signOut({ headers: request.headers });
|
|
521
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
522
|
+
status: 200,
|
|
523
|
+
headers: {
|
|
524
|
+
"Content-Type": "application/json",
|
|
525
|
+
"Set-Cookie": buildCookie("", true)
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
if (method === "GET" && path === "/session") {
|
|
530
|
+
const result = await getSession(request.headers);
|
|
531
|
+
if (!result.ok) {
|
|
532
|
+
return new Response(JSON.stringify({ error: result.error }), {
|
|
533
|
+
status: 500,
|
|
534
|
+
headers: { "Content-Type": "application/json" }
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
return new Response(JSON.stringify({ session: result.data }), {
|
|
538
|
+
status: 200,
|
|
539
|
+
headers: { "Content-Type": "application/json" }
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
if (method === "POST" && path === "/refresh") {
|
|
543
|
+
const result = await refreshSession({ headers: request.headers });
|
|
544
|
+
if (!result.ok) {
|
|
545
|
+
return new Response(JSON.stringify({ error: result.error }), {
|
|
546
|
+
status: result.error.status,
|
|
547
|
+
headers: { "Content-Type": "application/json" }
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
const cookieValue = await createJWT(result.data.user, jwtSecret, ttlMs, jwtAlgorithm, claims);
|
|
551
|
+
return new Response(JSON.stringify({ user: result.data.user }), {
|
|
552
|
+
status: 200,
|
|
553
|
+
headers: {
|
|
554
|
+
"Content-Type": "application/json",
|
|
555
|
+
"Set-Cookie": buildCookie(cookieValue)
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
return new Response(JSON.stringify({ error: "Not found" }), {
|
|
560
|
+
status: 404,
|
|
561
|
+
headers: { "Content-Type": "application/json" }
|
|
562
|
+
});
|
|
563
|
+
} catch (error) {
|
|
564
|
+
return new Response(JSON.stringify({ error: "Internal server error" }), {
|
|
565
|
+
status: 500,
|
|
566
|
+
headers: { "Content-Type": "application/json" }
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
function buildCookie(value, clear = false) {
|
|
571
|
+
const name = cookieConfig.name || "vertz.sid";
|
|
572
|
+
const maxAge = cookieConfig.maxAge ?? 60 * 60 * 24 * 7;
|
|
573
|
+
const path = cookieConfig.path || "/";
|
|
574
|
+
const sameSite = cookieConfig.sameSite || "lax";
|
|
575
|
+
const secure = cookieConfig.secure ?? true;
|
|
576
|
+
if (clear) {
|
|
577
|
+
return `${name}=; Path=${path}; HttpOnly; SameSite=${sameSite}; Max-Age=0`;
|
|
578
|
+
}
|
|
579
|
+
return `${name}=${value}; Path=${path}; HttpOnly${secure ? "; Secure" : ""}; SameSite=${sameSite}; Max-Age=${maxAge}`;
|
|
580
|
+
}
|
|
581
|
+
function createMiddleware() {
|
|
582
|
+
return async (ctx, next) => {
|
|
583
|
+
const sessionResult = await getSession(ctx.headers);
|
|
584
|
+
if (sessionResult.ok && sessionResult.data) {
|
|
585
|
+
ctx.user = sessionResult.data.user;
|
|
586
|
+
ctx.session = sessionResult.data;
|
|
587
|
+
} else {
|
|
588
|
+
ctx.user = null;
|
|
589
|
+
ctx.session = null;
|
|
590
|
+
}
|
|
591
|
+
await next();
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
async function initialize() {
|
|
595
|
+
console.log("[Auth] Initialized with JWT strategy");
|
|
596
|
+
}
|
|
597
|
+
const api = {
|
|
598
|
+
signUp,
|
|
599
|
+
signIn,
|
|
600
|
+
signOut,
|
|
601
|
+
getSession,
|
|
602
|
+
refreshSession
|
|
603
|
+
};
|
|
604
|
+
return {
|
|
605
|
+
handler: handleAuthRequest,
|
|
606
|
+
api,
|
|
607
|
+
middleware: createMiddleware,
|
|
608
|
+
initialize
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
export {
|
|
612
|
+
vertz,
|
|
613
|
+
verifyPassword,
|
|
614
|
+
validatePassword,
|
|
615
|
+
makeImmutable,
|
|
616
|
+
hashPassword,
|
|
617
|
+
domain,
|
|
618
|
+
defaultAccess,
|
|
619
|
+
deepFreeze,
|
|
620
|
+
createServer,
|
|
621
|
+
createModuleDef,
|
|
622
|
+
createModule,
|
|
623
|
+
createMiddleware,
|
|
624
|
+
createImmutableProxy,
|
|
625
|
+
createEnv,
|
|
626
|
+
createAuth,
|
|
627
|
+
createAccess,
|
|
628
|
+
VertzException,
|
|
629
|
+
ValidationException,
|
|
630
|
+
UnauthorizedException,
|
|
631
|
+
ServiceUnavailableException,
|
|
632
|
+
NotFoundException,
|
|
633
|
+
InternalServerErrorException,
|
|
634
|
+
ForbiddenException,
|
|
635
|
+
ConflictException,
|
|
636
|
+
BadRequestException,
|
|
637
|
+
AuthorizationError
|
|
638
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vertz/server",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"description": "Vertz server runtime — modules, routing, and auth",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/vertz-dev/vertz.git",
|
|
10
|
+
"directory": "packages/server"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public",
|
|
14
|
+
"provenance": true
|
|
15
|
+
},
|
|
16
|
+
"main": "dist/index.js",
|
|
17
|
+
"types": "dist/index.d.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"import": "./dist/index.js",
|
|
21
|
+
"types": "./dist/index.d.ts"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "bunup",
|
|
29
|
+
"test": "vitest run",
|
|
30
|
+
"test:watch": "vitest",
|
|
31
|
+
"typecheck": "tsc --noEmit"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@vertz/core": "workspace:*",
|
|
35
|
+
"bcryptjs": "^2.4.3",
|
|
36
|
+
"jose": "^6.0.11"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/bcryptjs": "^2.4.6",
|
|
40
|
+
"@types/node": "^22.0.0",
|
|
41
|
+
"@vertz/db": "workspace:*",
|
|
42
|
+
"bunup": "latest",
|
|
43
|
+
"typescript": "^5.7.0",
|
|
44
|
+
"vitest": "^4.0.18"
|
|
45
|
+
},
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=22"
|
|
48
|
+
},
|
|
49
|
+
"sideEffects": false
|
|
50
|
+
}
|