@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.
- package/README.md +106 -79
- package/dist/add.d.ts +59 -0
- package/dist/add.js +626 -0
- package/dist/build/artifacts.d.ts +115 -1
- package/dist/build/artifacts.js +4 -4
- package/dist/build/entries.js +1 -1
- package/dist/build/pipeline.d.ts +33 -1
- package/dist/build/pipeline.js +307 -65
- package/dist/cache/diff.js +9 -8
- package/dist/cache/reporters.js +1 -1
- package/dist/deploy-cli.d.ts +2 -0
- package/dist/deploy-cli.js +86 -0
- package/dist/diagnostics/summary.js +2 -2
- package/dist/index.d.ts +6 -0
- package/dist/index.js +4 -0
- package/dist/manifest/pipeline.js +103 -32
- package/dist/provider.js +35 -17
- package/dist/runtime/bun.d.ts +51 -0
- package/dist/runtime/bun.js +499 -0
- package/dist/runtime/core.d.ts +141 -0
- package/dist/runtime/core.js +316 -0
- package/dist/runtime/deploy-backend.d.ts +20 -0
- package/dist/runtime/deploy-backend.js +175 -0
- package/dist/runtime/deploy-shared.d.ts +43 -0
- package/dist/runtime/deploy-shared.js +75 -0
- package/dist/runtime/deploy-static.d.ts +2 -0
- package/dist/runtime/deploy-static.js +161 -0
- package/dist/runtime/deploy.d.ts +3 -0
- package/dist/runtime/deploy.js +91 -0
- package/dist/runtime/forms.d.ts +73 -0
- package/dist/runtime/forms.js +236 -0
- package/dist/runtime/request-hooks.d.ts +47 -0
- package/dist/runtime/request-hooks.js +102 -0
- package/dist/runtime/session-metadata.d.ts +13 -0
- package/dist/runtime/session-metadata.js +98 -0
- package/dist/runtime/session-runtime.d.ts +28 -0
- package/dist/runtime/session-runtime.js +180 -0
- package/dist/runtime/session.d.ts +83 -0
- package/dist/runtime/session.js +396 -0
- package/dist/runtime/views.d.ts +74 -0
- package/dist/runtime/views.js +221 -0
- package/dist/scaffold/assets.js +25 -21
- package/dist/testing/context.js +1 -1
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +100 -56
- package/dist/utils/bun.d.ts +2 -0
- package/dist/utils/bun.js +13 -0
- package/dist/watch.d.ts +13 -1
- package/dist/watch.js +345 -97
- package/dist/workspace.d.ts +8 -0
- package/dist/workspace.js +44 -3
- package/package.json +49 -14
- package/scripts/publish.sh +2 -92
- package/scripts/smoke.mjs +282 -107
- package/scripts/update-contract.sh +12 -10
- package/src/add.ts +964 -0
- package/src/build/artifacts.ts +49 -46
- package/src/build/entries.ts +12 -12
- package/src/build/pipeline.ts +779 -403
- package/src/cache/diff.ts +111 -105
- package/src/cache/reporters.ts +26 -26
- package/src/deploy-cli.ts +111 -0
- package/src/diagnostics/summary.ts +28 -22
- package/src/index.ts +11 -0
- package/src/manifest/pipeline.ts +328 -215
- package/src/provider.ts +115 -98
- package/src/runtime/bun.ts +793 -0
- package/src/runtime/core.ts +598 -0
- package/src/runtime/deploy-backend.ts +239 -0
- package/src/runtime/deploy-shared.ts +136 -0
- package/src/runtime/deploy-static.ts +191 -0
- package/src/runtime/deploy.ts +143 -0
- package/src/runtime/forms.ts +364 -0
- package/src/runtime/request-hooks.ts +165 -0
- package/src/runtime/session-metadata.ts +135 -0
- package/src/runtime/session-runtime.ts +267 -0
- package/src/runtime/session.ts +642 -0
- package/src/runtime/views.ts +385 -0
- package/src/scaffold/assets.ts +77 -73
- package/src/testing/context.js +8 -9
- package/src/testing/context.ts +9 -9
- package/src/testing/index.d.ts +14 -3
- package/src/testing/index.js +254 -175
- package/src/testing/index.ts +298 -195
- package/src/testing/types.d.ts +18 -19
- package/src/testing/types.ts +18 -18
- package/src/utils/bun.ts +26 -0
- package/src/watch.ts +503 -99
- package/src/workspace.ts +59 -3
- package/templates/backend/.env.example +15 -0
- package/templates/backend/auth/adapter.ts +335 -36
- package/templates/backend/db/connection.ts +190 -65
- package/templates/backend/db/migrate.ts +149 -43
- package/templates/backend/db/types.d.ts +1 -1
- package/templates/backend/env.ts +132 -20
- package/templates/backend/functions/hello/index.ts +1 -2
- package/templates/backend/index.ts +15 -508
- package/templates/backend/jobs/nightly/index.ts +1 -1
- package/templates/backend/jobs/runtime.ts +24 -11
- package/templates/backend/jobs/scheduler.ts +208 -46
- package/templates/backend/module.ts +227 -13
- package/templates/backend/observability/logger.ts +2 -12
- package/templates/backend/observability/metrics.ts +8 -5
- package/templates/backend/session/sqlite.ts +152 -0
- package/templates/backend/session/store.ts +45 -0
- package/templates/backend/tsconfig.json +1 -1
- package/tests/add.test.js +327 -0
- package/tests/authAdapter.test.js +315 -0
- package/tests/bundlerParity.test.js +217 -0
- package/tests/cacheReporter.test.js +10 -10
- package/tests/dbConnection.test.js +209 -0
- package/tests/deploy.test.js +357 -0
- package/tests/envLoader.test.js +271 -17
- package/tests/integration.test.js +2432 -3
- package/tests/jobsScheduler.test.js +253 -0
- package/tests/manifest.test.js +287 -12
- package/tests/migrationRunner.test.js +249 -0
- package/tests/sessionScaffoldStore.test.js +752 -0
- package/tests/sessionStore.test.js +490 -0
- package/tests/testing.test.js +252 -0
- package/tests/watch.test.js +192 -32
- package/tsconfig.json +3 -10
- package/templates/backend/server/fastify.ts +0 -288
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
8
|
+
import { build as esbuild } from 'esbuild';
|
|
9
|
+
|
|
10
|
+
import { backendProvider } from '../dist/index.js';
|
|
11
|
+
import { prepareSessionState } from '../dist/runtime/session.js';
|
|
12
|
+
import { prepareFormState, processFormSubmission } from '../dist/runtime/forms.js';
|
|
13
|
+
|
|
14
|
+
const config = {
|
|
15
|
+
secret: 'test-session-secret',
|
|
16
|
+
cookieName: 'webstir_session',
|
|
17
|
+
secure: false,
|
|
18
|
+
maxAgeSeconds: 60,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const loginRoute = {
|
|
22
|
+
form: {
|
|
23
|
+
session: { write: true },
|
|
24
|
+
flash: {
|
|
25
|
+
publish: [{ key: 'signed-in', level: 'success', when: 'success' }],
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const accountRoute = {
|
|
31
|
+
session: { mode: 'optional' },
|
|
32
|
+
flash: { consume: ['signed-in'] },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
async function createTempWorkspace(prefix = 'webstir-backend-session-store-') {
|
|
36
|
+
return await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function copyFile(src, dest) {
|
|
40
|
+
await fs.mkdir(path.dirname(dest), { recursive: true });
|
|
41
|
+
await fs.copyFile(src, dest);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function seedBackendWorkspace(workspace, name) {
|
|
45
|
+
const assets = await backendProvider.getScaffoldAssets();
|
|
46
|
+
for (const asset of assets) {
|
|
47
|
+
await copyFile(asset.sourcePath, path.join(workspace, asset.targetPath));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
await fs.writeFile(
|
|
51
|
+
path.join(workspace, 'package.json'),
|
|
52
|
+
JSON.stringify(
|
|
53
|
+
{
|
|
54
|
+
name,
|
|
55
|
+
version: '0.0.0',
|
|
56
|
+
type: 'module',
|
|
57
|
+
},
|
|
58
|
+
null,
|
|
59
|
+
2,
|
|
60
|
+
),
|
|
61
|
+
'utf8',
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function linkWorkspacePackage(workspace) {
|
|
66
|
+
const packageRoot = getPackageRoot();
|
|
67
|
+
const scopeRoot = path.join(workspace, 'node_modules', '@webstir-io');
|
|
68
|
+
await fs.mkdir(scopeRoot, { recursive: true });
|
|
69
|
+
await fs.symlink(packageRoot, path.join(scopeRoot, 'webstir-backend'), 'dir');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function getPackageRoot() {
|
|
73
|
+
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function compileTemplateSessionFiles(workspace) {
|
|
77
|
+
await esbuild({
|
|
78
|
+
entryPoints: [
|
|
79
|
+
path.join(workspace, 'src', 'backend', 'env.ts'),
|
|
80
|
+
path.join(workspace, 'src', 'backend', 'session', 'sqlite.ts'),
|
|
81
|
+
path.join(workspace, 'src', 'backend', 'session', 'store.ts'),
|
|
82
|
+
],
|
|
83
|
+
bundle: false,
|
|
84
|
+
format: 'esm',
|
|
85
|
+
platform: 'node',
|
|
86
|
+
target: 'node20',
|
|
87
|
+
outdir: path.join(workspace, 'build', 'backend'),
|
|
88
|
+
outbase: path.join(workspace, 'src', 'backend'),
|
|
89
|
+
logLevel: 'silent',
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function snapshotEnv(keys) {
|
|
94
|
+
return Object.fromEntries(keys.map((key) => [key, process.env[key]]));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function restoreEnv(snapshot) {
|
|
98
|
+
for (const [key, value] of Object.entries(snapshot)) {
|
|
99
|
+
if (value === undefined) {
|
|
100
|
+
delete process.env[key];
|
|
101
|
+
} else {
|
|
102
|
+
process.env[key] = value;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function extractCookieHeader(setCookie) {
|
|
108
|
+
assert.ok(setCookie, 'expected a session cookie');
|
|
109
|
+
return String(setCookie).split(';')[0];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function extractSessionId(cookieHeader, cookieName) {
|
|
113
|
+
const [nameValue] = String(cookieHeader).split(';');
|
|
114
|
+
const prefix = `${cookieName}=`;
|
|
115
|
+
assert.ok(nameValue.startsWith(prefix), `expected ${cookieName} cookie`);
|
|
116
|
+
const encodedValue = nameValue.slice(prefix.length);
|
|
117
|
+
const separatorIndex = encodedValue.indexOf('.');
|
|
118
|
+
assert.notEqual(separatorIndex, -1, 'expected signed session cookie');
|
|
119
|
+
return decodeURIComponent(encodedValue.slice(0, separatorIndex));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function importCompiledModule(filePath) {
|
|
123
|
+
return await import(`${pathToFileURL(filePath).href}?t=${Date.now()}-${Math.random()}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function runSqliteSessionProbe(workspace, { cwd = workspace, env = {} } = {}) {
|
|
127
|
+
const sessionStoreUrl = pathToFileURL(
|
|
128
|
+
path.join(workspace, 'build', 'backend', 'session', 'store.js'),
|
|
129
|
+
).href;
|
|
130
|
+
const runtimeSessionUrl = pathToFileURL(
|
|
131
|
+
path.join(getPackageRoot(), 'dist', 'runtime', 'session.js'),
|
|
132
|
+
).href;
|
|
133
|
+
const script = `
|
|
134
|
+
const [{ sessionStore }, { prepareSessionState }] = await Promise.all([
|
|
135
|
+
import(${JSON.stringify(sessionStoreUrl)}),
|
|
136
|
+
import(${JSON.stringify(runtimeSessionUrl)})
|
|
137
|
+
]);
|
|
138
|
+
|
|
139
|
+
const config = {
|
|
140
|
+
secret: 'test-session-secret',
|
|
141
|
+
cookieName: 'webstir_session',
|
|
142
|
+
secure: false,
|
|
143
|
+
maxAgeSeconds: 60
|
|
144
|
+
};
|
|
145
|
+
const loginRoute = {
|
|
146
|
+
form: {
|
|
147
|
+
session: { write: true },
|
|
148
|
+
flash: {
|
|
149
|
+
publish: [{ key: 'signed-in', level: 'success', when: 'success' }]
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
const accountRoute = {
|
|
154
|
+
session: { mode: 'optional' },
|
|
155
|
+
flash: { consume: ['signed-in'] }
|
|
156
|
+
};
|
|
157
|
+
const created = prepareSessionState({
|
|
158
|
+
cookies: '',
|
|
159
|
+
route: loginRoute,
|
|
160
|
+
config,
|
|
161
|
+
store: sessionStore
|
|
162
|
+
});
|
|
163
|
+
const createdCommit = created.commit({
|
|
164
|
+
session: {
|
|
165
|
+
userId: 'ada@example.com',
|
|
166
|
+
data: { email: 'ada@example.com' }
|
|
167
|
+
},
|
|
168
|
+
route: loginRoute,
|
|
169
|
+
result: {
|
|
170
|
+
status: 303,
|
|
171
|
+
redirect: { location: '/session/account' }
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
const cookie = String(createdCommit.setCookie).split(';')[0];
|
|
175
|
+
const read = prepareSessionState({
|
|
176
|
+
cookies: cookie,
|
|
177
|
+
route: accountRoute,
|
|
178
|
+
config,
|
|
179
|
+
store: sessionStore
|
|
180
|
+
});
|
|
181
|
+
console.log(JSON.stringify({
|
|
182
|
+
userId: read.session?.userId ?? null,
|
|
183
|
+
flash: read.flash.map((message) => ({ key: message.key, level: message.level }))
|
|
184
|
+
}));
|
|
185
|
+
`;
|
|
186
|
+
const child = spawn('bun', ['--eval', script], {
|
|
187
|
+
cwd,
|
|
188
|
+
env: {
|
|
189
|
+
...process.env,
|
|
190
|
+
...env,
|
|
191
|
+
},
|
|
192
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
let stdout = '';
|
|
196
|
+
let stderr = '';
|
|
197
|
+
child.stdout.on('data', (chunk) => {
|
|
198
|
+
stdout += chunk.toString();
|
|
199
|
+
});
|
|
200
|
+
child.stderr.on('data', (chunk) => {
|
|
201
|
+
stderr += chunk.toString();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const exitCode = await new Promise((resolve) => {
|
|
205
|
+
child.once('close', resolve);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
if (exitCode !== 0) {
|
|
209
|
+
throw new Error(
|
|
210
|
+
`Session probe failed (exit ${exitCode}).\nstdout:\n${stdout}\nstderr:\n${stderr}`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const line = stdout
|
|
215
|
+
.split(/\r?\n/)
|
|
216
|
+
.map((value) => value.trim())
|
|
217
|
+
.find((value) => value.startsWith('{'));
|
|
218
|
+
if (!line) {
|
|
219
|
+
throw new Error(`Session probe did not emit JSON.\nstdout:\n${stdout}\nstderr:\n${stderr}`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return JSON.parse(line);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
test('scaffold session store defaults to in-memory storage outside production', async () => {
|
|
226
|
+
const workspace = await createTempWorkspace('webstir-backend-session-memory-');
|
|
227
|
+
await seedBackendWorkspace(workspace, '@demo/session-memory');
|
|
228
|
+
await linkWorkspacePackage(workspace);
|
|
229
|
+
await compileTemplateSessionFiles(workspace);
|
|
230
|
+
|
|
231
|
+
const previousEnv = snapshotEnv([
|
|
232
|
+
'NODE_ENV',
|
|
233
|
+
'WORKSPACE_ROOT',
|
|
234
|
+
'WEBSTIR_WORKSPACE_ROOT',
|
|
235
|
+
'SESSION_STORE_DRIVER',
|
|
236
|
+
'SESSION_STORE_URL',
|
|
237
|
+
]);
|
|
238
|
+
const previousSqliteTarget = globalThis.__webstirSqliteTarget;
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
delete process.env.NODE_ENV;
|
|
242
|
+
delete process.env.WORKSPACE_ROOT;
|
|
243
|
+
delete process.env.WEBSTIR_WORKSPACE_ROOT;
|
|
244
|
+
delete process.env.SESSION_STORE_DRIVER;
|
|
245
|
+
delete process.env.SESSION_STORE_URL;
|
|
246
|
+
delete globalThis.__webstirSqliteTarget;
|
|
247
|
+
|
|
248
|
+
const { sessionStore } = await importCompiledModule(
|
|
249
|
+
path.join(workspace, 'build', 'backend', 'session', 'store.js'),
|
|
250
|
+
);
|
|
251
|
+
const created = prepareSessionState({
|
|
252
|
+
cookies: '',
|
|
253
|
+
route: loginRoute,
|
|
254
|
+
config,
|
|
255
|
+
store: sessionStore,
|
|
256
|
+
});
|
|
257
|
+
const createdCommit = created.commit({
|
|
258
|
+
session: {
|
|
259
|
+
userId: 'ada@example.com',
|
|
260
|
+
},
|
|
261
|
+
route: loginRoute,
|
|
262
|
+
result: {
|
|
263
|
+
status: 303,
|
|
264
|
+
redirect: { location: '/session/account' },
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
const read = prepareSessionState({
|
|
269
|
+
cookies: extractCookieHeader(createdCommit.setCookie),
|
|
270
|
+
route: accountRoute,
|
|
271
|
+
config,
|
|
272
|
+
store: sessionStore,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
assert.equal(read.session?.userId, 'ada@example.com');
|
|
276
|
+
assert.equal(globalThis.__webstirSqliteTarget, undefined);
|
|
277
|
+
} finally {
|
|
278
|
+
restoreEnv(previousEnv);
|
|
279
|
+
if (previousSqliteTarget === undefined) {
|
|
280
|
+
delete globalThis.__webstirSqliteTarget;
|
|
281
|
+
} else {
|
|
282
|
+
globalThis.__webstirSqliteTarget = previousSqliteTarget;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test('scaffold session store defaults to sqlite storage in production', async () => {
|
|
288
|
+
const workspace = await createTempWorkspace('webstir-backend-session-prod-sqlite-');
|
|
289
|
+
const alternateCwd = await createTempWorkspace('webstir-backend-session-prod-sqlite-cwd-');
|
|
290
|
+
await seedBackendWorkspace(workspace, '@demo/session-prod-sqlite');
|
|
291
|
+
await linkWorkspacePackage(workspace);
|
|
292
|
+
await compileTemplateSessionFiles(workspace);
|
|
293
|
+
|
|
294
|
+
const result = await runSqliteSessionProbe(workspace, {
|
|
295
|
+
cwd: alternateCwd,
|
|
296
|
+
env: {
|
|
297
|
+
NODE_ENV: 'production',
|
|
298
|
+
WORKSPACE_ROOT: ' ',
|
|
299
|
+
WEBSTIR_WORKSPACE_ROOT: workspace,
|
|
300
|
+
},
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
assert.equal(result.userId, 'ada@example.com');
|
|
304
|
+
assert.deepEqual(result.flash, [{ key: 'signed-in', level: 'success' }]);
|
|
305
|
+
assert.equal(
|
|
306
|
+
await fs
|
|
307
|
+
.access(path.join(workspace, 'data', 'sessions.sqlite'))
|
|
308
|
+
.then(() => true)
|
|
309
|
+
.catch(() => false),
|
|
310
|
+
true,
|
|
311
|
+
);
|
|
312
|
+
assert.equal(
|
|
313
|
+
await fs
|
|
314
|
+
.access(path.join(alternateCwd, 'data', 'sessions.sqlite'))
|
|
315
|
+
.then(() => true)
|
|
316
|
+
.catch(() => false),
|
|
317
|
+
false,
|
|
318
|
+
);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test('scaffold session store resolves sqlite paths from the workspace root outside the workspace cwd', async () => {
|
|
322
|
+
const workspace = await createTempWorkspace('webstir-backend-session-sqlite-');
|
|
323
|
+
const alternateCwd = await createTempWorkspace('webstir-backend-session-sqlite-cwd-');
|
|
324
|
+
await seedBackendWorkspace(workspace, '@demo/session-sqlite');
|
|
325
|
+
await linkWorkspacePackage(workspace);
|
|
326
|
+
await compileTemplateSessionFiles(workspace);
|
|
327
|
+
|
|
328
|
+
const result = await runSqliteSessionProbe(workspace, {
|
|
329
|
+
cwd: alternateCwd,
|
|
330
|
+
env: {
|
|
331
|
+
WORKSPACE_ROOT: ' ',
|
|
332
|
+
WEBSTIR_WORKSPACE_ROOT: workspace,
|
|
333
|
+
SESSION_STORE_DRIVER: 'sqlite',
|
|
334
|
+
SESSION_STORE_URL: 'file:./data/session-store.sqlite',
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
assert.equal(result.userId, 'ada@example.com');
|
|
339
|
+
assert.deepEqual(result.flash, [{ key: 'signed-in', level: 'success' }]);
|
|
340
|
+
assert.equal(
|
|
341
|
+
await fs
|
|
342
|
+
.access(path.join(workspace, 'data', 'session-store.sqlite'))
|
|
343
|
+
.then(() => true)
|
|
344
|
+
.catch(() => false),
|
|
345
|
+
true,
|
|
346
|
+
);
|
|
347
|
+
assert.equal(
|
|
348
|
+
await fs
|
|
349
|
+
.access(path.join(alternateCwd, 'data', 'session-store.sqlite'))
|
|
350
|
+
.then(() => true)
|
|
351
|
+
.catch(() => false),
|
|
352
|
+
false,
|
|
353
|
+
);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test('scaffold session store infers sqlite when SESSION_STORE_URL is configured', async () => {
|
|
357
|
+
const workspace = await createTempWorkspace('webstir-backend-session-sqlite-url-');
|
|
358
|
+
const alternateCwd = await createTempWorkspace('webstir-backend-session-sqlite-url-cwd-');
|
|
359
|
+
await seedBackendWorkspace(workspace, '@demo/session-sqlite-url');
|
|
360
|
+
await linkWorkspacePackage(workspace);
|
|
361
|
+
await compileTemplateSessionFiles(workspace);
|
|
362
|
+
|
|
363
|
+
const result = await runSqliteSessionProbe(workspace, {
|
|
364
|
+
cwd: alternateCwd,
|
|
365
|
+
env: {
|
|
366
|
+
WORKSPACE_ROOT: ' ',
|
|
367
|
+
WEBSTIR_WORKSPACE_ROOT: workspace,
|
|
368
|
+
SESSION_STORE_URL: 'file:./data/session-store-from-url.sqlite',
|
|
369
|
+
},
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
assert.equal(result.userId, 'ada@example.com');
|
|
373
|
+
assert.deepEqual(result.flash, [{ key: 'signed-in', level: 'success' }]);
|
|
374
|
+
assert.equal(
|
|
375
|
+
await fs
|
|
376
|
+
.access(path.join(workspace, 'data', 'session-store-from-url.sqlite'))
|
|
377
|
+
.then(() => true)
|
|
378
|
+
.catch(() => false),
|
|
379
|
+
true,
|
|
380
|
+
);
|
|
381
|
+
assert.equal(
|
|
382
|
+
await fs
|
|
383
|
+
.access(path.join(alternateCwd, 'data', 'session-store-from-url.sqlite'))
|
|
384
|
+
.then(() => true)
|
|
385
|
+
.catch(() => false),
|
|
386
|
+
false,
|
|
387
|
+
);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test('scaffold session store allows an explicit memory override in production', async () => {
|
|
391
|
+
const workspace = await createTempWorkspace('webstir-backend-session-prod-memory-');
|
|
392
|
+
await seedBackendWorkspace(workspace, '@demo/session-prod-memory');
|
|
393
|
+
await linkWorkspacePackage(workspace);
|
|
394
|
+
await compileTemplateSessionFiles(workspace);
|
|
395
|
+
|
|
396
|
+
const previousEnv = snapshotEnv([
|
|
397
|
+
'NODE_ENV',
|
|
398
|
+
'WORKSPACE_ROOT',
|
|
399
|
+
'WEBSTIR_WORKSPACE_ROOT',
|
|
400
|
+
'SESSION_STORE_DRIVER',
|
|
401
|
+
'SESSION_STORE_URL',
|
|
402
|
+
]);
|
|
403
|
+
const previousSqliteTarget = globalThis.__webstirSqliteTarget;
|
|
404
|
+
|
|
405
|
+
try {
|
|
406
|
+
process.env.NODE_ENV = 'production';
|
|
407
|
+
delete process.env.WORKSPACE_ROOT;
|
|
408
|
+
delete process.env.WEBSTIR_WORKSPACE_ROOT;
|
|
409
|
+
process.env.SESSION_STORE_DRIVER = 'memory';
|
|
410
|
+
delete process.env.SESSION_STORE_URL;
|
|
411
|
+
delete globalThis.__webstirSqliteTarget;
|
|
412
|
+
|
|
413
|
+
const { sessionStore } = await importCompiledModule(
|
|
414
|
+
path.join(workspace, 'build', 'backend', 'session', 'store.js'),
|
|
415
|
+
);
|
|
416
|
+
const created = prepareSessionState({
|
|
417
|
+
cookies: '',
|
|
418
|
+
route: loginRoute,
|
|
419
|
+
config,
|
|
420
|
+
store: sessionStore,
|
|
421
|
+
});
|
|
422
|
+
const createdCommit = created.commit({
|
|
423
|
+
session: {
|
|
424
|
+
userId: 'ada@example.com',
|
|
425
|
+
},
|
|
426
|
+
route: loginRoute,
|
|
427
|
+
result: {
|
|
428
|
+
status: 303,
|
|
429
|
+
redirect: { location: '/session/account' },
|
|
430
|
+
},
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
const read = prepareSessionState({
|
|
434
|
+
cookies: extractCookieHeader(createdCommit.setCookie),
|
|
435
|
+
route: accountRoute,
|
|
436
|
+
config,
|
|
437
|
+
store: sessionStore,
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
assert.equal(read.session?.userId, 'ada@example.com');
|
|
441
|
+
assert.equal(globalThis.__webstirSqliteTarget, undefined);
|
|
442
|
+
} finally {
|
|
443
|
+
restoreEnv(previousEnv);
|
|
444
|
+
if (previousSqliteTarget === undefined) {
|
|
445
|
+
delete globalThis.__webstirSqliteTarget;
|
|
446
|
+
} else {
|
|
447
|
+
globalThis.__webstirSqliteTarget = previousSqliteTarget;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test('scaffold sqlite session store preserves form and csrf transport without embedding legacy runtime keys', async () => {
|
|
453
|
+
const workspace = await createTempWorkspace('webstir-backend-session-form-sqlite-');
|
|
454
|
+
await seedBackendWorkspace(workspace, '@demo/session-form-sqlite');
|
|
455
|
+
await linkWorkspacePackage(workspace);
|
|
456
|
+
await compileTemplateSessionFiles(workspace);
|
|
457
|
+
|
|
458
|
+
const previousEnv = snapshotEnv([
|
|
459
|
+
'WORKSPACE_ROOT',
|
|
460
|
+
'WEBSTIR_WORKSPACE_ROOT',
|
|
461
|
+
'SESSION_STORE_DRIVER',
|
|
462
|
+
'SESSION_STORE_URL',
|
|
463
|
+
]);
|
|
464
|
+
|
|
465
|
+
try {
|
|
466
|
+
process.env.WORKSPACE_ROOT = ' ';
|
|
467
|
+
process.env.WEBSTIR_WORKSPACE_ROOT = workspace;
|
|
468
|
+
process.env.SESSION_STORE_DRIVER = 'sqlite';
|
|
469
|
+
process.env.SESSION_STORE_URL = 'file:./data/form-runtime.sqlite';
|
|
470
|
+
|
|
471
|
+
const { sessionStore } = await importCompiledModule(
|
|
472
|
+
path.join(workspace, 'build', 'backend', 'session', 'store.js'),
|
|
473
|
+
);
|
|
474
|
+
const route = {
|
|
475
|
+
path: '/account/settings',
|
|
476
|
+
form: {
|
|
477
|
+
csrf: true,
|
|
478
|
+
},
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
const initial = prepareSessionState({
|
|
482
|
+
cookies: '',
|
|
483
|
+
route,
|
|
484
|
+
config,
|
|
485
|
+
store: sessionStore,
|
|
486
|
+
});
|
|
487
|
+
const page = prepareFormState({
|
|
488
|
+
session: initial.session,
|
|
489
|
+
formId: 'account-settings',
|
|
490
|
+
route,
|
|
491
|
+
});
|
|
492
|
+
const initialCommit = initial.commit({
|
|
493
|
+
session: page.session,
|
|
494
|
+
route,
|
|
495
|
+
result: {
|
|
496
|
+
status: 200,
|
|
497
|
+
},
|
|
498
|
+
});
|
|
499
|
+
const postState = prepareSessionState({
|
|
500
|
+
cookies: extractCookieHeader(initialCommit.setCookie),
|
|
501
|
+
route,
|
|
502
|
+
config,
|
|
503
|
+
store: sessionStore,
|
|
504
|
+
});
|
|
505
|
+
const failure = processFormSubmission({
|
|
506
|
+
session: postState.session,
|
|
507
|
+
body: {
|
|
508
|
+
_csrf: page.csrfToken,
|
|
509
|
+
email: 'invalid-email',
|
|
510
|
+
},
|
|
511
|
+
auth: { source: 'service-token' },
|
|
512
|
+
formId: 'account-settings',
|
|
513
|
+
route,
|
|
514
|
+
redirectTo: route.path,
|
|
515
|
+
validate(values) {
|
|
516
|
+
return typeof values.email === 'string' && values.email.includes('@')
|
|
517
|
+
? []
|
|
518
|
+
: [{ field: 'email', message: 'Enter a valid email address.' }];
|
|
519
|
+
},
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
assert.equal(failure.ok, false);
|
|
523
|
+
|
|
524
|
+
const failureCommit = postState.commit({
|
|
525
|
+
session: failure.session,
|
|
526
|
+
route,
|
|
527
|
+
result: failure.result,
|
|
528
|
+
});
|
|
529
|
+
const cookieHeader = failureCommit.setCookie
|
|
530
|
+
? extractCookieHeader(failureCommit.setCookie)
|
|
531
|
+
: extractCookieHeader(initialCommit.setCookie);
|
|
532
|
+
assert.equal(Object.hasOwn(failureCommit.session ?? {}, '__webstir_form_runtime'), false);
|
|
533
|
+
|
|
534
|
+
const reread = prepareSessionState({
|
|
535
|
+
cookies: cookieHeader,
|
|
536
|
+
route,
|
|
537
|
+
config,
|
|
538
|
+
store: sessionStore,
|
|
539
|
+
});
|
|
540
|
+
assert.equal(Object.hasOwn(reread.session ?? {}, '__webstir_form_runtime'), false);
|
|
541
|
+
|
|
542
|
+
const rereadPage = prepareFormState({
|
|
543
|
+
session: reread.session,
|
|
544
|
+
formId: 'account-settings',
|
|
545
|
+
route,
|
|
546
|
+
});
|
|
547
|
+
assert.equal(rereadPage.values.email, 'invalid-email');
|
|
548
|
+
assert.deepEqual(rereadPage.issues, [
|
|
549
|
+
{
|
|
550
|
+
code: 'validation',
|
|
551
|
+
field: 'email',
|
|
552
|
+
message: 'Enter a valid email address.',
|
|
553
|
+
},
|
|
554
|
+
]);
|
|
555
|
+
assert.match(String(rereadPage.csrfToken), /^[a-f0-9-]+$/i);
|
|
556
|
+
assert.equal(
|
|
557
|
+
await fs
|
|
558
|
+
.access(path.join(workspace, 'data', 'form-runtime.sqlite'))
|
|
559
|
+
.then(() => true)
|
|
560
|
+
.catch(() => false),
|
|
561
|
+
true,
|
|
562
|
+
);
|
|
563
|
+
} finally {
|
|
564
|
+
restoreEnv(previousEnv);
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
test('scaffold sqlite session store keeps session metadata outside the persisted app payload', async () => {
|
|
569
|
+
const workspace = await createTempWorkspace('webstir-backend-session-metadata-sqlite-');
|
|
570
|
+
await seedBackendWorkspace(workspace, '@demo/session-metadata-sqlite');
|
|
571
|
+
await linkWorkspacePackage(workspace);
|
|
572
|
+
await compileTemplateSessionFiles(workspace);
|
|
573
|
+
|
|
574
|
+
const previousEnv = snapshotEnv([
|
|
575
|
+
'WORKSPACE_ROOT',
|
|
576
|
+
'WEBSTIR_WORKSPACE_ROOT',
|
|
577
|
+
'SESSION_STORE_DRIVER',
|
|
578
|
+
'SESSION_STORE_URL',
|
|
579
|
+
]);
|
|
580
|
+
|
|
581
|
+
try {
|
|
582
|
+
process.env.WORKSPACE_ROOT = ' ';
|
|
583
|
+
process.env.WEBSTIR_WORKSPACE_ROOT = workspace;
|
|
584
|
+
process.env.SESSION_STORE_DRIVER = 'sqlite';
|
|
585
|
+
process.env.SESSION_STORE_URL = 'file:./data/session-metadata.sqlite';
|
|
586
|
+
|
|
587
|
+
const { sessionStore } = await importCompiledModule(
|
|
588
|
+
path.join(workspace, 'build', 'backend', 'session', 'store.js'),
|
|
589
|
+
);
|
|
590
|
+
const created = prepareSessionState({
|
|
591
|
+
cookies: '',
|
|
592
|
+
route: loginRoute,
|
|
593
|
+
config,
|
|
594
|
+
store: sessionStore,
|
|
595
|
+
});
|
|
596
|
+
const createdCommit = created.commit({
|
|
597
|
+
session: {
|
|
598
|
+
userId: 'ada@example.com',
|
|
599
|
+
},
|
|
600
|
+
route: loginRoute,
|
|
601
|
+
result: {
|
|
602
|
+
status: 303,
|
|
603
|
+
redirect: { location: '/session/account' },
|
|
604
|
+
},
|
|
605
|
+
});
|
|
606
|
+
const cookieHeader = extractCookieHeader(createdCommit.setCookie);
|
|
607
|
+
const sessionId = extractSessionId(cookieHeader, config.cookieName);
|
|
608
|
+
const stored = sessionStore.get(sessionId);
|
|
609
|
+
|
|
610
|
+
assert.ok(stored, 'expected stored session record');
|
|
611
|
+
assert.equal(Object.hasOwn(stored.value, 'id'), false);
|
|
612
|
+
assert.equal(Object.hasOwn(stored.value, 'createdAt'), false);
|
|
613
|
+
assert.equal(Object.hasOwn(stored.value, 'expiresAt'), false);
|
|
614
|
+
assert.equal(stored.id, sessionId);
|
|
615
|
+
|
|
616
|
+
const read = prepareSessionState({
|
|
617
|
+
cookies: cookieHeader,
|
|
618
|
+
route: accountRoute,
|
|
619
|
+
config,
|
|
620
|
+
store: sessionStore,
|
|
621
|
+
});
|
|
622
|
+
assert.equal(read.session?.userId, 'ada@example.com');
|
|
623
|
+
assert.equal(read.session?.id, sessionId);
|
|
624
|
+
assert.match(String(read.session?.createdAt), /^\d{4}-\d{2}-\d{2}T/);
|
|
625
|
+
assert.match(String(read.session?.expiresAt), /^\d{4}-\d{2}-\d{2}T/);
|
|
626
|
+
assert.equal(Object.keys(read.session ?? {}).includes('id'), false);
|
|
627
|
+
assert.equal(Object.keys(read.session ?? {}).includes('createdAt'), false);
|
|
628
|
+
assert.equal(Object.keys(read.session ?? {}).includes('expiresAt'), false);
|
|
629
|
+
} finally {
|
|
630
|
+
restoreEnv(previousEnv);
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
test('scaffold sqlite session store keeps flash in runtime metadata while reading legacy top-level flash rows', async () => {
|
|
635
|
+
const workspace = await createTempWorkspace('webstir-backend-session-flash-sqlite-');
|
|
636
|
+
await seedBackendWorkspace(workspace, '@demo/session-flash-sqlite');
|
|
637
|
+
await linkWorkspacePackage(workspace);
|
|
638
|
+
await compileTemplateSessionFiles(workspace);
|
|
639
|
+
|
|
640
|
+
const previousEnv = snapshotEnv([
|
|
641
|
+
'WORKSPACE_ROOT',
|
|
642
|
+
'WEBSTIR_WORKSPACE_ROOT',
|
|
643
|
+
'SESSION_STORE_DRIVER',
|
|
644
|
+
'SESSION_STORE_URL',
|
|
645
|
+
]);
|
|
646
|
+
|
|
647
|
+
try {
|
|
648
|
+
process.env.WORKSPACE_ROOT = ' ';
|
|
649
|
+
process.env.WEBSTIR_WORKSPACE_ROOT = workspace;
|
|
650
|
+
process.env.SESSION_STORE_DRIVER = 'sqlite';
|
|
651
|
+
process.env.SESSION_STORE_URL = 'file:./data/session-flash.sqlite';
|
|
652
|
+
|
|
653
|
+
const { sessionStore } = await importCompiledModule(
|
|
654
|
+
path.join(workspace, 'build', 'backend', 'session', 'store.js'),
|
|
655
|
+
);
|
|
656
|
+
const created = prepareSessionState({
|
|
657
|
+
cookies: '',
|
|
658
|
+
route: loginRoute,
|
|
659
|
+
config,
|
|
660
|
+
store: sessionStore,
|
|
661
|
+
});
|
|
662
|
+
const createdCommit = created.commit({
|
|
663
|
+
session: {
|
|
664
|
+
userId: 'ada@example.com',
|
|
665
|
+
},
|
|
666
|
+
route: loginRoute,
|
|
667
|
+
result: {
|
|
668
|
+
status: 303,
|
|
669
|
+
redirect: { location: '/session/account' },
|
|
670
|
+
},
|
|
671
|
+
});
|
|
672
|
+
const cookieHeader = extractCookieHeader(createdCommit.setCookie);
|
|
673
|
+
const sessionId = extractSessionId(cookieHeader, config.cookieName);
|
|
674
|
+
const stored = sessionStore.get(sessionId);
|
|
675
|
+
|
|
676
|
+
assert.ok(stored, 'expected stored session record');
|
|
677
|
+
assert.equal(Object.hasOwn(stored, 'flash'), false);
|
|
678
|
+
assert.deepEqual(
|
|
679
|
+
(stored.runtime?.flash ?? []).map((message) => ({ key: message.key, level: message.level })),
|
|
680
|
+
[{ key: 'signed-in', level: 'success' }],
|
|
681
|
+
);
|
|
682
|
+
|
|
683
|
+
sessionStore.set({
|
|
684
|
+
...stored,
|
|
685
|
+
flash: stored.runtime?.flash ?? [],
|
|
686
|
+
runtime: undefined,
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
const read = prepareSessionState({
|
|
690
|
+
cookies: cookieHeader,
|
|
691
|
+
route: accountRoute,
|
|
692
|
+
config,
|
|
693
|
+
store: sessionStore,
|
|
694
|
+
});
|
|
695
|
+
assert.equal(read.session?.userId, 'ada@example.com');
|
|
696
|
+
assert.deepEqual(
|
|
697
|
+
read.flash.map((message) => ({ key: message.key, level: message.level })),
|
|
698
|
+
[{ key: 'signed-in', level: 'success' }],
|
|
699
|
+
);
|
|
700
|
+
} finally {
|
|
701
|
+
restoreEnv(previousEnv);
|
|
702
|
+
}
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
test('scaffold sqlite session store fails clearly for malformed persisted rows', async () => {
|
|
706
|
+
const workspace = await createTempWorkspace('webstir-backend-session-malformed-sqlite-');
|
|
707
|
+
await seedBackendWorkspace(workspace, '@demo/session-malformed-sqlite');
|
|
708
|
+
await linkWorkspacePackage(workspace);
|
|
709
|
+
await compileTemplateSessionFiles(workspace);
|
|
710
|
+
|
|
711
|
+
const previousEnv = snapshotEnv([
|
|
712
|
+
'WORKSPACE_ROOT',
|
|
713
|
+
'WEBSTIR_WORKSPACE_ROOT',
|
|
714
|
+
'SESSION_STORE_DRIVER',
|
|
715
|
+
'SESSION_STORE_URL',
|
|
716
|
+
]);
|
|
717
|
+
|
|
718
|
+
try {
|
|
719
|
+
process.env.WORKSPACE_ROOT = ' ';
|
|
720
|
+
process.env.WEBSTIR_WORKSPACE_ROOT = workspace;
|
|
721
|
+
process.env.SESSION_STORE_DRIVER = 'sqlite';
|
|
722
|
+
process.env.SESSION_STORE_URL = 'file:./data/malformed-session.sqlite';
|
|
723
|
+
|
|
724
|
+
const { sessionStore } = await importCompiledModule(
|
|
725
|
+
path.join(workspace, 'build', 'backend', 'session', 'store.js'),
|
|
726
|
+
);
|
|
727
|
+
const dbPath = path.join(workspace, 'data', 'malformed-session.sqlite');
|
|
728
|
+
const sqliteModule = await import('bun:sqlite');
|
|
729
|
+
const Database = sqliteModule.Database;
|
|
730
|
+
const db = new Database(dbPath);
|
|
731
|
+
|
|
732
|
+
db.prepare(
|
|
733
|
+
`INSERT INTO webstir_sessions (id, value, flash, runtime, created_at, expires_at)
|
|
734
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
735
|
+
).run(
|
|
736
|
+
'malformed-session-id',
|
|
737
|
+
'{not-json',
|
|
738
|
+
'[]',
|
|
739
|
+
'{}',
|
|
740
|
+
'2026-01-01T00:00:00.000Z',
|
|
741
|
+
'2099-01-02T00:00:00.000Z',
|
|
742
|
+
);
|
|
743
|
+
db.close();
|
|
744
|
+
|
|
745
|
+
assert.throws(
|
|
746
|
+
() => sessionStore.get('malformed-session-id'),
|
|
747
|
+
/Failed to deserialize SQLite session row 'malformed-session-id'/,
|
|
748
|
+
);
|
|
749
|
+
} finally {
|
|
750
|
+
restoreEnv(previousEnv);
|
|
751
|
+
}
|
|
752
|
+
});
|