@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/SKILL.md +118 -0
- package/dist/config.d.mts +16 -0
- package/dist/config.d.ts +16 -0
- package/dist/config.js +57 -0
- package/dist/config.mjs +32 -0
- package/dist/index.d.mts +143 -0
- package/dist/index.d.ts +143 -0
- package/dist/index.js +923 -0
- package/dist/index.mjs +878 -0
- package/dist/next/headers-shim.d.mts +38 -0
- package/dist/next/headers-shim.d.ts +38 -0
- package/dist/next/headers-shim.js +114 -0
- package/dist/next/headers-shim.mjs +86 -0
- package/dist/server-only-shim.d.mts +2 -0
- package/dist/server-only-shim.d.ts +2 -0
- package/dist/server-only-shim.js +18 -0
- package/dist/server-only-shim.mjs +0 -0
- package/dist/setup.d.mts +2 -0
- package/dist/setup.d.ts +2 -0
- package/dist/setup.js +658 -0
- package/dist/setup.mjs +634 -0
- package/package.json +91 -0
package/dist/setup.js
ADDED
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __copyProps = (to, from, except, desc) => {
|
|
9
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
10
|
+
for (let key of __getOwnPropNames(from))
|
|
11
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
12
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
13
|
+
}
|
|
14
|
+
return to;
|
|
15
|
+
};
|
|
16
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
17
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
18
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
19
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
20
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
21
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
22
|
+
mod
|
|
23
|
+
));
|
|
24
|
+
|
|
25
|
+
// src/global-singleton.ts
|
|
26
|
+
function globalSingleton(key, create) {
|
|
27
|
+
const symbol = /* @__PURE__ */ Symbol.for(`stardeck-testing.${key}`);
|
|
28
|
+
const holder = globalThis;
|
|
29
|
+
holder[symbol] ??= create();
|
|
30
|
+
return holder[symbol];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// src/state.ts
|
|
34
|
+
var state = globalSingleton("state", () => ({
|
|
35
|
+
db: null,
|
|
36
|
+
currentUser: null,
|
|
37
|
+
sessions: /* @__PURE__ */ new Map(),
|
|
38
|
+
refreshSessions: /* @__PURE__ */ new Map(),
|
|
39
|
+
emails: [],
|
|
40
|
+
emailCounter: 0,
|
|
41
|
+
allowNetwork: false
|
|
42
|
+
}));
|
|
43
|
+
function requireDb() {
|
|
44
|
+
if (!state.db) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
"[stardeck-testing] No active test app. Call `await createTestApp(...)` in beforeAll before using Stardeck SDKs in tests."
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
return state.db;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// src/constants.ts
|
|
53
|
+
var TEST_DOMAIN_SUFFIX = ".stardeck.test";
|
|
54
|
+
var CONTROL_PLANE_TEST_URL = "https://control-plane.stardeck.test";
|
|
55
|
+
var DATA_STORE_TEST_HOST = "db.stardeck.test";
|
|
56
|
+
var TEST_ENV_DEFAULTS = {
|
|
57
|
+
CONTROL_PLANE_URL: CONTROL_PLANE_TEST_URL,
|
|
58
|
+
DEPLOYMENT_SECRET: "stardeck-test-deployment-secret",
|
|
59
|
+
ORGANIZATION_ID: "00000000-0000-4000-8000-00000000000a",
|
|
60
|
+
PROJECT_ID: "00000000-0000-4000-8000-00000000000b",
|
|
61
|
+
DEPLOYMENT_ID: "00000000-0000-4000-8000-00000000000c",
|
|
62
|
+
DATA_STORE_URL: `postgresql://test:test@${DATA_STORE_TEST_HOST}/main`
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// src/simulator/hmac.ts
|
|
66
|
+
var import_node_crypto = __toESM(require("crypto"));
|
|
67
|
+
var TIMESTAMP_TOLERANCE_SECONDS = 300;
|
|
68
|
+
function verifyDeploymentAuthHeader(secret, header) {
|
|
69
|
+
const dotIndex = header.lastIndexOf(".");
|
|
70
|
+
if (dotIndex === -1) return null;
|
|
71
|
+
const payloadB64 = header.slice(0, dotIndex);
|
|
72
|
+
const signature = header.slice(dotIndex + 1);
|
|
73
|
+
let payloadJson;
|
|
74
|
+
try {
|
|
75
|
+
payloadJson = Buffer.from(payloadB64, "base64").toString("utf-8");
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
const expected = import_node_crypto.default.createHmac("sha256", secret).update(payloadJson).digest("hex");
|
|
80
|
+
const expectedBuf = Buffer.from(expected, "utf-8");
|
|
81
|
+
const actualBuf = Buffer.from(signature, "utf-8");
|
|
82
|
+
if (expectedBuf.length !== actualBuf.length || !import_node_crypto.default.timingSafeEqual(expectedBuf, actualBuf)) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
let payload;
|
|
86
|
+
try {
|
|
87
|
+
payload = JSON.parse(payloadJson);
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
if (payload.type !== "deployment-request") return null;
|
|
92
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
93
|
+
if (Math.abs(now - payload.timestamp) > TIMESTAMP_TOLERANCE_SECONDS) return null;
|
|
94
|
+
return payload;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// src/simulator/http.ts
|
|
98
|
+
function json(body, status = 200) {
|
|
99
|
+
return new Response(JSON.stringify(body), {
|
|
100
|
+
status,
|
|
101
|
+
headers: { "Content-Type": "application/json" }
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
function success(data) {
|
|
105
|
+
return json({ success: true, data });
|
|
106
|
+
}
|
|
107
|
+
function failure(error, status = 400) {
|
|
108
|
+
return json({ success: false, error }, status);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// src/simulator/data-store.ts
|
|
112
|
+
function quoteIdent(name) {
|
|
113
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
114
|
+
}
|
|
115
|
+
function normalizeIdent(name) {
|
|
116
|
+
return name.toLowerCase().replace(/\s+/g, "_");
|
|
117
|
+
}
|
|
118
|
+
async function getTableColumns(db, tableName) {
|
|
119
|
+
const { rows } = await db.query(
|
|
120
|
+
`SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1 ORDER BY ordinal_position`,
|
|
121
|
+
[tableName]
|
|
122
|
+
);
|
|
123
|
+
if (rows.length === 0) return null;
|
|
124
|
+
return rows.map((r) => r.column_name);
|
|
125
|
+
}
|
|
126
|
+
async function getPrimaryKeyColumns(db, tableName) {
|
|
127
|
+
const { rows } = await db.query(
|
|
128
|
+
`SELECT ku.column_name
|
|
129
|
+
FROM information_schema.table_constraints tc
|
|
130
|
+
JOIN information_schema.key_column_usage ku
|
|
131
|
+
ON tc.constraint_name = ku.constraint_name AND tc.table_schema = ku.table_schema
|
|
132
|
+
WHERE tc.constraint_type = 'PRIMARY KEY'
|
|
133
|
+
AND tc.table_schema = 'public'
|
|
134
|
+
AND tc.table_name = $1`,
|
|
135
|
+
[tableName]
|
|
136
|
+
);
|
|
137
|
+
return rows.map((r) => r.column_name);
|
|
138
|
+
}
|
|
139
|
+
function coerceNumericFields(result) {
|
|
140
|
+
const numericFields = result.fields.filter((f) => f.dataTypeID === 1700).map((f) => f.name);
|
|
141
|
+
if (numericFields.length === 0) return result.rows;
|
|
142
|
+
return result.rows.map((row) => {
|
|
143
|
+
const coerced = { ...row };
|
|
144
|
+
for (const name of numericFields) {
|
|
145
|
+
if (typeof coerced[name] === "string") {
|
|
146
|
+
coerced[name] = parseFloat(coerced[name]);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return coerced;
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
function buildWhereClause(filters, validColumns) {
|
|
153
|
+
const conditions = [];
|
|
154
|
+
const params = [];
|
|
155
|
+
let paramIndex = 1;
|
|
156
|
+
for (const filter of filters) {
|
|
157
|
+
if (!validColumns.includes(filter.column)) continue;
|
|
158
|
+
const col = quoteIdent(filter.column);
|
|
159
|
+
switch (filter.operator) {
|
|
160
|
+
case "eq":
|
|
161
|
+
conditions.push(`${col} = $${paramIndex++}`);
|
|
162
|
+
params.push(filter.value);
|
|
163
|
+
break;
|
|
164
|
+
case "neq":
|
|
165
|
+
conditions.push(`${col} != $${paramIndex++}`);
|
|
166
|
+
params.push(filter.value);
|
|
167
|
+
break;
|
|
168
|
+
case "contains": {
|
|
169
|
+
conditions.push(`${col}::text ILIKE $${paramIndex++}`);
|
|
170
|
+
const escaped = String(filter.value ?? "").replace(/[%_\\]/g, "\\$&");
|
|
171
|
+
params.push(`%${escaped}%`);
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
case "gt":
|
|
175
|
+
conditions.push(`${col} > $${paramIndex++}`);
|
|
176
|
+
params.push(filter.value);
|
|
177
|
+
break;
|
|
178
|
+
case "lt":
|
|
179
|
+
conditions.push(`${col} < $${paramIndex++}`);
|
|
180
|
+
params.push(filter.value);
|
|
181
|
+
break;
|
|
182
|
+
case "is_null":
|
|
183
|
+
conditions.push(`${col} IS NULL`);
|
|
184
|
+
break;
|
|
185
|
+
case "is_not_null":
|
|
186
|
+
conditions.push(`${col} IS NOT NULL`);
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (conditions.length === 0) return { clause: "", params: [] };
|
|
191
|
+
return { clause: `WHERE ${conditions.join(" AND ")}`, params };
|
|
192
|
+
}
|
|
193
|
+
async function handleQuery(db, body) {
|
|
194
|
+
if (typeof body.tableName !== "string" || body.tableName.length === 0) {
|
|
195
|
+
return failure("Invalid request body", 400);
|
|
196
|
+
}
|
|
197
|
+
const tableName = body.tableName;
|
|
198
|
+
const limit = body.limit === void 0 ? 50 : Number(body.limit);
|
|
199
|
+
const offset = body.offset === void 0 ? 0 : Number(body.offset);
|
|
200
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
|
|
201
|
+
return failure("Invalid request body", 400);
|
|
202
|
+
}
|
|
203
|
+
if (!Number.isInteger(offset) || offset < 0) {
|
|
204
|
+
return failure("Invalid request body", 400);
|
|
205
|
+
}
|
|
206
|
+
if (body.orderDir !== void 0 && body.orderDir !== "asc" && body.orderDir !== "desc") {
|
|
207
|
+
return failure("Invalid request body", 400);
|
|
208
|
+
}
|
|
209
|
+
const orderBy = body.orderBy ? String(body.orderBy) : void 0;
|
|
210
|
+
const orderDir = body.orderDir === "desc" ? "desc" : "asc";
|
|
211
|
+
const filters = body.filters ?? [];
|
|
212
|
+
const validColumns = await getTableColumns(db, tableName);
|
|
213
|
+
if (!validColumns) return failure(`Table "${tableName}" not found`, 404);
|
|
214
|
+
if (orderBy && !validColumns.includes(orderBy)) {
|
|
215
|
+
return failure(`Column "${orderBy}" not found in table "${tableName}"`);
|
|
216
|
+
}
|
|
217
|
+
const { clause: whereClause, params: whereParams } = buildWhereClause(filters, validColumns);
|
|
218
|
+
const orderClause = orderBy ? `ORDER BY ${quoteIdent(orderBy)} ${orderDir === "desc" ? "DESC" : "ASC"}` : "";
|
|
219
|
+
const nextParamIdx = whereParams.length + 1;
|
|
220
|
+
const quotedTable = quoteIdent(tableName);
|
|
221
|
+
const preparedWhereParams = whereParams.map(prepareParam);
|
|
222
|
+
const dataResult = await db.query(
|
|
223
|
+
`SELECT * FROM ${quotedTable} ${whereClause} ${orderClause} LIMIT $${nextParamIdx} OFFSET $${nextParamIdx + 1}`,
|
|
224
|
+
[...preparedWhereParams, limit, offset]
|
|
225
|
+
);
|
|
226
|
+
const countResult = await db.query(
|
|
227
|
+
`SELECT count(*)::int as total FROM ${quotedTable} ${whereClause}`,
|
|
228
|
+
preparedWhereParams
|
|
229
|
+
);
|
|
230
|
+
return success({
|
|
231
|
+
rows: coerceNumericFields(dataResult),
|
|
232
|
+
columns: validColumns,
|
|
233
|
+
total: countResult.rows[0]?.total ?? 0,
|
|
234
|
+
limit,
|
|
235
|
+
offset
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
function buildPkWhere(primaryKey, pkColumns, startParamIdx) {
|
|
239
|
+
const conditions = [];
|
|
240
|
+
const params = [];
|
|
241
|
+
let paramIdx = startParamIdx;
|
|
242
|
+
for (const col of pkColumns) {
|
|
243
|
+
if (!(col in primaryKey)) {
|
|
244
|
+
throw new Error(`Missing primary key column: ${col}`);
|
|
245
|
+
}
|
|
246
|
+
conditions.push(`${quoteIdent(col)} = $${paramIdx++}`);
|
|
247
|
+
params.push(primaryKey[col]);
|
|
248
|
+
}
|
|
249
|
+
return { clause: conditions.join(" AND "), params };
|
|
250
|
+
}
|
|
251
|
+
async function handleMutate(db, body) {
|
|
252
|
+
if (typeof body.tableName !== "string" || body.tableName.length === 0) {
|
|
253
|
+
return failure("Invalid request body", 400);
|
|
254
|
+
}
|
|
255
|
+
const tableName = body.tableName;
|
|
256
|
+
const operation = String(body.operation ?? "");
|
|
257
|
+
if (!["insert", "update", "delete"].includes(operation)) {
|
|
258
|
+
return failure("Invalid request body", 400);
|
|
259
|
+
}
|
|
260
|
+
const columns = await getTableColumns(db, tableName);
|
|
261
|
+
if (!columns) return failure(`Table "${tableName}" not found`, 404);
|
|
262
|
+
const pkColumns = await getPrimaryKeyColumns(db, tableName);
|
|
263
|
+
switch (operation) {
|
|
264
|
+
case "insert": {
|
|
265
|
+
const row = body.row ?? {};
|
|
266
|
+
const insertColumns = Object.keys(row).filter((col) => columns.includes(col));
|
|
267
|
+
let sql;
|
|
268
|
+
let values;
|
|
269
|
+
if (insertColumns.length === 0) {
|
|
270
|
+
sql = `INSERT INTO ${quoteIdent(tableName)} DEFAULT VALUES RETURNING *`;
|
|
271
|
+
values = [];
|
|
272
|
+
} else {
|
|
273
|
+
values = insertColumns.map((col) => prepareParam(row[col]));
|
|
274
|
+
const colList = insertColumns.map((c) => quoteIdent(c)).join(", ");
|
|
275
|
+
const paramList = insertColumns.map((_, i) => `$${i + 1}`).join(", ");
|
|
276
|
+
sql = `INSERT INTO ${quoteIdent(tableName)} (${colList}) VALUES (${paramList}) RETURNING *`;
|
|
277
|
+
}
|
|
278
|
+
const result = await db.query(sql, values);
|
|
279
|
+
return success({ row: result.rows[0] });
|
|
280
|
+
}
|
|
281
|
+
case "update": {
|
|
282
|
+
const column = String(body.column ?? "");
|
|
283
|
+
if (!columns.includes(column)) {
|
|
284
|
+
return failure(`Column "${column}" not found in table "${tableName}"`);
|
|
285
|
+
}
|
|
286
|
+
if (pkColumns.length === 0) {
|
|
287
|
+
return failure(`Table "${tableName}" has no primary key`);
|
|
288
|
+
}
|
|
289
|
+
const { clause: pkClause, params: pkParams } = buildPkWhere(
|
|
290
|
+
body.primaryKey ?? {},
|
|
291
|
+
pkColumns,
|
|
292
|
+
2
|
|
293
|
+
);
|
|
294
|
+
const updateValue = body.value === "" ? null : prepareParam(body.value);
|
|
295
|
+
const result = await db.query(
|
|
296
|
+
`UPDATE ${quoteIdent(tableName)} SET ${quoteIdent(column)} = $1 WHERE ${pkClause} RETURNING *`,
|
|
297
|
+
[updateValue, ...pkParams.map(prepareParam)]
|
|
298
|
+
);
|
|
299
|
+
if (result.rows.length === 0) return failure("Row not found", 404);
|
|
300
|
+
return success({ row: result.rows[0] });
|
|
301
|
+
}
|
|
302
|
+
case "delete": {
|
|
303
|
+
if (pkColumns.length === 0) {
|
|
304
|
+
return failure(`Table "${tableName}" has no primary key`);
|
|
305
|
+
}
|
|
306
|
+
const { clause: pkClause, params: pkParams } = buildPkWhere(
|
|
307
|
+
body.primaryKey ?? {},
|
|
308
|
+
pkColumns,
|
|
309
|
+
1
|
|
310
|
+
);
|
|
311
|
+
const result = await db.query(
|
|
312
|
+
`DELETE FROM ${quoteIdent(tableName)} WHERE ${pkClause} RETURNING *`,
|
|
313
|
+
pkParams.map(prepareParam)
|
|
314
|
+
);
|
|
315
|
+
if (result.rows.length === 0) return failure("Row not found", 404);
|
|
316
|
+
return success({ deleted: true, row: result.rows[0] });
|
|
317
|
+
}
|
|
318
|
+
default:
|
|
319
|
+
return failure(`Unknown operation "${operation}"`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
function prepareParam(value) {
|
|
323
|
+
if (value instanceof Date) return value.toISOString();
|
|
324
|
+
if (Array.isArray(value)) return encodeArrayLiteral(value);
|
|
325
|
+
if (value !== null && typeof value === "object") return JSON.stringify(value);
|
|
326
|
+
return value;
|
|
327
|
+
}
|
|
328
|
+
function encodeArrayLiteral(values) {
|
|
329
|
+
const items = values.map((v) => {
|
|
330
|
+
if (v === null || v === void 0) return "NULL";
|
|
331
|
+
if (Array.isArray(v)) return quoteArrayElement(encodeArrayLiteral(v));
|
|
332
|
+
if (v instanceof Date) return quoteArrayElement(v.toISOString());
|
|
333
|
+
if (typeof v === "object") return quoteArrayElement(JSON.stringify(v));
|
|
334
|
+
return quoteArrayElement(String(v));
|
|
335
|
+
});
|
|
336
|
+
return `{${items.join(",")}}`;
|
|
337
|
+
}
|
|
338
|
+
function quoteArrayElement(raw) {
|
|
339
|
+
return `"${raw.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
340
|
+
}
|
|
341
|
+
async function handleGetSchema(db) {
|
|
342
|
+
const { rows } = await db.query(
|
|
343
|
+
`SELECT
|
|
344
|
+
t.table_name,
|
|
345
|
+
c.column_name,
|
|
346
|
+
c.data_type,
|
|
347
|
+
c.is_nullable,
|
|
348
|
+
c.column_default,
|
|
349
|
+
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as is_primary_key
|
|
350
|
+
FROM information_schema.tables t
|
|
351
|
+
JOIN information_schema.columns c
|
|
352
|
+
ON t.table_name = c.table_name AND t.table_schema = c.table_schema
|
|
353
|
+
LEFT JOIN (
|
|
354
|
+
SELECT ku.column_name, ku.table_name
|
|
355
|
+
FROM information_schema.table_constraints tc
|
|
356
|
+
JOIN information_schema.key_column_usage ku
|
|
357
|
+
ON tc.constraint_name = ku.constraint_name AND tc.table_schema = ku.table_schema
|
|
358
|
+
WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_schema = 'public'
|
|
359
|
+
) pk ON pk.table_name = c.table_name AND pk.column_name = c.column_name
|
|
360
|
+
WHERE t.table_schema = 'public'
|
|
361
|
+
AND t.table_type = 'BASE TABLE'
|
|
362
|
+
AND t.table_name NOT LIKE '\\_deleted\\_%'
|
|
363
|
+
ORDER BY t.table_name, c.ordinal_position`
|
|
364
|
+
);
|
|
365
|
+
const tables = /* @__PURE__ */ new Map();
|
|
366
|
+
for (const row of rows) {
|
|
367
|
+
if (!tables.has(row.table_name)) tables.set(row.table_name, []);
|
|
368
|
+
const cols = tables.get(row.table_name);
|
|
369
|
+
cols.push({
|
|
370
|
+
columnName: row.column_name,
|
|
371
|
+
dataType: row.data_type,
|
|
372
|
+
isNullable: row.is_nullable === "YES",
|
|
373
|
+
columnDefault: row.column_default,
|
|
374
|
+
isPrimaryKey: row.is_primary_key,
|
|
375
|
+
position: cols.length
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
return success({
|
|
379
|
+
tables: Array.from(tables, ([tableName, columns]) => ({ tableName, columns }))
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
var FIELD_TYPE_TO_PG = {
|
|
383
|
+
text: "text",
|
|
384
|
+
long_text: "text",
|
|
385
|
+
number: "double precision",
|
|
386
|
+
boolean: "boolean",
|
|
387
|
+
date: "date",
|
|
388
|
+
datetime: "timestamptz",
|
|
389
|
+
select: "text",
|
|
390
|
+
multi_select: "jsonb",
|
|
391
|
+
url: "text",
|
|
392
|
+
email: "text",
|
|
393
|
+
phone: "text",
|
|
394
|
+
currency: "numeric",
|
|
395
|
+
rating: "integer",
|
|
396
|
+
relation: "uuid",
|
|
397
|
+
file_ref: "jsonb",
|
|
398
|
+
file_refs: "jsonb",
|
|
399
|
+
json: "jsonb"
|
|
400
|
+
};
|
|
401
|
+
function columnDdl(column) {
|
|
402
|
+
const pgType = FIELD_TYPE_TO_PG[column.fieldType];
|
|
403
|
+
if (!pgType) {
|
|
404
|
+
throw new Error(
|
|
405
|
+
`Unsupported fieldType "${column.fieldType}" in test simulator. Supported: ${Object.keys(FIELD_TYPE_TO_PG).join(", ")}`
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
let ddl = `${quoteIdent(normalizeIdent(column.name))} ${pgType}`;
|
|
409
|
+
if (column.nullable === false) ddl += " NOT NULL";
|
|
410
|
+
return ddl;
|
|
411
|
+
}
|
|
412
|
+
async function handleCreateTable(db, body) {
|
|
413
|
+
const name = normalizeIdent(String(body.name ?? ""));
|
|
414
|
+
const columns = body.columns ?? [];
|
|
415
|
+
if (!name) return failure("Table name is required");
|
|
416
|
+
try {
|
|
417
|
+
const columnDefs = [
|
|
418
|
+
`"id" uuid PRIMARY KEY DEFAULT gen_random_uuid()`,
|
|
419
|
+
...columns.map(columnDdl),
|
|
420
|
+
`"created_at" timestamptz NOT NULL DEFAULT now()`,
|
|
421
|
+
`"updated_at" timestamptz NOT NULL DEFAULT now()`
|
|
422
|
+
];
|
|
423
|
+
await db.exec(`CREATE TABLE ${quoteIdent(name)} (
|
|
424
|
+
${columnDefs.join(",\n ")}
|
|
425
|
+
)`);
|
|
426
|
+
} catch (error) {
|
|
427
|
+
return failure(error instanceof Error ? error.message : String(error));
|
|
428
|
+
}
|
|
429
|
+
return success({ tableName: name });
|
|
430
|
+
}
|
|
431
|
+
async function handleAddColumn(db, body) {
|
|
432
|
+
const tableName = normalizeIdent(String(body.tableName ?? ""));
|
|
433
|
+
const column = body.column;
|
|
434
|
+
if (!tableName || !column) return failure("tableName and column are required");
|
|
435
|
+
try {
|
|
436
|
+
await db.exec(`ALTER TABLE ${quoteIdent(tableName)} ADD COLUMN ${columnDdl(column)}`);
|
|
437
|
+
} catch (error) {
|
|
438
|
+
return failure(error instanceof Error ? error.message : String(error));
|
|
439
|
+
}
|
|
440
|
+
return success({ columnName: normalizeIdent(column.name) });
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// src/pglite/database.ts
|
|
444
|
+
var import_pglite = require("@electric-sql/pglite");
|
|
445
|
+
var import_citext = require("@electric-sql/pglite/contrib/citext");
|
|
446
|
+
var import_pg_trgm = require("@electric-sql/pglite/contrib/pg_trgm");
|
|
447
|
+
var import_pgcrypto = require("@electric-sql/pglite/contrib/pgcrypto");
|
|
448
|
+
var import_uuid_ossp = require("@electric-sql/pglite/contrib/uuid_ossp");
|
|
449
|
+
var identityParsers = null;
|
|
450
|
+
function getIdentityParsers() {
|
|
451
|
+
if (!identityParsers) {
|
|
452
|
+
identityParsers = {};
|
|
453
|
+
const identity = (value) => value;
|
|
454
|
+
for (let oid = 1; oid <= 13e3; oid++) {
|
|
455
|
+
identityParsers[oid] = identity;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return identityParsers;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// src/simulator/neon-proxy.ts
|
|
462
|
+
var BOOL_OID = 16;
|
|
463
|
+
var PG_BOOL_TRUE = /* @__PURE__ */ new Set(["true", "t", "1", "yes", "y", "on"]);
|
|
464
|
+
var PG_BOOL_FALSE = /* @__PURE__ */ new Set(["false", "f", "0", "no", "n", "off"]);
|
|
465
|
+
var neonParamSerializers = {
|
|
466
|
+
[BOOL_OID]: (value) => {
|
|
467
|
+
if (typeof value === "boolean") return value ? "t" : "f";
|
|
468
|
+
const normalized = String(value).trim().toLowerCase();
|
|
469
|
+
if (PG_BOOL_TRUE.has(normalized)) return "t";
|
|
470
|
+
if (PG_BOOL_FALSE.has(normalized)) return "f";
|
|
471
|
+
throw new Error(`Invalid input for boolean type: ${JSON.stringify(value)}`);
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
async function handleNeonSql(db, request) {
|
|
475
|
+
const body = await request.json();
|
|
476
|
+
if (body.queries) {
|
|
477
|
+
return json(
|
|
478
|
+
{
|
|
479
|
+
message: "Transactions over the Neon HTTP driver are not supported (matches production neon-http behavior). Use single statements in tests."
|
|
480
|
+
},
|
|
481
|
+
400
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
if (!body.query) {
|
|
485
|
+
return json({ message: "Missing query" }, 400);
|
|
486
|
+
}
|
|
487
|
+
const arrayMode = (request.headers.get("Neon-Array-Mode") ?? request.headers.get("neon-array-mode")) === "true";
|
|
488
|
+
try {
|
|
489
|
+
const result = await db.query(body.query, body.params ?? [], {
|
|
490
|
+
parsers: getIdentityParsers(),
|
|
491
|
+
serializers: neonParamSerializers,
|
|
492
|
+
rowMode: arrayMode ? "array" : "object"
|
|
493
|
+
});
|
|
494
|
+
const command = body.query.trim().split(/\s+/)[0]?.toUpperCase() ?? "SELECT";
|
|
495
|
+
return json({
|
|
496
|
+
command,
|
|
497
|
+
rowCount: result.affectedRows || result.rows.length,
|
|
498
|
+
fields: result.fields.map((f) => ({ name: f.name, dataTypeID: f.dataTypeID })),
|
|
499
|
+
rows: result.rows,
|
|
500
|
+
rowAsArray: arrayMode
|
|
501
|
+
});
|
|
502
|
+
} catch (error) {
|
|
503
|
+
const err = error;
|
|
504
|
+
return json({ message: err.message, code: err.code }, 400);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// src/simulator/auth.ts
|
|
509
|
+
var import_node_crypto2 = __toESM(require("crypto"));
|
|
510
|
+
function handleAuthVerify(request) {
|
|
511
|
+
const authHeader = request.headers.get("Authorization");
|
|
512
|
+
const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
|
|
513
|
+
const user = token ? state.sessions.get(token) : null;
|
|
514
|
+
if (!user) {
|
|
515
|
+
return json({ authenticated: false, user: null }, 401);
|
|
516
|
+
}
|
|
517
|
+
return json({ authenticated: true, user });
|
|
518
|
+
}
|
|
519
|
+
function handleAuthRefresh(request) {
|
|
520
|
+
const authHeader = request.headers.get("Authorization");
|
|
521
|
+
const refreshToken = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
|
|
522
|
+
const user = refreshToken ? state.refreshSessions.get(refreshToken) : null;
|
|
523
|
+
if (!user || !refreshToken) {
|
|
524
|
+
return json({ error: "invalid_grant" }, 401);
|
|
525
|
+
}
|
|
526
|
+
const accessToken = `test-access-${import_node_crypto2.default.randomUUID()}`;
|
|
527
|
+
state.sessions.set(accessToken, user);
|
|
528
|
+
return json({
|
|
529
|
+
access_token: accessToken,
|
|
530
|
+
refresh_token: refreshToken,
|
|
531
|
+
token_type: "Bearer",
|
|
532
|
+
expires_in: 3600,
|
|
533
|
+
refresh_token_expires_in: 86400
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// src/simulator/email.ts
|
|
538
|
+
function toArray(value) {
|
|
539
|
+
if (!value) return [];
|
|
540
|
+
return Array.isArray(value) ? value.map(String) : [String(value)];
|
|
541
|
+
}
|
|
542
|
+
async function handleEmailSend(request) {
|
|
543
|
+
const body = await request.json();
|
|
544
|
+
const from = body.from ?? {};
|
|
545
|
+
if (!from.name || !from.prefix || !body.to || !body.subject) {
|
|
546
|
+
return failure("Missing required email fields");
|
|
547
|
+
}
|
|
548
|
+
state.emailCounter += 1;
|
|
549
|
+
const resendId = `test-email-${state.emailCounter}`;
|
|
550
|
+
const fromAddress = `${from.name} <${from.prefix}@test.stardeck.email>`;
|
|
551
|
+
const email = {
|
|
552
|
+
resendId,
|
|
553
|
+
fromAddress,
|
|
554
|
+
from: { name: from.name, prefix: from.prefix },
|
|
555
|
+
to: toArray(body.to),
|
|
556
|
+
cc: toArray(body.cc),
|
|
557
|
+
bcc: toArray(body.bcc),
|
|
558
|
+
subject: String(body.subject),
|
|
559
|
+
html: body.html ? String(body.html) : void 0,
|
|
560
|
+
text: body.text ? String(body.text) : void 0,
|
|
561
|
+
replyTo: body.replyTo ? String(body.replyTo) : void 0,
|
|
562
|
+
attachments: (body.attachments ?? []).map((a) => ({
|
|
563
|
+
filename: String(a.filename ?? ""),
|
|
564
|
+
contentType: a.contentType ? String(a.contentType) : void 0
|
|
565
|
+
})),
|
|
566
|
+
sentAt: /* @__PURE__ */ new Date()
|
|
567
|
+
};
|
|
568
|
+
state.emails.push(email);
|
|
569
|
+
return success({ resendId, fromAddress });
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// src/simulator/router.ts
|
|
573
|
+
var fetchHolder = globalSingleton("fetch-holder", () => ({
|
|
574
|
+
originalFetch: null
|
|
575
|
+
}));
|
|
576
|
+
function isLocalHost(hostname) {
|
|
577
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "0.0.0.0";
|
|
578
|
+
}
|
|
579
|
+
async function handleSimulatedRequest(request, url) {
|
|
580
|
+
if (url.pathname === "/sql") {
|
|
581
|
+
return handleNeonSql(requireDb(), request);
|
|
582
|
+
}
|
|
583
|
+
const authMatch = url.pathname.match(/^\/api\/deployments\/[^/]+\/auth\/(verify|refresh)$/);
|
|
584
|
+
if (authMatch) {
|
|
585
|
+
return authMatch[1] === "verify" ? handleAuthVerify(request) : handleAuthRefresh(request);
|
|
586
|
+
}
|
|
587
|
+
const dataStoreMatch = url.pathname.match(/^\/api\/data-stores\/[^/]+(\/.*)?$/);
|
|
588
|
+
const isEmail = url.pathname === "/api/email/send";
|
|
589
|
+
if (dataStoreMatch || isEmail) {
|
|
590
|
+
const authHeader = request.headers.get("X-Stardeck-Auth");
|
|
591
|
+
if (!authHeader) {
|
|
592
|
+
return failure("Missing authentication header", 401);
|
|
593
|
+
}
|
|
594
|
+
const secret = process.env.DEPLOYMENT_SECRET;
|
|
595
|
+
if (!secret || !verifyDeploymentAuthHeader(secret, authHeader)) {
|
|
596
|
+
return failure("Invalid authentication", 401);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
if (isEmail && request.method === "POST") {
|
|
600
|
+
return handleEmailSend(request);
|
|
601
|
+
}
|
|
602
|
+
if (dataStoreMatch) {
|
|
603
|
+
const subPath = dataStoreMatch[1] ?? "";
|
|
604
|
+
const db = requireDb();
|
|
605
|
+
const readBody = async () => await request.json();
|
|
606
|
+
if (subPath === "/query" && request.method === "POST") {
|
|
607
|
+
return handleQuery(db, await readBody());
|
|
608
|
+
}
|
|
609
|
+
if (subPath === "/mutate" && request.method === "POST") {
|
|
610
|
+
return handleMutate(db, await readBody());
|
|
611
|
+
}
|
|
612
|
+
if (subPath === "/schema" && request.method === "GET") {
|
|
613
|
+
return handleGetSchema(db);
|
|
614
|
+
}
|
|
615
|
+
if (subPath === "/schema/tables" && request.method === "POST") {
|
|
616
|
+
return handleCreateTable(db, await readBody());
|
|
617
|
+
}
|
|
618
|
+
if (subPath === "/schema/columns" && request.method === "POST") {
|
|
619
|
+
return handleAddColumn(db, await readBody());
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
return failure(
|
|
623
|
+
`[stardeck-testing] No simulator for ${request.method} ${url.pathname}. Supported: data-store query/mutate/schema, email send, auth verify/refresh, Neon /sql.`,
|
|
624
|
+
404
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
function installFetchRouter() {
|
|
628
|
+
if (fetchHolder.originalFetch) return;
|
|
629
|
+
fetchHolder.originalFetch = globalThis.fetch;
|
|
630
|
+
globalThis.fetch = async (input, init) => {
|
|
631
|
+
const request = input instanceof Request ? input : new Request(input, init);
|
|
632
|
+
const merged = input instanceof Request && init ? new Request(request, init) : request;
|
|
633
|
+
const url = new URL(merged.url);
|
|
634
|
+
if (url.hostname.endsWith(TEST_DOMAIN_SUFFIX)) {
|
|
635
|
+
return handleSimulatedRequest(merged, url);
|
|
636
|
+
}
|
|
637
|
+
if (isLocalHost(url.hostname) || state.allowNetwork) {
|
|
638
|
+
return fetchHolder.originalFetch(merged);
|
|
639
|
+
}
|
|
640
|
+
throw new Error(
|
|
641
|
+
`[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.`
|
|
642
|
+
);
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// src/setup.ts
|
|
647
|
+
if (state.db) {
|
|
648
|
+
const stale = state.db;
|
|
649
|
+
state.db = null;
|
|
650
|
+
state.currentUser = null;
|
|
651
|
+
state.sessions.clear();
|
|
652
|
+
state.refreshSessions.clear();
|
|
653
|
+
state.emails = [];
|
|
654
|
+
state.emailCounter = 0;
|
|
655
|
+
void stale.close().catch(() => {
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
installFetchRouter();
|