@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
@@ -1,10 +1,16 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
+ import crypto from 'node:crypto';
4
+ import http from 'node:http';
3
5
  import fs from 'node:fs/promises';
4
6
  import fssync from 'node:fs';
7
+ import net from 'node:net';
5
8
  import os from 'node:os';
6
9
  import path from 'node:path';
7
- import { fileURLToPath } from 'node:url';
10
+ import { spawn } from 'node:child_process';
11
+ import { fileURLToPath, pathToFileURL } from 'node:url';
12
+ import { setTimeout as delay } from 'node:timers/promises';
13
+ import { build as esbuild } from 'esbuild';
8
14
 
9
15
  import { backendProvider } from '../dist/index.js';
10
16
 
@@ -36,6 +42,12 @@ async function hydrateBackendScaffold(workspace) {
36
42
  }
37
43
  }
38
44
 
45
+ async function writePackageRuntimeEntry(workspace, filename, specifier, exportsBlock = '*') {
46
+ const target = path.join(workspace, filename);
47
+ await ensureDir(path.dirname(target));
48
+ await fs.writeFile(target, `export ${exportsBlock} from '${specifier}';\n`, 'utf8');
49
+ return target;
50
+ }
39
51
 
40
52
  function getLocalBinPath() {
41
53
  const here = path.dirname(fileURLToPath(import.meta.url));
@@ -43,6 +55,2278 @@ function getLocalBinPath() {
43
55
  return path.join(pkgRoot, 'node_modules', '.bin');
44
56
  }
45
57
 
58
+ function getPackageRoot() {
59
+ const here = path.dirname(fileURLToPath(import.meta.url));
60
+ return path.resolve(here, '..');
61
+ }
62
+
63
+ async function linkWorkspaceNodeModules(workspace) {
64
+ const packageRoot = getPackageRoot();
65
+ const source = path.join(packageRoot, 'node_modules');
66
+ const target = path.join(workspace, 'node_modules');
67
+ await fs.mkdir(target, { recursive: true });
68
+
69
+ const entries = await fs.readdir(source, { withFileTypes: true });
70
+ for (const entry of entries) {
71
+ if (entry.name === '@webstir-io') {
72
+ continue;
73
+ }
74
+ await createSymlinkIfMissing(
75
+ path.join(source, entry.name),
76
+ path.join(target, entry.name),
77
+ entry.isDirectory() ? 'dir' : 'file',
78
+ );
79
+ }
80
+
81
+ const scopeSource = path.join(source, '@webstir-io');
82
+ const scopeTarget = path.join(target, '@webstir-io');
83
+ await fs.mkdir(scopeTarget, { recursive: true });
84
+ const scopeEntries = await fs.readdir(scopeSource, { withFileTypes: true });
85
+ for (const entry of scopeEntries) {
86
+ await createSymlinkIfMissing(
87
+ path.join(scopeSource, entry.name),
88
+ path.join(scopeTarget, entry.name),
89
+ entry.isDirectory() ? 'dir' : 'file',
90
+ );
91
+ }
92
+
93
+ await createSymlinkIfMissing(packageRoot, path.join(scopeTarget, 'webstir-backend'), 'dir');
94
+ }
95
+
96
+ async function createSymlinkIfMissing(source, target, type) {
97
+ try {
98
+ await fs.symlink(source, target, type);
99
+ } catch (error) {
100
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'EEXIST') {
101
+ return;
102
+ }
103
+ throw error;
104
+ }
105
+ }
106
+
107
+ async function getOpenPort() {
108
+ return await new Promise((resolve, reject) => {
109
+ const server = net.createServer();
110
+ server.once('error', reject);
111
+ server.listen(0, '127.0.0.1', () => {
112
+ const address = server.address();
113
+ if (!address || typeof address === 'string') {
114
+ reject(new Error('Failed to allocate an open port.'));
115
+ return;
116
+ }
117
+ server.close((error) => {
118
+ if (error) {
119
+ reject(error);
120
+ return;
121
+ }
122
+ resolve(address.port);
123
+ });
124
+ });
125
+ });
126
+ }
127
+
128
+ async function canListenOnTcp() {
129
+ return await new Promise((resolve) => {
130
+ const server = net.createServer();
131
+ const settle = (value) => {
132
+ server.removeAllListeners();
133
+ server.close(() => resolve(value));
134
+ };
135
+ server.once('error', (error) => {
136
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'EPERM') {
137
+ resolve(false);
138
+ return;
139
+ }
140
+ resolve(false);
141
+ });
142
+ server.listen(0, '127.0.0.1', () => settle(true));
143
+ });
144
+ }
145
+
146
+ async function startBuiltServer(workspace, port, extraEnv = {}, options = {}) {
147
+ const entryPath = path.join(workspace, 'build', 'backend', 'index.js');
148
+ const entryUrl = pathToFileURL(entryPath).href;
149
+ const runtime = options.runtime ?? 'bun';
150
+ let candidatePort = port;
151
+ let lastError = null;
152
+
153
+ for (let attempt = 0; attempt < 10; attempt += 1) {
154
+ const child =
155
+ runtime === 'bun'
156
+ ? spawn('bun', [entryPath], {
157
+ cwd: options.cwd ?? workspace,
158
+ env: {
159
+ ...process.env,
160
+ PORT: String(candidatePort),
161
+ NODE_ENV: 'test',
162
+ ...extraEnv,
163
+ },
164
+ stdio: ['ignore', 'pipe', 'pipe'],
165
+ })
166
+ : runtime === 'bun-import'
167
+ ? spawn(
168
+ 'bun',
169
+ ['--eval', `import(${JSON.stringify(entryUrl)}).then((mod) => mod.start())`],
170
+ {
171
+ cwd: options.cwd ?? workspace,
172
+ env: {
173
+ ...process.env,
174
+ PORT: String(candidatePort),
175
+ NODE_ENV: 'test',
176
+ ...extraEnv,
177
+ },
178
+ stdio: ['ignore', 'pipe', 'pipe'],
179
+ },
180
+ )
181
+ : spawn(
182
+ 'node',
183
+ [
184
+ '--input-type=module',
185
+ '--eval',
186
+ `import(${JSON.stringify(entryUrl)}).then((mod) => mod.start())`,
187
+ ],
188
+ {
189
+ cwd: options.cwd ?? workspace,
190
+ env: {
191
+ ...process.env,
192
+ PORT: String(candidatePort),
193
+ NODE_ENV: 'test',
194
+ ...extraEnv,
195
+ },
196
+ stdio: ['ignore', 'pipe', 'pipe'],
197
+ },
198
+ );
199
+
200
+ let stdout = '';
201
+ let stderr = '';
202
+ child.stdout.setEncoding('utf8');
203
+ child.stderr.setEncoding('utf8');
204
+ child.stdout.on('data', (chunk) => {
205
+ stdout += chunk;
206
+ });
207
+ child.stderr.on('data', (chunk) => {
208
+ stderr += chunk;
209
+ });
210
+
211
+ try {
212
+ await waitForProcessReady(child, 'API server running', 10000);
213
+ return {
214
+ child,
215
+ port: candidatePort,
216
+ getStdout: () => stdout,
217
+ getStderr: () => stderr,
218
+ async stop() {
219
+ child.kill('SIGTERM');
220
+ await onceExit(child);
221
+ },
222
+ };
223
+ } catch (error) {
224
+ child.kill('SIGTERM');
225
+ await onceExit(child);
226
+ const message = error instanceof Error ? error.message : String(error);
227
+ lastError = new Error(
228
+ `Backend server did not become ready on port ${candidatePort}.\nstdout:\n${stdout}\nstderr:\n${stderr}\nerror:\n${message}`,
229
+ );
230
+
231
+ if (
232
+ attempt < 9 &&
233
+ [stdout, stderr, message].some(
234
+ (value) =>
235
+ value.includes('EADDRINUSE') ||
236
+ value.includes('address already in use') ||
237
+ value.includes('Failed to listen at 127.0.0.1'),
238
+ )
239
+ ) {
240
+ candidatePort += 1;
241
+ continue;
242
+ }
243
+
244
+ throw lastError;
245
+ }
246
+ }
247
+
248
+ throw lastError ?? new Error('Backend server did not become ready.');
249
+ }
250
+
251
+ async function buildRuntimeWorkspace(workspace, { moduleSource, mode = 'publish' } = {}) {
252
+ await hydrateBackendScaffold(workspace);
253
+ await linkWorkspaceNodeModules(workspace);
254
+ await fs.writeFile(
255
+ path.join(workspace, 'package.json'),
256
+ JSON.stringify({ type: 'module' }, null, 2),
257
+ 'utf8',
258
+ );
259
+ await fs.writeFile(path.join(workspace, 'src', 'backend', 'module.ts'), moduleSource, 'utf8');
260
+
261
+ await backendProvider.build({
262
+ workspaceRoot: workspace,
263
+ env: {
264
+ WEBSTIR_MODULE_MODE: mode,
265
+ WEBSTIR_BACKEND_TYPECHECK: 'skip',
266
+ PATH: `${getLocalBinPath()}${path.delimiter}${process.env.PATH ?? ''}`,
267
+ },
268
+ incremental: false,
269
+ });
270
+ }
271
+
272
+ async function writeFrontendDocument(workspace, pageName, html) {
273
+ const targetPath = path.join(workspace, 'build', 'frontend', 'pages', pageName, 'index.html');
274
+ await fs.mkdir(path.dirname(targetPath), { recursive: true });
275
+ await fs.writeFile(targetPath, html, 'utf8');
276
+ }
277
+
278
+ async function writePublishedFrontendAliasDocument(workspace, pageName, html) {
279
+ const targetPath =
280
+ pageName === 'home'
281
+ ? path.join(workspace, 'dist', 'frontend', 'index.html')
282
+ : path.join(workspace, 'dist', 'frontend', pageName, 'index.html');
283
+ await fs.mkdir(path.dirname(targetPath), { recursive: true });
284
+ await fs.writeFile(targetPath, html, 'utf8');
285
+ }
286
+
287
+ function createRequestHookRuntimeModuleSource() {
288
+ return `const routes = [
289
+ {
290
+ definition: {
291
+ name: 'hookRoute',
292
+ method: 'GET',
293
+ path: '/hooks/demo',
294
+ requestHooks: [
295
+ { id: 'after-response' },
296
+ { id: 'short-circuit' },
297
+ { id: 'annotate-auth' },
298
+ { id: 'setup-request' }
299
+ ]
300
+ },
301
+ handler: async (ctx) => {
302
+ ctx.db.trace.push('handler');
303
+ return {
304
+ status: 200,
305
+ body: {
306
+ trace: [...ctx.db.trace],
307
+ authSource: ctx.auth?.source ?? null,
308
+ sessionId: ctx.session?.id ?? null
309
+ }
310
+ };
311
+ }
312
+ }
313
+ ];
314
+
315
+ const requestHooks = [
316
+ {
317
+ id: 'setup-request',
318
+ handler: async (ctx) => {
319
+ ctx.db.trace = ['beforeAuth'];
320
+ ctx.session = { id: 'session-from-hook' };
321
+ }
322
+ },
323
+ {
324
+ id: 'annotate-auth',
325
+ handler: async (ctx) => {
326
+ ctx.db.trace.push(\`beforeHandler:\${String(ctx.auth?.source ?? 'missing')}\`);
327
+ }
328
+ },
329
+ {
330
+ id: 'short-circuit',
331
+ handler: async (ctx) => {
332
+ ctx.db.trace.push('beforeHandler:short-check');
333
+ if (ctx.query.short === '1') {
334
+ return {
335
+ status: 202,
336
+ body: {
337
+ trace: [...ctx.db.trace],
338
+ authSource: ctx.auth?.source ?? null,
339
+ sessionId: ctx.session?.id ?? null,
340
+ shortCircuited: true
341
+ }
342
+ };
343
+ }
344
+ if (ctx.query.fail === '1') {
345
+ throw new Error('request hook failed');
346
+ }
347
+ }
348
+ },
349
+ {
350
+ id: 'after-response',
351
+ handler: async (ctx, input) => ({
352
+ ...input.result,
353
+ headers: {
354
+ ...(input.result?.headers ?? {}),
355
+ 'x-hook-after': '1'
356
+ },
357
+ body: {
358
+ ...(input.result?.body ?? {}),
359
+ trace: [...(input.result?.body?.trace ?? []), 'afterHandler'],
360
+ authSource: input.result?.body?.authSource ?? ctx.auth?.source ?? null,
361
+ sessionId: input.result?.body?.sessionId ?? ctx.session?.id ?? null
362
+ }
363
+ })
364
+ }
365
+ ];
366
+
367
+ export const module = {
368
+ manifest: {
369
+ contractVersion: '1.0.0',
370
+ name: '@demo/runtime-hooks',
371
+ version: '0.1.0',
372
+ kind: 'backend',
373
+ capabilities: ['http', 'auth'],
374
+ requestHooks: [
375
+ { id: 'setup-request', phase: 'beforeAuth', order: 10 },
376
+ { id: 'short-circuit', phase: 'beforeHandler', order: 20 },
377
+ { id: 'annotate-auth', phase: 'beforeHandler', order: 10 },
378
+ { id: 'after-response', phase: 'afterHandler', order: 10 }
379
+ ],
380
+ routes: routes.map((route) => route.definition)
381
+ },
382
+ routes,
383
+ requestHooks
384
+ };
385
+ `;
386
+ }
387
+
388
+ function createAuthRuntimeModuleSource() {
389
+ return `const routes = [
390
+ {
391
+ definition: {
392
+ name: 'authWhoAmI',
393
+ method: 'GET',
394
+ path: '/auth/whoami'
395
+ },
396
+ handler: async (ctx) => {
397
+ if (!ctx.auth) {
398
+ return {
399
+ status: 401,
400
+ body: { error: 'unauthorized' }
401
+ };
402
+ }
403
+
404
+ return {
405
+ status: 200,
406
+ body: {
407
+ source: ctx.auth.source,
408
+ userId: ctx.auth.userId ?? null,
409
+ email: ctx.auth.email ?? null,
410
+ scopes: ctx.auth.scopes,
411
+ roles: ctx.auth.roles
412
+ }
413
+ };
414
+ }
415
+ }
416
+ ];
417
+
418
+ export const module = {
419
+ manifest: {
420
+ contractVersion: '1.0.0',
421
+ name: '@demo/runtime-auth',
422
+ version: '0.1.0',
423
+ kind: 'backend',
424
+ capabilities: ['http', 'auth'],
425
+ routes: routes.map((route) => route.definition)
426
+ },
427
+ routes
428
+ };
429
+ `;
430
+ }
431
+
432
+ function createBodyLimitRuntimeModuleSource() {
433
+ return `const routes = [
434
+ {
435
+ definition: {
436
+ name: 'echoPayload',
437
+ method: 'POST',
438
+ path: '/echo',
439
+ interaction: 'mutation'
440
+ },
441
+ handler: async (ctx) => ({
442
+ status: 200,
443
+ body: {
444
+ echoed: ctx.body
445
+ }
446
+ })
447
+ }
448
+ ];
449
+
450
+ export const module = {
451
+ manifest: {
452
+ contractVersion: '1.0.0',
453
+ name: '@demo/runtime-body-limit',
454
+ version: '0.1.0',
455
+ kind: 'backend',
456
+ capabilities: ['http'],
457
+ routes: routes.map((route) => route.definition)
458
+ },
459
+ routes
460
+ };
461
+ `;
462
+ }
463
+
464
+ function createSessionRuntimeModuleSource() {
465
+ return `const routes = [
466
+ {
467
+ definition: {
468
+ name: 'sessionLogin',
469
+ method: 'POST',
470
+ path: '/session/login',
471
+ interaction: 'mutation',
472
+ form: {
473
+ contentType: 'application/x-www-form-urlencoded',
474
+ session: { write: true },
475
+ flash: {
476
+ publish: [{ key: 'signed-in', level: 'success', when: 'success' }]
477
+ }
478
+ }
479
+ },
480
+ handler: async (ctx) => {
481
+ const email = String(ctx.body?.email ?? 'guest@example.com');
482
+ ctx.session = {
483
+ userId: email,
484
+ data: { email }
485
+ };
486
+ return {
487
+ status: 303,
488
+ redirect: { location: '/session/account' }
489
+ };
490
+ }
491
+ },
492
+ {
493
+ definition: {
494
+ name: 'sessionAccount',
495
+ method: 'GET',
496
+ path: '/session/account',
497
+ interaction: 'navigation',
498
+ session: { mode: 'optional' },
499
+ flash: { consume: ['signed-in'] }
500
+ },
501
+ handler: async (ctx) => ({
502
+ status: 200,
503
+ body: \`<main data-user="\${String(ctx.session?.userId ?? 'guest')}" data-flash="\${ctx.flash.map((message) => \`\${message.key}:\${message.level}\`).join(',')}">\${String(ctx.session?.data?.email ?? 'guest')}</main>\`
504
+ })
505
+ },
506
+ {
507
+ definition: {
508
+ name: 'sessionLogout',
509
+ method: 'POST',
510
+ path: '/session/logout',
511
+ interaction: 'mutation',
512
+ form: {
513
+ contentType: 'application/x-www-form-urlencoded',
514
+ session: { write: true }
515
+ }
516
+ },
517
+ handler: async (ctx) => {
518
+ ctx.session = null;
519
+ return {
520
+ status: 303,
521
+ redirect: { location: '/session/account' }
522
+ };
523
+ }
524
+ }
525
+ ];
526
+
527
+ export const module = {
528
+ manifest: {
529
+ contractVersion: '1.0.0',
530
+ name: '@demo/runtime-session',
531
+ version: '0.1.0',
532
+ kind: 'backend',
533
+ capabilities: ['http'],
534
+ routes: routes.map((route) => route.definition)
535
+ },
536
+ routes
537
+ };
538
+ `;
539
+ }
540
+
541
+ function createFormWorkflowModuleSource() {
542
+ return `import {
543
+ groupFormIssuesByField,
544
+ prepareFormState,
545
+ processFormSubmission
546
+ } from '@webstir-io/webstir-backend/runtime/forms';
547
+
548
+ const accountSettingsPageDefinition = {
549
+ name: 'accountSettingsPage',
550
+ method: 'GET',
551
+ path: '/account/settings',
552
+ interaction: 'navigation',
553
+ session: { mode: 'optional' },
554
+ flash: { consume: ['settings-saved'] }
555
+ };
556
+
557
+ const updateAccountSettingsDefinition = {
558
+ name: 'accountSettingsUpdate',
559
+ method: 'POST',
560
+ path: '/account/settings',
561
+ interaction: 'mutation',
562
+ form: {
563
+ contentType: 'application/x-www-form-urlencoded',
564
+ csrf: true,
565
+ session: { write: true },
566
+ flash: {
567
+ publish: [{ key: 'settings-saved', level: 'success', when: 'success' }]
568
+ }
569
+ }
570
+ };
571
+
572
+ const routes = [
573
+ {
574
+ definition: accountSettingsPageDefinition,
575
+ handler: async (ctx) => {
576
+ const form = prepareFormState({
577
+ session: ctx.session,
578
+ formId: 'account-settings',
579
+ route: updateAccountSettingsDefinition,
580
+ now: ctx.now
581
+ });
582
+ ctx.session = form.session;
583
+ const grouped = groupFormIssuesByField(form.issues);
584
+ const email =
585
+ (typeof form.values.email === 'string' ? form.values.email : undefined) ??
586
+ ctx.session?.profile?.email ??
587
+ 'guest@example.com';
588
+
589
+ return {
590
+ status: 200,
591
+ body: \`<main data-user="\${String(email)}" data-form-errors="\${grouped.form.join('|')}" data-field-errors="\${(grouped.fields.email ?? []).join('|')}" data-flash="\${ctx.flash.map((message) => \`\${message.key}:\${message.level}\`).join('|')}"><form method="post" action="/account/settings"><input type="hidden" name="_csrf" value="\${form.csrfToken ?? ''}" /><input name="email" value="\${String(email)}" /><button type="submit">Save</button></form></main>\`
592
+ };
593
+ }
594
+ },
595
+ {
596
+ definition: updateAccountSettingsDefinition,
597
+ handler: async (ctx) => {
598
+ const submission = processFormSubmission({
599
+ session: ctx.session,
600
+ body: ctx.body,
601
+ auth: ctx.auth,
602
+ formId: 'account-settings',
603
+ route: updateAccountSettingsDefinition,
604
+ redirectTo: accountSettingsPageDefinition.path,
605
+ requireAuth: {
606
+ redirectTo: accountSettingsPageDefinition.path,
607
+ message: 'Sign-in required to update account settings.'
608
+ },
609
+ validate(values) {
610
+ const email = typeof values.email === 'string' ? values.email.trim() : '';
611
+ const issues = [];
612
+ if (email.length === 0) {
613
+ issues.push({ field: 'email', message: 'Email is required.' });
614
+ } else if (!email.includes('@')) {
615
+ issues.push({ field: 'email', message: 'Enter a valid email address.' });
616
+ }
617
+ return issues;
618
+ },
619
+ now: ctx.now
620
+ });
621
+ ctx.session = submission.session;
622
+ if (!submission.ok) {
623
+ return submission.result;
624
+ }
625
+
626
+ ctx.session.profile = {
627
+ email: typeof submission.values.email === 'string' ? submission.values.email.trim() : 'guest@example.com'
628
+ };
629
+
630
+ return {
631
+ status: 303,
632
+ redirect: { location: accountSettingsPageDefinition.path }
633
+ };
634
+ }
635
+ }
636
+ ];
637
+
638
+ export const module = {
639
+ manifest: {
640
+ contractVersion: '1.0.0',
641
+ name: '@demo/runtime-forms',
642
+ version: '0.1.0',
643
+ kind: 'backend',
644
+ capabilities: ['http', 'auth'],
645
+ routes: routes.map((route) => route.definition)
646
+ },
647
+ routes
648
+ };
649
+ `;
650
+ }
651
+
652
+ async function assertRequestHookRuntimeBehavior() {
653
+ const workspace = await createTempWorkspace('webstir-backend-hooks-');
654
+ await buildRuntimeWorkspace(workspace, {
655
+ moduleSource: createRequestHookRuntimeModuleSource(),
656
+ });
657
+
658
+ let port = await getOpenPort();
659
+ const server = await startBuiltServer(workspace, port, {
660
+ AUTH_SERVICE_TOKENS: 'service-secret',
661
+ });
662
+ port = server.port;
663
+
664
+ try {
665
+ const normalResponse = await fetch(`http://127.0.0.1:${port}/hooks/demo`, {
666
+ headers: {
667
+ 'x-service-token': 'service-secret',
668
+ },
669
+ });
670
+ assert.equal(normalResponse.status, 200);
671
+ assert.equal(normalResponse.headers.get('x-hook-after'), '1');
672
+ assert.deepEqual(await normalResponse.json(), {
673
+ trace: [
674
+ 'beforeAuth',
675
+ 'beforeHandler:service-token',
676
+ 'beforeHandler:short-check',
677
+ 'handler',
678
+ 'afterHandler',
679
+ ],
680
+ authSource: 'service-token',
681
+ sessionId: 'session-from-hook',
682
+ });
683
+
684
+ const shortCircuitResponse = await fetch(`http://127.0.0.1:${port}/hooks/demo?short=1`, {
685
+ headers: {
686
+ 'x-service-token': 'service-secret',
687
+ },
688
+ });
689
+ assert.equal(shortCircuitResponse.status, 202);
690
+ assert.deepEqual(await shortCircuitResponse.json(), {
691
+ trace: ['beforeAuth', 'beforeHandler:service-token', 'beforeHandler:short-check'],
692
+ authSource: 'service-token',
693
+ sessionId: 'session-from-hook',
694
+ shortCircuited: true,
695
+ });
696
+
697
+ const failureResponse = await fetch(`http://127.0.0.1:${port}/hooks/demo?fail=1`, {
698
+ headers: {
699
+ 'x-service-token': 'service-secret',
700
+ },
701
+ });
702
+ assert.equal(failureResponse.status, 500);
703
+ assert.deepEqual(await failureResponse.json(), {
704
+ error: 'internal_error',
705
+ message: 'request hook failed',
706
+ });
707
+ } finally {
708
+ await server.stop();
709
+ }
710
+ }
711
+
712
+ async function assertBunRequestHookRuntimeBehavior() {
713
+ const workspace = await createTempWorkspace('webstir-backend-bun-hooks-');
714
+ await buildRuntimeWorkspace(workspace, {
715
+ moduleSource: createRequestHookRuntimeModuleSource(),
716
+ });
717
+
718
+ let port = await getOpenPort();
719
+ const server = await startBuiltServer(
720
+ workspace,
721
+ port,
722
+ {
723
+ AUTH_SERVICE_TOKENS: 'service-secret',
724
+ WEBSTIR_BACKEND_SERVER_RUNTIME: 'bun',
725
+ },
726
+ {
727
+ runtime: 'bun',
728
+ },
729
+ );
730
+ port = server.port;
731
+
732
+ try {
733
+ const normalResponse = await fetch(`http://127.0.0.1:${port}/hooks/demo`, {
734
+ headers: {
735
+ 'x-service-token': 'service-secret',
736
+ },
737
+ });
738
+ assert.equal(normalResponse.status, 200);
739
+ assert.equal(normalResponse.headers.get('x-hook-after'), '1');
740
+ assert.deepEqual(await normalResponse.json(), {
741
+ trace: [
742
+ 'beforeAuth',
743
+ 'beforeHandler:service-token',
744
+ 'beforeHandler:short-check',
745
+ 'handler',
746
+ 'afterHandler',
747
+ ],
748
+ authSource: 'service-token',
749
+ sessionId: 'session-from-hook',
750
+ });
751
+
752
+ const shortCircuitResponse = await fetch(`http://127.0.0.1:${port}/hooks/demo?short=1`, {
753
+ headers: {
754
+ 'x-service-token': 'service-secret',
755
+ },
756
+ });
757
+ assert.equal(shortCircuitResponse.status, 202);
758
+ assert.deepEqual(await shortCircuitResponse.json(), {
759
+ trace: ['beforeAuth', 'beforeHandler:service-token', 'beforeHandler:short-check'],
760
+ authSource: 'service-token',
761
+ sessionId: 'session-from-hook',
762
+ shortCircuited: true,
763
+ });
764
+
765
+ const failureResponse = await fetch(`http://127.0.0.1:${port}/hooks/demo?fail=1`, {
766
+ headers: {
767
+ 'x-service-token': 'service-secret',
768
+ },
769
+ });
770
+ assert.equal(failureResponse.status, 500);
771
+ assert.deepEqual(await failureResponse.json(), {
772
+ error: 'internal_error',
773
+ message: 'request hook failed',
774
+ });
775
+ } finally {
776
+ await server.stop();
777
+ }
778
+ }
779
+
780
+ function signJwtToken(payload, key, options = {}) {
781
+ const alg = options.alg ?? 'HS256';
782
+ const encodedHeader = encodeJwtSegment({
783
+ alg,
784
+ typ: 'JWT',
785
+ ...(options.kid ? { kid: options.kid } : {}),
786
+ });
787
+ const encodedPayload = encodeJwtSegment(payload);
788
+ const signedContent = `${encodedHeader}.${encodedPayload}`;
789
+ const signature =
790
+ alg === 'HS256'
791
+ ? crypto.createHmac('sha256', key).update(signedContent).digest('base64url')
792
+ : alg === 'RS256'
793
+ ? crypto.sign('RSA-SHA256', Buffer.from(signedContent), key).toString('base64url')
794
+ : (() => {
795
+ throw new Error(`Unsupported test JWT alg '${alg}'.`);
796
+ })();
797
+ return `${signedContent}.${signature}`;
798
+ }
799
+
800
+ function encodeJwtSegment(value) {
801
+ return Buffer.from(JSON.stringify(value)).toString('base64url');
802
+ }
803
+
804
+ async function startJwksServer(payload) {
805
+ const server = http.createServer((req, res) => {
806
+ if ((req.url ?? '/') !== '/.well-known/jwks.json') {
807
+ res.statusCode = 404;
808
+ res.end('not found');
809
+ return;
810
+ }
811
+
812
+ res.statusCode = 200;
813
+ res.setHeader('content-type', 'application/json');
814
+ res.setHeader('cache-control', 'public, max-age=60');
815
+ res.end(JSON.stringify(payload));
816
+ });
817
+
818
+ await new Promise((resolve, reject) => {
819
+ server.once('error', reject);
820
+ server.listen(0, '127.0.0.1', () => resolve());
821
+ });
822
+
823
+ const address = server.address();
824
+ if (!address || typeof address === 'string') {
825
+ throw new Error('Failed to read JWKS test server address.');
826
+ }
827
+
828
+ return {
829
+ url: `http://127.0.0.1:${address.port}/.well-known/jwks.json`,
830
+ async stop() {
831
+ await new Promise((resolve, reject) => {
832
+ server.close((error) => {
833
+ if (error) {
834
+ reject(error);
835
+ return;
836
+ }
837
+ resolve();
838
+ });
839
+ });
840
+ },
841
+ };
842
+ }
843
+
844
+ async function assertJwtTimeClaimBehavior() {
845
+ const workspace = await createTempWorkspace('webstir-backend-auth-');
846
+ await buildRuntimeWorkspace(workspace, {
847
+ moduleSource: createAuthRuntimeModuleSource(),
848
+ });
849
+
850
+ let port = await getOpenPort();
851
+ const secret = 'jwt-test-secret';
852
+ const issuer = 'https://issuer.example.com/';
853
+ const audience = 'webstir-tests';
854
+ const server = await startBuiltServer(workspace, port, {
855
+ AUTH_JWT_SECRET: secret,
856
+ AUTH_JWT_ISSUER: issuer,
857
+ AUTH_JWT_AUDIENCE: audience,
858
+ });
859
+ port = server.port;
860
+
861
+ try {
862
+ const now = Math.floor(Date.now() / 1000);
863
+ const validToken = signJwtToken(
864
+ {
865
+ sub: 'user-123',
866
+ email: 'ada@example.com',
867
+ scope: 'profile:read',
868
+ roles: ['admin'],
869
+ iss: issuer,
870
+ aud: audience,
871
+ nbf: now - 60,
872
+ exp: now + 60,
873
+ },
874
+ secret,
875
+ );
876
+ const expiredToken = signJwtToken(
877
+ {
878
+ sub: 'user-123',
879
+ iss: issuer,
880
+ aud: audience,
881
+ exp: now - 30,
882
+ },
883
+ secret,
884
+ );
885
+ const notYetValidToken = signJwtToken(
886
+ {
887
+ sub: 'user-123',
888
+ iss: issuer,
889
+ aud: audience,
890
+ nbf: now + 60,
891
+ exp: now + 120,
892
+ },
893
+ secret,
894
+ );
895
+
896
+ const successResponse = await fetch(`http://127.0.0.1:${port}/auth/whoami`, {
897
+ headers: {
898
+ authorization: `Bearer ${validToken}`,
899
+ },
900
+ });
901
+ assert.equal(successResponse.status, 200);
902
+ assert.deepEqual(await successResponse.json(), {
903
+ source: 'jwt',
904
+ userId: 'user-123',
905
+ email: 'ada@example.com',
906
+ scopes: ['profile:read'],
907
+ roles: ['admin'],
908
+ });
909
+
910
+ for (const token of [expiredToken, notYetValidToken]) {
911
+ const invalidResponse = await fetch(`http://127.0.0.1:${port}/auth/whoami`, {
912
+ headers: {
913
+ authorization: `Bearer ${token}`,
914
+ },
915
+ });
916
+ assert.equal(invalidResponse.status, 401);
917
+ assert.deepEqual(await invalidResponse.json(), {
918
+ error: 'unauthorized',
919
+ });
920
+ }
921
+ } finally {
922
+ await server.stop();
923
+ }
924
+ }
925
+
926
+ async function assertJwtAsymmetricBehavior() {
927
+ const workspace = await createTempWorkspace('webstir-backend-auth-rsa-');
928
+ await buildRuntimeWorkspace(workspace, {
929
+ moduleSource: createAuthRuntimeModuleSource(),
930
+ });
931
+
932
+ const publicKeyPair = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
933
+ const jwksKeyPair = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
934
+ const issuer = 'https://issuer.example.com/';
935
+ const audience = 'webstir-tests';
936
+ const now = Math.floor(Date.now() / 1000);
937
+
938
+ const jwks = await startJwksServer({
939
+ keys: [
940
+ {
941
+ ...jwksKeyPair.publicKey.export({ format: 'jwk' }),
942
+ kid: 'jwks-key',
943
+ alg: 'RS256',
944
+ use: 'sig',
945
+ },
946
+ ],
947
+ });
948
+
949
+ let port = await getOpenPort();
950
+ const server = await startBuiltServer(workspace, port, {
951
+ AUTH_JWT_PUBLIC_KEY: publicKeyPair.publicKey.export({ type: 'spki', format: 'pem' }).toString(),
952
+ AUTH_JWKS_URL: jwks.url,
953
+ AUTH_JWT_ISSUER: issuer,
954
+ AUTH_JWT_AUDIENCE: audience,
955
+ });
956
+ port = server.port;
957
+
958
+ try {
959
+ const publicKeyToken = signJwtToken(
960
+ {
961
+ sub: 'user-public-key',
962
+ email: 'public@example.com',
963
+ scope: 'profile:read',
964
+ roles: ['editor'],
965
+ iss: issuer,
966
+ aud: audience,
967
+ nbf: now - 60,
968
+ exp: now + 60,
969
+ },
970
+ publicKeyPair.privateKey,
971
+ { alg: 'RS256' },
972
+ );
973
+
974
+ const publicKeyResponse = await fetch(`http://127.0.0.1:${port}/auth/whoami`, {
975
+ headers: {
976
+ authorization: `Bearer ${publicKeyToken}`,
977
+ },
978
+ });
979
+ assert.equal(publicKeyResponse.status, 200);
980
+ assert.deepEqual(await publicKeyResponse.json(), {
981
+ source: 'jwt',
982
+ userId: 'user-public-key',
983
+ email: 'public@example.com',
984
+ scopes: ['profile:read'],
985
+ roles: ['editor'],
986
+ });
987
+
988
+ const jwksToken = signJwtToken(
989
+ {
990
+ sub: 'user-jwks',
991
+ email: 'jwks@example.com',
992
+ scope: 'profile:read',
993
+ roles: ['viewer'],
994
+ iss: issuer,
995
+ aud: audience,
996
+ nbf: now - 60,
997
+ exp: now + 60,
998
+ },
999
+ jwksKeyPair.privateKey,
1000
+ { alg: 'RS256', kid: 'jwks-key' },
1001
+ );
1002
+
1003
+ const jwksResponse = await fetch(`http://127.0.0.1:${port}/auth/whoami`, {
1004
+ headers: {
1005
+ authorization: `Bearer ${jwksToken}`,
1006
+ },
1007
+ });
1008
+ assert.equal(jwksResponse.status, 200);
1009
+ assert.deepEqual(await jwksResponse.json(), {
1010
+ source: 'jwt',
1011
+ userId: 'user-jwks',
1012
+ email: 'jwks@example.com',
1013
+ scopes: ['profile:read'],
1014
+ roles: ['viewer'],
1015
+ });
1016
+
1017
+ const invalidKidToken = signJwtToken(
1018
+ {
1019
+ sub: 'user-invalid',
1020
+ iss: issuer,
1021
+ aud: audience,
1022
+ nbf: now - 60,
1023
+ exp: now + 60,
1024
+ },
1025
+ jwksKeyPair.privateKey,
1026
+ { alg: 'RS256', kid: 'missing-key' },
1027
+ );
1028
+
1029
+ const invalidResponse = await fetch(`http://127.0.0.1:${port}/auth/whoami`, {
1030
+ headers: {
1031
+ authorization: `Bearer ${invalidKidToken}`,
1032
+ },
1033
+ });
1034
+ assert.equal(invalidResponse.status, 401);
1035
+ assert.deepEqual(await invalidResponse.json(), {
1036
+ error: 'unauthorized',
1037
+ });
1038
+ } finally {
1039
+ await server.stop();
1040
+ await jwks.stop();
1041
+ }
1042
+ }
1043
+
1044
+ async function assertRequestBodyLimitBehavior() {
1045
+ const workspace = await createTempWorkspace('webstir-backend-body-limit-');
1046
+ await buildRuntimeWorkspace(workspace, {
1047
+ moduleSource: createBodyLimitRuntimeModuleSource(),
1048
+ });
1049
+
1050
+ let port = await getOpenPort();
1051
+ const server = await startBuiltServer(workspace, port, {
1052
+ REQUEST_BODY_MAX_BYTES: '16',
1053
+ });
1054
+ port = server.port;
1055
+
1056
+ try {
1057
+ const acceptedResponse = await fetch(`http://127.0.0.1:${port}/echo`, {
1058
+ method: 'POST',
1059
+ headers: {
1060
+ 'content-type': 'text/plain',
1061
+ },
1062
+ body: 'small',
1063
+ });
1064
+ assert.equal(acceptedResponse.status, 200);
1065
+ assert.deepEqual(await acceptedResponse.json(), {
1066
+ echoed: 'small',
1067
+ });
1068
+
1069
+ const oversizedResponse = await fetch(`http://127.0.0.1:${port}/echo`, {
1070
+ method: 'POST',
1071
+ headers: {
1072
+ 'content-type': 'text/plain',
1073
+ },
1074
+ body: 'payload-that-is-too-large',
1075
+ });
1076
+ assert.equal(oversizedResponse.status, 413);
1077
+ assert.deepEqual(await oversizedResponse.json(), {
1078
+ error: 'payload_too_large',
1079
+ message: 'Request body exceeded 16 bytes.',
1080
+ });
1081
+ } finally {
1082
+ await server.stop();
1083
+ }
1084
+ }
1085
+
1086
+ async function assertSessionRuntimeBehavior() {
1087
+ const workspace = await createTempWorkspace('webstir-backend-session-');
1088
+ await buildRuntimeWorkspace(workspace, {
1089
+ moduleSource: createSessionRuntimeModuleSource(),
1090
+ });
1091
+
1092
+ let port = await getOpenPort();
1093
+ const server = await startBuiltServer(workspace, port);
1094
+ port = server.port;
1095
+
1096
+ try {
1097
+ const loginResponse = await fetch(`http://127.0.0.1:${port}/session/login`, {
1098
+ method: 'POST',
1099
+ headers: {
1100
+ 'content-type': 'application/x-www-form-urlencoded',
1101
+ },
1102
+ body: 'email=ada%40example.com',
1103
+ redirect: 'manual',
1104
+ });
1105
+ assert.equal(loginResponse.status, 303);
1106
+ assert.equal(loginResponse.headers.get('location'), '/session/account');
1107
+
1108
+ const cookieHeader = extractCookieHeader(loginResponse.headers.get('set-cookie'));
1109
+ assert.match(cookieHeader, /^webstir_session=/);
1110
+
1111
+ const accountResponse = await fetch(`http://127.0.0.1:${port}/session/account`, {
1112
+ headers: {
1113
+ cookie: cookieHeader,
1114
+ },
1115
+ });
1116
+ assert.equal(accountResponse.status, 200);
1117
+ assert.equal(accountResponse.headers.get('content-type'), 'text/html; charset=utf-8');
1118
+ assert.equal(
1119
+ await accountResponse.text(),
1120
+ '<main data-user="ada@example.com" data-flash="signed-in:success">ada@example.com</main>',
1121
+ );
1122
+
1123
+ const secondAccountResponse = await fetch(`http://127.0.0.1:${port}/session/account`, {
1124
+ headers: {
1125
+ cookie: cookieHeader,
1126
+ },
1127
+ });
1128
+ assert.equal(
1129
+ await secondAccountResponse.text(),
1130
+ '<main data-user="ada@example.com" data-flash="">ada@example.com</main>',
1131
+ );
1132
+
1133
+ const logoutResponse = await fetch(`http://127.0.0.1:${port}/session/logout`, {
1134
+ method: 'POST',
1135
+ headers: {
1136
+ 'content-type': 'application/x-www-form-urlencoded',
1137
+ cookie: cookieHeader,
1138
+ },
1139
+ redirect: 'manual',
1140
+ });
1141
+ assert.equal(logoutResponse.status, 303);
1142
+ assert.match(String(logoutResponse.headers.get('set-cookie')), /Max-Age=0/);
1143
+
1144
+ const postLogoutAccountResponse = await fetch(`http://127.0.0.1:${port}/session/account`, {
1145
+ headers: {
1146
+ cookie: cookieHeader,
1147
+ },
1148
+ });
1149
+ assert.equal(
1150
+ await postLogoutAccountResponse.text(),
1151
+ '<main data-user="guest" data-flash="">guest</main>',
1152
+ );
1153
+ } finally {
1154
+ await server.stop();
1155
+ }
1156
+ }
1157
+
1158
+ async function assertFormWorkflowRuntimeBehavior() {
1159
+ const workspace = await createTempWorkspace('webstir-backend-forms-');
1160
+ await buildRuntimeWorkspace(workspace, {
1161
+ moduleSource: createFormWorkflowModuleSource(),
1162
+ });
1163
+
1164
+ let port = await getOpenPort();
1165
+ const server = await startBuiltServer(workspace, port, {
1166
+ AUTH_SERVICE_TOKENS: 'service-secret',
1167
+ });
1168
+ port = server.port;
1169
+
1170
+ try {
1171
+ const initialPageResponse = await fetch(`http://127.0.0.1:${port}/account/settings`, {
1172
+ redirect: 'manual',
1173
+ });
1174
+ assert.equal(initialPageResponse.status, 200);
1175
+ const cookieHeader = extractCookieHeader(initialPageResponse.headers.get('set-cookie'));
1176
+ assert.match(cookieHeader, /^webstir_session=/);
1177
+ const initialPageHtml = await initialPageResponse.text();
1178
+ const initialCsrfToken = extractHiddenInputValue(initialPageHtml, '_csrf');
1179
+ assert.match(initialCsrfToken, /^[a-f0-9-]+$/i);
1180
+ assert.match(initialPageHtml, /data-user="guest@example\.com"/);
1181
+
1182
+ const authFailureResponse = await fetch(`http://127.0.0.1:${port}/account/settings`, {
1183
+ method: 'POST',
1184
+ headers: {
1185
+ 'content-type': 'application/x-www-form-urlencoded',
1186
+ cookie: cookieHeader,
1187
+ },
1188
+ body: `_csrf=${encodeURIComponent(initialCsrfToken)}&email=ada%40example.com`,
1189
+ redirect: 'manual',
1190
+ });
1191
+ assert.equal(authFailureResponse.status, 303);
1192
+ assert.equal(authFailureResponse.headers.get('location'), '/account/settings');
1193
+
1194
+ const authFailurePage = await fetch(`http://127.0.0.1:${port}/account/settings`, {
1195
+ headers: {
1196
+ cookie: cookieHeader,
1197
+ },
1198
+ });
1199
+ const authFailureHtml = await authFailurePage.text();
1200
+ assert.match(
1201
+ authFailureHtml,
1202
+ /data-form-errors="Sign-in required to update account settings\."/,
1203
+ );
1204
+ assert.match(authFailureHtml, /value="ada@example\.com"/);
1205
+ const validationCsrfToken = extractHiddenInputValue(authFailureHtml, '_csrf');
1206
+
1207
+ const validationFailureResponse = await fetch(`http://127.0.0.1:${port}/account/settings`, {
1208
+ method: 'POST',
1209
+ headers: {
1210
+ 'content-type': 'application/x-www-form-urlencoded',
1211
+ cookie: cookieHeader,
1212
+ 'x-service-token': 'service-secret',
1213
+ },
1214
+ body: `_csrf=${encodeURIComponent(validationCsrfToken)}&email=invalid-email`,
1215
+ redirect: 'manual',
1216
+ });
1217
+ assert.equal(validationFailureResponse.status, 303);
1218
+ assert.equal(validationFailureResponse.headers.get('location'), '/account/settings');
1219
+
1220
+ const validationFailurePage = await fetch(`http://127.0.0.1:${port}/account/settings`, {
1221
+ headers: {
1222
+ cookie: cookieHeader,
1223
+ },
1224
+ });
1225
+ const validationFailureHtml = await validationFailurePage.text();
1226
+ assert.match(validationFailureHtml, /data-field-errors="Enter a valid email address\."/);
1227
+ assert.match(validationFailureHtml, /value="invalid-email"/);
1228
+ const successCsrfToken = extractHiddenInputValue(validationFailureHtml, '_csrf');
1229
+
1230
+ const csrfFailureResponse = await fetch(`http://127.0.0.1:${port}/account/settings`, {
1231
+ method: 'POST',
1232
+ headers: {
1233
+ 'content-type': 'application/x-www-form-urlencoded',
1234
+ cookie: cookieHeader,
1235
+ 'x-service-token': 'service-secret',
1236
+ },
1237
+ body: `_csrf=wrong-token&email=ada%40example.com`,
1238
+ redirect: 'manual',
1239
+ });
1240
+ assert.equal(csrfFailureResponse.status, 303);
1241
+ assert.equal(csrfFailureResponse.headers.get('location'), '/account/settings');
1242
+
1243
+ const csrfFailurePage = await fetch(`http://127.0.0.1:${port}/account/settings`, {
1244
+ headers: {
1245
+ cookie: cookieHeader,
1246
+ },
1247
+ });
1248
+ const csrfFailureHtml = await csrfFailurePage.text();
1249
+ assert.match(
1250
+ csrfFailureHtml,
1251
+ /data-form-errors="Form session expired\. Reload the page and try again\."/,
1252
+ );
1253
+
1254
+ const successResponse = await fetch(`http://127.0.0.1:${port}/account/settings`, {
1255
+ method: 'POST',
1256
+ headers: {
1257
+ 'content-type': 'application/x-www-form-urlencoded',
1258
+ cookie: cookieHeader,
1259
+ 'x-service-token': 'service-secret',
1260
+ },
1261
+ body: `_csrf=${encodeURIComponent(successCsrfToken)}&email=ada%40example.com`,
1262
+ redirect: 'manual',
1263
+ });
1264
+ assert.equal(successResponse.status, 303);
1265
+ assert.equal(successResponse.headers.get('location'), '/account/settings');
1266
+
1267
+ const successPage = await fetch(`http://127.0.0.1:${port}/account/settings`, {
1268
+ headers: {
1269
+ cookie: cookieHeader,
1270
+ },
1271
+ });
1272
+ const successHtml = await successPage.text();
1273
+ assert.match(successHtml, /data-user="ada@example\.com"/);
1274
+ assert.match(successHtml, /data-flash="settings-saved:success"/);
1275
+ assert.match(successHtml, /data-form-errors=""/);
1276
+ assert.match(successHtml, /data-field-errors=""/);
1277
+ } finally {
1278
+ await server.stop();
1279
+ }
1280
+ }
1281
+
1282
+ function createViewRuntimeModuleSource() {
1283
+ return `const loginRoute = {
1284
+ definition: {
1285
+ name: 'viewSessionLogin',
1286
+ method: 'POST',
1287
+ path: '/session/login',
1288
+ interaction: 'mutation',
1289
+ form: {
1290
+ contentType: 'application/x-www-form-urlencoded',
1291
+ session: { write: true }
1292
+ }
1293
+ },
1294
+ handler: async (ctx) => {
1295
+ const email = String(ctx.body?.email ?? 'viewer@example.com');
1296
+ ctx.session = {
1297
+ userId: email,
1298
+ profile: { email }
1299
+ };
1300
+ return {
1301
+ status: 303,
1302
+ redirect: { location: '/accounts/demo' }
1303
+ };
1304
+ }
1305
+ };
1306
+
1307
+ const accountView = {
1308
+ definition: {
1309
+ name: 'AccountView',
1310
+ path: '/accounts/:id',
1311
+ renderMode: 'ssr'
1312
+ },
1313
+ load: async (ctx) => ({
1314
+ accountId: ctx.params.id,
1315
+ authSource: ctx.auth?.source ?? null,
1316
+ sessionUser: ctx.session?.userId ?? null,
1317
+ requestId: ctx.requestId ?? null,
1318
+ pathname: ctx.url.pathname,
1319
+ host: ctx.headers.host ?? null
1320
+ })
1321
+ };
1322
+
1323
+ export const module = {
1324
+ manifest: {
1325
+ contractVersion: '1.0.0',
1326
+ name: '@demo/runtime-views',
1327
+ version: '0.1.0',
1328
+ kind: 'backend',
1329
+ capabilities: ['http', 'auth', 'views'],
1330
+ routes: [loginRoute.definition],
1331
+ views: [accountView.definition]
1332
+ },
1333
+ routes: [loginRoute],
1334
+ views: [accountView]
1335
+ };
1336
+ `;
1337
+ }
1338
+
1339
+ async function assertRequestTimeViewRuntimeBehavior() {
1340
+ const workspace = await createTempWorkspace('webstir-backend-views-');
1341
+ await buildRuntimeWorkspace(workspace, {
1342
+ moduleSource: createViewRuntimeModuleSource(),
1343
+ });
1344
+ await writeFrontendDocument(
1345
+ workspace,
1346
+ 'accounts',
1347
+ [
1348
+ '<!DOCTYPE html>',
1349
+ '<html lang="en">',
1350
+ '<head><title>Account shell</title></head>',
1351
+ '<body><main><h1>Account shell</h1></main></body>',
1352
+ '</html>',
1353
+ ].join('\n'),
1354
+ );
1355
+
1356
+ let port = await getOpenPort();
1357
+ const server = await startBuiltServer(workspace, port, {
1358
+ AUTH_SERVICE_TOKENS: 'service-secret',
1359
+ });
1360
+ port = server.port;
1361
+
1362
+ try {
1363
+ const loginResponse = await fetch(`http://127.0.0.1:${port}/session/login`, {
1364
+ method: 'POST',
1365
+ headers: {
1366
+ 'content-type': 'application/x-www-form-urlencoded',
1367
+ },
1368
+ body: 'email=viewer%40example.com',
1369
+ redirect: 'manual',
1370
+ });
1371
+ assert.equal(loginResponse.status, 303);
1372
+ assert.equal(loginResponse.headers.get('location'), '/accounts/demo');
1373
+
1374
+ const cookieHeader = extractCookieHeader(loginResponse.headers.get('set-cookie'));
1375
+ assert.match(cookieHeader, /^webstir_session=/);
1376
+
1377
+ const accountResponse = await fetch(`http://127.0.0.1:${port}/accounts/demo`, {
1378
+ headers: {
1379
+ cookie: cookieHeader,
1380
+ 'x-service-token': 'service-secret',
1381
+ },
1382
+ });
1383
+ assert.equal(accountResponse.status, 200);
1384
+ assert.equal(accountResponse.headers.get('cache-control'), 'no-store');
1385
+ assert.equal(accountResponse.headers.get('content-type'), 'text/html; charset=utf-8');
1386
+ assert.equal(accountResponse.headers.get('x-webstir-document-cache'), 'miss');
1387
+ const requestId = accountResponse.headers.get('x-request-id');
1388
+ assert.ok(requestId, 'Expected request-time view responses to expose x-request-id.');
1389
+
1390
+ const accountHtml = await accountResponse.text();
1391
+ assert.match(accountHtml, /<h1>Account shell<\/h1>/);
1392
+ assert.match(accountHtml, /data-webstir-view-name="AccountView"/);
1393
+ assert.match(accountHtml, /data-webstir-view-pathname="\/accounts\/demo"/);
1394
+
1395
+ const viewState = extractViewState(accountHtml);
1396
+ assert.deepEqual(viewState, {
1397
+ view: {
1398
+ name: 'AccountView',
1399
+ path: '/accounts/:id',
1400
+ pathname: '/accounts/demo',
1401
+ params: { id: 'demo' },
1402
+ },
1403
+ data: {
1404
+ accountId: 'demo',
1405
+ authSource: 'service-token',
1406
+ sessionUser: 'viewer@example.com',
1407
+ requestId,
1408
+ pathname: '/accounts/demo',
1409
+ host: `127.0.0.1:${port}`,
1410
+ },
1411
+ requestId,
1412
+ });
1413
+
1414
+ const cachedAccountResponse = await fetch(`http://127.0.0.1:${port}/accounts/demo`, {
1415
+ headers: {
1416
+ cookie: cookieHeader,
1417
+ 'x-service-token': 'service-secret',
1418
+ },
1419
+ });
1420
+ assert.equal(cachedAccountResponse.status, 200);
1421
+ assert.equal(cachedAccountResponse.headers.get('x-webstir-document-cache'), 'hit');
1422
+ const cachedAccountHtml = await cachedAccountResponse.text();
1423
+ assert.match(cachedAccountHtml, /<h1>Account shell<\/h1>/);
1424
+
1425
+ await writeFrontendDocument(
1426
+ workspace,
1427
+ 'accounts',
1428
+ [
1429
+ '<!DOCTYPE html>',
1430
+ '<html lang="en">',
1431
+ '<head><title>Account shell refreshed</title></head>',
1432
+ '<body><main><h1>Account shell refreshed</h1><p>Updated shell</p></main></body>',
1433
+ '</html>',
1434
+ ].join('\n'),
1435
+ );
1436
+
1437
+ const refreshedAccountResponse = await fetch(`http://127.0.0.1:${port}/accounts/demo`, {
1438
+ headers: {
1439
+ cookie: cookieHeader,
1440
+ 'x-service-token': 'service-secret',
1441
+ },
1442
+ });
1443
+ assert.equal(refreshedAccountResponse.status, 200);
1444
+ assert.equal(refreshedAccountResponse.headers.get('x-webstir-document-cache'), 'stale');
1445
+ const refreshedAccountHtml = await refreshedAccountResponse.text();
1446
+ assert.match(refreshedAccountHtml, /<h1>Account shell refreshed<\/h1>/);
1447
+ assert.match(refreshedAccountHtml, /<p>Updated shell<\/p>/);
1448
+
1449
+ const warmAccountResponse = await fetch(`http://127.0.0.1:${port}/accounts/demo`, {
1450
+ headers: {
1451
+ cookie: cookieHeader,
1452
+ 'x-service-token': 'service-secret',
1453
+ },
1454
+ });
1455
+ assert.equal(warmAccountResponse.status, 200);
1456
+ assert.equal(warmAccountResponse.headers.get('x-webstir-document-cache'), 'hit');
1457
+
1458
+ const missingResponse = await fetch(`http://127.0.0.1:${port}/missing-page`);
1459
+ assert.equal(missingResponse.status, 404);
1460
+ assert.deepEqual(await missingResponse.json(), {
1461
+ error: 'not_found',
1462
+ path: '/missing-page',
1463
+ });
1464
+ } finally {
1465
+ await server.stop();
1466
+ }
1467
+ }
1468
+
1469
+ async function assertRequestTimeViewWorkspaceRootBehavior({
1470
+ extraEnv = (workspace) => ({ WORKSPACE_ROOT: workspace }),
1471
+ } = {}) {
1472
+ const workspace = await createTempWorkspace('webstir-backend-view-root-');
1473
+ await buildRuntimeWorkspace(workspace, {
1474
+ moduleSource: createViewRuntimeModuleSource(),
1475
+ });
1476
+ await writePublishedFrontendAliasDocument(
1477
+ workspace,
1478
+ 'accounts',
1479
+ [
1480
+ '<!DOCTYPE html>',
1481
+ '<html lang="en">',
1482
+ '<head><title>Published account shell</title></head>',
1483
+ '<body><main><h1>Published account shell</h1></main></body>',
1484
+ '</html>',
1485
+ ].join('\n'),
1486
+ );
1487
+
1488
+ let port = await getOpenPort();
1489
+ const alternateCwd = await fs.mkdtemp(path.join(os.tmpdir(), 'webstir-backend-alt-cwd-'));
1490
+ const server = await startBuiltServer(
1491
+ workspace,
1492
+ port,
1493
+ {
1494
+ AUTH_SERVICE_TOKENS: 'service-secret',
1495
+ ...extraEnv(workspace),
1496
+ },
1497
+ {
1498
+ cwd: alternateCwd,
1499
+ },
1500
+ );
1501
+ port = server.port;
1502
+
1503
+ try {
1504
+ const response = await fetch(`http://127.0.0.1:${port}/accounts/demo`, {
1505
+ headers: {
1506
+ 'x-service-token': 'service-secret',
1507
+ },
1508
+ });
1509
+
1510
+ assert.equal(response.status, 200);
1511
+ assert.equal(response.headers.get('x-webstir-document-cache'), 'miss');
1512
+ const html = await response.text();
1513
+ assert.match(html, /<h1>Published account shell<\/h1>/);
1514
+ assert.match(html, /data-webstir-view-pathname="\/accounts\/demo"/);
1515
+ } finally {
1516
+ await server.stop();
1517
+ }
1518
+ }
1519
+
1520
+ function createFragmentRuntimeModuleSource() {
1521
+ return `const routes = [
1522
+ {
1523
+ definition: {
1524
+ name: 'redirectRoute',
1525
+ method: 'POST',
1526
+ path: '/actions/redirect',
1527
+ interaction: 'mutation',
1528
+ form: { contentType: 'application/x-www-form-urlencoded' }
1529
+ },
1530
+ handler: async (ctx) => ({
1531
+ status: 303,
1532
+ redirect: {
1533
+ location: \`/done?name=\${encodeURIComponent(String(ctx.body?.name ?? 'unknown'))}\`
1534
+ }
1535
+ })
1536
+ },
1537
+ {
1538
+ definition: {
1539
+ name: 'fragmentRoute',
1540
+ method: 'POST',
1541
+ path: '/actions/fragment',
1542
+ interaction: 'mutation',
1543
+ form: { contentType: 'application/x-www-form-urlencoded' },
1544
+ fragment: { target: 'greeting', mode: 'replace' }
1545
+ },
1546
+ handler: async (ctx) => ({
1547
+ status: 200,
1548
+ fragment: {
1549
+ target: 'greeting',
1550
+ mode: 'replace',
1551
+ body: \`<p>Hello \${String(ctx.body?.name ?? 'world')}</p>\`
1552
+ }
1553
+ })
1554
+ },
1555
+ {
1556
+ definition: {
1557
+ name: 'fragmentMissingTargetRoute',
1558
+ method: 'POST',
1559
+ path: '/actions/fragment-missing-target',
1560
+ interaction: 'mutation',
1561
+ form: { contentType: 'application/x-www-form-urlencoded' }
1562
+ },
1563
+ handler: async () => ({
1564
+ status: 200,
1565
+ fragment: {
1566
+ target: ' ',
1567
+ mode: 'replace',
1568
+ body: '<p>Missing target</p>'
1569
+ }
1570
+ })
1571
+ },
1572
+ {
1573
+ definition: {
1574
+ name: 'fragmentInvalidModeRoute',
1575
+ method: 'POST',
1576
+ path: '/actions/fragment-invalid-mode',
1577
+ interaction: 'mutation',
1578
+ form: { contentType: 'application/x-www-form-urlencoded' }
1579
+ },
1580
+ handler: async () => ({
1581
+ status: 200,
1582
+ fragment: {
1583
+ target: 'greeting',
1584
+ mode: 'swap',
1585
+ body: '<p>Invalid mode</p>'
1586
+ }
1587
+ })
1588
+ },
1589
+ {
1590
+ definition: {
1591
+ name: 'fragmentInvalidSelectorRoute',
1592
+ method: 'POST',
1593
+ path: '/actions/fragment-invalid-selector',
1594
+ interaction: 'mutation',
1595
+ form: { contentType: 'application/x-www-form-urlencoded' }
1596
+ },
1597
+ handler: async () => ({
1598
+ status: 200,
1599
+ fragment: {
1600
+ target: 'greeting',
1601
+ selector: ' ',
1602
+ mode: 'replace',
1603
+ body: '<p>Invalid selector</p>'
1604
+ }
1605
+ })
1606
+ },
1607
+ {
1608
+ definition: {
1609
+ name: 'fragmentMissingBodyRoute',
1610
+ method: 'POST',
1611
+ path: '/actions/fragment-missing-body',
1612
+ interaction: 'mutation',
1613
+ form: { contentType: 'application/x-www-form-urlencoded' }
1614
+ },
1615
+ handler: async () => ({
1616
+ status: 200,
1617
+ fragment: {
1618
+ target: 'greeting',
1619
+ mode: 'replace'
1620
+ }
1621
+ })
1622
+ }
1623
+ ];
1624
+
1625
+ export const module = {
1626
+ manifest: {
1627
+ contractVersion: '1.0.0',
1628
+ name: '@demo/runtime',
1629
+ version: '0.1.0',
1630
+ kind: 'backend',
1631
+ capabilities: ['http'],
1632
+ routes: routes.map((route) => route.definition)
1633
+ },
1634
+ routes
1635
+ };
1636
+ `;
1637
+ }
1638
+
1639
+ async function assertFragmentRuntimeBehavior() {
1640
+ const workspace = await createTempWorkspace('webstir-backend-fragments-');
1641
+ await buildRuntimeWorkspace(workspace, {
1642
+ moduleSource: createFragmentRuntimeModuleSource(),
1643
+ });
1644
+
1645
+ let port = await getOpenPort();
1646
+ const server = await startBuiltServer(workspace, port);
1647
+ port = server.port;
1648
+
1649
+ try {
1650
+ const redirectResponse = await fetch(`http://127.0.0.1:${port}/actions/redirect`, {
1651
+ method: 'POST',
1652
+ headers: {
1653
+ 'content-type': 'application/x-www-form-urlencoded',
1654
+ },
1655
+ body: 'name=Webstir',
1656
+ redirect: 'manual',
1657
+ });
1658
+ assert.equal(redirectResponse.status, 303);
1659
+ assert.equal(redirectResponse.headers.get('location'), '/done?name=Webstir');
1660
+
1661
+ const fragmentResponse = await fetch(`http://127.0.0.1:${port}/actions/fragment`, {
1662
+ method: 'POST',
1663
+ headers: {
1664
+ 'content-type': 'application/x-www-form-urlencoded',
1665
+ },
1666
+ body: 'name=Webstir',
1667
+ });
1668
+ assert.equal(fragmentResponse.status, 200);
1669
+ assert.equal(fragmentResponse.headers.get('cache-control'), 'no-store');
1670
+ assert.equal(fragmentResponse.headers.get('x-webstir-fragment-cache'), 'bypass');
1671
+ assert.equal(fragmentResponse.headers.get('x-webstir-fragment-target'), 'greeting');
1672
+ assert.equal(fragmentResponse.headers.get('x-webstir-fragment-mode'), 'replace');
1673
+ assert.equal(fragmentResponse.headers.get('content-type'), 'text/html; charset=utf-8');
1674
+ assert.equal(await fragmentResponse.text(), '<p>Hello Webstir</p>');
1675
+
1676
+ for (const pathname of [
1677
+ '/actions/fragment-missing-target',
1678
+ '/actions/fragment-invalid-mode',
1679
+ '/actions/fragment-invalid-selector',
1680
+ '/actions/fragment-missing-body',
1681
+ ]) {
1682
+ const invalidResponse = await fetch(`http://127.0.0.1:${port}${pathname}`, {
1683
+ method: 'POST',
1684
+ headers: {
1685
+ 'content-type': 'application/x-www-form-urlencoded',
1686
+ },
1687
+ body: 'name=Webstir',
1688
+ });
1689
+ assert.equal(invalidResponse.status, 500);
1690
+ assert.equal(invalidResponse.headers.get('x-webstir-fragment-target'), null);
1691
+ assert.match(String(invalidResponse.headers.get('content-type')), /^application\/json\b/);
1692
+ assert.deepEqual(await invalidResponse.json(), {
1693
+ errors: [
1694
+ {
1695
+ code: 'invalid_fragment_response',
1696
+ message: 'Fragment responses require a non-empty target, supported mode, and body.',
1697
+ details:
1698
+ pathname === '/actions/fragment-missing-target'
1699
+ ? ['target']
1700
+ : pathname === '/actions/fragment-invalid-mode'
1701
+ ? ['mode']
1702
+ : pathname === '/actions/fragment-invalid-selector'
1703
+ ? ['selector']
1704
+ : ['body'],
1705
+ },
1706
+ ],
1707
+ });
1708
+ }
1709
+ } finally {
1710
+ await server.stop();
1711
+ }
1712
+ }
1713
+
1714
+ test('request hook scaffold helper preserves ordered phase execution', async () => {
1715
+ const workspace = await createTempWorkspace('webstir-backend-hook-helper-');
1716
+ await buildRuntimeWorkspace(workspace, {
1717
+ moduleSource: createRequestHookRuntimeModuleSource(),
1718
+ mode: 'build',
1719
+ });
1720
+
1721
+ const helperBuildPath = path.join(workspace, 'build', 'request-hooks.mjs');
1722
+ const helperEntryPoint = await writePackageRuntimeEntry(
1723
+ workspace,
1724
+ 'request-hooks-entry.ts',
1725
+ '@webstir-io/webstir-backend/runtime/request-hooks',
1726
+ );
1727
+ await esbuild({
1728
+ entryPoints: [helperEntryPoint],
1729
+ bundle: true,
1730
+ format: 'esm',
1731
+ platform: 'node',
1732
+ outfile: helperBuildPath,
1733
+ logLevel: 'silent',
1734
+ });
1735
+ const helperUrl = pathToFileURL(helperBuildPath).href;
1736
+ const { executeRequestHookPhase, resolveRequestHooks } = await import(helperUrl);
1737
+
1738
+ const context = {
1739
+ trace: [],
1740
+ auth: undefined,
1741
+ session: null,
1742
+ };
1743
+ const route = { name: 'hookRoute', path: '/hooks/demo' };
1744
+ const resolved = resolveRequestHooks({
1745
+ routeName: route.name,
1746
+ routeReferences: [{ id: 'after' }, { id: 'short' }, { id: 'auth' }, { id: 'session' }],
1747
+ manifestDefinitions: [
1748
+ { id: 'session', phase: 'beforeAuth', order: 10 },
1749
+ { id: 'short', phase: 'beforeHandler', order: 20 },
1750
+ { id: 'auth', phase: 'beforeHandler', order: 10 },
1751
+ { id: 'after', phase: 'afterHandler', order: 10 },
1752
+ ],
1753
+ registrations: [
1754
+ {
1755
+ id: 'session',
1756
+ handler: async (ctx) => {
1757
+ ctx.trace.push('beforeAuth');
1758
+ ctx.session = { id: 'session-from-hook' };
1759
+ },
1760
+ },
1761
+ {
1762
+ id: 'auth',
1763
+ handler: async (ctx) => {
1764
+ ctx.auth = { source: 'service-token' };
1765
+ ctx.trace.push(`beforeHandler:${ctx.auth.source}`);
1766
+ },
1767
+ },
1768
+ {
1769
+ id: 'short',
1770
+ handler: async (ctx) => {
1771
+ ctx.trace.push('beforeHandler:short-check');
1772
+ return ctx.shortCircuit
1773
+ ? {
1774
+ status: 202,
1775
+ body: {
1776
+ trace: [...ctx.trace],
1777
+ authSource: ctx.auth?.source ?? null,
1778
+ sessionId: ctx.session?.id ?? null,
1779
+ shortCircuited: true,
1780
+ },
1781
+ }
1782
+ : undefined;
1783
+ },
1784
+ },
1785
+ {
1786
+ id: 'after',
1787
+ handler: async (ctx, input) => ({
1788
+ ...input.result,
1789
+ headers: {
1790
+ ...(input.result?.headers ?? {}),
1791
+ 'x-hook-after': '1',
1792
+ },
1793
+ body: {
1794
+ ...(input.result?.body ?? {}),
1795
+ trace: [...(input.result?.body?.trace ?? []), 'afterHandler'],
1796
+ authSource: input.result?.body?.authSource ?? ctx.auth?.source ?? null,
1797
+ sessionId: input.result?.body?.sessionId ?? ctx.session?.id ?? null,
1798
+ },
1799
+ }),
1800
+ },
1801
+ ],
1802
+ });
1803
+
1804
+ assert.deepEqual(
1805
+ resolved.hooks.map((hook) => `${hook.phase}:${hook.id}`),
1806
+ ['beforeAuth:session', 'beforeHandler:auth', 'beforeHandler:short', 'afterHandler:after'],
1807
+ );
1808
+
1809
+ await executeRequestHookPhase({
1810
+ hooks: resolved.hooks,
1811
+ phase: 'beforeAuth',
1812
+ context,
1813
+ route,
1814
+ });
1815
+ const beforeHandler = await executeRequestHookPhase({
1816
+ hooks: resolved.hooks,
1817
+ phase: 'beforeHandler',
1818
+ context,
1819
+ route,
1820
+ });
1821
+
1822
+ assert.equal(beforeHandler.shortCircuited, false);
1823
+
1824
+ const afterHandler = await executeRequestHookPhase({
1825
+ hooks: resolved.hooks,
1826
+ phase: 'afterHandler',
1827
+ context,
1828
+ route,
1829
+ result: {
1830
+ status: 200,
1831
+ body: {
1832
+ trace: [...context.trace, 'handler'],
1833
+ authSource: context.auth?.source ?? null,
1834
+ sessionId: context.session?.id ?? null,
1835
+ },
1836
+ },
1837
+ });
1838
+
1839
+ assert.deepEqual(afterHandler.result, {
1840
+ status: 200,
1841
+ headers: {
1842
+ 'x-hook-after': '1',
1843
+ },
1844
+ body: {
1845
+ trace: [
1846
+ 'beforeAuth',
1847
+ 'beforeHandler:service-token',
1848
+ 'beforeHandler:short-check',
1849
+ 'handler',
1850
+ 'afterHandler',
1851
+ ],
1852
+ authSource: 'service-token',
1853
+ sessionId: 'session-from-hook',
1854
+ },
1855
+ });
1856
+
1857
+ const shortContext = {
1858
+ trace: [],
1859
+ auth: undefined,
1860
+ session: null,
1861
+ shortCircuit: true,
1862
+ };
1863
+ await executeRequestHookPhase({
1864
+ hooks: resolved.hooks,
1865
+ phase: 'beforeAuth',
1866
+ context: shortContext,
1867
+ route,
1868
+ });
1869
+ const shortCircuitResult = await executeRequestHookPhase({
1870
+ hooks: resolved.hooks,
1871
+ phase: 'beforeHandler',
1872
+ context: shortContext,
1873
+ route,
1874
+ });
1875
+
1876
+ assert.equal(shortCircuitResult.shortCircuited, true);
1877
+ assert.deepEqual(shortCircuitResult.result, {
1878
+ status: 202,
1879
+ body: {
1880
+ trace: ['beforeAuth', 'beforeHandler:service-token', 'beforeHandler:short-check'],
1881
+ authSource: 'service-token',
1882
+ sessionId: 'session-from-hook',
1883
+ shortCircuited: true,
1884
+ },
1885
+ });
1886
+ });
1887
+
1888
+ test('session scaffold helper resolves, consumes, and invalidates session state', async () => {
1889
+ const workspace = await createTempWorkspace('webstir-backend-session-helper-');
1890
+ await buildRuntimeWorkspace(workspace, {
1891
+ moduleSource: createSessionRuntimeModuleSource(),
1892
+ mode: 'build',
1893
+ });
1894
+
1895
+ const helperBuildPath = path.join(workspace, 'build', 'session.mjs');
1896
+ const helperEntryPoint = await writePackageRuntimeEntry(
1897
+ workspace,
1898
+ 'session-entry.ts',
1899
+ '@webstir-io/webstir-backend/runtime/session',
1900
+ );
1901
+ await esbuild({
1902
+ entryPoints: [helperEntryPoint],
1903
+ bundle: true,
1904
+ format: 'esm',
1905
+ platform: 'node',
1906
+ outfile: helperBuildPath,
1907
+ logLevel: 'silent',
1908
+ });
1909
+ const helperUrl = pathToFileURL(helperBuildPath).href;
1910
+ const { prepareSessionState, resetInMemorySessionStore } = await import(helperUrl);
1911
+ resetInMemorySessionStore();
1912
+
1913
+ const config = {
1914
+ secret: 'test-session-secret',
1915
+ cookieName: 'webstir_session',
1916
+ secure: false,
1917
+ maxAgeSeconds: 60,
1918
+ };
1919
+ const loginRoute = {
1920
+ form: {
1921
+ session: { write: true },
1922
+ flash: {
1923
+ publish: [{ key: 'signed-in', level: 'success', when: 'success' }],
1924
+ },
1925
+ },
1926
+ };
1927
+ const accountRoute = {
1928
+ session: { mode: 'optional' },
1929
+ flash: { consume: ['signed-in'] },
1930
+ };
1931
+
1932
+ const created = prepareSessionState({
1933
+ cookies: '',
1934
+ route: loginRoute,
1935
+ config,
1936
+ });
1937
+ assert.equal(created.session, null);
1938
+ assert.deepEqual(created.flash, []);
1939
+
1940
+ const createdCommit = created.commit({
1941
+ session: {
1942
+ userId: 'ada@example.com',
1943
+ data: { email: 'ada@example.com' },
1944
+ },
1945
+ route: loginRoute,
1946
+ result: {
1947
+ status: 303,
1948
+ redirect: { location: '/session/account' },
1949
+ },
1950
+ });
1951
+ const cookieHeader = extractCookieHeader(createdCommit.setCookie);
1952
+ assert.match(cookieHeader, /^webstir_session=/);
1953
+
1954
+ const firstRead = prepareSessionState({
1955
+ cookies: cookieHeader,
1956
+ route: accountRoute,
1957
+ config,
1958
+ });
1959
+ assert.equal(firstRead.session.userId, 'ada@example.com');
1960
+ assert.deepEqual(
1961
+ firstRead.flash.map((message) => ({ key: message.key, level: message.level })),
1962
+ [{ key: 'signed-in', level: 'success' }],
1963
+ );
1964
+ const firstReadCommit = firstRead.commit({
1965
+ session: firstRead.session,
1966
+ route: accountRoute,
1967
+ result: {
1968
+ status: 200,
1969
+ body: '<main>ok</main>',
1970
+ },
1971
+ });
1972
+ assert.equal(firstReadCommit.setCookie, undefined);
1973
+
1974
+ const secondRead = prepareSessionState({
1975
+ cookies: cookieHeader,
1976
+ route: accountRoute,
1977
+ config,
1978
+ });
1979
+ assert.equal(secondRead.session.userId, 'ada@example.com');
1980
+ assert.deepEqual(secondRead.flash, []);
1981
+
1982
+ const invalidated = secondRead.commit({
1983
+ session: null,
1984
+ route: accountRoute,
1985
+ result: {
1986
+ status: 303,
1987
+ redirect: { location: '/signed-out' },
1988
+ },
1989
+ });
1990
+ assert.match(String(invalidated.setCookie), /Max-Age=0/);
1991
+
1992
+ const afterInvalidation = prepareSessionState({
1993
+ cookies: cookieHeader,
1994
+ route: accountRoute,
1995
+ config,
1996
+ });
1997
+ assert.equal(afterInvalidation.session, null);
1998
+ assert.deepEqual(afterInvalidation.flash, []);
1999
+ });
2000
+
2001
+ test('form scaffold helper redirects validation and auth failures with csrf protection', async () => {
2002
+ const workspace = await createTempWorkspace('webstir-backend-form-helper-');
2003
+ await buildRuntimeWorkspace(workspace, {
2004
+ moduleSource: createFormWorkflowModuleSource(),
2005
+ mode: 'build',
2006
+ });
2007
+
2008
+ const helperBuildPath = path.join(workspace, 'build', 'forms.mjs');
2009
+ const helperEntryPoint = await writePackageRuntimeEntry(
2010
+ workspace,
2011
+ 'forms-entry.ts',
2012
+ '@webstir-io/webstir-backend/runtime/forms',
2013
+ );
2014
+ await esbuild({
2015
+ entryPoints: [helperEntryPoint],
2016
+ bundle: true,
2017
+ format: 'esm',
2018
+ platform: 'node',
2019
+ outfile: helperBuildPath,
2020
+ logLevel: 'silent',
2021
+ });
2022
+ const helperUrl = pathToFileURL(helperBuildPath).href;
2023
+ const { groupFormIssuesByField, prepareFormState, processFormSubmission } = await import(
2024
+ helperUrl
2025
+ );
2026
+
2027
+ const pageRoute = {
2028
+ path: '/account/settings',
2029
+ };
2030
+ const submitRoute = {
2031
+ path: '/account/settings',
2032
+ form: {
2033
+ csrf: true,
2034
+ },
2035
+ };
2036
+
2037
+ const initialPage = prepareFormState({
2038
+ session: null,
2039
+ formId: 'account-settings',
2040
+ route: submitRoute,
2041
+ });
2042
+ assert.match(String(initialPage.csrfToken), /^[a-f0-9-]+$/i);
2043
+ assert.deepEqual(initialPage.values, {});
2044
+ assert.deepEqual(initialPage.issues, []);
2045
+
2046
+ const authFailure = processFormSubmission({
2047
+ session: initialPage.session,
2048
+ body: {
2049
+ _csrf: initialPage.csrfToken,
2050
+ email: 'ada@example.com',
2051
+ },
2052
+ auth: undefined,
2053
+ formId: 'account-settings',
2054
+ route: submitRoute,
2055
+ redirectTo: pageRoute.path,
2056
+ requireAuth: {
2057
+ redirectTo: pageRoute.path,
2058
+ message: 'Sign-in required to update account settings.',
2059
+ },
2060
+ });
2061
+ assert.equal(authFailure.ok, false);
2062
+ assert.deepEqual(authFailure.result, {
2063
+ status: 303,
2064
+ redirect: {
2065
+ location: '/account/settings',
2066
+ },
2067
+ });
2068
+
2069
+ const authFailurePage = prepareFormState({
2070
+ session: authFailure.session,
2071
+ formId: 'account-settings',
2072
+ route: submitRoute,
2073
+ });
2074
+ assert.deepEqual(groupFormIssuesByField(authFailurePage.issues), {
2075
+ form: ['Sign-in required to update account settings.'],
2076
+ fields: {},
2077
+ });
2078
+ assert.equal(authFailurePage.values.email, 'ada@example.com');
2079
+
2080
+ const validationFailure = processFormSubmission({
2081
+ session: authFailurePage.session,
2082
+ body: {
2083
+ _csrf: authFailurePage.csrfToken,
2084
+ email: 'invalid-email',
2085
+ },
2086
+ auth: { source: 'service-token' },
2087
+ formId: 'account-settings',
2088
+ route: submitRoute,
2089
+ redirectTo: pageRoute.path,
2090
+ validate(values) {
2091
+ return typeof values.email === 'string' && values.email.includes('@')
2092
+ ? []
2093
+ : [{ field: 'email', message: 'Enter a valid email address.' }];
2094
+ },
2095
+ });
2096
+ assert.equal(validationFailure.ok, false);
2097
+
2098
+ const validationFailurePage = prepareFormState({
2099
+ session: validationFailure.session,
2100
+ formId: 'account-settings',
2101
+ route: submitRoute,
2102
+ });
2103
+ assert.deepEqual(groupFormIssuesByField(validationFailurePage.issues), {
2104
+ form: [],
2105
+ fields: {
2106
+ email: ['Enter a valid email address.'],
2107
+ },
2108
+ });
2109
+ assert.equal(validationFailurePage.values.email, 'invalid-email');
2110
+
2111
+ const csrfFailure = processFormSubmission({
2112
+ session: validationFailurePage.session,
2113
+ body: {
2114
+ _csrf: 'wrong-token',
2115
+ email: 'ada@example.com',
2116
+ },
2117
+ auth: { source: 'service-token' },
2118
+ formId: 'account-settings',
2119
+ route: submitRoute,
2120
+ redirectTo: pageRoute.path,
2121
+ });
2122
+ assert.equal(csrfFailure.ok, false);
2123
+
2124
+ const csrfFailurePage = prepareFormState({
2125
+ session: csrfFailure.session,
2126
+ formId: 'account-settings',
2127
+ route: submitRoute,
2128
+ });
2129
+ assert.deepEqual(groupFormIssuesByField(csrfFailurePage.issues), {
2130
+ form: ['Form session expired. Reload the page and try again.'],
2131
+ fields: {},
2132
+ });
2133
+
2134
+ const success = processFormSubmission({
2135
+ session: csrfFailurePage.session,
2136
+ body: {
2137
+ _csrf: csrfFailurePage.csrfToken,
2138
+ email: 'ada@example.com',
2139
+ },
2140
+ auth: { source: 'service-token' },
2141
+ formId: 'account-settings',
2142
+ route: submitRoute,
2143
+ redirectTo: pageRoute.path,
2144
+ validate(values) {
2145
+ return typeof values.email === 'string' && values.email.includes('@')
2146
+ ? []
2147
+ : [{ field: 'email', message: 'Enter a valid email address.' }];
2148
+ },
2149
+ });
2150
+ assert.equal(success.ok, true);
2151
+ assert.equal(success.values.email, 'ada@example.com');
2152
+
2153
+ const successPage = prepareFormState({
2154
+ session: success.session,
2155
+ formId: 'account-settings',
2156
+ route: submitRoute,
2157
+ });
2158
+ assert.deepEqual(successPage.issues, []);
2159
+ assert.deepEqual(successPage.values, {});
2160
+ });
2161
+
2162
+ test('request hook scaffold builds for the default Bun entry', async () => {
2163
+ const defaultWorkspace = await createTempWorkspace('webstir-backend-default-hooks-build-');
2164
+ await buildRuntimeWorkspace(defaultWorkspace, {
2165
+ moduleSource: createRequestHookRuntimeModuleSource(),
2166
+ mode: 'build',
2167
+ });
2168
+ assert.equal(
2169
+ fssync.existsSync(path.join(defaultWorkspace, 'src', 'backend', 'server', 'fastify.ts')),
2170
+ false,
2171
+ );
2172
+ assert.equal(
2173
+ fssync.existsSync(path.join(defaultWorkspace, 'build', 'backend', 'index.js')),
2174
+ true,
2175
+ );
2176
+ });
2177
+
2178
+ test('default backend scaffold uses the package-managed Bun bootstrap', async () => {
2179
+ const workspace = await createTempWorkspace('webstir-backend-bun-scaffold-');
2180
+ await hydrateBackendScaffold(workspace);
2181
+
2182
+ const indexSource = await fs.readFile(path.join(workspace, 'src', 'backend', 'index.ts'), 'utf8');
2183
+ assert.match(indexSource, /createDefaultBunBackendBootstrap/);
2184
+ assert.doesNotMatch(indexSource, /http\.createServer/);
2185
+ assert.doesNotMatch(indexSource, /Bun\.serve/);
2186
+
2187
+ assert.equal(
2188
+ fssync.existsSync(path.join(workspace, 'src', 'backend', 'server', 'bun.ts')),
2189
+ false,
2190
+ );
2191
+ assert.equal(
2192
+ fssync.existsSync(path.join(workspace, 'src', 'backend', 'runtime', 'forms.ts')),
2193
+ false,
2194
+ );
2195
+ assert.equal(
2196
+ fssync.existsSync(path.join(workspace, 'src', 'backend', 'runtime', 'request-hooks.ts')),
2197
+ false,
2198
+ );
2199
+ });
2200
+
2201
+ test('default backend scaffold preserves the readiness log contract', async (t) => {
2202
+ if (!(await canListenOnTcp())) {
2203
+ t.skip('TCP listen is not permitted in this environment.');
2204
+ return;
2205
+ }
2206
+
2207
+ const workspace = await createTempWorkspace('webstir-backend-bun-ready-log-');
2208
+ await buildRuntimeWorkspace(workspace, {
2209
+ moduleSource: `export const module = {
2210
+ manifest: {
2211
+ contractVersion: '1.0.0',
2212
+ name: '@demo/bootstrap-runtime',
2213
+ version: '0.1.0',
2214
+ kind: 'backend',
2215
+ capabilities: ['http'],
2216
+ routes: []
2217
+ },
2218
+ routes: []
2219
+ };
2220
+ `,
2221
+ });
2222
+
2223
+ let port = await getOpenPort();
2224
+ const server = await startBuiltServer(workspace, port);
2225
+ port = server.port;
2226
+
2227
+ try {
2228
+ await waitFor(() => server.getStdout().includes('API server running'), 5000, 50);
2229
+ } finally {
2230
+ await server.stop();
2231
+ }
2232
+ });
2233
+
2234
+ function extractCookieHeader(setCookie) {
2235
+ return String(setCookie ?? '').split(';')[0];
2236
+ }
2237
+
2238
+ function extractHiddenInputValue(html, name) {
2239
+ const pattern = new RegExp(`<input[^>]+name="${name}"[^>]+value="([^"]*)"`, 'i');
2240
+ const match = pattern.exec(String(html));
2241
+ assert.ok(match, `Expected hidden input ${name} to exist.`);
2242
+ return match[1];
2243
+ }
2244
+
2245
+ function extractViewState(html) {
2246
+ const match =
2247
+ /<script type="application\/json" id="webstir-view-state">([\s\S]*?)<\/script>/i.exec(
2248
+ String(html),
2249
+ );
2250
+ assert.ok(match, 'Expected request-time view payload to be injected.');
2251
+ return JSON.parse(match[1]);
2252
+ }
2253
+
2254
+ async function onceExit(child) {
2255
+ if (child.exitCode !== null) {
2256
+ return;
2257
+ }
2258
+ await new Promise((resolve) => child.once('exit', resolve));
2259
+ }
2260
+
2261
+ async function waitForProcessReady(child, readyText, timeoutMs) {
2262
+ const normalized = readyText
2263
+ .split('|')
2264
+ .map((token) => token.trim())
2265
+ .filter(Boolean);
2266
+ const readinessMatches = (line) =>
2267
+ normalized.length === 0 ? line.length > 0 : normalized.some((token) => line.includes(token));
2268
+
2269
+ await new Promise((resolve, reject) => {
2270
+ const cleanup = () => {
2271
+ child.stdout?.off('data', onStdout);
2272
+ child.stderr?.off('data', onStderr);
2273
+ child.off('exit', onExit);
2274
+ clearTimeout(timer);
2275
+ };
2276
+
2277
+ const onStdout = (chunk) => {
2278
+ const text = chunk.toString();
2279
+ for (const line of text.split(/\r?\n/)) {
2280
+ if (line && readinessMatches(line)) {
2281
+ cleanup();
2282
+ resolve();
2283
+ return;
2284
+ }
2285
+ }
2286
+ };
2287
+
2288
+ const onStderr = (chunk) => {
2289
+ const text = chunk.toString();
2290
+ for (const line of text.split(/\r?\n/)) {
2291
+ if (line && readinessMatches(line)) {
2292
+ cleanup();
2293
+ resolve();
2294
+ return;
2295
+ }
2296
+ }
2297
+ };
2298
+
2299
+ const onExit = (code, signal) => {
2300
+ cleanup();
2301
+ reject(
2302
+ new Error(
2303
+ `Backend server exited before readiness (code=${code ?? 'null'} signal=${signal ?? 'null'}).`,
2304
+ ),
2305
+ );
2306
+ };
2307
+
2308
+ const timer = setTimeout(() => {
2309
+ cleanup();
2310
+ reject(new Error(`Backend server did not become ready within ${timeoutMs}ms.`));
2311
+ }, timeoutMs);
2312
+
2313
+ child.stdout?.on('data', onStdout);
2314
+ child.stderr?.on('data', onStderr);
2315
+ child.once('exit', onExit);
2316
+ });
2317
+ }
2318
+
2319
+ async function waitFor(checkFn, timeoutMs = 5000, pollMs = 50) {
2320
+ const start = Date.now();
2321
+ while (Date.now() - start < timeoutMs) {
2322
+ if (await checkFn()) {
2323
+ return;
2324
+ }
2325
+ await delay(pollMs);
2326
+ }
2327
+ throw new Error('waitFor timed out');
2328
+ }
2329
+
46
2330
  test('build mode produces transpiled output and manifest', async () => {
47
2331
  const workspace = await createTempWorkspace();
48
2332
  await hydrateBackendScaffold(workspace);
@@ -64,6 +2348,45 @@ test('build mode produces transpiled output and manifest', async () => {
64
2348
  assert.ok(result.manifest.entryPoints.some((e) => e.endsWith('index.js')));
65
2349
  });
66
2350
 
2351
+ test('build mode resolves WORKSPACE_ROOT from env overrides outside the workspace cwd', async () => {
2352
+ const workspace = await createTempWorkspace('webstir-backend-build-root-');
2353
+ const alternateCwd = await createTempWorkspace('webstir-backend-build-cwd-');
2354
+ const previousCwd = process.cwd();
2355
+ await hydrateBackendScaffold(workspace);
2356
+ await fs.writeFile(
2357
+ path.join(workspace, 'package.json'),
2358
+ JSON.stringify({ name: '@demo/env-root-build', version: '7.8.9', type: 'module' }, null, 2),
2359
+ 'utf8',
2360
+ );
2361
+
2362
+ const bin = getLocalBinPath();
2363
+
2364
+ try {
2365
+ process.chdir(alternateCwd);
2366
+
2367
+ const result = await backendProvider.build({
2368
+ env: {
2369
+ WEBSTIR_MODULE_MODE: 'build',
2370
+ WEBSTIR_BACKEND_TYPECHECK: 'skip',
2371
+ WORKSPACE_ROOT: workspace,
2372
+ PATH: `${bin}${path.delimiter}${process.env.PATH ?? ''}`,
2373
+ },
2374
+ incremental: false,
2375
+ });
2376
+
2377
+ assert.equal(result.manifest.module?.name, '@demo/env-root-build');
2378
+ assert.equal(result.manifest.module?.version, '7.8.9');
2379
+ assert.equal(fssync.existsSync(path.join(workspace, 'build', 'backend', 'index.js')), true);
2380
+ assert.equal(fssync.existsSync(path.join(workspace, '.webstir', 'backend-outputs.json')), true);
2381
+ assert.equal(
2382
+ fssync.existsSync(path.join(workspace, '.webstir', 'backend-manifest-digest.json')),
2383
+ true,
2384
+ );
2385
+ } finally {
2386
+ process.chdir(previousCwd);
2387
+ }
2388
+ });
2389
+
67
2390
  test('publish mode bundles output and manifest has entry', async () => {
68
2391
  const workspace = await createTempWorkspace();
69
2392
  await hydrateBackendScaffold(workspace);
@@ -102,7 +2425,113 @@ test('publish mode emits sourcemaps when opt-in flag is set', async () => {
102
2425
  const mapFile = path.join(buildRoot, 'index.js.map');
103
2426
  assert.equal(fssync.existsSync(mapFile), true, 'expected build/backend/index.js.map to exist');
104
2427
  assert.ok(
105
- result.artifacts.some((artifact) => artifact.path.endsWith('index.js.map') && artifact.type === 'asset'),
106
- 'expected index.js.map to be included as an asset artifact'
2428
+ result.artifacts.some(
2429
+ (artifact) => artifact.path.endsWith('index.js.map') && artifact.type === 'asset',
2430
+ ),
2431
+ 'expected index.js.map to be included as an asset artifact',
107
2432
  );
108
2433
  });
2434
+
2435
+ test('built backend server validates fragment route responses', async (t) => {
2436
+ if (!(await canListenOnTcp())) {
2437
+ t.skip('TCP listen is not permitted in this environment.');
2438
+ return;
2439
+ }
2440
+
2441
+ await assertFragmentRuntimeBehavior();
2442
+ });
2443
+
2444
+ test('built backend server executes request hooks with ordered context handoff', async (t) => {
2445
+ if (!(await canListenOnTcp())) {
2446
+ t.skip('TCP listen is not permitted in this environment.');
2447
+ return;
2448
+ }
2449
+
2450
+ await assertRequestHookRuntimeBehavior();
2451
+ });
2452
+
2453
+ test('bun backend scaffold executes request hooks with ordered context handoff', async (t) => {
2454
+ if (!(await canListenOnTcp())) {
2455
+ t.skip('TCP listen is not permitted in this environment.');
2456
+ return;
2457
+ }
2458
+
2459
+ await assertBunRequestHookRuntimeBehavior();
2460
+ });
2461
+
2462
+ test('built backend server enforces jwt exp and nbf claims', async (t) => {
2463
+ if (!(await canListenOnTcp())) {
2464
+ t.skip('TCP listen is not permitted in this environment.');
2465
+ return;
2466
+ }
2467
+
2468
+ await assertJwtTimeClaimBehavior();
2469
+ });
2470
+
2471
+ test('built backend server accepts rsa public-key and jwks bearer tokens', async (t) => {
2472
+ if (!(await canListenOnTcp())) {
2473
+ t.skip('TCP listen is not permitted in this environment.');
2474
+ return;
2475
+ }
2476
+
2477
+ await assertJwtAsymmetricBehavior();
2478
+ });
2479
+
2480
+ test('built backend server resolves session state and flash transport', async (t) => {
2481
+ if (!(await canListenOnTcp())) {
2482
+ t.skip('TCP listen is not permitted in this environment.');
2483
+ return;
2484
+ }
2485
+
2486
+ await assertSessionRuntimeBehavior();
2487
+ });
2488
+
2489
+ test('built backend server rejects oversized request bodies with 413', async (t) => {
2490
+ if (!(await canListenOnTcp())) {
2491
+ t.skip('TCP listen is not permitted in this environment.');
2492
+ return;
2493
+ }
2494
+
2495
+ await assertRequestBodyLimitBehavior();
2496
+ });
2497
+
2498
+ test('built backend server handles auth-aware form workflows with csrf and validation', async (t) => {
2499
+ if (!(await canListenOnTcp())) {
2500
+ t.skip('TCP listen is not permitted in this environment.');
2501
+ return;
2502
+ }
2503
+
2504
+ await assertFormWorkflowRuntimeBehavior();
2505
+ });
2506
+
2507
+ test('built backend server renders request-time views with live SSR context', async (t) => {
2508
+ if (!(await canListenOnTcp())) {
2509
+ t.skip('TCP listen is not permitted in this environment.');
2510
+ return;
2511
+ }
2512
+
2513
+ await assertRequestTimeViewRuntimeBehavior();
2514
+ });
2515
+
2516
+ test('built backend server resolves request-time view documents from WORKSPACE_ROOT outside the workspace cwd', async (t) => {
2517
+ if (!(await canListenOnTcp())) {
2518
+ t.skip('TCP listen is not permitted in this environment.');
2519
+ return;
2520
+ }
2521
+
2522
+ await assertRequestTimeViewWorkspaceRootBehavior();
2523
+ });
2524
+
2525
+ test('built backend server resolves request-time view documents from WEBSTIR_WORKSPACE_ROOT outside the workspace cwd', async (t) => {
2526
+ if (!(await canListenOnTcp())) {
2527
+ t.skip('TCP listen is not permitted in this environment.');
2528
+ return;
2529
+ }
2530
+
2531
+ await assertRequestTimeViewWorkspaceRootBehavior({
2532
+ extraEnv: (workspace) => ({
2533
+ WORKSPACE_ROOT: ' ',
2534
+ WEBSTIR_WORKSPACE_ROOT: workspace,
2535
+ }),
2536
+ });
2537
+ });