@webstir-io/webstir-backend 0.1.15 → 0.1.16

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.
Files changed (123) hide show
  1. package/README.md +106 -79
  2. package/dist/add.d.ts +59 -0
  3. package/dist/add.js +626 -0
  4. package/dist/build/artifacts.d.ts +115 -1
  5. package/dist/build/artifacts.js +4 -4
  6. package/dist/build/entries.js +1 -1
  7. package/dist/build/pipeline.d.ts +33 -1
  8. package/dist/build/pipeline.js +307 -65
  9. package/dist/cache/diff.js +9 -8
  10. package/dist/cache/reporters.js +1 -1
  11. package/dist/deploy-cli.d.ts +2 -0
  12. package/dist/deploy-cli.js +86 -0
  13. package/dist/diagnostics/summary.js +2 -2
  14. package/dist/index.d.ts +6 -0
  15. package/dist/index.js +4 -0
  16. package/dist/manifest/pipeline.js +103 -32
  17. package/dist/provider.js +35 -17
  18. package/dist/runtime/bun.d.ts +51 -0
  19. package/dist/runtime/bun.js +499 -0
  20. package/dist/runtime/core.d.ts +141 -0
  21. package/dist/runtime/core.js +316 -0
  22. package/dist/runtime/deploy-backend.d.ts +20 -0
  23. package/dist/runtime/deploy-backend.js +175 -0
  24. package/dist/runtime/deploy-shared.d.ts +43 -0
  25. package/dist/runtime/deploy-shared.js +75 -0
  26. package/dist/runtime/deploy-static.d.ts +2 -0
  27. package/dist/runtime/deploy-static.js +161 -0
  28. package/dist/runtime/deploy.d.ts +3 -0
  29. package/dist/runtime/deploy.js +91 -0
  30. package/dist/runtime/forms.d.ts +73 -0
  31. package/dist/runtime/forms.js +236 -0
  32. package/dist/runtime/request-hooks.d.ts +47 -0
  33. package/dist/runtime/request-hooks.js +102 -0
  34. package/dist/runtime/session-metadata.d.ts +13 -0
  35. package/dist/runtime/session-metadata.js +98 -0
  36. package/dist/runtime/session-runtime.d.ts +28 -0
  37. package/dist/runtime/session-runtime.js +180 -0
  38. package/dist/runtime/session.d.ts +83 -0
  39. package/dist/runtime/session.js +396 -0
  40. package/dist/runtime/views.d.ts +74 -0
  41. package/dist/runtime/views.js +221 -0
  42. package/dist/scaffold/assets.js +25 -21
  43. package/dist/testing/context.js +1 -1
  44. package/dist/testing/index.d.ts +1 -1
  45. package/dist/testing/index.js +100 -56
  46. package/dist/utils/bun.d.ts +2 -0
  47. package/dist/utils/bun.js +13 -0
  48. package/dist/watch.d.ts +13 -1
  49. package/dist/watch.js +345 -97
  50. package/dist/workspace.d.ts +8 -0
  51. package/dist/workspace.js +44 -3
  52. package/package.json +49 -14
  53. package/scripts/publish.sh +2 -92
  54. package/scripts/smoke.mjs +282 -107
  55. package/scripts/update-contract.sh +12 -10
  56. package/src/add.ts +964 -0
  57. package/src/build/artifacts.ts +49 -46
  58. package/src/build/entries.ts +12 -12
  59. package/src/build/pipeline.ts +779 -403
  60. package/src/cache/diff.ts +111 -105
  61. package/src/cache/reporters.ts +26 -26
  62. package/src/deploy-cli.ts +111 -0
  63. package/src/diagnostics/summary.ts +28 -22
  64. package/src/index.ts +11 -0
  65. package/src/manifest/pipeline.ts +328 -215
  66. package/src/provider.ts +115 -98
  67. package/src/runtime/bun.ts +793 -0
  68. package/src/runtime/core.ts +598 -0
  69. package/src/runtime/deploy-backend.ts +239 -0
  70. package/src/runtime/deploy-shared.ts +136 -0
  71. package/src/runtime/deploy-static.ts +191 -0
  72. package/src/runtime/deploy.ts +143 -0
  73. package/src/runtime/forms.ts +364 -0
  74. package/src/runtime/request-hooks.ts +165 -0
  75. package/src/runtime/session-metadata.ts +135 -0
  76. package/src/runtime/session-runtime.ts +267 -0
  77. package/src/runtime/session.ts +642 -0
  78. package/src/runtime/views.ts +385 -0
  79. package/src/scaffold/assets.ts +77 -73
  80. package/src/testing/context.js +8 -9
  81. package/src/testing/context.ts +9 -9
  82. package/src/testing/index.d.ts +14 -3
  83. package/src/testing/index.js +254 -175
  84. package/src/testing/index.ts +298 -195
  85. package/src/testing/types.d.ts +18 -19
  86. package/src/testing/types.ts +18 -18
  87. package/src/utils/bun.ts +26 -0
  88. package/src/watch.ts +503 -99
  89. package/src/workspace.ts +59 -3
  90. package/templates/backend/.env.example +15 -0
  91. package/templates/backend/auth/adapter.ts +335 -36
  92. package/templates/backend/db/connection.ts +190 -65
  93. package/templates/backend/db/migrate.ts +149 -43
  94. package/templates/backend/db/types.d.ts +1 -1
  95. package/templates/backend/env.ts +132 -20
  96. package/templates/backend/functions/hello/index.ts +1 -2
  97. package/templates/backend/index.ts +15 -508
  98. package/templates/backend/jobs/nightly/index.ts +1 -1
  99. package/templates/backend/jobs/runtime.ts +24 -11
  100. package/templates/backend/jobs/scheduler.ts +208 -46
  101. package/templates/backend/module.ts +227 -13
  102. package/templates/backend/observability/logger.ts +2 -12
  103. package/templates/backend/observability/metrics.ts +8 -5
  104. package/templates/backend/session/sqlite.ts +152 -0
  105. package/templates/backend/session/store.ts +45 -0
  106. package/templates/backend/tsconfig.json +1 -1
  107. package/tests/add.test.js +327 -0
  108. package/tests/authAdapter.test.js +315 -0
  109. package/tests/bundlerParity.test.js +217 -0
  110. package/tests/cacheReporter.test.js +10 -10
  111. package/tests/dbConnection.test.js +209 -0
  112. package/tests/deploy.test.js +357 -0
  113. package/tests/envLoader.test.js +271 -17
  114. package/tests/integration.test.js +2432 -3
  115. package/tests/jobsScheduler.test.js +253 -0
  116. package/tests/manifest.test.js +287 -12
  117. package/tests/migrationRunner.test.js +249 -0
  118. package/tests/sessionScaffoldStore.test.js +752 -0
  119. package/tests/sessionStore.test.js +490 -0
  120. package/tests/testing.test.js +252 -0
  121. package/tests/watch.test.js +192 -32
  122. package/tsconfig.json +3 -10
  123. package/templates/backend/server/fastify.ts +0 -288
@@ -0,0 +1,364 @@
1
+ import { randomUUID, timingSafeEqual } from 'node:crypto';
2
+
3
+ import {
4
+ getFormSessionRuntimeState,
5
+ pruneSessionRuntimeState,
6
+ type SessionRuntimeFormState,
7
+ } from './session-runtime.js';
8
+
9
+ export type FormIssueCode = 'validation' | 'auth' | 'csrf';
10
+ export type FormValue = string | string[];
11
+ export type FormValues = Record<string, FormValue>;
12
+
13
+ export interface FormIssue {
14
+ code?: FormIssueCode;
15
+ field?: string;
16
+ message: string;
17
+ }
18
+
19
+ export interface FormRouteDefinitionLike {
20
+ path?: string;
21
+ form?: {
22
+ csrf?: boolean;
23
+ };
24
+ }
25
+
26
+ export interface PreparedFormState<TSession extends Record<string, unknown>> {
27
+ session: TSession;
28
+ csrfToken?: string;
29
+ values: FormValues;
30
+ issues: FormIssue[];
31
+ }
32
+
33
+ type FormRuntimeStore = SessionRuntimeFormState;
34
+
35
+ interface RouteHandlerResultLike {
36
+ status?: number;
37
+ headers?: Record<string, string>;
38
+ body?: unknown;
39
+ redirect?: {
40
+ location: string;
41
+ };
42
+ errors?: { code: string; message: string; details?: unknown }[];
43
+ }
44
+
45
+ export type FormSubmissionResult<TSession extends Record<string, unknown>, TAuth> =
46
+ | {
47
+ ok: true;
48
+ session: TSession;
49
+ values: FormValues;
50
+ auth: TAuth | undefined;
51
+ }
52
+ | {
53
+ ok: false;
54
+ session: TSession;
55
+ values: FormValues;
56
+ issues: FormIssue[];
57
+ result: RouteHandlerResultLike;
58
+ };
59
+
60
+ const DEFAULT_CSRF_FIELD_NAME = '_csrf';
61
+
62
+ export function prepareFormState<TSession extends Record<string, unknown>>(options: {
63
+ session: TSession | null;
64
+ formId: string;
65
+ route?: FormRouteDefinitionLike;
66
+ csrf?: boolean;
67
+ now?: () => Date;
68
+ }): PreparedFormState<TSession> {
69
+ const session = ensureSession(options.session);
70
+ const store = getFormRuntimeStore(session);
71
+ const stored = store.states[options.formId];
72
+ if (stored) {
73
+ delete store.states[options.formId];
74
+ }
75
+
76
+ let csrfToken: string | undefined;
77
+ if (isCsrfEnabled(options)) {
78
+ csrfToken = ensureCsrfToken(store, options.formId);
79
+ }
80
+
81
+ cleanupFormRuntimeStore(session);
82
+ return {
83
+ session,
84
+ csrfToken,
85
+ values: cloneFormValues(stored?.values),
86
+ issues: cloneIssues(stored?.issues),
87
+ };
88
+ }
89
+
90
+ export function processFormSubmission<TSession extends Record<string, unknown>, TAuth>(options: {
91
+ session: TSession | null;
92
+ body: unknown;
93
+ auth?: TAuth;
94
+ formId: string;
95
+ route?: FormRouteDefinitionLike;
96
+ csrf?: boolean;
97
+ csrfFieldName?: string;
98
+ redirectTo?: string;
99
+ requireAuth?:
100
+ | boolean
101
+ | {
102
+ redirectTo?: string;
103
+ message?: string;
104
+ };
105
+ validate?: (values: FormValues) => readonly FormIssue[] | FormIssue[] | undefined;
106
+ now?: () => Date;
107
+ }): FormSubmissionResult<TSession, TAuth> {
108
+ const now = options.now ?? (() => new Date());
109
+ const session = ensureSession(options.session);
110
+ const store = getFormRuntimeStore(session);
111
+ const csrfFieldName = options.csrfFieldName ?? DEFAULT_CSRF_FIELD_NAME;
112
+ const values = normalizeFormValues(options.body, csrfFieldName);
113
+ const redirectTo = options.redirectTo;
114
+
115
+ if (isCsrfEnabled(options)) {
116
+ const expectedToken = ensureCsrfToken(store, options.formId);
117
+ const providedToken = readCsrfToken(options.body, csrfFieldName);
118
+ if (!providedToken || !tokensMatch(providedToken, expectedToken)) {
119
+ return failSubmission({
120
+ session,
121
+ store,
122
+ formId: options.formId,
123
+ values,
124
+ redirectTo,
125
+ now,
126
+ issues: [
127
+ {
128
+ code: 'csrf',
129
+ message: 'Form session expired. Reload the page and try again.',
130
+ },
131
+ ],
132
+ });
133
+ }
134
+ delete store.csrf[options.formId];
135
+ }
136
+
137
+ if (requiresAuth(options.requireAuth) && options.auth === undefined) {
138
+ return failSubmission({
139
+ session,
140
+ store,
141
+ formId: options.formId,
142
+ values,
143
+ redirectTo: resolveAuthRedirect(options.requireAuth, redirectTo),
144
+ now,
145
+ issues: [
146
+ {
147
+ code: 'auth',
148
+ message:
149
+ typeof options.requireAuth === 'object' && options.requireAuth.message
150
+ ? options.requireAuth.message
151
+ : 'Sign-in required to submit this form.',
152
+ },
153
+ ],
154
+ });
155
+ }
156
+
157
+ const validationResult = options.validate?.(values);
158
+ const validationIssues = cloneIssues(
159
+ Array.isArray(validationResult) ? validationResult : undefined,
160
+ );
161
+ if (validationIssues.length > 0) {
162
+ return failSubmission({
163
+ session,
164
+ store,
165
+ formId: options.formId,
166
+ values,
167
+ redirectTo,
168
+ now,
169
+ issues: validationIssues.map((issue) => ({
170
+ ...issue,
171
+ code: issue.code ?? 'validation',
172
+ })),
173
+ });
174
+ }
175
+
176
+ delete store.states[options.formId];
177
+ cleanupFormRuntimeStore(session);
178
+ return {
179
+ ok: true,
180
+ session,
181
+ values,
182
+ auth: options.auth,
183
+ };
184
+ }
185
+
186
+ export function groupFormIssuesByField(issues: readonly FormIssue[] | undefined): {
187
+ form: string[];
188
+ fields: Record<string, string[]>;
189
+ } {
190
+ const grouped = {
191
+ form: [] as string[],
192
+ fields: {} as Record<string, string[]>,
193
+ };
194
+ for (const issue of issues ?? []) {
195
+ if (!issue?.message) {
196
+ continue;
197
+ }
198
+ if (!issue.field) {
199
+ grouped.form.push(issue.message);
200
+ continue;
201
+ }
202
+ grouped.fields[issue.field] ??= [];
203
+ grouped.fields[issue.field].push(issue.message);
204
+ }
205
+ return grouped;
206
+ }
207
+
208
+ function failSubmission<TSession extends Record<string, unknown>>(options: {
209
+ session: TSession;
210
+ store: FormRuntimeStore;
211
+ formId: string;
212
+ values: FormValues;
213
+ redirectTo?: string;
214
+ issues: FormIssue[];
215
+ now: () => Date;
216
+ }): FormSubmissionResult<TSession, never> {
217
+ options.store.states[options.formId] = {
218
+ values: cloneFormValues(options.values),
219
+ issues: cloneIssues(options.issues),
220
+ createdAt: options.now().toISOString(),
221
+ };
222
+ cleanupFormRuntimeStore(options.session);
223
+
224
+ if (options.redirectTo) {
225
+ return {
226
+ ok: false,
227
+ session: options.session,
228
+ values: options.values,
229
+ issues: options.issues,
230
+ result: {
231
+ status: 303,
232
+ redirect: {
233
+ location: options.redirectTo,
234
+ },
235
+ },
236
+ };
237
+ }
238
+
239
+ const status = options.issues.some((issue) => issue.code === 'auth')
240
+ ? 401
241
+ : options.issues.some((issue) => issue.code === 'csrf')
242
+ ? 403
243
+ : 422;
244
+
245
+ return {
246
+ ok: false,
247
+ session: options.session,
248
+ values: options.values,
249
+ issues: options.issues,
250
+ result: {
251
+ status,
252
+ errors: options.issues.map((issue) => ({
253
+ code: issue.code === 'auth' ? 'auth' : 'validation',
254
+ message: issue.message,
255
+ details: issue.field
256
+ ? { field: issue.field, reason: issue.code ?? 'validation' }
257
+ : { reason: issue.code ?? 'validation' },
258
+ })),
259
+ },
260
+ };
261
+ }
262
+
263
+ function ensureSession<TSession extends Record<string, unknown>>(
264
+ session: TSession | null,
265
+ ): TSession {
266
+ if (session && typeof session === 'object' && !Array.isArray(session)) {
267
+ return session;
268
+ }
269
+ return {} as TSession;
270
+ }
271
+
272
+ function getFormRuntimeStore(session: Record<string, unknown>): FormRuntimeStore {
273
+ return getFormSessionRuntimeState(session);
274
+ }
275
+
276
+ function cleanupFormRuntimeStore(session: Record<string, unknown>): void {
277
+ pruneSessionRuntimeState(session);
278
+ }
279
+
280
+ function ensureCsrfToken(store: FormRuntimeStore, formId: string): string {
281
+ const existing = store.csrf[formId];
282
+ if (existing) {
283
+ return existing;
284
+ }
285
+ const generated = randomUUID();
286
+ store.csrf[formId] = generated;
287
+ return generated;
288
+ }
289
+
290
+ function readCsrfToken(body: unknown, fieldName: string): string | undefined {
291
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
292
+ return undefined;
293
+ }
294
+ const value = (body as Record<string, unknown>)[fieldName];
295
+ if (typeof value === 'string' && value.length > 0) {
296
+ return value;
297
+ }
298
+ if (Array.isArray(value) && typeof value[0] === 'string' && value[0].length > 0) {
299
+ return value[0];
300
+ }
301
+ return undefined;
302
+ }
303
+
304
+ function normalizeFormValues(body: unknown, csrfFieldName: string): FormValues {
305
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
306
+ return {};
307
+ }
308
+ const values: FormValues = {};
309
+ for (const [key, raw] of Object.entries(body as Record<string, unknown>)) {
310
+ if (key === csrfFieldName) {
311
+ continue;
312
+ }
313
+ if (typeof raw === 'string') {
314
+ values[key] = raw;
315
+ continue;
316
+ }
317
+ if (Array.isArray(raw) && raw.every((value) => typeof value === 'string')) {
318
+ values[key] = [...raw];
319
+ }
320
+ }
321
+ return values;
322
+ }
323
+
324
+ function cloneFormValues(values: FormValues | undefined): FormValues {
325
+ if (!values) {
326
+ return {};
327
+ }
328
+ return Object.fromEntries(
329
+ Object.entries(values).map(([key, value]) => [key, Array.isArray(value) ? [...value] : value]),
330
+ );
331
+ }
332
+
333
+ function cloneIssues(issues: readonly FormIssue[] | undefined): FormIssue[] {
334
+ return (issues ?? []).map((issue) => ({ ...issue }));
335
+ }
336
+
337
+ function tokensMatch(left: string, right: string): boolean {
338
+ const leftBuffer = Buffer.from(left);
339
+ const rightBuffer = Buffer.from(right);
340
+ if (leftBuffer.length !== rightBuffer.length) {
341
+ return false;
342
+ }
343
+ return timingSafeEqual(leftBuffer, rightBuffer);
344
+ }
345
+
346
+ function isCsrfEnabled(options: { route?: FormRouteDefinitionLike; csrf?: boolean }): boolean {
347
+ return options.csrf ?? options.route?.form?.csrf ?? false;
348
+ }
349
+
350
+ function requiresAuth(
351
+ option: { redirectTo?: string; message?: string } | boolean | undefined,
352
+ ): boolean {
353
+ return option === true || typeof option === 'object';
354
+ }
355
+
356
+ function resolveAuthRedirect(
357
+ option: { redirectTo?: string; message?: string } | boolean | undefined,
358
+ fallback: string | undefined,
359
+ ): string | undefined {
360
+ if (typeof option === 'object' && option.redirectTo) {
361
+ return option.redirectTo;
362
+ }
363
+ return fallback;
364
+ }
@@ -0,0 +1,165 @@
1
+ export type RequestHookPhase = 'beforeAuth' | 'beforeHandler' | 'afterHandler';
2
+
3
+ export interface RequestHookDefinitionLike {
4
+ id?: string;
5
+ phase?: RequestHookPhase;
6
+ order?: number;
7
+ }
8
+
9
+ export interface RequestHookReferenceLike {
10
+ id?: string;
11
+ }
12
+
13
+ export type RequestHookHandler<TContext, TResult, TRoute> = (
14
+ context: TContext,
15
+ input: { phase: RequestHookPhase; route: TRoute; result?: TResult },
16
+ ) => Promise<TResult | undefined> | TResult | undefined;
17
+
18
+ export interface RegisteredRequestHook<TContext, TResult, TRoute> {
19
+ id?: string;
20
+ handler?: RequestHookHandler<TContext, TResult, TRoute>;
21
+ }
22
+
23
+ export interface CompiledRequestHook<TContext, TResult, TRoute> {
24
+ id: string;
25
+ phase: RequestHookPhase;
26
+ order: number;
27
+ handler: RequestHookHandler<TContext, TResult, TRoute>;
28
+ }
29
+
30
+ export function resolveRequestHooks<TContext, TResult, TRoute>(options: {
31
+ routeName: string;
32
+ routeReferences?: readonly RequestHookReferenceLike[];
33
+ manifestDefinitions?: readonly RequestHookDefinitionLike[];
34
+ registrations?: readonly RegisteredRequestHook<TContext, TResult, TRoute>[];
35
+ }): { hooks: CompiledRequestHook<TContext, TResult, TRoute>[]; warnings: string[] } {
36
+ const definitions = new Map<string, RequestHookDefinitionLike>();
37
+ for (const definition of options.manifestDefinitions ?? []) {
38
+ if (!definition?.id) {
39
+ continue;
40
+ }
41
+ definitions.set(definition.id, definition);
42
+ }
43
+
44
+ const registrations = new Map<string, RequestHookHandler<TContext, TResult, TRoute>>();
45
+ for (const registration of options.registrations ?? []) {
46
+ if (!registration?.id || typeof registration.handler !== 'function') {
47
+ continue;
48
+ }
49
+ registrations.set(registration.id, registration.handler);
50
+ }
51
+
52
+ const hooks: CompiledRequestHook<TContext, TResult, TRoute>[] = [];
53
+ const warnings: string[] = [];
54
+ const seen = new Set<string>();
55
+
56
+ for (const reference of options.routeReferences ?? []) {
57
+ const hookId = reference?.id?.trim();
58
+ if (!hookId || seen.has(hookId)) {
59
+ continue;
60
+ }
61
+ seen.add(hookId);
62
+
63
+ const definition = definitions.get(hookId);
64
+ if (!definition?.phase) {
65
+ warnings.push(
66
+ `Route "${options.routeName}" references request hook "${hookId}" without manifest metadata.`,
67
+ );
68
+ continue;
69
+ }
70
+
71
+ const handler = registrations.get(hookId);
72
+ if (!handler) {
73
+ warnings.push(
74
+ `Route "${options.routeName}" references request hook "${hookId}" without an implementation.`,
75
+ );
76
+ continue;
77
+ }
78
+
79
+ hooks.push({
80
+ id: hookId,
81
+ phase: definition.phase,
82
+ order: Number.isInteger(definition.order) ? Number(definition.order) : 0,
83
+ handler,
84
+ });
85
+ }
86
+
87
+ hooks.sort((left, right) => {
88
+ const phaseDelta = compareRequestHookPhase(left.phase, right.phase);
89
+ if (phaseDelta !== 0) {
90
+ return phaseDelta;
91
+ }
92
+ if (left.order !== right.order) {
93
+ return left.order - right.order;
94
+ }
95
+ return left.id.localeCompare(right.id);
96
+ });
97
+
98
+ return { hooks, warnings };
99
+ }
100
+
101
+ export async function executeRequestHookPhase<TContext, TResult, TRoute>(options: {
102
+ hooks: readonly CompiledRequestHook<TContext, TResult, TRoute>[];
103
+ phase: RequestHookPhase;
104
+ context: TContext;
105
+ route: TRoute;
106
+ logger?: {
107
+ info?(message: string, metadata?: Record<string, unknown>): void;
108
+ error?(message: string, metadata?: Record<string, unknown>): void;
109
+ };
110
+ result?: TResult;
111
+ }): Promise<{ result?: TResult; shortCircuited: boolean }> {
112
+ let currentResult = options.result;
113
+
114
+ for (const hook of options.hooks) {
115
+ if (hook.phase !== options.phase) {
116
+ continue;
117
+ }
118
+
119
+ try {
120
+ const hookResult = await hook.handler(options.context, {
121
+ phase: options.phase,
122
+ route: options.route,
123
+ result: currentResult,
124
+ });
125
+
126
+ if (options.phase === 'afterHandler') {
127
+ if (hookResult !== undefined) {
128
+ currentResult = hookResult;
129
+ }
130
+ continue;
131
+ }
132
+
133
+ if (hookResult !== undefined) {
134
+ options.logger?.info?.('request hook produced early response', {
135
+ hookId: hook.id,
136
+ phase: hook.phase,
137
+ });
138
+ return { result: hookResult, shortCircuited: true };
139
+ }
140
+ } catch (error) {
141
+ options.logger?.error?.('request hook failed', {
142
+ err: error,
143
+ hookId: hook.id,
144
+ phase: hook.phase,
145
+ });
146
+ throw error;
147
+ }
148
+ }
149
+
150
+ return { result: currentResult, shortCircuited: false };
151
+ }
152
+
153
+ function compareRequestHookPhase(left: RequestHookPhase, right: RequestHookPhase): number {
154
+ return requestHookPhaseWeight(left) - requestHookPhaseWeight(right);
155
+ }
156
+
157
+ function requestHookPhaseWeight(phase: RequestHookPhase): number {
158
+ if (phase === 'beforeAuth') {
159
+ return 0;
160
+ }
161
+ if (phase === 'beforeHandler') {
162
+ return 1;
163
+ }
164
+ return 2;
165
+ }
@@ -0,0 +1,135 @@
1
+ export interface SessionMetadata {
2
+ id: string;
3
+ createdAt: string;
4
+ expiresAt: string;
5
+ }
6
+
7
+ export interface SessionMetadataInput {
8
+ id?: string;
9
+ createdAt?: string;
10
+ expiresAt?: string;
11
+ }
12
+
13
+ const SESSION_METADATA_KEY = Symbol.for('webstir.webstir-backend.session-metadata');
14
+ const SESSION_METADATA_FIELDS = ['id', 'createdAt', 'expiresAt'] as const;
15
+
16
+ export function attachSessionMetadata<TSession extends Record<string, unknown>>(
17
+ session: TSession,
18
+ metadata: SessionMetadata | undefined,
19
+ ): TSession {
20
+ const normalized = cloneSessionMetadata(metadata);
21
+ if (!normalized) {
22
+ return session;
23
+ }
24
+
25
+ Object.defineProperty(session, SESSION_METADATA_KEY, {
26
+ configurable: true,
27
+ enumerable: false,
28
+ value: normalized,
29
+ writable: true,
30
+ });
31
+
32
+ for (const field of SESSION_METADATA_FIELDS) {
33
+ Object.defineProperty(session, field, {
34
+ configurable: true,
35
+ enumerable: false,
36
+ value: normalized[field],
37
+ writable: true,
38
+ });
39
+ }
40
+
41
+ return session;
42
+ }
43
+
44
+ export function readSessionMetadata(
45
+ session: Record<string, unknown> | null | undefined,
46
+ ): SessionMetadataInput | undefined {
47
+ if (!session || !isRecord(session)) {
48
+ return undefined;
49
+ }
50
+
51
+ const attached = readAttachedSessionMetadata(session);
52
+ const metadata = cloneSessionMetadataInput({
53
+ id: normalizeText(session.id) ?? attached?.id,
54
+ createdAt: normalizeDate(session.createdAt) ?? attached?.createdAt,
55
+ expiresAt: normalizeDate(session.expiresAt) ?? attached?.expiresAt,
56
+ });
57
+ return metadata && Object.keys(metadata).length > 0 ? metadata : undefined;
58
+ }
59
+
60
+ export function stripSessionMetadataFields(session: Record<string, unknown>): void {
61
+ for (const field of SESSION_METADATA_FIELDS) {
62
+ Reflect.deleteProperty(session, field);
63
+ }
64
+ }
65
+
66
+ function readAttachedSessionMetadata(
67
+ session: Record<string, unknown>,
68
+ ): SessionMetadata | undefined {
69
+ const attached = (session as Record<PropertyKey, unknown>)[SESSION_METADATA_KEY];
70
+ return cloneSessionMetadata(attached);
71
+ }
72
+
73
+ function cloneSessionMetadata(value: unknown): SessionMetadata | undefined {
74
+ if (!isRecord(value)) {
75
+ return undefined;
76
+ }
77
+
78
+ const metadata = cloneSessionMetadataInput(value);
79
+ const id = metadata?.id;
80
+ const createdAt = metadata?.createdAt;
81
+ const expiresAt = metadata?.expiresAt;
82
+ if (!id || !createdAt || !expiresAt) {
83
+ return undefined;
84
+ }
85
+
86
+ return {
87
+ id,
88
+ createdAt,
89
+ expiresAt,
90
+ };
91
+ }
92
+
93
+ function cloneSessionMetadataInput(value: unknown): SessionMetadataInput | undefined {
94
+ if (!isRecord(value)) {
95
+ return undefined;
96
+ }
97
+
98
+ const metadata: SessionMetadataInput = {};
99
+ const id = normalizeText(value.id);
100
+ const createdAt = normalizeDate(value.createdAt);
101
+ const expiresAt = normalizeDate(value.expiresAt);
102
+
103
+ if (id) {
104
+ metadata.id = id;
105
+ }
106
+ if (createdAt) {
107
+ metadata.createdAt = createdAt;
108
+ }
109
+ if (expiresAt) {
110
+ metadata.expiresAt = expiresAt;
111
+ }
112
+
113
+ return metadata;
114
+ }
115
+
116
+ function normalizeText(value: unknown): string | undefined {
117
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
118
+ }
119
+
120
+ function normalizeDate(value: unknown): string | undefined {
121
+ if (value instanceof Date && !Number.isNaN(value.getTime())) {
122
+ return value.toISOString();
123
+ }
124
+ if (typeof value === 'string') {
125
+ const timestamp = Date.parse(value);
126
+ if (!Number.isNaN(timestamp)) {
127
+ return new Date(timestamp).toISOString();
128
+ }
129
+ }
130
+ return undefined;
131
+ }
132
+
133
+ function isRecord(value: unknown): value is Record<string, unknown> {
134
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
135
+ }