@stardeck-customer-apps/testing 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs ADDED
@@ -0,0 +1,878 @@
1
+ // src/test-app.ts
2
+ import crypto3 from "crypto";
3
+ import { readFileSync, existsSync } from "fs";
4
+ import { resolve } from "path";
5
+
6
+ // src/pglite/database.ts
7
+ import { PGlite } from "@electric-sql/pglite";
8
+ import { citext } from "@electric-sql/pglite/contrib/citext";
9
+ import { pg_trgm } from "@electric-sql/pglite/contrib/pg_trgm";
10
+ import { pgcrypto } from "@electric-sql/pglite/contrib/pgcrypto";
11
+ import { uuid_ossp } from "@electric-sql/pglite/contrib/uuid_ossp";
12
+ async function createPgliteDatabase() {
13
+ const db = new PGlite({
14
+ extensions: { pg_trgm, pgcrypto, uuid_ossp, citext }
15
+ });
16
+ await db.waitReady;
17
+ return db;
18
+ }
19
+ async function applySchema(db, schemaSql) {
20
+ const trimmed = schemaSql.trim();
21
+ if (!trimmed) return;
22
+ await db.exec(trimmed);
23
+ }
24
+ async function dropAllTables(db) {
25
+ await db.exec(`DROP SCHEMA public CASCADE; CREATE SCHEMA public;`);
26
+ }
27
+ var identityParsers = null;
28
+ function getIdentityParsers() {
29
+ if (!identityParsers) {
30
+ identityParsers = {};
31
+ const identity = (value) => value;
32
+ for (let oid = 1; oid <= 13e3; oid++) {
33
+ identityParsers[oid] = identity;
34
+ }
35
+ }
36
+ return identityParsers;
37
+ }
38
+
39
+ // src/global-singleton.ts
40
+ function globalSingleton(key, create) {
41
+ const symbol = /* @__PURE__ */ Symbol.for(`stardeck-testing.${key}`);
42
+ const holder = globalThis;
43
+ holder[symbol] ??= create();
44
+ return holder[symbol];
45
+ }
46
+
47
+ // src/state.ts
48
+ var state = globalSingleton("state", () => ({
49
+ db: null,
50
+ currentUser: null,
51
+ sessions: /* @__PURE__ */ new Map(),
52
+ refreshSessions: /* @__PURE__ */ new Map(),
53
+ emails: [],
54
+ emailCounter: 0,
55
+ allowNetwork: false
56
+ }));
57
+ function requireDb() {
58
+ if (!state.db) {
59
+ throw new Error(
60
+ "[stardeck-testing] No active test app. Call `await createTestApp(...)` in beforeAll before using Stardeck SDKs in tests."
61
+ );
62
+ }
63
+ return state.db;
64
+ }
65
+
66
+ // src/constants.ts
67
+ var TEST_DOMAIN_SUFFIX = ".stardeck.test";
68
+ var CONTROL_PLANE_TEST_URL = "https://control-plane.stardeck.test";
69
+ var DATA_STORE_TEST_HOST = "db.stardeck.test";
70
+ var TEST_ENV_DEFAULTS = {
71
+ CONTROL_PLANE_URL: CONTROL_PLANE_TEST_URL,
72
+ DEPLOYMENT_SECRET: "stardeck-test-deployment-secret",
73
+ ORGANIZATION_ID: "00000000-0000-4000-8000-00000000000a",
74
+ PROJECT_ID: "00000000-0000-4000-8000-00000000000b",
75
+ DEPLOYMENT_ID: "00000000-0000-4000-8000-00000000000c",
76
+ DATA_STORE_URL: `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`
77
+ };
78
+ var DEFAULT_TEST_USER = {
79
+ id: "test-user-1",
80
+ email: "test-user@example.com",
81
+ name: "Test User",
82
+ role: "member",
83
+ permissions: []
84
+ };
85
+ var DEFAULT_SCHEMA_PATH = "./src/generated/data-store-schema.sql";
86
+
87
+ // src/simulator/hmac.ts
88
+ import crypto from "crypto";
89
+ var TIMESTAMP_TOLERANCE_SECONDS = 300;
90
+ function verifyDeploymentAuthHeader(secret, header) {
91
+ const dotIndex = header.lastIndexOf(".");
92
+ if (dotIndex === -1) return null;
93
+ const payloadB64 = header.slice(0, dotIndex);
94
+ const signature = header.slice(dotIndex + 1);
95
+ let payloadJson;
96
+ try {
97
+ payloadJson = Buffer.from(payloadB64, "base64").toString("utf-8");
98
+ } catch {
99
+ return null;
100
+ }
101
+ const expected = crypto.createHmac("sha256", secret).update(payloadJson).digest("hex");
102
+ const expectedBuf = Buffer.from(expected, "utf-8");
103
+ const actualBuf = Buffer.from(signature, "utf-8");
104
+ if (expectedBuf.length !== actualBuf.length || !crypto.timingSafeEqual(expectedBuf, actualBuf)) {
105
+ return null;
106
+ }
107
+ let payload;
108
+ try {
109
+ payload = JSON.parse(payloadJson);
110
+ } catch {
111
+ return null;
112
+ }
113
+ if (payload.type !== "deployment-request") return null;
114
+ const now = Math.floor(Date.now() / 1e3);
115
+ if (Math.abs(now - payload.timestamp) > TIMESTAMP_TOLERANCE_SECONDS) return null;
116
+ return payload;
117
+ }
118
+
119
+ // src/simulator/http.ts
120
+ function json(body, status = 200) {
121
+ return new Response(JSON.stringify(body), {
122
+ status,
123
+ headers: { "Content-Type": "application/json" }
124
+ });
125
+ }
126
+ function success(data) {
127
+ return json({ success: true, data });
128
+ }
129
+ function failure(error, status = 400) {
130
+ return json({ success: false, error }, status);
131
+ }
132
+
133
+ // src/simulator/data-store.ts
134
+ function quoteIdent(name) {
135
+ return `"${name.replace(/"/g, '""')}"`;
136
+ }
137
+ function normalizeIdent(name) {
138
+ return name.toLowerCase().replace(/\s+/g, "_");
139
+ }
140
+ async function getTableColumns(db, tableName) {
141
+ const { rows } = await db.query(
142
+ `SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1 ORDER BY ordinal_position`,
143
+ [tableName]
144
+ );
145
+ if (rows.length === 0) return null;
146
+ return rows.map((r) => r.column_name);
147
+ }
148
+ async function getPrimaryKeyColumns(db, tableName) {
149
+ const { rows } = await db.query(
150
+ `SELECT ku.column_name
151
+ FROM information_schema.table_constraints tc
152
+ JOIN information_schema.key_column_usage ku
153
+ ON tc.constraint_name = ku.constraint_name AND tc.table_schema = ku.table_schema
154
+ WHERE tc.constraint_type = 'PRIMARY KEY'
155
+ AND tc.table_schema = 'public'
156
+ AND tc.table_name = $1`,
157
+ [tableName]
158
+ );
159
+ return rows.map((r) => r.column_name);
160
+ }
161
+ function coerceNumericFields(result) {
162
+ const numericFields = result.fields.filter((f) => f.dataTypeID === 1700).map((f) => f.name);
163
+ if (numericFields.length === 0) return result.rows;
164
+ return result.rows.map((row) => {
165
+ const coerced = { ...row };
166
+ for (const name of numericFields) {
167
+ if (typeof coerced[name] === "string") {
168
+ coerced[name] = parseFloat(coerced[name]);
169
+ }
170
+ }
171
+ return coerced;
172
+ });
173
+ }
174
+ function buildWhereClause(filters, validColumns) {
175
+ const conditions = [];
176
+ const params = [];
177
+ let paramIndex = 1;
178
+ for (const filter of filters) {
179
+ if (!validColumns.includes(filter.column)) continue;
180
+ const col = quoteIdent(filter.column);
181
+ switch (filter.operator) {
182
+ case "eq":
183
+ conditions.push(`${col} = $${paramIndex++}`);
184
+ params.push(filter.value);
185
+ break;
186
+ case "neq":
187
+ conditions.push(`${col} != $${paramIndex++}`);
188
+ params.push(filter.value);
189
+ break;
190
+ case "contains": {
191
+ conditions.push(`${col}::text ILIKE $${paramIndex++}`);
192
+ const escaped = String(filter.value ?? "").replace(/[%_\\]/g, "\\$&");
193
+ params.push(`%${escaped}%`);
194
+ break;
195
+ }
196
+ case "gt":
197
+ conditions.push(`${col} > $${paramIndex++}`);
198
+ params.push(filter.value);
199
+ break;
200
+ case "lt":
201
+ conditions.push(`${col} < $${paramIndex++}`);
202
+ params.push(filter.value);
203
+ break;
204
+ case "is_null":
205
+ conditions.push(`${col} IS NULL`);
206
+ break;
207
+ case "is_not_null":
208
+ conditions.push(`${col} IS NOT NULL`);
209
+ break;
210
+ }
211
+ }
212
+ if (conditions.length === 0) return { clause: "", params: [] };
213
+ return { clause: `WHERE ${conditions.join(" AND ")}`, params };
214
+ }
215
+ async function handleQuery(db, body) {
216
+ if (typeof body.tableName !== "string" || body.tableName.length === 0) {
217
+ return failure("Invalid request body", 400);
218
+ }
219
+ const tableName = body.tableName;
220
+ const limit = body.limit === void 0 ? 50 : Number(body.limit);
221
+ const offset = body.offset === void 0 ? 0 : Number(body.offset);
222
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
223
+ return failure("Invalid request body", 400);
224
+ }
225
+ if (!Number.isInteger(offset) || offset < 0) {
226
+ return failure("Invalid request body", 400);
227
+ }
228
+ if (body.orderDir !== void 0 && body.orderDir !== "asc" && body.orderDir !== "desc") {
229
+ return failure("Invalid request body", 400);
230
+ }
231
+ const orderBy = body.orderBy ? String(body.orderBy) : void 0;
232
+ const orderDir = body.orderDir === "desc" ? "desc" : "asc";
233
+ const filters = body.filters ?? [];
234
+ const validColumns = await getTableColumns(db, tableName);
235
+ if (!validColumns) return failure(`Table "${tableName}" not found`, 404);
236
+ if (orderBy && !validColumns.includes(orderBy)) {
237
+ return failure(`Column "${orderBy}" not found in table "${tableName}"`);
238
+ }
239
+ const { clause: whereClause, params: whereParams } = buildWhereClause(filters, validColumns);
240
+ const orderClause = orderBy ? `ORDER BY ${quoteIdent(orderBy)} ${orderDir === "desc" ? "DESC" : "ASC"}` : "";
241
+ const nextParamIdx = whereParams.length + 1;
242
+ const quotedTable = quoteIdent(tableName);
243
+ const preparedWhereParams = whereParams.map(prepareParam);
244
+ const dataResult = await db.query(
245
+ `SELECT * FROM ${quotedTable} ${whereClause} ${orderClause} LIMIT $${nextParamIdx} OFFSET $${nextParamIdx + 1}`,
246
+ [...preparedWhereParams, limit, offset]
247
+ );
248
+ const countResult = await db.query(
249
+ `SELECT count(*)::int as total FROM ${quotedTable} ${whereClause}`,
250
+ preparedWhereParams
251
+ );
252
+ return success({
253
+ rows: coerceNumericFields(dataResult),
254
+ columns: validColumns,
255
+ total: countResult.rows[0]?.total ?? 0,
256
+ limit,
257
+ offset
258
+ });
259
+ }
260
+ function buildPkWhere(primaryKey, pkColumns, startParamIdx) {
261
+ const conditions = [];
262
+ const params = [];
263
+ let paramIdx = startParamIdx;
264
+ for (const col of pkColumns) {
265
+ if (!(col in primaryKey)) {
266
+ throw new Error(`Missing primary key column: ${col}`);
267
+ }
268
+ conditions.push(`${quoteIdent(col)} = $${paramIdx++}`);
269
+ params.push(primaryKey[col]);
270
+ }
271
+ return { clause: conditions.join(" AND "), params };
272
+ }
273
+ async function handleMutate(db, body) {
274
+ if (typeof body.tableName !== "string" || body.tableName.length === 0) {
275
+ return failure("Invalid request body", 400);
276
+ }
277
+ const tableName = body.tableName;
278
+ const operation = String(body.operation ?? "");
279
+ if (!["insert", "update", "delete"].includes(operation)) {
280
+ return failure("Invalid request body", 400);
281
+ }
282
+ const columns = await getTableColumns(db, tableName);
283
+ if (!columns) return failure(`Table "${tableName}" not found`, 404);
284
+ const pkColumns = await getPrimaryKeyColumns(db, tableName);
285
+ switch (operation) {
286
+ case "insert": {
287
+ const row = body.row ?? {};
288
+ const insertColumns = Object.keys(row).filter((col) => columns.includes(col));
289
+ let sql;
290
+ let values;
291
+ if (insertColumns.length === 0) {
292
+ sql = `INSERT INTO ${quoteIdent(tableName)} DEFAULT VALUES RETURNING *`;
293
+ values = [];
294
+ } else {
295
+ values = insertColumns.map((col) => prepareParam(row[col]));
296
+ const colList = insertColumns.map((c) => quoteIdent(c)).join(", ");
297
+ const paramList = insertColumns.map((_, i) => `$${i + 1}`).join(", ");
298
+ sql = `INSERT INTO ${quoteIdent(tableName)} (${colList}) VALUES (${paramList}) RETURNING *`;
299
+ }
300
+ const result = await db.query(sql, values);
301
+ return success({ row: result.rows[0] });
302
+ }
303
+ case "update": {
304
+ const column = String(body.column ?? "");
305
+ if (!columns.includes(column)) {
306
+ return failure(`Column "${column}" not found in table "${tableName}"`);
307
+ }
308
+ if (pkColumns.length === 0) {
309
+ return failure(`Table "${tableName}" has no primary key`);
310
+ }
311
+ const { clause: pkClause, params: pkParams } = buildPkWhere(
312
+ body.primaryKey ?? {},
313
+ pkColumns,
314
+ 2
315
+ );
316
+ const updateValue = body.value === "" ? null : prepareParam(body.value);
317
+ const result = await db.query(
318
+ `UPDATE ${quoteIdent(tableName)} SET ${quoteIdent(column)} = $1 WHERE ${pkClause} RETURNING *`,
319
+ [updateValue, ...pkParams.map(prepareParam)]
320
+ );
321
+ if (result.rows.length === 0) return failure("Row not found", 404);
322
+ return success({ row: result.rows[0] });
323
+ }
324
+ case "delete": {
325
+ if (pkColumns.length === 0) {
326
+ return failure(`Table "${tableName}" has no primary key`);
327
+ }
328
+ const { clause: pkClause, params: pkParams } = buildPkWhere(
329
+ body.primaryKey ?? {},
330
+ pkColumns,
331
+ 1
332
+ );
333
+ const result = await db.query(
334
+ `DELETE FROM ${quoteIdent(tableName)} WHERE ${pkClause} RETURNING *`,
335
+ pkParams.map(prepareParam)
336
+ );
337
+ if (result.rows.length === 0) return failure("Row not found", 404);
338
+ return success({ deleted: true, row: result.rows[0] });
339
+ }
340
+ default:
341
+ return failure(`Unknown operation "${operation}"`);
342
+ }
343
+ }
344
+ function prepareParam(value) {
345
+ if (value instanceof Date) return value.toISOString();
346
+ if (Array.isArray(value)) return encodeArrayLiteral(value);
347
+ if (value !== null && typeof value === "object") return JSON.stringify(value);
348
+ return value;
349
+ }
350
+ function encodeArrayLiteral(values) {
351
+ const items = values.map((v) => {
352
+ if (v === null || v === void 0) return "NULL";
353
+ if (Array.isArray(v)) return quoteArrayElement(encodeArrayLiteral(v));
354
+ if (v instanceof Date) return quoteArrayElement(v.toISOString());
355
+ if (typeof v === "object") return quoteArrayElement(JSON.stringify(v));
356
+ return quoteArrayElement(String(v));
357
+ });
358
+ return `{${items.join(",")}}`;
359
+ }
360
+ function quoteArrayElement(raw) {
361
+ return `"${raw.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
362
+ }
363
+ async function handleGetSchema(db) {
364
+ const { rows } = await db.query(
365
+ `SELECT
366
+ t.table_name,
367
+ c.column_name,
368
+ c.data_type,
369
+ c.is_nullable,
370
+ c.column_default,
371
+ CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as is_primary_key
372
+ FROM information_schema.tables t
373
+ JOIN information_schema.columns c
374
+ ON t.table_name = c.table_name AND t.table_schema = c.table_schema
375
+ LEFT JOIN (
376
+ SELECT ku.column_name, ku.table_name
377
+ FROM information_schema.table_constraints tc
378
+ JOIN information_schema.key_column_usage ku
379
+ ON tc.constraint_name = ku.constraint_name AND tc.table_schema = ku.table_schema
380
+ WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_schema = 'public'
381
+ ) pk ON pk.table_name = c.table_name AND pk.column_name = c.column_name
382
+ WHERE t.table_schema = 'public'
383
+ AND t.table_type = 'BASE TABLE'
384
+ AND t.table_name NOT LIKE '\\_deleted\\_%'
385
+ ORDER BY t.table_name, c.ordinal_position`
386
+ );
387
+ const tables = /* @__PURE__ */ new Map();
388
+ for (const row of rows) {
389
+ if (!tables.has(row.table_name)) tables.set(row.table_name, []);
390
+ const cols = tables.get(row.table_name);
391
+ cols.push({
392
+ columnName: row.column_name,
393
+ dataType: row.data_type,
394
+ isNullable: row.is_nullable === "YES",
395
+ columnDefault: row.column_default,
396
+ isPrimaryKey: row.is_primary_key,
397
+ position: cols.length
398
+ });
399
+ }
400
+ return success({
401
+ tables: Array.from(tables, ([tableName, columns]) => ({ tableName, columns }))
402
+ });
403
+ }
404
+ var FIELD_TYPE_TO_PG = {
405
+ text: "text",
406
+ long_text: "text",
407
+ number: "double precision",
408
+ boolean: "boolean",
409
+ date: "date",
410
+ datetime: "timestamptz",
411
+ select: "text",
412
+ multi_select: "jsonb",
413
+ url: "text",
414
+ email: "text",
415
+ phone: "text",
416
+ currency: "numeric",
417
+ rating: "integer",
418
+ relation: "uuid",
419
+ file_ref: "jsonb",
420
+ file_refs: "jsonb",
421
+ json: "jsonb"
422
+ };
423
+ function columnDdl(column) {
424
+ const pgType = FIELD_TYPE_TO_PG[column.fieldType];
425
+ if (!pgType) {
426
+ throw new Error(
427
+ `Unsupported fieldType "${column.fieldType}" in test simulator. Supported: ${Object.keys(FIELD_TYPE_TO_PG).join(", ")}`
428
+ );
429
+ }
430
+ let ddl = `${quoteIdent(normalizeIdent(column.name))} ${pgType}`;
431
+ if (column.nullable === false) ddl += " NOT NULL";
432
+ return ddl;
433
+ }
434
+ async function handleCreateTable(db, body) {
435
+ const name = normalizeIdent(String(body.name ?? ""));
436
+ const columns = body.columns ?? [];
437
+ if (!name) return failure("Table name is required");
438
+ try {
439
+ const columnDefs = [
440
+ `"id" uuid PRIMARY KEY DEFAULT gen_random_uuid()`,
441
+ ...columns.map(columnDdl),
442
+ `"created_at" timestamptz NOT NULL DEFAULT now()`,
443
+ `"updated_at" timestamptz NOT NULL DEFAULT now()`
444
+ ];
445
+ await db.exec(`CREATE TABLE ${quoteIdent(name)} (
446
+ ${columnDefs.join(",\n ")}
447
+ )`);
448
+ } catch (error) {
449
+ return failure(error instanceof Error ? error.message : String(error));
450
+ }
451
+ return success({ tableName: name });
452
+ }
453
+ async function handleAddColumn(db, body) {
454
+ const tableName = normalizeIdent(String(body.tableName ?? ""));
455
+ const column = body.column;
456
+ if (!tableName || !column) return failure("tableName and column are required");
457
+ try {
458
+ await db.exec(`ALTER TABLE ${quoteIdent(tableName)} ADD COLUMN ${columnDdl(column)}`);
459
+ } catch (error) {
460
+ return failure(error instanceof Error ? error.message : String(error));
461
+ }
462
+ return success({ columnName: normalizeIdent(column.name) });
463
+ }
464
+
465
+ // src/simulator/neon-proxy.ts
466
+ var BOOL_OID = 16;
467
+ var PG_BOOL_TRUE = /* @__PURE__ */ new Set(["true", "t", "1", "yes", "y", "on"]);
468
+ var PG_BOOL_FALSE = /* @__PURE__ */ new Set(["false", "f", "0", "no", "n", "off"]);
469
+ var neonParamSerializers = {
470
+ [BOOL_OID]: (value) => {
471
+ if (typeof value === "boolean") return value ? "t" : "f";
472
+ const normalized = String(value).trim().toLowerCase();
473
+ if (PG_BOOL_TRUE.has(normalized)) return "t";
474
+ if (PG_BOOL_FALSE.has(normalized)) return "f";
475
+ throw new Error(`Invalid input for boolean type: ${JSON.stringify(value)}`);
476
+ }
477
+ };
478
+ async function handleNeonSql(db, request) {
479
+ const body = await request.json();
480
+ if (body.queries) {
481
+ return json(
482
+ {
483
+ message: "Transactions over the Neon HTTP driver are not supported (matches production neon-http behavior). Use single statements in tests."
484
+ },
485
+ 400
486
+ );
487
+ }
488
+ if (!body.query) {
489
+ return json({ message: "Missing query" }, 400);
490
+ }
491
+ const arrayMode = (request.headers.get("Neon-Array-Mode") ?? request.headers.get("neon-array-mode")) === "true";
492
+ try {
493
+ const result = await db.query(body.query, body.params ?? [], {
494
+ parsers: getIdentityParsers(),
495
+ serializers: neonParamSerializers,
496
+ rowMode: arrayMode ? "array" : "object"
497
+ });
498
+ const command = body.query.trim().split(/\s+/)[0]?.toUpperCase() ?? "SELECT";
499
+ return json({
500
+ command,
501
+ rowCount: result.affectedRows || result.rows.length,
502
+ fields: result.fields.map((f) => ({ name: f.name, dataTypeID: f.dataTypeID })),
503
+ rows: result.rows,
504
+ rowAsArray: arrayMode
505
+ });
506
+ } catch (error) {
507
+ const err = error;
508
+ return json({ message: err.message, code: err.code }, 400);
509
+ }
510
+ }
511
+
512
+ // src/simulator/auth.ts
513
+ import crypto2 from "crypto";
514
+ function handleAuthVerify(request) {
515
+ const authHeader = request.headers.get("Authorization");
516
+ const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
517
+ const user = token ? state.sessions.get(token) : null;
518
+ if (!user) {
519
+ return json({ authenticated: false, user: null }, 401);
520
+ }
521
+ return json({ authenticated: true, user });
522
+ }
523
+ function handleAuthRefresh(request) {
524
+ const authHeader = request.headers.get("Authorization");
525
+ const refreshToken = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
526
+ const user = refreshToken ? state.refreshSessions.get(refreshToken) : null;
527
+ if (!user || !refreshToken) {
528
+ return json({ error: "invalid_grant" }, 401);
529
+ }
530
+ const accessToken = `test-access-${crypto2.randomUUID()}`;
531
+ state.sessions.set(accessToken, user);
532
+ return json({
533
+ access_token: accessToken,
534
+ refresh_token: refreshToken,
535
+ token_type: "Bearer",
536
+ expires_in: 3600,
537
+ refresh_token_expires_in: 86400
538
+ });
539
+ }
540
+
541
+ // src/simulator/email.ts
542
+ function toArray(value) {
543
+ if (!value) return [];
544
+ return Array.isArray(value) ? value.map(String) : [String(value)];
545
+ }
546
+ async function handleEmailSend(request) {
547
+ const body = await request.json();
548
+ const from = body.from ?? {};
549
+ if (!from.name || !from.prefix || !body.to || !body.subject) {
550
+ return failure("Missing required email fields");
551
+ }
552
+ state.emailCounter += 1;
553
+ const resendId = `test-email-${state.emailCounter}`;
554
+ const fromAddress = `${from.name} <${from.prefix}@test.stardeck.email>`;
555
+ const email = {
556
+ resendId,
557
+ fromAddress,
558
+ from: { name: from.name, prefix: from.prefix },
559
+ to: toArray(body.to),
560
+ cc: toArray(body.cc),
561
+ bcc: toArray(body.bcc),
562
+ subject: String(body.subject),
563
+ html: body.html ? String(body.html) : void 0,
564
+ text: body.text ? String(body.text) : void 0,
565
+ replyTo: body.replyTo ? String(body.replyTo) : void 0,
566
+ attachments: (body.attachments ?? []).map((a) => ({
567
+ filename: String(a.filename ?? ""),
568
+ contentType: a.contentType ? String(a.contentType) : void 0
569
+ })),
570
+ sentAt: /* @__PURE__ */ new Date()
571
+ };
572
+ state.emails.push(email);
573
+ return success({ resendId, fromAddress });
574
+ }
575
+ function createInbox() {
576
+ return {
577
+ all: () => [...state.emails],
578
+ to: (address) => state.emails.filter(
579
+ (e) => e.to.includes(address) || e.cc.includes(address) || e.bcc.includes(address)
580
+ ),
581
+ latest: (address) => {
582
+ const pool = address ? state.emails.filter(
583
+ (e) => e.to.includes(address) || e.cc.includes(address) || e.bcc.includes(address)
584
+ ) : state.emails;
585
+ return pool[pool.length - 1];
586
+ },
587
+ clear: () => {
588
+ state.emails = [];
589
+ },
590
+ get count() {
591
+ return state.emails.length;
592
+ }
593
+ };
594
+ }
595
+
596
+ // src/simulator/router.ts
597
+ var fetchHolder = globalSingleton("fetch-holder", () => ({
598
+ originalFetch: null
599
+ }));
600
+ function isLocalHost(hostname) {
601
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "0.0.0.0";
602
+ }
603
+ async function handleSimulatedRequest(request, url) {
604
+ if (url.pathname === "/sql") {
605
+ return handleNeonSql(requireDb(), request);
606
+ }
607
+ const authMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/auth\/(verify|refresh)$/);
608
+ if (authMatch) {
609
+ return authMatch[1] === "verify" ? handleAuthVerify(request) : handleAuthRefresh(request);
610
+ }
611
+ const dataStoreMatch = url.pathname.match(/^\/api\/data-stores\/[^/]+(\/.*)?$/);
612
+ const isEmail = url.pathname === "/api/email/send";
613
+ if (dataStoreMatch || isEmail) {
614
+ const authHeader = request.headers.get("X-Stardeck-Auth");
615
+ if (!authHeader) {
616
+ return failure("Missing authentication header", 401);
617
+ }
618
+ const secret = process.env.DEPLOYMENT_SECRET;
619
+ if (!secret || !verifyDeploymentAuthHeader(secret, authHeader)) {
620
+ return failure("Invalid authentication", 401);
621
+ }
622
+ }
623
+ if (isEmail && request.method === "POST") {
624
+ return handleEmailSend(request);
625
+ }
626
+ if (dataStoreMatch) {
627
+ const subPath = dataStoreMatch[1] ?? "";
628
+ const db = requireDb();
629
+ const readBody = async () => await request.json();
630
+ if (subPath === "/query" && request.method === "POST") {
631
+ return handleQuery(db, await readBody());
632
+ }
633
+ if (subPath === "/mutate" && request.method === "POST") {
634
+ return handleMutate(db, await readBody());
635
+ }
636
+ if (subPath === "/schema" && request.method === "GET") {
637
+ return handleGetSchema(db);
638
+ }
639
+ if (subPath === "/schema/tables" && request.method === "POST") {
640
+ return handleCreateTable(db, await readBody());
641
+ }
642
+ if (subPath === "/schema/columns" && request.method === "POST") {
643
+ return handleAddColumn(db, await readBody());
644
+ }
645
+ }
646
+ return failure(
647
+ `[stardeck-testing] No simulator for ${request.method} ${url.pathname}. Supported: data-store query/mutate/schema, email send, auth verify/refresh, Neon /sql.`,
648
+ 404
649
+ );
650
+ }
651
+ function installFetchRouter() {
652
+ if (fetchHolder.originalFetch) return;
653
+ fetchHolder.originalFetch = globalThis.fetch;
654
+ globalThis.fetch = async (input, init) => {
655
+ const request = input instanceof Request ? input : new Request(input, init);
656
+ const merged = input instanceof Request && init ? new Request(request, init) : request;
657
+ const url = new URL(merged.url);
658
+ if (url.hostname.endsWith(TEST_DOMAIN_SUFFIX)) {
659
+ return handleSimulatedRequest(merged, url);
660
+ }
661
+ if (isLocalHost(url.hostname) || state.allowNetwork) {
662
+ return fetchHolder.originalFetch(merged);
663
+ }
664
+ throw new Error(
665
+ `[stardeck-testing] Blocked outbound request to ${url.origin}${url.pathname}. Tests run offline by default for determinism. If this request is intentional, pass { allowNetwork: true } to createTestApp(), or point the code at a simulated service.`
666
+ );
667
+ };
668
+ }
669
+ function uninstallFetchRouter() {
670
+ if (fetchHolder.originalFetch) {
671
+ globalThis.fetch = fetchHolder.originalFetch;
672
+ fetchHolder.originalFetch = null;
673
+ }
674
+ }
675
+
676
+ // src/test-app.ts
677
+ function applyTestEnv() {
678
+ for (const [key, value] of Object.entries(TEST_ENV_DEFAULTS)) {
679
+ process.env[key] = value;
680
+ }
681
+ delete process.env.DISABLE_AUTH;
682
+ delete process.env.NEXT_PUBLIC_DISABLE_AUTH;
683
+ delete process.env.SANDBOX_MODE;
684
+ }
685
+ function buildUser(partial) {
686
+ return {
687
+ ...DEFAULT_TEST_USER,
688
+ organizationId: process.env.ORGANIZATION_ID ?? null,
689
+ projectId: process.env.PROJECT_ID ?? null,
690
+ ...partial
691
+ };
692
+ }
693
+ function loadSchemaSql(options) {
694
+ if (options.schemaSql !== void 0) return options.schemaSql;
695
+ if (options.schema === null) return null;
696
+ const schemaPath = resolve(process.cwd(), options.schema ?? DEFAULT_SCHEMA_PATH);
697
+ if (!existsSync(schemaPath)) {
698
+ if (options.schema) {
699
+ throw new Error(
700
+ `[stardeck-testing] Schema snapshot not found at ${schemaPath}. Run \`npx stardeck-data-store generate-types\` to regenerate it.`
701
+ );
702
+ }
703
+ return null;
704
+ }
705
+ return readFileSync(schemaPath, "utf-8");
706
+ }
707
+ async function createTestApp(options = {}) {
708
+ if (state.db) {
709
+ throw new Error(
710
+ "[stardeck-testing] A test app is already active in this test file. Call `app.close()` first, or share one app per file."
711
+ );
712
+ }
713
+ applyTestEnv();
714
+ state.allowNetwork = options.allowNetwork ?? false;
715
+ installFetchRouter();
716
+ const db = await createPgliteDatabase();
717
+ state.db = db;
718
+ const initialize = async () => {
719
+ const schemaSql = loadSchemaSql(options);
720
+ if (schemaSql) await applySchema(db, schemaSql);
721
+ if (options.seed) await options.seed(db);
722
+ };
723
+ try {
724
+ await initialize();
725
+ } catch (error) {
726
+ state.db = null;
727
+ await db.close().catch(() => {
728
+ });
729
+ throw error;
730
+ }
731
+ const inbox = createInbox();
732
+ const app = {
733
+ db,
734
+ inbox,
735
+ async query(sql, params = []) {
736
+ const result = await db.query(sql, params);
737
+ return result.rows;
738
+ },
739
+ asUser(user) {
740
+ const fullUser = buildUser(user);
741
+ state.currentUser = fullUser;
742
+ return fullUser;
743
+ },
744
+ signOut() {
745
+ state.currentUser = null;
746
+ },
747
+ issueSession(user) {
748
+ const fullUser = buildUser(user);
749
+ const tokens = {
750
+ accessToken: `test-access-${crypto3.randomUUID()}`,
751
+ refreshToken: `test-refresh-${crypto3.randomUUID()}`
752
+ };
753
+ state.sessions.set(tokens.accessToken, fullUser);
754
+ state.refreshSessions.set(tokens.refreshToken, fullUser);
755
+ return tokens;
756
+ },
757
+ async reset() {
758
+ await dropAllTables(db);
759
+ await initialize();
760
+ state.currentUser = null;
761
+ state.sessions.clear();
762
+ state.refreshSessions.clear();
763
+ state.emails = [];
764
+ state.emailCounter = 0;
765
+ },
766
+ async close() {
767
+ state.db = null;
768
+ state.currentUser = null;
769
+ state.sessions.clear();
770
+ state.refreshSessions.clear();
771
+ state.emails = [];
772
+ uninstallFetchRouter();
773
+ await db.close();
774
+ }
775
+ };
776
+ return app;
777
+ }
778
+
779
+ // src/next/headers-shim.ts
780
+ import { AsyncLocalStorage } from "async_hooks";
781
+ var requestScopeStorage = globalSingleton(
782
+ "request-scope",
783
+ () => new AsyncLocalStorage()
784
+ );
785
+
786
+ // src/next/call-route.ts
787
+ function parseCookieHeader(header) {
788
+ const map = /* @__PURE__ */ new Map();
789
+ if (!header) return map;
790
+ for (const part of header.split(";")) {
791
+ const eq = part.indexOf("=");
792
+ if (eq === -1) continue;
793
+ map.set(part.slice(0, eq).trim(), part.slice(eq + 1).trim());
794
+ }
795
+ return map;
796
+ }
797
+ async function importNextServer() {
798
+ try {
799
+ return await import("next/server.js");
800
+ } catch {
801
+ return await import("next/server");
802
+ }
803
+ }
804
+ async function callRoute(handler, options = {}) {
805
+ const { NextRequest } = await importNextServer();
806
+ const path = options.path ?? "/api/test-route";
807
+ const url = new URL(`http://localhost:3333${path}`);
808
+ for (const [key, value] of Object.entries(options.searchParams ?? {})) {
809
+ url.searchParams.set(key, value);
810
+ }
811
+ const method = options.method ?? (options.body !== void 0 ? "POST" : "GET");
812
+ const headers = new Headers(options.headers);
813
+ const user = options.user !== void 0 ? options.user : state.currentUser;
814
+ if (user && !headers.has("x-stardeck-user")) {
815
+ headers.set("x-stardeck-user", JSON.stringify(user));
816
+ }
817
+ if (options.body !== void 0 && !headers.has("Content-Type")) {
818
+ headers.set("Content-Type", "application/json");
819
+ }
820
+ const cookiePairs = Object.entries(options.cookies ?? {});
821
+ if (cookiePairs.length > 0) {
822
+ const existing = headers.get("cookie");
823
+ const cookieString = cookiePairs.map(([k, v]) => `${k}=${v}`).join("; ");
824
+ headers.set("cookie", existing ? `${existing}; ${cookieString}` : cookieString);
825
+ }
826
+ const request = new NextRequest(url, {
827
+ method,
828
+ headers,
829
+ body: options.body !== void 0 ? JSON.stringify(options.body) : void 0
830
+ });
831
+ const scope = {
832
+ headers,
833
+ cookies: parseCookieHeader(headers.get("cookie"))
834
+ };
835
+ try {
836
+ return await requestScopeStorage.run(
837
+ scope,
838
+ () => Promise.resolve(
839
+ handler(request, {
840
+ params: Promise.resolve(options.params ?? {})
841
+ })
842
+ )
843
+ );
844
+ } catch (error) {
845
+ const redirect = decodeNextRedirect(error);
846
+ if (redirect) return redirect;
847
+ throw error;
848
+ }
849
+ }
850
+ function decodeNextRedirect(error) {
851
+ const digest = error?.digest;
852
+ if (typeof digest !== "string" || !digest.startsWith("NEXT_REDIRECT")) return null;
853
+ const parts = digest.split(";");
854
+ const location = parts[2] ?? "/";
855
+ const status = Number(parts[3]) || 307;
856
+ return new Response(null, { status, headers: { location } });
857
+ }
858
+
859
+ // src/workflow.ts
860
+ import { describe } from "vitest";
861
+ function describeWorkflow(name, fn) {
862
+ describe(`workflow:${name}`, fn);
863
+ }
864
+ var WORKFLOW_NAME_PREFIX = "workflow:";
865
+ function parseWorkflowName(describeTitle) {
866
+ return describeTitle.startsWith(WORKFLOW_NAME_PREFIX) ? describeTitle.slice(WORKFLOW_NAME_PREFIX.length) : null;
867
+ }
868
+ export {
869
+ CONTROL_PLANE_TEST_URL,
870
+ DATA_STORE_TEST_HOST,
871
+ DEFAULT_SCHEMA_PATH,
872
+ TEST_ENV_DEFAULTS,
873
+ WORKFLOW_NAME_PREFIX,
874
+ callRoute,
875
+ createTestApp,
876
+ describeWorkflow,
877
+ parseWorkflowName
878
+ };